Files
obsidian-excalidraw-plugin/src/MarkdownPostProcessor.ts
Zsolt Viczian b292ca0fa3 1.5.15
2022-01-07 19:34:24 +01:00

482 lines
14 KiB
TypeScript

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<HTMLElement>} - the IMG HTML element containing the image
*/
const getIMG = async (
imgAttributes: imgElementAttributes,
): Promise<HTMLElement> => {
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<HTMLDivElement> => {
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<Element>|[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);
});