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