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);
 | |
| } |