488 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
			
		
		
	
	
			488 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
/*
 | 
						|

 | 
						|

 | 
						|
The script will convert your drawing into a slideshow presentation.
 | 
						|
 | 
						|
```javascript
 | 
						|
*/
 | 
						|
if(!ea.verifyMinimumPluginVersion || !ea.verifyMinimumPluginVersion("1.8.17")) {
 | 
						|
  new Notice("This script requires a newer version of Excalidraw. Please install the latest version.");
 | 
						|
  return;
 | 
						|
}
 | 
						|
 | 
						|
//constants
 | 
						|
const STEPCOUNT = 100;
 | 
						|
const FRAME_SLEEP = 1; //milliseconds
 | 
						|
const EDIT_ZOOMOUT = 0.7; //70% of original slide zoom, set to a value between 1 and 0
 | 
						|
 | 
						|
//utility & convenience functions
 | 
						|
const doc = ea.targetView.ownerDocument;
 | 
						|
const win = ea.targetView.ownerWindow;
 | 
						|
const api = ea.getExcalidrawAPI();
 | 
						|
const contentEl = ea.targetView.contentEl;
 | 
						|
const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
 | 
						|
 | 
						|
//clean up potential clutter from previous run
 | 
						|
window.removePresentationEventHandlers?.();
 | 
						|
 | 
						|
//check if line or arrow is selected, if not inform the user and terminate presentation
 | 
						|
let lineEl = ea.getViewElements().filter(el=>["line","arrow"].contains(el.type) && el.customData?.slideshow)[0];
 | 
						|
const selectedEl = ea.getViewSelectedElement();
 | 
						|
let preventHideAction = false;
 | 
						|
if(lineEl && selectedEl && ["line","arrow"].contains(selectedEl.type)) {
 | 
						|
  api.setToast({
 | 
						|
    message:"Using selected line instead of hidden line. Note that there is a hidden presentation path for this drawing. Run the slideshow script without selecting any elements to access the hidden presentation path",
 | 
						|
    duration: 5000,
 | 
						|
    closable: true
 | 
						|
  })
 | 
						|
  preventHideAction = true;
 | 
						|
  lineEl = selectedEl;
 | 
						|
}
 | 
						|
if(!lineEl) lineEl = selectedEl;
 | 
						|
if(!lineEl || !["line","arrow"].contains(lineEl.type)) {
 | 
						|
  api.setToast({
 | 
						|
    message:"Please select the line or arrow for the presentation path",
 | 
						|
    duration: 3000,
 | 
						|
    closable: true
 | 
						|
  })
 | 
						|
  return;
 | 
						|
}
 | 
						|
 | 
						|
//goto fullscreen
 | 
						|
const gotoFullscreen = async () => {
 | 
						|
	if(app.isMobile) {
 | 
						|
	  ea.viewToggleFullScreen(true);
 | 
						|
	} else {
 | 
						|
	  await contentEl.webkitRequestFullscreen();
 | 
						|
	  await sleep(500);
 | 
						|
	  ea.setViewModeEnabled(true);
 | 
						|
	}
 | 
						|
	const deltaWidth = () => contentEl.clientWidth-api.getAppState().width;
 | 
						|
	let watchdog = 0;
 | 
						|
	while (deltaWidth()>50 && watchdog++<20) await sleep(100); //wait for Excalidraw to resize to fullscreen
 | 
						|
	contentEl.querySelector(".layer-ui__wrapper").addClass("excalidraw-hidden");
 | 
						|
}
 | 
						|
 | 
						|
//hide the arrow and save the arrow color before doing so
 | 
						|
const originalProps = lineEl.customData?.slideshow?.hidden
 | 
						|
  ? lineEl.customData.slideshow.originalProps
 | 
						|
  : {
 | 
						|
	  strokeColor: lineEl.strokeColor,
 | 
						|
	  backgroundColor: lineEl.backgroundColor,
 | 
						|
	  locked: lineEl.locked,
 | 
						|
  };
 | 
						|
let hidden = lineEl.customData?.slideshow?.hidden ?? false;
 | 
						|
 | 
						|
