import { settings } from "cluster"; 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 { embedFontsInSVG, getIMGFilename, isObsidianThemeDark, splitFolderAndFilename, svgToBase64 } from "./Utils"; 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; export const initializeMarkdownPostProcessor = (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); if (!(f && f instanceof TFile)) { return null; } file = f; } const exportSettings: ExportSettings = { withBackground: plugin.settings.exportWithBackground, withTheme: plugin.settings.exportWithTheme, }; const img = createEl("img"); let style = `max-width:${imgAttributes.fwidth}px !important; width:100%;`; if (imgAttributes.fheight) { style += `height:${imgAttributes.fheight}px;`; } img.setAttribute("style", style); img.addClass(imgAttributes.style); const theme = 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); let scale = 1; if (width >= 600) { scale = 2; } if (width >= 1200) { scale = 3; } if (width >= 1800) { scale = 4; } if (width >= 2400) { scale = 5; } const png = await createPNG( file.path, scale, exportSettings, loader, theme, null, null, [], plugin, ); //const png = await getPNG(JSON_parse(scene),exportSettings, scale); if (!png) { return null; } img.src = URL.createObjectURL(png); return img; } const svgSnapshot = ( await createSVG( file.path, true, exportSettings, loader, theme, null, null, [], plugin, ) ).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); 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.file.path); if (attr.fwidth) { el.setAttribute("w", attr.fwidth); } if (attr.fheight) { el.setAttribute("h", attr.fheight); } el.onClickEvent((ev) => { if ( ev.target instanceof Element && ev.target.tagName.toLowerCase() != "img" ) { return; } const src = el.getAttribute("src"); if (src) { plugin.openDrawing( vault.getAbstractFileByPath(src) as TFile, ev[CTRL_OR_CMD], ); } //.ctrlKey||ev.metaKey); }); 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 processInternalEmbeds = async ( embeddedItems:NodeListOf|[HTMLElement], ctx: MarkdownPostProcessorContext, ) => { //if not, then we are processing a non-excalidraw file in reading mode //in that cases embedded files will be displayed in an .internal-embed container const attr: imgElementAttributes = { fname: "", fheight: "", fwidth: "", style: "", }; let alt: string; let parts; let file: TFile; //Iterating through all the containers to check which one is an excalidraw drawing //This is a for loop instead of embeddedItems.forEach() because createImageDiv 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 attr.fname = maybeDrawing.getAttribute("src"); file = metadataCache.getFirstLinkpathDest( attr.fname?.split("#")[0], 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)) { attr.fwidth = maybeDrawing.getAttribute("width") ? maybeDrawing.getAttribute("width") : plugin.settings.width; attr.fheight = maybeDrawing.getAttribute("height"); alt = maybeDrawing.getAttribute("alt"); if (alt == attr.fname) { alt = ""; } //when the filename starts with numbers followed by a space Obsidian recognizes the filename as alt-text attr.style = "excalidraw-svg"; if (alt) { //for some reason Obsidian renders ![]() in a DIV and ![[]] in a SPAN //also the alt-text of the DIV does not include the alt-text of the image //thus need to add an additional "|" character when its a SPAN if (maybeDrawing.tagName.toLowerCase() == "span") { alt = `|${alt}`; } //1:width, 2:height, 3:style 1 2 3 parts = alt.match(/[^\|]*\|?(\d*%?)x?(\d*%?)\|?(.*)/); attr.fwidth = parts[1] ? parts[1] : plugin.settings.width; attr.fheight = parts[2]; if (parts[3] != attr.fname) { attr.style = `excalidraw-svg${parts[3] ? `-${parts[3]}` : ""}`; } } attr.fname = file?.path; attr.file = file; const div = await createImageDiv(attr); maybeDrawing.parentElement.replaceChild(div, maybeDrawing); } } } const tmpObsidianWYSIWYG = async ( el: HTMLElement, ctx: MarkdownPostProcessorContext, ) => { if (!ctx.frontmatter) { return; } if (!ctx.frontmatter.hasOwnProperty("excalidraw-plugin")) { return; } //@ts-ignore if (ctx.remainingNestLevel < 4) { return; } if (!el.querySelector(".frontmatter")) { el.style.display = "none"; return; } const attr: imgElementAttributes = { fname: ctx.sourcePath, fheight: "", fwidth: plugin.settings.width, style: "excalidraw-svg", }; attr.file = metadataCache.getFirstLinkpathDest( ctx.sourcePath, "", ); el.empty(); if(!plugin.settings.experimentalLivePreview) { el.appendChild(await createImageDiv(attr)); return; } const div = createDiv(); el.appendChild(div); //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 ()=>{ let internalEmbedDiv:HTMLElement = div; while(!internalEmbedDiv.hasClass("internal-embed") && internalEmbedDiv.parentElement) { internalEmbedDiv = internalEmbedDiv.parentElement; } if(!internalEmbedDiv.hasClass("internal-embed")) { el.empty(); el.appendChild(await createImageDiv(attr)); return; } internalEmbedDiv.empty(); const basename = splitFolderAndFilename(attr.fname).basename; const setAttr = () => { const hasWidth = internalEmbedDiv.getAttribute("width")!==""; const hasHeight = internalEmbedDiv.getAttribute("height")!==""; if(hasWidth) attr.fwidth = internalEmbedDiv.getAttribute("width"); if(hasHeight) attr.fheight = internalEmbedDiv.getAttribute("height"); const alt = internalEmbedDiv.getAttribute("alt"); const hasAttr = alt && alt!=="" && alt!==basename; if(hasAttr) { //1:width, 2:height, 3:style 1 2 3 const parts = alt.match(/(\d*%?)x?(\d*%?)\|?(.*)/); attr.fwidth = parts[1] ? parts[1] : plugin.settings.width; attr.fheight = parts[2]; if (parts[3] != attr.fname) { attr.style = `excalidraw-svg${parts[3] ? `-${parts[3]}` : ""}`; } } if(!hasWidth && !hasHeight && !hasAttr) { attr.fheight = ""; attr.fwidth = plugin.settings.width; attr.style = "excalidraw-svg"; } } const createImgElement = async () => { setAttr(); const imgDiv = await createImageDiv(attr); internalEmbedDiv.appendChild(imgDiv); } await createImgElement(); //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(() => { timer = null; setAttr(); internalEmbedDiv.empty(); createImgElement(); },500); }); observer.observe(internalEmbedDiv, { attributes: true, //configure it to listen to attribute changes }); },300); }; /** * * @param el * @param ctx */ export const markdownPostProcessor = async ( el: HTMLElement, ctx: MarkdownPostProcessorContext, ) => { //check to see if we are rendering in editing mode of 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 if (ctx.frontmatter?.hasOwnProperty("excalidraw-plugin")) { el.style.display = "none"; return; } await processInternalEmbeds( embeddedItems,ctx); }; /** * internal-link quick preview * @param e * @returns */ export const hoverEvent = (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 = 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].className != "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], ); } //.ctrlKey||ev.metaKey); }); }); node.appendChild(div); });