From da89e32213be8cb21ec8e0705ab5d5f8bcbac3dc Mon Sep 17 00:00:00 2001 From: zsviczian Date: Sat, 17 Dec 2022 14:04:46 +0100 Subject: [PATCH] 1.8.6 --- manifest.json | 4 +- src/MarkdownPostProcessor.ts | 9 +- src/MarkdownPostProcessor_Legacy.ts | 572 ++++++++++++++++++++++++++++ src/dialogs/Messages.ts | 7 +- src/main.ts | 23 +- 5 files changed, 602 insertions(+), 13 deletions(-) create mode 100644 src/MarkdownPostProcessor_Legacy.ts diff --git a/manifest.json b/manifest.json index 8473876..a59b598 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "id": "obsidian-excalidraw-plugin", "name": "Excalidraw", - "version": "1.8.5", - "minAppVersion": "1.1.0", + "version": "1.8.6", + "minAppVersion": "1.0.0", "description": "An Obsidian plugin to edit and view Excalidraw drawings", "author": "Zsolt Viczian", "authorUrl": "https://zsolt.blog", diff --git a/src/MarkdownPostProcessor.ts b/src/MarkdownPostProcessor.ts index 83adb9b..d768c76 100644 --- a/src/MarkdownPostProcessor.ts +++ b/src/MarkdownPostProcessor.ts @@ -21,7 +21,6 @@ import { svgToBase64, } from "./utils/Utils"; import { isObsidianThemeDark } from "./utils/ObsidianUtils"; -import { image } from "html2canvas/dist/types/css/types/image"; interface imgElementAttributes { file?: TFile; @@ -80,7 +79,7 @@ const getIMG = async ( withTheme: forceTheme ? true : plugin.settings.exportWithTheme, }; const img = createEl("img"); - let style = `max-width:${imgAttributes.fwidth}px; width:100%;`; //removed !important https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/886 + let style = `max-width:${imgAttributes.fwidth}${imgAttributes.fwidth.match(/\d$/) ? "px":""}; width:100%;`; //removed !important https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/886 if (imgAttributes.fheight) { style += `height:${imgAttributes.fheight}px;`; } @@ -399,15 +398,13 @@ const tmpObsidianWYSIWYG = async ( const markdownEmbed = internalEmbedDiv.hasClass("markdown-embed"); const markdownReadingView = internalEmbedDiv.hasClass("markdown-reading-view"); - if (!internalEmbedDiv.hasClass("internal-embed") && - ( markdownEmbed || markdownReadingView) - ) { + if (!internalEmbedDiv.hasClass("internal-embed") && (markdownEmbed || markdownReadingView)) { //We are processing the markdown preview of an actual Excalidraw file //the excalidraw file in markdown preview mode const isFrontmatterDiv = Boolean(el.querySelector(".frontmatter")); el.empty(); if(!isFrontmatterDiv) { - containerEl.removeChild(el); + if(el.parentElement === containerEl) containerEl.removeChild(el); return; } internalEmbedDiv.empty(); diff --git a/src/MarkdownPostProcessor_Legacy.ts b/src/MarkdownPostProcessor_Legacy.ts new file mode 100644 index 0000000..8248bea --- /dev/null +++ b/src/MarkdownPostProcessor_Legacy.ts @@ -0,0 +1,572 @@ +import { + MarkdownPostProcessorContext, + MetadataCache, + TFile, + Vault, +} from "obsidian"; +import { CTRL_OR_CMD, RERENDER_EVENT } from "./Constants"; +import { EmbeddedFilesLoader } from "./EmbeddedFileLoader"; +import { createPNG, createSVG } from "./ExcalidrawAutomate"; +import { ExportSettings } from "./ExcalidrawView"; +import ExcalidrawPlugin from "./main"; +import {getIMGFilename,} from "./utils/FileUtils"; +import { + embedFontsInSVG, + getEmbeddedFilenameParts, + getExportTheme, + getQuickImagePreview, + getExportPadding, + getWithBackground, + hasExportTheme, + svgToBase64, +} from "./utils/Utils"; +import { isObsidianThemeDark } from "./utils/ObsidianUtils"; + +interface imgElementAttributes { + file?: TFile; + fname: string; //Excalidraw filename + fwidth: string; //Display width of image + fheight: string; //Display height of image + style: string; //css style to apply to IMG element +} + +let plugin: ExcalidrawPlugin; +let vault: Vault; +let metadataCache: MetadataCache; + +const getDefaultWidth = (plugin: ExcalidrawPlugin): string => { + const width = parseInt(plugin.settings.width); + if (isNaN(width) || width === 0 || width === null) { + return "400"; + } + return plugin.settings.width; +}; + +export const initializeMarkdownPostProcessor_Legacy = (p: ExcalidrawPlugin) => { + plugin = p; + vault = p.app.vault; + metadataCache = p.app.metadataCache; +}; + +/** + * Generates an img element with the drawing encoded as a base64 SVG or a PNG (depending on settings) + * @param parts {imgElementAttributes} - display properties of the image + * @returns {Promise} - the IMG HTML element containing the image + */ +const getIMG = async ( + imgAttributes: imgElementAttributes, +): Promise => { + let file = imgAttributes.file; + if (!imgAttributes.file) { + const f = vault.getAbstractFileByPath(imgAttributes.fname?.split("#")[0]); + if (!(f && f instanceof TFile)) { + return null; + } + file = f; + } + + const filenameParts = getEmbeddedFilenameParts(imgAttributes.fname); + + // https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/387 + imgAttributes.style = imgAttributes.style.replaceAll(" ", "-"); + + const forceTheme = hasExportTheme(plugin, file) + ? getExportTheme(plugin, file, "light") + : undefined; + + const exportSettings: ExportSettings = { + withBackground: getWithBackground(plugin, file), + withTheme: forceTheme ? true : plugin.settings.exportWithTheme, + }; + const img = createEl("img"); + let style = `max-width:${imgAttributes.fwidth}px; width:100%;`; //removed !important https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/886 + if (imgAttributes.fheight) { + style += `height:${imgAttributes.fheight}px;`; + } + img.setAttribute("style", style); + img.addClass(imgAttributes.style); + + const theme = + forceTheme ?? + (plugin.settings.previewMatchObsidianTheme + ? isObsidianThemeDark() + ? "dark" + : "light" + : !plugin.settings.exportWithTheme + ? "light" + : undefined); + if (theme) { + exportSettings.withTheme = true; + } + const loader = new EmbeddedFilesLoader( + plugin, + theme ? theme === "dark" : undefined, + ); + + if (!plugin.settings.displaySVGInPreview) { + const width = parseInt(imgAttributes.fwidth); + const scale = width >= 2400 + ? 5 + : width >= 1800 + ? 4 + : width >= 1200 + ? 3 + : width >= 600 + ? 2 + : 1; + + //In case of PNG I cannot change the viewBox to select the area of the element + //being referenced. For PNG only the group reference works + const quickPNG = !filenameParts.hasGroupref + ? await getQuickImagePreview(plugin, file.path, "png") + : undefined; + + const png = + quickPNG ?? + (await createPNG( + filenameParts.hasGroupref + ? filenameParts.filepath + filenameParts.linkpartReference + : file.path, + scale, + exportSettings, + loader, + theme, + null, + null, + [], + plugin, + 0 + )); + if (!png) { + return null; + } + img.src = URL.createObjectURL(png); + return img; + } + + if(!(filenameParts.hasBlockref || filenameParts.hasSectionref)) { + const quickSVG = await getQuickImagePreview(plugin, file.path, "svg"); + if (quickSVG) { + img.setAttribute("src", svgToBase64(quickSVG)); + return img; + } + } + const svgSnapshot = ( + await createSVG( + filenameParts.hasGroupref || filenameParts.hasBlockref || filenameParts.hasSectionref + ? filenameParts.filepath + filenameParts.linkpartReference + : file.path, + true, + exportSettings, + loader, + theme, + null, + null, + [], + plugin, + 0, + getExportPadding(plugin, file), + ) + ).outerHTML; + let svg: SVGSVGElement = null; + const el = document.createElement("div"); + el.innerHTML = svgSnapshot; + const firstChild = el.firstChild; + if (firstChild instanceof SVGSVGElement) { + svg = firstChild; + } + if (!svg) { + return null; + } + svg = embedFontsInSVG(svg, plugin); + svg.removeAttribute("width"); + svg.removeAttribute("height"); + img.setAttribute("src", svgToBase64(svg.outerHTML)); + return img; +}; + +const createImageDiv = async ( + attr: imgElementAttributes, +): Promise => { + const img = await getIMG(attr); + return createDiv(attr.style, (el) => { + el.append(img); + el.setAttribute("src", attr.fname); + if (attr.fwidth) { + el.setAttribute("w", attr.fwidth); + } + if (attr.fheight) { + el.setAttribute("h", attr.fheight); + } + let timer:NodeJS.Timeout; + const clickEvent = (ev:PointerEvent) => { + if ( + ev.target instanceof Element && + ev.target.tagName.toLowerCase() != "img" + ) { + return; + } + const src = el.getAttribute("src"); + if (src) { + const srcParts = src.match(/([^#]*)(.*)/); + if(!srcParts) return; + plugin.openDrawing( + vault.getAbstractFileByPath(srcParts[1]) as TFile, + ev[CTRL_OR_CMD] + ? "new-pane" + : (ev.metaKey && !app.isMobile) + ? "popout-window" + : "active-pane", + true, + srcParts[2], + ); + } //.ctrlKey||ev.metaKey); + }; + el.addEventListener("pointerdown",(ev)=>{ + timer = setTimeout(()=>clickEvent(ev),500); + }); + el.addEventListener("pointerup",()=>{ + if(timer) clearTimeout(timer); + timer = null; + }) + el.addEventListener("dblclick",clickEvent); + el.addEventListener(RERENDER_EVENT, async (e) => { + e.stopPropagation(); + el.empty(); + const img = await getIMG({ + fname: el.getAttribute("src"), + fwidth: el.getAttribute("w"), + fheight: el.getAttribute("h"), + style: el.getAttribute("class"), + }); + el.append(img); + }); + }); +}; + +const processReadingMode = async ( + embeddedItems: NodeListOf | [HTMLElement], + ctx: MarkdownPostProcessorContext, +) => { + //We are processing a non-excalidraw file in reading mode + //Embedded files will be displayed in an .internal-embed container + + //Iterating all the containers in the file to check which one is an excalidraw drawing + //This is a for loop instead of embeddedItems.forEach() because processInternalEmbed at the end + //is awaited, otherwise excalidraw images would not display in the Kanban plugin + for (const maybeDrawing of embeddedItems) { + //check to see if the file in the src attribute exists + const fname = maybeDrawing.getAttribute("src")?.split("#")[0]; + if(!fname) continue; + + const file = metadataCache.getFirstLinkpathDest(fname, ctx.sourcePath); + + //if the embeddedFile exits and it is an Excalidraw file + //then lets replace the .internal-embed with the generated PNG or SVG image + if (file && file instanceof TFile && plugin.isExcalidrawFile(file)) { + if(isTextOnlyEmbed(maybeDrawing)) { + //legacy reference to a block or section as text + //should be embedded as legacy text + continue; + } + + maybeDrawing.parentElement.replaceChild( + await processInternalEmbed(maybeDrawing,file), + maybeDrawing + ); + } + } +}; + +const processInternalEmbed = async (internalEmbedEl: Element, file: TFile ):Promise => { + const attr: imgElementAttributes = { + fname: "", + fheight: "", + fwidth: "", + style: "", + }; + + const src = internalEmbedEl.getAttribute("src"); + if(!src) return; + attr.fwidth = internalEmbedEl.getAttribute("width") + ? internalEmbedEl.getAttribute("width") + : getDefaultWidth(plugin); + attr.fheight = internalEmbedEl.getAttribute("height"); + let alt = internalEmbedEl.getAttribute("alt"); + attr.style = "excalidraw-svg"; + processAltText(src.split("#")[0],alt,attr); + const fnameParts = getEmbeddedFilenameParts(src); + attr.fname = file?.path + (fnameParts.hasBlockref||fnameParts.hasSectionref?fnameParts.linkpartReference:""); + attr.file = file; + return await createImageDiv(attr); +} + +const processAltText = ( + fname: string, + alt:string, + attr: imgElementAttributes +) => { + if (alt && !alt.startsWith(fname)) { + //2:width, 3:height, 4:style 12 3 4 + const parts = alt.match(/[^\|\d]*\|?((\d*%?)x?(\d*%?))?\|?(.*)/); + attr.fwidth = parts[2] ?? attr.fwidth; + attr.fheight = parts[3] ?? attr.fheight; + if (parts[4] && !parts[4].startsWith(fname)) { + attr.style = `excalidraw-svg${`-${parts[4]}`}`; + } + if ( + (!parts[4] || parts[4]==="") && + (!parts[2] || parts[2]==="") && + parts[0] && parts[0] !== "" + ) { + attr.style = `excalidraw-svg${`-${parts[0]}`}`; + } + } +} + +const isTextOnlyEmbed = (internalEmbedEl: Element):boolean => { + const src = internalEmbedEl.getAttribute("src"); + if(!src) return true; //technically this does not mean this is a text only embed, but still should abort further processing + const fnameParts = getEmbeddedFilenameParts(src); + return !(fnameParts.hasArearef || fnameParts.hasGroupref) && + (fnameParts.hasBlockref || fnameParts.hasSectionref) +} + +const tmpObsidianWYSIWYG = async ( + el: HTMLElement, + ctx: MarkdownPostProcessorContext, +) => { + const file = app.vault.getAbstractFileByPath(ctx.sourcePath); + if(!(file instanceof TFile)) return; + if(!plugin.isExcalidrawFile(file)) return; + + //@ts-ignore + if (ctx.remainingNestLevel < 4) { + return; + } + + //The timeout gives time for Obsidian to attach el to the displayed document + //Once the element is attached, I can traverse up the dom tree to find .internal-embed + //If internal embed is not found, it means the that the excalidraw.md file + //is being rendered in "reading" mode. In that case, the image with the default width + //specified in setting should be displayed + //if .internal-embed is found, then contents is replaced with the image using the + //alt, width, and height attributes of .internal-embed to size and style the image + setTimeout(async () => { + //wait for el to be attached to the displayed document + let counter = 0; + while(!el.parentElement && counter++<=50) await sleep(50); + if(!el.parentElement) return; + + let internalEmbedDiv: HTMLElement = el; + while ( + !internalEmbedDiv.hasClass("dataview") && + !internalEmbedDiv.hasClass("cm-preview-code-block") && + !internalEmbedDiv.hasClass("cm-embed-block") && + !internalEmbedDiv.hasClass("internal-embed") && + internalEmbedDiv.parentElement + ) { + internalEmbedDiv = internalEmbedDiv.parentElement; + } + + if( + internalEmbedDiv.hasClass("dataview") || + internalEmbedDiv.hasClass("cm-preview-code-block") || + internalEmbedDiv.hasClass("cm-embed-block") + ) { + return; //https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/835 + } + + const attr: imgElementAttributes = { + fname: ctx.sourcePath, + fheight: "", + fwidth: getDefaultWidth(plugin), + style: "excalidraw-svg", + }; + + attr.file = file; + + if (!internalEmbedDiv.hasClass("internal-embed")) { + //We are processing the markdown preview of an actual Excalidraw file + //This could be in a hover preview of the file + //Or the file could be in markdown mode and the user switched markdown + //view of the drawing to reading mode + el.empty(); + const mdPreviewSection = el.parentElement; + if(!mdPreviewSection.hasClass("markdown-preview-section")) return; + if(mdPreviewSection.hasAttribute("ready")) { + mdPreviewSection.removeChild(el); + return; + } + mdPreviewSection.setAttribute("ready",""); + const imgDiv = await createImageDiv(attr); + el.appendChild(imgDiv); + return; + } + + if(isTextOnlyEmbed(internalEmbedDiv)) { + //legacy reference to a block or section as text + //should be embedded as legacy text + return; + } + + el.empty(); + + if(internalEmbedDiv.hasAttribute("ready")) { + return; + } + internalEmbedDiv.setAttribute("ready",""); + + internalEmbedDiv.empty(); + const imgDiv = await processInternalEmbed(internalEmbedDiv,file); + internalEmbedDiv.appendChild(imgDiv); + + //timer to avoid the image flickering when the user is typing + let timer: NodeJS.Timeout = null; + const observer = new MutationObserver((m) => { + if (!["alt", "width", "height"].contains(m[0]?.attributeName)) { + return; + } + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(async () => { + timer = null; + internalEmbedDiv.empty(); + const imgDiv = await processInternalEmbed(internalEmbedDiv,file); + internalEmbedDiv.appendChild(imgDiv); + }, 500); + }); + observer.observe(internalEmbedDiv, { + attributes: true, //configure it to listen to attribute changes + }); + }); +}; + +/** + * + * @param el + * @param ctx + */ +export const markdownPostProcessor_Legacy = async ( + el: HTMLElement, + ctx: MarkdownPostProcessorContext, +) => { + + //check to see if we are rendering in editing mode or live preview + //if yes, then there should be no .internal-embed containers + const embeddedItems = el.querySelectorAll(".internal-embed"); + if (embeddedItems.length === 0) { + tmpObsidianWYSIWYG(el, ctx); + return; + } + + //If the file being processed is an excalidraw file, + //then I want to hide all embedded items as these will be + //transcluded text element or some other transcluded content inside the Excalidraw file + //in reading mode these elements should be hidden + const excalidrawFile = Boolean(ctx.frontmatter?.hasOwnProperty("excalidraw-plugin")); + if (excalidrawFile) { + el.style.display = "none"; + return; + } + + await processReadingMode(embeddedItems, ctx); +}; + +/** + * internal-link quick preview + * @param e + * @returns + */ +export const hoverEvent_Legacy = (e: any) => { + if (!e.linktext) { + plugin.hover.linkText = null; + return; + } + plugin.hover.linkText = e.linktext; + plugin.hover.sourcePath = e.sourcePath; +}; + +//monitoring for div.popover.hover-popover.file-embed.is-loaded to be added to the DOM tree +export const observer_Legacy = new MutationObserver(async (m) => { + if (m.length == 0) { + return; + } + if (!plugin.hover.linkText) { + return; + } + const file = metadataCache.getFirstLinkpathDest( + plugin.hover.linkText, + plugin.hover.sourcePath ? plugin.hover.sourcePath : "", + ); + if (!file) { + return; + } + if (!(file instanceof TFile)) { + return; + } + if (file.extension !== "excalidraw") { + return; + } + + const svgFileName = getIMGFilename(file.path, "svg"); + const svgFile = vault.getAbstractFileByPath(svgFileName); + if (svgFile && svgFile instanceof TFile) { + return; + } //If auto export SVG or PNG is enabled it will be inserted at the top of the excalidraw file. No need to manually insert hover preview + + const pngFileName = getIMGFilename(file.path, "png"); + const pngFile = vault.getAbstractFileByPath(pngFileName); + if (pngFile && pngFile instanceof TFile) { + return; + } //If auto export SVG or PNG is enabled it will be inserted at the top of the excalidraw file. No need to manually insert hover preview + + if (!plugin.hover.linkText) { + return; + } + if (m.length != 1) { + return; + } + if (m[0].addedNodes.length != 1) { + return; + } + if ( + //@ts-ignore + !m[0].addedNodes[0].classNames != + "popover hover-popover file-embed is-loaded" + ) { + return; + } + const node = m[0].addedNodes[0]; + node.empty(); + + //this div will be on top of original DIV. By stopping the propagation of the click + //I prevent the default Obsidian feature of openning the link in the native app + const img = await getIMG({ + file, + fname: file.path, + fwidth: "300", + fheight: null, + style: "excalidraw-svg", + }); + const div = createDiv("", async (el) => { + el.appendChild(img); + el.setAttribute("src", file.path); + el.onClickEvent((ev) => { + ev.stopImmediatePropagation(); + const src = el.getAttribute("src"); + if (src) { + plugin.openDrawing( + vault.getAbstractFileByPath(src) as TFile, + ev[CTRL_OR_CMD] + ? "new-pane" + : (ev.metaKey && !app.isMobile) + ? "popout-window" + : "active-pane", + ); + } //.ctrlKey||ev.metaKey); + }); + }); + node.appendChild(div); +}); \ No newline at end of file diff --git a/src/dialogs/Messages.ts b/src/dialogs/Messages.ts index 6c02cd3..051b9c4 100644 --- a/src/dialogs/Messages.ts +++ b/src/dialogs/Messages.ts @@ -17,7 +17,7 @@ I develop this plugin as a hobby, spending my free time doing this. If you find
`, -"1.8.5":` +"1.8.6":` ## New from Excalidraw.com: - Better default radius for rectangles [#5553](https://github.com/excalidraw/excalidraw/pull/5553). Existing drawings will look unchanged, this applies only to new rectangles. ![image|200](https://user-images.githubusercontent.com/5153846/206264345-59fd7436-e87b-4bc9-ade8-9e6f6a6fd8c1.png) @@ -34,8 +34,9 @@ I develop this plugin as a hobby, spending my free time doing this. If you find > DEFAULT_ADAPTIVE_RADIUS = 32; ## New -- Improved embedding into Obsidian Canvas -- Improved embedding into Markdown documents +- For Obsidian 1.1.6 and above + - Improved embedding into Obsidian Canvas + - Improved embedding into Markdown documents - Added setting under ${String.fromCharCode(96)}Display/Default mode when opening Excalidraw${String.fromCharCode(96)} to always open the drawing in view mode on Mobile, but in normal mode on desktop. [#939](https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/939) ## Fixed diff --git a/src/main.ts b/src/main.ts index d060f97..47dbecb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,7 +16,8 @@ import { request, MetadataCache, FrontMatterCache, - Command + Command, + requireApiVersion } from "obsidian"; import { BLANK_DRAWING, @@ -96,6 +97,7 @@ import { markdownPostProcessor, observer, } from "./MarkdownPostProcessor"; + import { FieldSuggester } from "./dialogs/FieldSuggester"; import { ReleaseNotes } from "./dialogs/ReleaseNotes"; import { decompressFromBase64 } from "lz-string"; @@ -104,6 +106,7 @@ import * as React from "react"; import { ScriptInstallPrompt } from "./dialogs/ScriptInstallPrompt"; import { check } from "prettier"; import Taskbone from "./ocr/Taskbone"; +import { hoverEvent_Legacy, initializeMarkdownPostProcessor_Legacy, markdownPostProcessor_Legacy, observer_Legacy } from "./MarkdownPostProcessor_Legacy"; declare module "obsidian" { @@ -238,7 +241,11 @@ export default class ExcalidrawPlugin extends Plugin { //Compatibility mode with .excalidraw files this.registerExtensions(["excalidraw"], VIEW_TYPE_EXCALIDRAW); - this.addMarkdownPostProcessor(); + if(requireApiVersion("1.1.6")) { + this.addMarkdownPostProcessor(); + } else { + this.addLegacyMarkdownPostProcessor(); + } this.registerInstallCodeblockProcessor(); this.addThemeObserver(); this.experimentalFileTypeDisplayToggle(this.settings.experimentalFileType); @@ -608,6 +615,18 @@ export default class ExcalidrawPlugin extends Plugin { this.observer.observe(document, { childList: true, subtree: true }); } + private addLegacyMarkdownPostProcessor() { + initializeMarkdownPostProcessor_Legacy(this); + this.registerMarkdownPostProcessor(markdownPostProcessor_Legacy); + + // internal-link quick preview + this.registerEvent(this.app.workspace.on("hover-link", hoverEvent_Legacy)); + + //monitoring for div.popover.hover-popover.file-embed.is-loaded to be added to the DOM tree + this.observer = observer_Legacy; + this.observer.observe(document, { childList: true, subtree: true }); + } + private addThemeObserver() { this.themeObserver = new MutationObserver(async (m: MutationRecord[]) => { if (!this.settings.matchThemeTrigger) {