const hideArrow = async (setToHidden) => {
 | 
						|
  ea.clear();
 | 
						|
  ea.copyViewElementsToEAforEditing(ea.getViewElements().filter(el=>el.id === lineEl.id));
 | 
						|
  const el = ea.getElement(lineEl.id);
 | 
						|
	el.strokeColor = "transparent";
 | 
						|
	el.backgroundColor = "transparent";
 | 
						|
  const customData = el.customData;
 | 
						|
	if(setToHidden && !preventHideAction) {
 | 
						|
    el.locked = true;
 | 
						|
		el.customData = {
 | 
						|
		  ...customData,
 | 
						|
		  slideshow: {
 | 
						|
			  originalProps,
 | 
						|
			  hidden: true
 | 
						|
		  }
 | 
						|
		}
 | 
						|
    hidden = true;
 | 
						|
	} else {
 | 
						|
    if(customData) delete el.customData.slideshow;
 | 
						|
    hidden = false;
 | 
						|
  }
 | 
						|
	await ea.addElementsToView();
 | 
						|
}
 | 
						|
 | 
						|
//----------------------------
 | 
						|
//scroll-to-location functions
 | 
						|
//----------------------------
 | 
						|
let slide = -1;
 | 
						|
const slideCount = Math.floor(lineEl.points.length/2)-1;
 | 
						|
 | 
						|
