From f768548f60efad8b4e255a32a515146f8cc61e95 Mon Sep 17 00:00:00 2001 From: zsviczian Date: Mon, 22 Apr 2024 20:10:21 +0200 Subject: [PATCH] 2.1.5 --- manifest.json | 2 +- src/ExcalidrawAutomate.ts | 1 + src/ExcalidrawData.ts | 114 +++++++++-- src/ExcalidrawView.ts | 313 ++++++++++++++++++----------- src/dialogs/Messages.ts | 35 +++- src/dialogs/SelectCard.ts | 11 +- src/dialogs/SuggesterInfo.ts | 7 + src/lang/locale/en.ts | 3 +- src/main.ts | 4 +- src/menu/EmbeddableActionsMenu.tsx | 4 +- src/utils/DynamicStyling.ts | 4 + src/utils/GetElementAtPointer.ts | 2 +- src/utils/ObsidianUtils.ts | 8 +- src/utils/Utils.ts | 7 +- 14 files changed, 376 insertions(+), 139 deletions(-) diff --git a/manifest.json b/manifest.json index 9b15419..9d54ff1 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "obsidian-excalidraw-plugin", "name": "Excalidraw", - "version": "2.1.4", + "version": "2.1.5", "minAppVersion": "1.1.6", "description": "An Obsidian plugin to edit and view Excalidraw drawings", "author": "Zsolt Viczian", diff --git a/src/ExcalidrawAutomate.ts b/src/ExcalidrawAutomate.ts index b534500..3fa3928 100644 --- a/src/ExcalidrawAutomate.ts +++ b/src/ExcalidrawAutomate.ts @@ -601,6 +601,7 @@ export class ExcalidrawAutomate { "excalidraw-export-dark"?: boolean; "excalidraw-export-padding"?: number; "excalidraw-export-pngscale"?: number; + "excalidraw-export-embed-scene"?: boolean; "excalidraw-default-mode"?: "view" | "zen"; "excalidraw-onload-script"?: string; "excalidraw-linkbutton-opacity"?: number; diff --git a/src/ExcalidrawData.ts b/src/ExcalidrawData.ts index dc6cd52..13da581 100644 --- a/src/ExcalidrawData.ts +++ b/src/ExcalidrawData.ts @@ -51,6 +51,7 @@ import { BinaryFiles, DataURL, SceneData } from "@zsviczian/excalidraw/types/exc import { EmbeddedFile, MimeType } from "./EmbeddedFileLoader"; import { ConfirmationPrompt } from "./dialogs/Prompt"; import { getMermaidImageElements, getMermaidText, shouldRenderMermaid } from "./utils/MermaidUtils"; +import { add } from "@zsviczian/excalidraw/types/excalidraw/ga"; type SceneDataWithFiles = SceneData & { files: BinaryFiles }; @@ -240,7 +241,11 @@ const estimateMaxLineLen = (text: string, originalText: string): number => { const wrap = (text: string, lineLen: number) => lineLen ? wrapTextAtCharLength(text, lineLen, false, 0) : text; -const RE_TEXTELEMENTS = new RegExp(`^(%%\n)?${MD_TEXTELEMENTS}(?:\n|$)`, "m"); +//WITHSECTION refers to back of the card note (see this.inputEl.onkeyup in SelectCard.ts) +const RE_TEXTELEMENTS_WITHSECTION_OK = new RegExp(`^#\n%%\n${MD_TEXTELEMENTS}(?:\n|$)`, "m"); +const RE_TEXTELEMENTS_WITHSECTION_NOTOK = new RegExp(`#\n%%\n${MD_TEXTELEMENTS}(?:\n|$)`, "m"); +const RE_TEXTELEMENTS_NOSECTION_OK = new RegExp(`^(%%\n)?${MD_TEXTELEMENTS}(?:\n|$)`, "m"); + //The issue is that when editing in markdown embeds the user can delete the last enter causing two sections //to collide. This is particularly problematic when the user is editing the lest section before # Text Elements @@ -251,19 +256,70 @@ const RE_TEXTELEMENTS_FALLBACK_2 = new RegExp(`(.*)${MD_TEXTELEMENTS}(?:\n|$)`, const RE_DRAWING = new RegExp(`(%%\n)?${MD_DRAWING}\n`); export const getExcalidrawMarkdownHeaderSection = (data:string, keys?:[string,string][]):string => { - let trimLocation = data.search(RE_TEXTELEMENTS); + + /* Expected markdown structure: + bla bla bla + # + %% + # Text Elements + */ + let trimLocation = data.search(RE_TEXTELEMENTS_WITHSECTION_OK); + let shouldFixTrailingHashtag = false; + if(trimLocation > 0) { + trimLocation += 2; + } + + /* Expected markdown structure: + bla bla bla# + %% + # Text Elements + */ if(trimLocation === -1) { + trimLocation = data.search(RE_TEXTELEMENTS_WITHSECTION_NOTOK); + if(trimLocation > 0) { + shouldFixTrailingHashtag = true; + } + } + /* Expected markdown structure + a) + bla bla bla + %% + # Text Elements + b) + bla bla bla + # Text Elements + */ + if(trimLocation === -1) { + trimLocation = data.search(RE_TEXTELEMENTS_NOSECTION_OK); + } + /* Expected markdown structure: + bla bla bla%% + # Text Elements + */ + if(trimLocation === -1) { const res = data.match(RE_TEXTELEMENTS_FALLBACK_1); if(res && Boolean(res[1])) { trimLocation = res.index + res[1].length; } } + /* Expected markdown structure: + bla bla bla# Text Elements + */ if(trimLocation === -1) { const res = data.match(RE_TEXTELEMENTS_FALLBACK_2); if(res && Boolean(res[1])) { trimLocation = res.index + res[1].length; } - } + } + /* Expected markdown structure: + a) + bla bla bla + # Drawing + b) + bla bla bla + %% + # Drawing + */ if (trimLocation === -1) { trimLocation = data.search(RE_DRAWING); } @@ -278,7 +334,9 @@ export const getExcalidrawMarkdownHeaderSection = (data:string, keys?:[string,st header = header.replace(REG_IMG, "$1"); } //end of remove - return header.endsWith("\n") ? header : (header + "\n"); + return shouldFixTrailingHashtag + ? header + "\n#\n" + : header.endsWith("\n") ? header : (header + "\n"); } @@ -580,19 +638,36 @@ export class ExcalidrawData { data = data.substring(0, sceneJSONandPOS.pos); - //The Markdown # Text Elements take priority over the JSON text elements. Assuming the scenario in which the link was updated due to filename changes + //The Markdown # Text Elements take priority over the JSON text elements. Assuming the scenario in which the + //link was updated due to filename changes //The .excalidraw JSON is modified to reflect the MD in case of difference //Read the text elements into the textElements Map - let position = data.search(RE_TEXTELEMENTS); + let position = data.search(RE_TEXTELEMENTS_NOSECTION_OK); + if (position === -1) { + //resillience in case back of the note was saved right on top of text elements + // # back of note section + // ....# Text Elements + // .... + // -------------- + // instead of + // -------------- + // # back of note section + // .... + // # Text Elements + position = data.search(RE_TEXTELEMENTS_FALLBACK_2); + } if (position === -1) { await this.setTextMode(textMode, false); this.loaded = true; return true; //Text Elements header does not exist } - const textElementsMatch = data.match(new RegExp(`^((%%\n)?${MD_TEXTELEMENTS}(?:\n|$))`, "m"))[0] - position += textElementsMatch.length; - data = data.slice(position); + const normalMatch = data.match(new RegExp(`^((%%\n)?${MD_TEXTELEMENTS}(?:\n|$))`, "m")); + const textElementsMatch = normalMatch + ? normalMatch[0] + : data.match(new RegExp(`(.*${MD_TEXTELEMENTS}(?:\n|$))`, "m"))[0]; + + data = data.slice(textElementsMatch.length); this.textElementCommentedOut = textElementsMatch.startsWith("%%\n"); position = 0; let parts; @@ -1360,14 +1435,18 @@ export class ExcalidrawData { const processedIds = new Set(); fileIds.forEach((fileId,idx)=>{ if(processedIds.has(fileId)) { - const file = this.getFile(fileId); + const embeddedFile = this.getFile(fileId); const equation = this.getEquation(fileId); const mermaid = this.getMermaid(fileId); - - //images should have a single reference, but equations, and markdown embeds should have as many as instances of the file in the scene - if(file && (file.isHyperLink || file.isLocalLink || (file.file && (file.file.extension !== "md" || this.plugin.isExcalidrawFile(file.file))))) { + if (embeddedFile && + (embeddedFile.isHyperLink || embeddedFile.isLocalLink || + (embeddedFile.file && + (embeddedFile.file.extension !== "md" || this.plugin.isExcalidrawFile(embeddedFile.file)) + ) + ) + ) { return; } if(mermaid) { @@ -1379,6 +1458,11 @@ export class ExcalidrawData { return; } + if(!embeddedFile && !equation && !mermaid) { + //processing freshly pasted images from likely anotehr instance of excalidraw (e.g. Excalidraw.com, or another Obsidian instance) + return; + } + const newId = fileid(); (scene .elements @@ -1387,8 +1471,8 @@ export class ExcalidrawData { .fileId = newId; dirty = true; processedIds.add(newId); - if(file) { - this.setFile(newId as FileId,new EmbeddedFile(this.plugin,this.file.path,file.linkParts.original)); + if(embeddedFile) { + this.setFile(newId as FileId,new EmbeddedFile(this.plugin,this.file.path,embeddedFile.linkParts.original)); } if(equation) { this.setEquation(newId as FileId, {latex:equation.latex, isLoaded:false}); diff --git a/src/ExcalidrawView.ts b/src/ExcalidrawView.ts index 717de5e..da64a58 100644 --- a/src/ExcalidrawView.ts +++ b/src/ExcalidrawView.ts @@ -9,14 +9,12 @@ import { MarkdownView, request, requireApiVersion, - requestUrl, } from "obsidian"; //import * as React from "react"; //import * as ReactDOM from "react-dom"; //import Excalidraw from "@zsviczian/excalidraw"; import { ExcalidrawElement, - ExcalidrawGenericElement, ExcalidrawImageElement, ExcalidrawTextElement, FileId, @@ -82,12 +80,10 @@ import { } from "./utils/FileUtils"; import { checkExcalidrawVersion, - debug, embedFontsInSVG, errorlog, getEmbeddedFilenameParts, getExportTheme, - getLinkParts, getPNG, getPNGScale, getSVG, @@ -105,7 +101,7 @@ import { shouldEmbedScene, getContainerElement, } from "./utils/Utils"; -import { cleanSectionHeading, getLeaf, getParentOfClass, obsidianPDFQuoteWithRef, openLeaf } from "./utils/ObsidianUtils"; +import { cleanBlockRef, cleanSectionHeading, getLeaf, getParentOfClass, obsidianPDFQuoteWithRef, openLeaf } from "./utils/ObsidianUtils"; import { splitFolderAndFilename } from "./utils/FileUtils"; import { ConfirmationPrompt, GenericInputPrompt, NewFileActions, Prompt, linkPrompt } from "./dialogs/Prompt"; import { ClipboardData } from "@zsviczian/excalidraw/types/excalidraw/clipboard"; @@ -140,7 +136,9 @@ import { CustomMutationObserver, isDebugMode } from "./utils/DebugHelper"; import { extractCodeBlocks, postOpenAI } from "./utils/AIUtils"; import { Mutable } from "@zsviczian/excalidraw/types/excalidraw/utility-types"; import { SelectCard } from "./dialogs/SelectCard"; -import { link } from "fs"; + +const EMBEDDABLE_SEMAPHORE_TIMEOUT = 2000; +const PREVENT_RELOAD_TIMEOUT = 2000; declare const PLUGIN_VERSION:string; @@ -620,7 +618,7 @@ export default class ExcalidrawView extends TextFileView { public setPreventReload() { this.semaphores.preventReload = true; const self = this; - this.preventReloadResetTimer = setTimeout(()=>self.semaphores.preventReload = false,2000); + this.preventReloadResetTimer = setTimeout(()=>self.semaphores.preventReload = false,PREVENT_RELOAD_TIMEOUT); } public clearPreventReloadTimer() { @@ -647,7 +645,7 @@ export default class ExcalidrawView extends TextFileView { public clearEmbeddableIsEditingSelf() { const self = this; this.clearEmbeddableIsEditingSelfTimer(); - this.editingSelfResetTimer = setTimeout(()=>self.semaphores.embeddableIsEditingSelf = false,2000); + this.editingSelfResetTimer = setTimeout(()=>self.semaphores.embeddableIsEditingSelf = false,EMBEDDABLE_SEMAPHORE_TIMEOUT); } async save(preventReload: boolean = true, forcesave: boolean = false) { @@ -681,30 +679,28 @@ export default class ExcalidrawView extends TextFileView { } try { - const allowSave = Boolean ( - (this.semaphores.dirty !== null && this.semaphores.dirty) || - this.semaphores.autosaving || - forcesave - ); //dirty == false when view.file == null; - const scene = this.getScene(); - - if (this.compatibilityMode) { - await this.excalidrawData.syncElements(scene); - } else if ( - await this.excalidrawData.syncElements(scene, this.excalidrawAPI.getAppState().selectedElementIds) - && !this.semaphores.popoutUnload //Obsidian going black after REACT 18 migration when closing last leaf on popout - ) { - await this.loadDrawing( - false, - this.excalidrawAPI.getSceneElementsIncludingDeleted().filter((el:ExcalidrawElement)=>el.isDeleted) - ); - } + const allowSave = this.isDirty() || forcesave; //removed this.semaphores.autosaving + if(isDebugMode) console.log({allowSave, isDirty: this.isDirty(), autosaving: this.semaphores.autosaving, forcesave}); if (allowSave) { + const scene = this.getScene(); + + if (this.compatibilityMode) { + await this.excalidrawData.syncElements(scene); + } else if ( + await this.excalidrawData.syncElements(scene, this.excalidrawAPI.getAppState().selectedElementIds) + && !this.semaphores.popoutUnload //Obsidian going black after REACT 18 migration when closing last leaf on popout + ) { + await this.loadDrawing( + false, + this.excalidrawAPI.getSceneElementsIncludingDeleted().filter((el:ExcalidrawElement)=>el.isDeleted) + ); + } + //reload() is triggered indirectly when saving by the modifyEventHandler in main.ts //prevent reload is set here to override reload when not wanted: typically when the user is editing //and we do not want to interrupt the flow by reloading the drawing into the canvas. - + this.clearDirty(); this.clearPreventReloadTimer(); this.semaphores.preventReload = preventReload; @@ -717,7 +713,7 @@ export default class ExcalidrawView extends TextFileView { triggerReload = (this.lastSaveTimestamp === this.file.stat.mtime) && !preventReload && forcesave; this.lastSaveTimestamp = this.file.stat.mtime; - this.clearDirty(); + //this.clearDirty(); //moved to right after allow save, to avoid autosave collision with load drawing //https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/629 //there were odd cases when preventReload semaphore did not get cleared and consequently a synchronized image @@ -942,6 +938,46 @@ export default class ExcalidrawView extends TextFileView { return false; } + private getLinkTextForElement( + selectedText:SelectedElementWithLink, + selectedElementWithLink?:SelectedElementWithLink + ): { + linkText: string, + selectedElement: ExcalidrawElement, + } { + if (selectedText?.id || selectedElementWithLink?.id) { + const selectedTextElement: ExcalidrawTextElement = selectedText.id + ? this.excalidrawAPI.getSceneElements().find((el:ExcalidrawElement)=>el.id === selectedText.id) + : null; + + const selectedElement = selectedElementWithLink.id + ? this.excalidrawAPI.getSceneElements().find((el:ExcalidrawElement)=>el.id === selectedElementWithLink.id) + : null; + + let linkText = + selectedElementWithLink?.text ?? + (this.textMode === TextMode.parsed + ? this.excalidrawData.getRawText(selectedText.id) + : selectedText.text); + + const partsArray = REGEX_LINK.getResList(linkText); + if (!linkText || partsArray.length === 0) { + //the container link takes precedence over the text link + if(selectedTextElement?.containerId) { + const container = getContainerElement(selectedTextElement, {elements: this.excalidrawAPI.getSceneElements()}); + if(container) { + linkText = container.link; + } + } + if(!linkText || partsArray.length === 0) { + linkText = selectedTextElement?.link; + } + } + return {linkText, selectedElement: selectedTextElement ?? selectedElement}; + } + return {linkText: null, selectedElement: null}; + } + async linkClick( ev: MouseEvent | null, selectedText: SelectedElementWithLink, @@ -959,41 +995,16 @@ export default class ExcalidrawView extends TextFileView { let file = null; let subpath: string = null; - let linkText: string = null; - - if (selectedText?.id || selectedElementWithLink?.id) { - const selectedTextElement: ExcalidrawTextElement = selectedText.id - ? this.excalidrawAPI.getSceneElements().find((el:ExcalidrawElement)=>el.id === selectedText.id) - : null; - - linkText = - selectedElementWithLink?.text ?? - (this.textMode === TextMode.parsed - ? this.excalidrawData.getRawText(selectedText.id) - : selectedText.text); - - const partsArray = REGEX_LINK.getResList(linkText); - if (!linkText || partsArray.length === 0) { - //the container link takes precedence over the text link - if(selectedTextElement?.containerId) { - const container = getContainerElement(selectedTextElement, {elements: this.excalidrawAPI.getSceneElements()}); - if(container) { - linkText = container.link; - } - } - if(!linkText) { - linkText = selectedTextElement?.link; - } - } - - + let {linkText, selectedElement} = this.getLinkTextForElement(selectedText, selectedElementWithLink); + //if (selectedText?.id || selectedElementWithLink?.id) { + if (selectedElement) { if (!linkText) { return; } linkText = linkText.replaceAll("\n", ""); //https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/187 - if(this.handleLinkHookCall(selectedTextElement,linkText,ev)) return; + if(this.handleLinkHookCall(selectedElement,linkText,ev)) return; if(openExternalLink(linkText, this.app)) return; const result = await linkPrompt(linkText, this.app, this); @@ -1010,8 +1021,6 @@ export default class ExcalidrawView extends TextFileView { selectedImage.fileId, ).latex; GenericInputPrompt.Prompt(this,this.plugin,this.app,t("ENTER_LATEX"),undefined,equation, undefined, 3).then(async (formula: string) => { -// const prompt = new Prompt(this.app, t("ENTER_LATEX"), equation, ""); -// prompt.openAndGetValue(async (formula: string) => { if (!formula || formula === equation) { return; } @@ -1182,7 +1191,7 @@ export default class ExcalidrawView extends TextFileView { ? null : this.getSelectedImageElement(); const selectedElementWithLink = - selectedImage?.id || selectedText?.id + (selectedImage?.id || selectedText?.id) ? null : this.getSelectedElementWithLink(); this.linkClick( @@ -1336,7 +1345,7 @@ export default class ExcalidrawView extends TextFileView { const self = this; this.slidingPanesListner = () => { if (self.excalidrawAPI) { - self.refresh(); + self.refreshCanvasOffset(); } }; let rootSplit = this.app.workspace.rootSplit as WorkspaceItem as WorkspaceItemExt; @@ -1380,7 +1389,7 @@ export default class ExcalidrawView extends TextFileView { const { offsetLeft, offsetTop } = target; if (offsetLeft !== self.offsetLeft || offsetTop != self.offsetTop) { if (self.excalidrawAPI) { - self.refresh(); + self.refreshCanvasOffset(); } self.offsetLeft = offsetLeft; self.offsetTop = offsetTop; @@ -1474,19 +1483,19 @@ export default class ExcalidrawView extends TextFileView { return; } const st = api.getAppState(); - const editing = st.editingElement !== null; + const isEditing = st.editingElement !== null; + const isDragging = st.draggingElement !== null; //this will reset positioning of the cursor in case due to the popup keyboard, //or the command palette, or some other unexpected reason the onResize would not fire... - this.refresh(); + this.refreshCanvasOffset(); if ( - this.semaphores.dirty && - this.semaphores.dirty == this.file?.path && + this.isDirty() && this.plugin.settings.autosave && !this.semaphores.forceSaving && !this.semaphores.autosaving && !this.semaphores.embeddableIsEditingSelf && - !editing && - st.draggingElement === null //https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/630 + !isEditing && + !isDragging //https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/630 ) { //console.log("autosave"); this.autosaveTimer = null; @@ -1513,14 +1522,22 @@ export default class ExcalidrawView extends TextFileView { }; this.autosaveFunction = timer; + this.resetAutosaveTimer(); + } + + + private resetAutosaveTimer() { + if(!this.autosaveFunction) return; + if (this.autosaveTimer) { clearTimeout(this.autosaveTimer); this.autosaveTimer = null; } // clear previous timer if one exists this.autosaveTimer = setTimeout( - timer, + this.autosaveFunction, this.plugin.settings.autosaveInterval, ); + } //save current drawing when user closes workspace leaf @@ -1683,10 +1700,26 @@ export default class ExcalidrawView extends TextFileView { ) await sleep(50); //https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/734 } - const filenameParts = getEmbeddedFilenameParts(state.subpath); + const filenameParts = getEmbeddedFilenameParts( + (state.subpath && state.subpath.startsWith("#^group") && !state.subpath.startsWith("#^group=")) + ? "#^group=" + state.subpath.substring(7) + : (state.subpath && state.subpath.startsWith("#^area") && !state.subpath.startsWith("#^area=")) + ? "#^area=" + state.subpath.substring(6) + : state.subpath + ); if(filenameParts.hasBlockref) { setTimeout(async () => { await waitForExcalidraw(); + if(filenameParts.blockref && !filenameParts.hasGroupref) { + if(!self.getScene()?.elements.find(el=>el.id === filenameParts.blockref)) { + const cleanQuery = cleanSectionHeading(filenameParts.blockref).replaceAll(" ",""); + const blocks = await self.getBackOfTheNoteBlocks(); + if(blocks.includes(cleanQuery)) { + this.setMarkdownView(state); + return; + } + } + } setTimeout(()=>self.zoomToElementId(filenameParts.blockref, filenameParts.hasGroupref)); }); } @@ -1731,13 +1764,20 @@ export default class ExcalidrawView extends TextFileView { } } - self.selectElementsMatchingQuery( + if(!self.selectElementsMatchingQuery( elements, query, !api.getAppState().viewModeEnabled, filenameParts.hasSectionref, filenameParts.hasGroupref - ); + )) { + const cleanQuery = cleanSectionHeading(query[0]).replaceAll(" ",""); + const sections = await self.getBackOfTheNoteSections(); + if(sections.includes(cleanQuery)) { + self.setMarkdownView(state); + return; + } + } }); } @@ -2223,6 +2263,7 @@ export default class ExcalidrawView extends TextFileView { } public setDirty(debug?:number) { + if(this.semaphores.saving) return; //do not set dirty if saving if(isDebugMode) console.log(debug); this.semaphores.dirty = this.file?.path; this.diskIcon.querySelector("svg").addClass("excalidraw-dirty"); @@ -2237,6 +2278,10 @@ export default class ExcalidrawView extends TextFileView { } } + public isDirty() { + return Boolean(this.semaphores.dirty) && (this.semaphores.dirty === this.file?.path); + } + public clearDirty() { if(this.semaphores.viewunload) return; const api = this.excalidrawAPI; @@ -2302,17 +2347,17 @@ export default class ExcalidrawView extends TextFileView { return ICON_NAME; } - setMarkdownView() { + setMarkdownView(eState?: any) { this.plugin.excalidrawFileModes[this.id || this.file.path] = "markdown"; - this.plugin.setMarkdownView(this.leaf); + this.plugin.setMarkdownView(this.leaf, eState); } - public async openAsMarkdown() { + public async openAsMarkdown(eState?: any) { if (this.plugin.settings.compress === true) { this.excalidrawData.disableCompression = true; await this.save(true, true); } - this.setMarkdownView(); + this.setMarkdownView(eState); } public async convertExcalidrawToMD() { @@ -2891,6 +2936,7 @@ export default class ExcalidrawView extends TextFileView { currentStrokeOptions: st.currentStrokeOptions, previousGridSize: st.previousGridSize, frameRendering: st.frameRendering, + objectsSnapModeEnabled: st.objectsSnapModeEnabled, }, prevTextMode: this.prevTextMode, files, @@ -2901,7 +2947,7 @@ export default class ExcalidrawView extends TextFileView { * ExcalidrawAPI refreshes canvas offsets * @returns */ - private refresh() { + private refreshCanvasOffset() { if(this.contentEl.clientWidth === 0 || this.contentEl.clientHeight === 0) return; const api = this.excalidrawAPI; if (!api) { @@ -3002,41 +3048,52 @@ export default class ExcalidrawView extends TextFileView { if (!linktext) { if(!this.currentPosition) return; linktext = ""; - const selectedElement = getTextElementAtPointer(this.currentPosition, this); - if (!selectedElement || !selectedElement.text) { + const selectedEl = getTextElementAtPointer(this.currentPosition, this); + if (!selectedEl || !selectedEl.text) { const selectedImgElement = getImageElementAtPointer(this.currentPosition, this); + const selectedElementWithLink = (selectedImgElement?.id || selectedImgElement?.id) + ? null + : getElementWithLinkAtPointer(this.currentPosition, this); element = this.excalidrawAPI.getSceneElements().find((el:ExcalidrawElement)=>el.id === selectedImgElement.id); - if (!selectedImgElement || !selectedImgElement.fileId) { + if ((!selectedImgElement || !selectedImgElement.fileId) && !selectedElementWithLink?.id) { return; } - if (!this.excalidrawData.hasFile(selectedImgElement.fileId)) { - return; + if (selectedImgElement?.id) { + if (!this.excalidrawData.hasFile(selectedImgElement.fileId)) { + return; + } + const ef = this.excalidrawData.getFile(selectedImgElement.fileId); + if ( + (ef.isHyperLink || ef.isLocalLink) || //web images don't have a preview + (IMAGE_TYPES.contains(ef.file.extension)) || //images don't have a preview + (ef.file.extension.toLowerCase() === "pdf") || //pdfs don't have a preview + (this.plugin.ea.isExcalidrawFile(ef.file)) + ) {//excalidraw files don't have a preview + linktext = getLinkTextFromLink(element.link); + if(!linktext) return; + } else { + const ref = ef.linkParts.ref + ? `#${ef.linkParts.isBlockRef ? "^" : ""}${ef.linkParts.ref}` + : ""; + linktext = + ef.file.path + ref; + } } - const ef = this.excalidrawData.getFile(selectedImgElement.fileId); - if ( - (ef.isHyperLink || ef.isLocalLink) || //web images don't have a preview - (IMAGE_TYPES.contains(ef.file.extension)) || //images don't have a preview - (ef.file.extension.toLowerCase() === "pdf") || //pdfs don't have a preview - (this.plugin.ea.isExcalidrawFile(ef.file)) - ) {//excalidraw files don't have a preview - linktext = getLinkTextFromLink(element.link); + if (selectedElementWithLink?.id) { + linktext = getLinkTextFromLink(selectedElementWithLink.text); if(!linktext) return; - } else { - const ref = ef.linkParts.ref - ? `#${ef.linkParts.isBlockRef ? "^" : ""}${ef.linkParts.ref}` - : ""; - linktext = - ef.file.path + ref; } } else { - element = this.excalidrawAPI.getSceneElements().filter((el:ExcalidrawElement)=>el.id === selectedElement.id)[0]; - const text: string = - this.textMode === TextMode.parsed - ? this.excalidrawData.getRawText(selectedElement.id) - : selectedElement.text; + const {linkText, selectedElement} = this.getLinkTextForElement(selectedEl, selectedEl); + element = selectedElement; + /*this.excalidrawAPI.getSceneElements().filter((el:ExcalidrawElement)=>el.id === selectedElement.id)[0]; + const text: string = + this.textMode === TextMode.parsed + ? this.excalidrawData.getRawText(selectedElement.id) + : selectedElement.text;*/ - linktext = getLinkTextFromLink(text); + linktext = getLinkTextFromLink(linkText); if(!linktext) return; } } @@ -3381,7 +3438,7 @@ export default class ExcalidrawView extends TextFileView { } if (data.elements) { const self = this; - setTimeout(() => self.save(false), 300); + setTimeout(() => self.save(), 300); //removed prevent reload = false, as reload was triggered when pasted containers were processed and there was a conflict with the new elements } return true; } @@ -4052,6 +4109,19 @@ export default class ExcalidrawView extends TextFileView { } } + private async getBackOfTheNoteSections() { + return (await this.app.metadataCache.blockCache.getForFile({ isCancelled: () => false },this.file)) + .blocks.filter((b: any) => b.display && b.node?.type === "heading") + .filter((b: any) => !MD_EX_SECTIONS.includes(b.display)) + .map((b: any) => cleanSectionHeading(b.display)); + } + + private async getBackOfTheNoteBlocks() { + return (await this.app.metadataCache.blockCache.getForFile({ isCancelled: () => false },this.file)) + .blocks.filter((b:any) => b.display && b.node && b.node.hasOwnProperty("type") && b.node.hasOwnProperty("id")) + .map((b:any) => cleanBlockRef(b.node.id)); + } + public getSingleSelectedImage(): {imageEl: ExcalidrawImageElement, embeddedFile: EmbeddedFile} { if(!this.excalidrawAPI) return null; const els = this.getViewSelectedElements().filter(el=>el.type==="image"); @@ -4064,13 +4134,9 @@ export default class ExcalidrawView extends TextFileView { } public async insertBackOfTheNoteCard() { - const sections = (await this.app.metadataCache.blockCache - .getForFile({ isCancelled: () => false },this.file)) - .blocks.filter((b: any) => b.display && b.node?.type === "heading") - .filter((b: any) => !MD_EX_SECTIONS.includes(b.display)) - .map((b: any) => cleanSectionHeading(b.display)); + const sections = await this.getBackOfTheNoteSections(); const selectCardDialog = new SelectCard(this.app,this,sections); - selectCardDialog.start(); + selectCardDialog.start(); } public async convertImageElWithURLToLocalFile(data: {imageEl: ExcalidrawImageElement, embeddedFile: EmbeddedFile}) { @@ -4176,6 +4242,7 @@ export default class ExcalidrawView extends TextFileView { const onContextMenu = (elements: readonly ExcalidrawElement[], appState: AppState, onClose: (callback?: () => void) => void) => { const contextMenuActions = []; const api = this.excalidrawAPI as ExcalidrawImperativeAPI; + const areElementsSelected = Object.keys(api.getAppState().selectedElementIds).length>0 if(this.isLinkSelected()) { contextMenuActions.push([ @@ -4290,6 +4357,17 @@ export default class ExcalidrawView extends TextFileView { ]); } + if(areElementsSelected) { + contextMenuActions.push([ + renderContextMenuAction( + t("COPY_ELEMENT_LINK"), + () => { + this.copyLinkToSelectedElementToClipboard(""); + }, + onClose + ), + ]); + } contextMenuActions.push([ renderContextMenuAction( t("INSERT_CARD"), @@ -4670,7 +4748,7 @@ export default class ExcalidrawView extends TextFileView { this.toolsPanelRef.current.updatePosition(); } if(this.ownerDocument !== document) { - this.refresh(); //because resizeobserver in Excalidraw does not seem to work when in Obsidian Window + this.refreshCanvasOffset(); //because resizeobserver in Excalidraw does not seem to work when in Obsidian Window } } catch (err) { errorlog({ @@ -4909,13 +4987,23 @@ export default class ExcalidrawView extends TextFileView { this.plugin.saveSettings(); } + /** + * + * @param elements + * @param query + * @param selectResult + * @param exactMatch + * @param selectGroup + * @returns true if element found, false if no element is found. + */ + public selectElementsMatchingQuery( elements: ExcalidrawElement[], query: string[], selectResult: boolean = true, exactMatch: boolean = false, //https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/530 selectGroup: boolean = false, - ) { + ):boolean { let match = getTextElementsMatchingQuery( elements.filter((el: ExcalidrawElement) => el.type === "text"), query, @@ -4928,7 +5016,7 @@ export default class ExcalidrawView extends TextFileView { if (match.length === 0) { new Notice("I could not find a matching text element"); - return; + return false; } if(selectGroup) { @@ -4939,6 +5027,7 @@ export default class ExcalidrawView extends TextFileView { } this.zoomToElements(selectResult,match); + return true; } public zoomToElements( diff --git a/src/dialogs/Messages.ts b/src/dialogs/Messages.ts index fb1e73e..0abaf5f 100644 --- a/src/dialogs/Messages.ts +++ b/src/dialogs/Messages.ts @@ -17,6 +17,39 @@ I develop this plugin as a hobby, spending my free time doing this. If you find
`, +"2.1.5":` +## New +- Save "Snap to objects" with the scene state. If this is the only change you make to the scene, force save it using CTRL+S (note, use CTRL on Mac as well). +- Added "Copy markdown link" to the context menu. + +## Fixed +- Paste operation occasionally duplicated text elements. +- Pasting multiple instances of the same image from excalidraw.com or another instance of Obsidian, or pasting an image from anywhere and making copies with ALT/OPT + drag immediately after pasting (before autosave triggered) led to broken images when reopening the drawing. +- CTRL/CMD+Click on a Text Element with an element link did not work (previously, you had to click the top right link indicator). Now, you can click anywhere on the element. +- Hover preview for elements with a link only worked when hovering over the element link. Now, you can hover anywhere. If there are multiple elements with links, the top-level element will take precedence. +- Link navigation within drawing when the "Focus on Existing Tab" feature is enabled under "Links, transclusion and TODOs" in settings works again. +- If a link points to a back-of-the-card section or block the drawing will automatically switch to markdown view mode and navigate to the block or section. +- DynamicSytle, dark mode when canvas background is set to transparent. +- Scale to maintain the aspect ratio of a markdown notes embedded as images. +- You can now borrow interactive markdown embeds to tables, blockquotes, list elements and callouts - not just paragraphs. +- Back of the drawing cards: + - Leaving the Section Name empty when creating the first back of the card note resulted in an error. + - If you add the markdown comment (${String.fromCharCode(96)}%%${String.fromCharCode(96)}) directly before ${String.fromCharCode(96)}# Text Elements${String.fromCharCode(96)}, a trailing ${String.fromCharCode(96)}#${String.fromCharCode(96)} will be added to your document, when adding a back of the card note. This is to hide the markdown comment from the card. The trailing (empty) ${String.fromCharCode(96)}#${String.fromCharCode(96)} will not be visible in reading mode, pdf exports, and when publishing with Obsidian Publish. +Here's a sample markdown structure of your document: + +${String.fromCharCode(96,96,96)}markdown +--- +excalidraw-plugin: parsed +--- +# Your back of the card section +bla bla bla + +# +%% +# Text Elements +... the rest of the Excalidraw file +${String.fromCharCode(96,96,96)} +`, "2.1.4":` ## Fixed - Fixed the **aspect ratio** of an Excalidraw embedded within another Excalidraw **not updating**. [#1707](https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/1707) @@ -33,7 +66,7 @@ I develop this plugin as a hobby, spending my free time doing this. If you find - **Enhanced annotation and cropping** of images in Markdown documents: - Newly embedded **links will now follow the style of the original link**. If the original format was a ${String.fromCharCode(96)}![markdown](link)${String.fromCharCode(96)}, the annotated file will follow this format. For ${String.fromCharCode(96)}[[wiki links]]${String.fromCharCode(96)}, it will follow that style. Additionally, if an alias was specified like ${String.fromCharCode(96)}[[link|alias]]${String.fromCharCode(96)}, the annotated or cropped image will retain the alias. - Introduced a new setting under "Saving" titled **"Preserve image size when annotating"**. This setting is disabled by default. When enabled, the embed link replacing the annotated image will maintain the size of the original image. -- Option to **automaticaly embed the scene in exported PNG and SVG image files**. Including the scene will allow users to open the picture on Excalidraw.com or in another Obsidian Vault as an editable Excalidraw file.New setting is under the Export category. The new frontmatter tag is: ${String.fromCharCode(96)}excalidraw-export-embed-scene: true/false${String.fromCharCode(96)}. +- Option to **automatically embed the scene in exported PNG and SVG image files**. Including the scene will allow users to open the picture on Excalidraw.com or in another Obsidian Vault as an editable Excalidraw file.New setting is under the Export category. The new frontmatter tag is: ${String.fromCharCode(96)}excalidraw-export-embed-scene: true/false${String.fromCharCode(96)}. `, "2.1.3":` This is a republish of 2.1.2 with a minor change. Sorry about the frequent releases. I will hold back for a few weeks now. diff --git a/src/dialogs/SelectCard.ts b/src/dialogs/SelectCard.ts index 8e28771..7cf8ab8 100644 --- a/src/dialogs/SelectCard.ts +++ b/src/dialogs/SelectCard.ts @@ -5,7 +5,6 @@ import { getEA } from "src"; import { ExcalidrawAutomate } from "src/ExcalidrawAutomate"; import { getExcalidrawMarkdownHeaderSection } from "src/ExcalidrawData"; import { MD_EX_SECTIONS } from "src/constants/constants"; -import { ExcalidrawImageElement } from "@zsviczian/excalidraw/types/excalidraw/element/types"; import { ExcalidrawImperativeAPI } from "@zsviczian/excalidraw/types/excalidraw/types"; import { cleanSectionHeading } from "src/utils/ObsidianUtils"; @@ -29,7 +28,7 @@ export class SelectCard extends FuzzySuggestModal { if (e.key == "Enter") { if (this.containerEl.innerText.includes(t("EMPTY_SECTION_MESSAGE"))) { const item = this.inputEl.value; - if(MD_EX_SECTIONS.includes(item)) { + if(item === "" || MD_EX_SECTIONS.includes(item)) { new Notice(t("INVALID_SECTION_NAME")); this.close(); return; @@ -37,7 +36,13 @@ export class SelectCard extends FuzzySuggestModal { (async () => { const data = view.data; const header = getExcalidrawMarkdownHeaderSection(data); - view.data = data.replace(header, header + `\n# ${item}\n\n`); + const body = data.split(header)[1]; + const shouldAddHashtag = body && body.startsWith("%%"); + const shouldRemoveTrailingHashtag = header.endsWith("#\n"); + view.data = data.replace( + header, + (shouldRemoveTrailingHashtag ? header.substring(0,header.length-2) : header) + + `\n# ${item}\n\n${shouldAddHashtag ? "#\n" : ""}`); await view.forceSave(true); let watchdog = 0; await sleep(200); diff --git a/src/dialogs/SuggesterInfo.ts b/src/dialogs/SuggesterInfo.ts index 477c84a..35b15ad 100644 --- a/src/dialogs/SuggesterInfo.ts +++ b/src/dialogs/SuggesterInfo.ts @@ -197,6 +197,7 @@ export const EXCALIDRAW_AUTOMATE_INFO: SuggesterInfo[] = [ ' "excalidraw-export-dark"?: boolean;\n' + ' "excalidraw-export-padding"?: number;\n' + ' "excalidraw-export-pngscale"?: number;\n' + + ' "excalidraw-export-embed-scene"?: boolean;\n' + ' "excalidraw-default-mode"?: "view" | "zen";\n' + ' "excalidraw-onload-script"?: string;\n' + ' "excalidraw-linkbutton-opacity"?: number;\n' + @@ -821,6 +822,12 @@ export const FRONTMATTER_KEYS_INFO: SuggesterInfo[] = [ desc: "If this key is present it will override the default excalidraw embed and export setting. This only affects export to PNG. Specify the export scale for the image. The typical range is between 0.5 and 5, but you can experiment with other values as well.", after: ": 1", }, + { + field: "excalidraw-export-embed-scene", + code: null, + desc: "If this key is present it will override the default excalidraw embed and export setting.", + after: ": false", + }, { field: "open-md", code: null, diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts index 4f0b195..c6412e1 100644 --- a/src/lang/locale/en.ts +++ b/src/lang/locale/en.ts @@ -48,6 +48,7 @@ export default { NEW_IN_POPOUT_WINDOW_EMBED: "Create new drawing - IN A POPOUT WINDOW - and embed into active document", TOGGLE_LOCK: "Toggle Text Element between edit RAW and PREVIEW", DELETE_FILE: "Delete selected image or Markdown file from Obsidian Vault", + COPY_ELEMENT_LINK: "Copy markdown link for selected element(s)", INSERT_LINK_TO_ELEMENT: `Copy markdown link for selected element to clipboard. ${labelCTRL()}+CLICK to copy 'group=' link. ${labelSHIFT()}+CLICK to copy an 'area=' link. ${labelALT()}+CLICK to watch a help video.`, INSERT_LINK_TO_ELEMENT_GROUP: @@ -684,7 +685,7 @@ FILENAME_HEAD: "Filename", SELECT_SECTION_OR_TYPE_NEW: "Select existing section or type name of a new section then press Enter.", INVALID_SECTION_NAME: "Invalid section name.", - EMPTY_SECTION_MESSAGE: "Hit enter to create a new Section", + EMPTY_SECTION_MESSAGE: "Type the Section Name and hit enter to create a new Section", //EmbeddedFileLoader.ts INFINITE_LOOP_WARNING: diff --git a/src/main.ts b/src/main.ts index 8cc3fc1..6eb1c59 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3193,7 +3193,7 @@ export default class ExcalidrawPlugin extends Plugin { return file.path; } - public async setMarkdownView(leaf: WorkspaceLeaf) { + public async setMarkdownView(leaf: WorkspaceLeaf, eState?: any) { const state = leaf.view.getState(); //Note v2.0.19: I have absolutely no idea why I thought this is necessary. Removing this. @@ -3209,7 +3209,7 @@ export default class ExcalidrawPlugin extends Plugin { state, popstate: true, } as ViewState, - { focus: true }, + eState ? eState : { focus: true }, ); } diff --git a/src/menu/EmbeddableActionsMenu.tsx b/src/menu/EmbeddableActionsMenu.tsx index c7306b3..96fc0b2 100644 --- a/src/menu/EmbeddableActionsMenu.tsx +++ b/src/menu/EmbeddableActionsMenu.tsx @@ -172,7 +172,9 @@ export class EmbeddableMenu { view.updateScene({appState: {activeEmbeddable: null}}); const paragraphs = (await app.metadataCache.blockCache .getForFile({ isCancelled: () => false },file)) - .blocks.filter((b: any) => b.display && b.node?.type === "paragraph"); + .blocks.filter((b: any) => b.display && b.node && + (b.node.type === "paragraph" || b.node.type === "blockquote" || b.node.type === "listItem" || b.node.type === "table" || b.node.type === "callout") + ); const values = ["entire-file"].concat(paragraphs); const display = [t("SHOW_ENTIRE_FILE")].concat( paragraphs.map((b: any) => `${b.node?.id ? `#^${b.node.id}: ` : ``}${b.display.trim()}`)); diff --git a/src/utils/DynamicStyling.ts b/src/utils/DynamicStyling.ts index 3e50c41..4ba6b78 100644 --- a/src/utils/DynamicStyling.ts +++ b/src/utils/DynamicStyling.ts @@ -32,6 +32,10 @@ export const setDynamicStyle = ( view?.excalidrawAPI?.getAppState?.()?.theme === "light" || view?.excalidrawData?.scene?.appState?.theme === "light"; + if (color==="transparent") { + color = "#ffffff"; + } + const darker = "#101010"; const lighter = "#f0f0f0"; const step = 10; diff --git a/src/utils/GetElementAtPointer.ts b/src/utils/GetElementAtPointer.ts index 2ad8b4a..583fff0 100644 --- a/src/utils/GetElementAtPointer.ts +++ b/src/utils/GetElementAtPointer.ts @@ -22,7 +22,7 @@ export const getElementsAtPointer = ( y <= pointer.y && y + h >= pointer.y ); - }); + }).reverse(); }; export const getTextElementAtPointer = (pointer: any, view: ExcalidrawView) => { diff --git a/src/utils/ObsidianUtils.ts b/src/utils/ObsidianUtils.ts index 927c845..328c43a 100644 --- a/src/utils/ObsidianUtils.ts +++ b/src/utils/ObsidianUtils.ts @@ -285,7 +285,13 @@ export const openLeaf = ({ leaf = l; } }); - if(leaf) return {leaf, promise: Promise.resolve()}; + if(leaf) { + if(openState) { + const promise = leaf.openFile(file, openState); + return {leaf, promise}; + } + return {leaf, promise: Promise.resolve()}; + } } leaf = fnGetLeaf(); const promise = leaf.openFile(file, openState); diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index ded83b7..a297a64 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -19,6 +19,7 @@ import { IMAGE_TYPES, FRONTMATTER_KEYS, EXCALIDRAW_PLUGIN, + getCommonBoundingBox, } from "../constants/constants"; import ExcalidrawPlugin from "../main"; import { ExcalidrawElement } from "@zsviczian/excalidraw/types/excalidraw/element/types"; @@ -157,6 +158,10 @@ const rotate = ( export const rotatedDimensions = ( element: ExcalidrawElement, ): [number, number, number, number] => { + const bb = getCommonBoundingBox([element]); + return [bb.minX, bb.minY, bb.maxX - bb.minX, bb.maxY - bb.minY]; + + //removed with 2.1.5... will delete later if (element.angle === 0) { return [element.x, element.y, element.width, element.height]; } @@ -450,7 +455,7 @@ export const scaleLoadedImage = ( if(!ef) return false; const file = EXCALIDRAW_PLUGIN.app.vault.getAbstractFileByPath(ef.path.replace(/#.*$/,"").replace(/\|.*$/,"")); if(!file || (file instanceof TFolder)) return false; - return EXCALIDRAW_PLUGIN.isExcalidrawFile(file as TFile) + return (file as TFile).extension==="md" || EXCALIDRAW_PLUGIN.isExcalidrawFile(file as TFile) })) { const [w_image, h_image] = [f.size.width, f.size.height]; const imageAspectRatio = f.size.width / f.size.height;