oscar.plaisant@icloud.com 38fbb1938d from github to this gitea
2023-10-23 23:09:51 +02:00

14 KiB

/* The script will convert your drawing into a slideshow presentation.

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