const getNextSlide = (forward) => {
 | 
						|
  slide = forward
 | 
						|
    ? slide < slideCount ? slide + 1  : 0
 | 
						|
    : slide <= 0         ? slideCount : slide - 1;
 | 
						|
	return {
 | 
						|
    pointA:lineEl.points[slide*2],
 | 
						|
    pointB:lineEl.points[slide*2+1]
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
const getSlideRect = ({pointA, pointB}) => {
 | 
						|
  const {width, height} = api.getAppState();
 | 
						|
  const x1 = lineEl.x+pointA[0];
 | 
						|
  const y1 = lineEl.y+pointA[1];
 | 
						|
  const x2 = lineEl.x+pointB[0];
 | 
						|
  const y2 = lineEl.y+pointB[1];
 | 
						|
  const ratioX = width/Math.abs(x1-x2);
 | 
						|
  const ratioY = height/Math.abs(y1-y2);
 | 
						|
  let ratio = ratioX<ratioY?ratioX:ratioY;
 | 
						|
  if (ratio < 0.1) ratio = 0.1;
 | 
						|
  if (ratio > 10) ratio = 10;
 | 
						|
  const deltaX = (ratio===ratioY)?(width/ratio - Math.abs(x1-x2))/2:0;
 | 
						|
  const deltaY = (ratio===ratioX)?(height/ratio - Math.abs(y1-y2))/2:0;
 | 
						|
  return {
 | 
						|
    left: (x1<x2?x1:x2)-deltaX,
 | 
						|
    top: (y1<y2?y1:y2)-deltaY,
 | 
						|
    right: (x1<x2?x2:x1)+deltaX,
 | 
						|
    bottom: (y1<y2?y2:y1)+deltaY,
 | 
						|
    nextZoom: ratio
 | 
						|
  };
 | 
						|
}
 | 
						|
 | 
						|
let busy = false;
 | 
						|
const scrollToNextRect = async ({left,top,right,bottom,nextZoom},steps = STEPCOUNT) => {
 | 
						|
  let watchdog = 0;
 | 
						|
  while(busy && watchdog++<15) await(100);
 | 
						|
  if(busy && watchdog >= 15) return;
 | 
						|
  busy = true;
 | 
						|
  api.updateScene({appState:{shouldCacheIgnoreZoom:true}});
 | 
						|
  const {scrollX, scrollY, zoom} = api.getAppState();
 | 
						|
  const zoomStep = (zoom.value-nextZoom)/steps;
 | 
						|
  const xStep = (left+scrollX)/steps;
 | 
						|
  const yStep = (top+scrollY)/steps;
 | 
						|
  for(i=1;i<=steps;i++) {
 | 
						|
    api.updateScene({
 | 
						|
      appState: {
 | 
						|
        scrollX:scrollX-(xStep*i),
 | 
						|
        scrollY:scrollY-(yStep*i),
 | 
						|
        zoom:{value:zoom.value-zoomStep*i},
 | 
						|
      }
 | 
						|
    });
 | 
						|
    await sleep(FRAME_SLEEP);
 | 
						|
  }
 | 
						|
  api.updateScene({appState:{shouldCacheIgnoreZoom:false}});
 | 
						|
  busy = false;
 | 
						|
}
 | 
						|
 | 
						|
const navigate = async (dir) => {
 | 
						|
  const forward = dir === "fwd";
 | 
						|
  const prevSlide = slide;
 | 
						|
  const nextSlide = getNextSlide(forward);
 | 
						|
  
 | 
						|
  //exit if user navigates from last slide forward or first slide backward
 | 
						|
  const shouldExit = forward
 | 
						|
    ? slide<=prevSlide
 | 
						|
    : slide>=prevSlide;
 | 
						|
  if(shouldExit) {
 | 
						|
    exitPresentation();
 | 
						|
    return;
 | 
						|
  }
 | 
						|
  if(slideNumberEl) slideNumberEl.innerText = `${slide+1}/${slideCount+1}`;
 | 
						|
  const nextRect = getSlideRect(nextSlide);
 | 
						|
  await scrollToNextRect(nextRect);
 | 
						|
  if(settingsModal) {
 | 
						|
    slideNumberDropdown.setValue(`${slide}`.padStart(3,"0"));
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
//--------------------------
 | 
						|
// Settings Modal
 | 
						|
//--------------------------
 | 
						|
let settingsModal;
 | 
						|
let slideNumberDropdown;
 | 
						|
const presentationSettings = () => {
 | 
						|
	let dirty = false;
 | 
						|
	settingsModal = new ea.obsidian.Modal(app);
 | 
						|
 | 
						|
	const getSlideNumberLabel = (i) => {
 | 
						|
		switch(i) {
 | 
						|
		  case 0: return "1 - Start";
 | 
						|
		  case slideCount: return `${i+1} - End`;
 | 
						|
		  default: return `${i+1}`;
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
  const getSlidesList = () => {
 | 
						|
	  const options = {};
 | 
						|
	  for(i=0;i<=slideCount;i++) {
 | 
						|
	    options[`${i}`.padStart(3,"0")] = getSlideNumberLabel(i);
 | 
						|
	  }
 | 
						|
	  return options;
 | 
						|
	}
 | 
						|
 | 
						|
	settingsModal.onOpen = () => {
 | 
						|
		settingsModal.contentEl.createEl("h1",{text: "Slideshow Actions"});
 | 
						|
    settingsModal.contentEl.createEl("p",{text: "To open this window double click presentation script icon or press ENTER during presentation."});
 | 
						|
		new ea.obsidian.Setting(settingsModal.contentEl)
 | 
						|
		  .setName("Jump to slide")
 | 
						|
		  .addDropdown(dropdown => {
 | 
						|
        slideNumberDropdown = dropdown;
 | 
						|
        dropdown
 | 
						|
          .addOptions(getSlidesList()) 
 | 
						|
          .setValue(`${slide}`.padStart(3,"0"))
 | 
						|
          .onChange(value => {
 | 
						|
            slide = parseInt(value)-1;
 | 
						|
            navigate("fwd");
 | 
						|
          })
 | 
						|
      })
 | 
						|
    
 | 
						|
    if(!preventHideAction) {
 | 
						|
      new ea.obsidian.Setting(settingsModal.contentEl)
 | 
						|
        .setName("Hide navigation arrow after slideshow")
 | 
						|
        .setDesc("Toggle on: arrow hidden, toggle off: arrow visible")
 | 
						|
        .addToggle(toggle => toggle
 | 
						|
          .setValue(hidden)
 | 
						|
          .onChange(value => hideArrow(value))
 | 
						|
        )  
 | 
						|
    }
 | 
						|
 | 
						|
    new ea.obsidian.Setting(settingsModal.contentEl)
 | 
						|
		  .setName("Edit current slide")
 | 
						|
      .setDesc("Pressing 'e' during the presentation will open the current slide for editing.")
 | 
						|
		  .addButton(button => button
 | 
						|
		    .setButtonText("Edit")
 | 
						|
        .onClick(async ()=>{
 | 
						|
          await hideArrow(false);
 | 
						|
          exitPresentation(true);
 | 
						|
        })
 | 
						|
      )  
 | 
						|
	}
 | 
						|
	
 | 
						|
	settingsModal.onClose = () => {
 | 
						|
    setTimeout(()=>delete settingsModal);
 | 
						|
	}
 | 
						|
	
 | 
						|
	settingsModal.open();
 | 
						|
	contentEl.appendChild(settingsModal.containerEl);
 | 
						|
}
 | 
						|
 | 
						|
//--------------------------------------
 | 
						|
//Slideshow control
 | 
						|
//--------------------------------------
 | 
						|
let controlPanelEl;
 | 
						|
let slideNumberEl;
 | 
						|
const createNavigationPanel = () => {
 | 
						|
  //create slideshow controlpanel container
 | 
						|
  const top = contentEl.innerHeight; 
 | 
						|
  const left = contentEl.innerWidth; 
 | 
						|
  controlPanelEl = contentEl.createDiv({
 | 
						|
    cls: ["excalidraw","excalidraw-presentation-panel"],
 | 
						|
    attr: {
 | 
						|
      style: `
 | 
						|
        width: calc(var(--default-button-size)*3);
 | 
						|
        z-index:5;
 | 
						|
        position: absolute;
 | 
						|
        top:calc(${top}px - var(--default-button-size)*2);
 | 
						|
        left:calc(${left}px - var(--default-button-size)*3.5);`
 | 
						|
    }
 | 
						|
  });
 | 
						|
  const panelColumn = controlPanelEl.createDiv({
 | 
						|
    cls: "panelColumn",
 | 
						|
  });
 | 
						|
	panelColumn.createDiv({
 | 
						|
	  cls: ["Island", "buttonList"],
 | 
						|
	  attr: {
 | 
						|
	    style: `
 | 
						|
	      height: calc(var(--default-button-size)*1.5);
 | 
						|
	      width: 100%;
 | 
						|
	      background: var(--island-bg-color);`,
 | 
						|
	  }
 | 
						|
	}, el=>{
 | 
						|
	  el.createEl("button",{
 | 
						|
	    text: "<",
 | 
						|
	    attr: {
 | 
						|
	      style: `
 | 
						|
	        margin-top: calc(var(--default-button-size)*0.25);
 | 
						|
	        margin-left: calc(var(--default-button-size)*0.25);`
 | 
						|
	    }
 | 
						|
	  }, button => button .onclick = () => navigate("bkwd"));
 | 
						|
	  el.createEl("button",{
 | 
						|
	    text: ">",
 | 
						|
	    attr: {
 | 
						|
	      style: `
 | 
						|
	        margin-top: calc(var(--default-button-size)*0.25);
 | 
						|
	        margin-right: calc(var(--default-button-size)*0.25);`
 | 
						|
	    }
 | 
						|
	  }, button => button.onclick = () => navigate("fwd"));
 | 
						|
	  slideNumberEl = el.createEl("span",{
 | 
						|
		  text: "1",
 | 
						|
		  cls: ["ToolIcon__keybinding"],
 | 
						|
	  })
 | 
						|
	});
 | 
						|
}
 | 
						|
 | 
						|
//keyboard navigation
 | 
						|
const keydownListener = (e) => {
 | 
						|
  e.preventDefault();
 | 
						|
  switch(e.key) {
 | 
						|
    case "escape":
 | 
						|
      if(app.isMobile) exitPresentation();
 | 
						|
      break;
 | 
						|
    case "ArrowRight":
 | 
						|
    case "ArrowDown": 
 | 
						|
      navigate("fwd");
 | 
						|
      break;
 | 
						|
    case "ArrowLeft":
 | 
						|
    case "ArrowUp":
 | 
						|
      navigate("bkwd");
 | 
						|
      break;
 | 
						|
    case "Enter":
 | 
						|
      presentationSettings();
 | 
						|
      break;
 | 
						|
    case "End":
 | 
						|
      slide = slideCount - 1;
 | 
						|
      navigate("fwd");
 | 
						|
      break;
 | 
						|
    case "Home":
 | 
						|
      slide = -1;
 | 
						|
      navigate("fwd");
 | 
						|
      break;
 | 
						|
    case "e": 
 | 
						|
      (async ()=>{
 | 
						|
        await hideArrow(false);
 | 
						|
        exitPresentation(true);
 | 
						|
      })()
 | 
						|
      break;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
//slideshow panel drag
 | 
						|
let pos1 = pos2 = pos3 = pos4 = 0;
 | 
						|
 | 
						|
const updatePosition = (deltaY = 0, deltaX = 0) => {
 | 
						|
  const {
 | 
						|
    offsetTop,
 | 
						|
    offsetLeft,
 | 
						|
    clientWidth: width,
 | 
						|
    clientHeight: height,
 | 
						|
   } = controlPanelEl;
 | 
						|
  controlPanelEl.style.top = (offsetTop - deltaY) + 'px';
 | 
						|
  controlPanelEl.style.left = (offsetLeft - deltaX) + 'px';
 | 
						|
}
 | 
						|
   
 | 
						|
const pointerUp = () => {
 | 
						|
  win.removeEventListener('pointermove', onDrag, true);
 | 
						|
}
 | 
						|
 | 
						|
let dblClickTimer = 0;
 | 
						|
const pointerDown = (e) => {
 | 
						|
  const now = Date.now();
 | 
						|
  pos3 = e.clientX;
 | 
						|
  pos4 = e.clientY;
 | 
						|
  win.addEventListener('pointermove', onDrag, true);
 | 
						|
  if(now-dblClickTimer < 400) {
 | 
						|
    presentationSettings();
 | 
						|
  }
 | 
						|
  dblClickTimer = now;
 | 
						|
}
 | 
						|
 | 
						|
const onDrag = (e) => {
 | 
						|
  e.preventDefault();
 | 
						|
  pos1 = pos3 - e.clientX;
 | 
						|
  pos2 = pos4 - e.clientY;
 | 
						|
  pos3 = e.clientX;
 | 
						|
  pos4 = e.clientY;
 | 
						|
  updatePosition(pos2, pos1);
 | 
						|
}
 | 
						|
 | 
						|
const initializeEventListners = () => {
 | 
						|
	doc.addEventListener('keydown',keydownListener);
 | 
						|
  controlPanelEl.addEventListener('pointerdown', pointerDown, false);
 | 
						|
  win.addEventListener('pointerup', pointerUp, false);
 | 
						|
 | 
						|
	//event listners for terminating the presentation
 | 
						|
	window.removePresentationEventHandlers = () => {
 | 
						|
	  ea.onLinkClickHook = null;
 | 
						|
	  controlPanelEl.parentElement?.removeChild(controlPanelEl);
 | 
						|
	  if(!app.isMobile) win.removeEventListener('fullscreenchange', fullscreenListener);
 | 
						|
	  doc.removeEventListener('keydown',keydownListener);
 | 
						|
	  win.removeEventListener('pointerup',pointerUp);
 | 
						|
	  contentEl.querySelector(".layer-ui__wrapper")?.removeClass("excalidraw-hidden");
 | 
						|
	  delete window.removePresentationEventHandlers;
 | 
						|
	}
 | 
						|
 | 
						|
	ea.onLinkClickHook = () => {
 | 
						|
    exitPresentation();
 | 
						|
    return true;
 | 
						|
  };
 | 
						|
  
 | 
						|
  if(!app.isMobile) {
 | 
						|
    win.addEventListener('fullscreenchange', fullscreenListener);
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
const exitPresentation = async (openForEdit = false) => {
 | 
						|
  if(openForEdit) ea.targetView.preventAutozoom();
 | 
						|
  if(!app.isMobile) await doc.exitFullscreen();
 | 
						|
  if(app.isMobile) {
 | 
						|
    ea.viewToggleFullScreen(true);
 | 
						|
  } else {
 | 
						|
    ea.setViewModeEnabled(false);
 | 
						|
  }
 | 
						|
  if(settingsModal) settingsModal.close();
 | 
						|
  ea.clear();
 | 
						|
  ea.copyViewElementsToEAforEditing(ea.getViewElements().filter(el=>el.id === lineEl.id));
 | 
						|
  const el = ea.getElement(lineEl.id);
 | 
						|
  if(!hidden) {
 | 
						|
    el.strokeColor = originalProps.strokeColor;
 | 
						|
    el.backgroundProps = originalProps.backgroundColor;
 | 
						|
    el.locked = openForEdit ? false : originalProps.locked;
 | 
						|
  }
 | 
						|
  await ea.addElementsToView();
 | 
						|
  ea.selectElementsInView([el]);
 | 
						|
  if(openForEdit) {
 | 
						|
    const nextSlide = getNextSlide(--slide);
 | 
						|
    let nextRect = getSlideRect(nextSlide);
 | 
						|
    const offsetW = (nextRect.right-nextRect.left)*(1-EDIT_ZOOMOUT)/2;
 | 
						|
    const offsetH = (nextRect.bottom-nextRect.top)*(1-EDIT_ZOOMOUT)/2
 | 
						|
    nextRect = {
 | 
						|
      left: nextRect.left-offsetW,
 | 
						|
      right: nextRect.right+offsetW,
 | 
						|
      top: nextRect.top-offsetH,
 | 
						|
      bottom: nextRect.bottom+offsetH,
 | 
						|
      nextZoom: nextRect.nextZoom*EDIT_ZOOMOUT > 0.1 ? nextRect.nextZoom*EDIT_ZOOMOUT : 0.1 //0.1 is the minimu zoom value
 | 
						|
    };
 | 
						|
    await scrollToNextRect(nextRect,1);
 | 
						|
    api.startLineEditor(
 | 
						|
      ea.getViewSelectedElement(),
 | 
						|
      [slide*2,slide*2+1]
 | 
						|
    );
 | 
						|
  }
 | 
						|
  window.removePresentationEventHandlers?.();
 | 
						|
  setTimeout(()=>{
 | 
						|
    //Resets pointer offsets. Ugly solution. 
 | 
						|
    //During testing offsets were wrong after presentation, but don't know why.
 | 
						|
    //This should solve it even if they are wrong.
 | 
						|
    ea.targetView.refresh(); 
 | 
						|
  })
 | 
						|
}
 | 
						|
 | 
						|
const fullscreenListener = (e) => {
 | 
						|
  e.preventDefault();
 | 
						|
  exitPresentation();
 | 
						|
}
 | 
						|
 | 
						|
 | 
						|
//--------------------------
 | 
						|
// Start presentation or open presentation settings on double click
 | 
						|
//--------------------------
 | 
						|
const start = async () => {
 | 
						|
  await gotoFullscreen();
 | 
						|
  await hideArrow(hidden);
 | 
						|
  createNavigationPanel();
 | 
						|
  initializeEventListners();
 | 
						|
  //navigate to the first slide on start
 | 
						|
  setTimeout(()=>navigate("fwd"));
 | 
						|
}
 | 
						|
 | 
						|
const timestamp = Date.now();
 | 
						|
if(window.ExcalidrawSlideshow && (window.ExcalidrawSlideshow.script === utils.scriptFile.path) && (timestamp - window.ExcalidrawSlideshow.timestamp <400) ) {
 | 
						|
  if(window.ExcalidrawSlideshowStartTimer) {
 | 
						|
    clearTimeout(window.ExcalidrawSlideshowStartTimer);
 | 
						|
    delete window.ExcalidrawSlideshowStartTimer;
 | 
						|
  }
 | 
						|
  await start();
 | 
						|
  presentationSettings();
 | 
						|
} else {
 | 
						|
  window.ExcalidrawSlideshow = {
 | 
						|
    script: utils.scriptFile.path,
 | 
						|
    timestamp
 | 
						|
  };
 | 
						|
  window.ExcalidrawSlideshowStartTimer = setTimeout(start,500);
 | 
						|
} |