import { App, Notice, request,requestUrl, TFile, TFolder, } from "obsidian"; import { Random } from "roughjs/bin/math"; import { BinaryFileData, DataURL} from "@zsviczian/excalidraw/types/excalidraw/types"; import { exportToSvg, exportToBlob, IMAGE_TYPES, FRONTMATTER_KEYS, EXCALIDRAW_PLUGIN, getCommonBoundingBox, DEVICE, getContainerElement, } from "../constants/constants"; import ExcalidrawPlugin from "../main"; import { ExcalidrawElement, ExcalidrawTextElement } from "@zsviczian/excalidraw/types/excalidraw/element/types"; import { ExportSettings } from "../ExcalidrawView"; import { getDataURLFromURL, getIMGFilename, getMimeType, getURLImageExtension } from "./FileUtils"; import { generateEmbeddableLink } from "./CustomEmbeddableUtils"; import { FILENAMEPARTS } from "./UtilTypes"; import { Mutable } from "@zsviczian/excalidraw/types/excalidraw/utility-types"; import { cleanBlockRef, cleanSectionHeading, getFileCSSClasses } from "./ObsidianUtils"; import { updateElementLinksToObsidianLinks } from "src/ExcalidrawAutomate"; import { CropImage } from "./CropImage"; import opentype from 'opentype.js'; import { runCompressionWorker } from "src/workers/compression-worker"; declare const PLUGIN_VERSION:string; declare var LZString: any; declare module "obsidian" { interface Workspace { getAdjacentLeafInDirection( leaf: WorkspaceLeaf, direction: string, ): WorkspaceLeaf; } interface Vault { getConfig(option: "attachmentFolderPath"): string; } } export let versionUpdateCheckTimer: number = null; let versionUpdateChecked = false; export async function checkExcalidrawVersion() { if (versionUpdateChecked) { return; } versionUpdateChecked = true; try { const gitAPIrequest = async () => { return JSON.parse( await request({ url: `https://api.github.com/repos/zsviczian/obsidian-excalidraw-plugin/releases?per_page=15&page=1`, }), ); }; const latestVersion = (await gitAPIrequest()) .filter((el: any) => !el.draft && !el.prerelease) .map((el: any) => { return { version: el.tag_name, published: new Date(el.published_at), }; }) .filter((el: any) => el.version.match(/^\d+\.\d+\.\d+$/)) .sort((el1: any, el2: any) => el2.published - el1.published)[0].version; if (isVersionNewerThanOther(latestVersion,PLUGIN_VERSION)) { new Notice( `A newer version of Excalidraw is available in Community Plugins.\n\nYou are using ${PLUGIN_VERSION}.\nThe latest is ${latestVersion}`, ); } } catch (e) { errorlog({ where: "Utils/checkExcalidrawVersion", error: e }); } versionUpdateCheckTimer = window.setTimeout(() => { versionUpdateChecked = false; versionUpdateCheckTimer = null; }, 28800000); //reset after 8 hours }; const random = new Random(Date.now()); export function randomInteger () { return Math.floor(random.next() * 2 ** 31) }; //https://macromates.com/blog/2006/wrapping-text-with-regular-expressions/ export function wrapTextAtCharLength( text: string, lineLen: number, forceWrap: boolean = false, tolerance: number = 0, ): string { if (!lineLen) { return text; } let outstring = ""; if (forceWrap) { for (const t of text.split("\n")) { const v = t.match(new RegExp(`(.){1,${lineLen}}`, "g")); outstring += v ? `${v.join("\n")}\n` : "\n"; } return outstring.replace(/\n$/, ""); } // 1 2 3 4 const reg = new RegExp( `(.{1,${lineLen}})(\\s+|$\\n?)|([^\\s]{1,${ lineLen + tolerance }})(\\s+|$\\n?)?`, //`(.{1,${lineLen}})(\\s+|$\\n?)|([^\\s]+)(\\s+|$\\n?)`, "gm", ); const res = text.matchAll(reg); let parts; while (!(parts = res.next()).done) { outstring += parts.value[1] ? parts.value[1].trimEnd() : parts.value[3].trimEnd(); const newLine = (parts.value[2] ? parts.value[2].split("\n").length - 1 : 0) + (parts.value[4] ? parts.value[4].split("\n").length - 1 : 0); outstring += "\n".repeat(newLine); if (newLine === 0) { outstring += "\n"; } } return outstring.replace(/\n$/, ""); } const rotate = ( pointX: number, pointY: number, centerX: number, centerY: number, angle: number, ): [number, number] => // π‘Žβ€²π‘₯=(π‘Žπ‘₯βˆ’π‘π‘₯)cosπœƒβˆ’(π‘Žπ‘¦βˆ’π‘π‘¦)sinπœƒ+𝑐π‘₯ // π‘Žβ€²π‘¦=(π‘Žπ‘₯βˆ’π‘π‘₯)sinπœƒ+(π‘Žπ‘¦βˆ’π‘π‘¦)cosπœƒ+𝑐𝑦. // https://math.stackexchange.com/questions/2204520/how-do-i-rotate-a-line-segment-in-a-specific-point-on-the-line [ (pointX - centerX) * Math.cos(angle) - (pointY - centerY) * Math.sin(angle) + centerX, (pointX - centerX) * Math.sin(angle) + (pointY - centerY) * Math.cos(angle) + centerY, ]; export function rotatedDimensions ( element: ExcalidrawElement, ): [number, number, number, number] { const bb = getCommonBoundingBox([element]); return [bb.minX, bb.minY, bb.maxX - bb.minX, bb.maxY - bb.minY]; }; export async function getDataURL( file: ArrayBuffer, mimeType: string, ): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const dataURL = reader.result as DataURL; resolve(dataURL); }; reader.onerror = (error) => reject(error); reader.readAsDataURL(new Blob([new Uint8Array(file)], { type: mimeType })); }); }; export async function getFontDataURL ( app: App, fontFileName: string, sourcePath: string, name?: string, ): Promise<{ fontDef: string; fontName: string; dataURL: string }> { let fontDef: string = ""; let fontName = ""; let dataURL = ""; const f = app.metadataCache.getFirstLinkpathDest(fontFileName, sourcePath); if (f) { const ab = await app.vault.readBinary(f); let mimeType = ""; let format = ""; switch (f.extension) { case "woff": mimeType = "application/font-woff"; format = "woff"; break; case "woff2": mimeType = "font/woff2"; format = "woff2"; break; case "ttf": mimeType = "font/ttf"; format = "truetype"; break; case "otf": mimeType = "font/otf"; format = "opentype"; break; default: mimeType = "application/octet-stream"; // Fallback if file type is unexpected } fontName = name ?? f.basename; dataURL = await getDataURL(ab, mimeType); const split = dataURL.split(";base64,", 2); dataURL = `${split[0]};charset=utf-8;base64,${split[1]}`; fontDef = ` @font-face {font-family: "${fontName}";src: url("${dataURL}") format("${format}")}`; /* const mimeType = f.extension.startsWith("woff") ? "application/font-woff" : "font/truetype"; fontName = name ?? f.basename; dataURL = await getDataURL(ab, mimeType); fontDef = ` @font-face {font-family: "${fontName}";src: url("${dataURL}")}`; //format("${f.extension === "ttf" ? "truetype" : f.extension}");}`; const split = fontDef.split(";base64,", 2); fontDef = `${split[0]};charset=utf-8;base64,${split[1]}`;*/ } return { fontDef, fontName, dataURL }; }; export function base64StringToBlob (base64String: string, mimeType: string): Blob { const buffer = Buffer.from(base64String, 'base64'); return new Blob([buffer], { type: mimeType }); }; export function svgToBase64 (svg: string): string { return `data:image/svg+xml;base64,${btoa( unescape(encodeURIComponent(svg.replaceAll(" ", " "))), )}`; }; export async function getBinaryFileFromDataURL (dataURL: string): Promise { if (!dataURL) { return null; } if(dataURL.match(/^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/i)) { const hyperlink = dataURL; const extension = getURLImageExtension(hyperlink) const mimeType = getMimeType(extension); dataURL = await getDataURLFromURL(hyperlink, mimeType) } const parts = dataURL.matchAll(/base64,(.*)/g).next(); if (!parts.value) { return null; } const binary_string = window.atob(parts.value[1]); const len = binary_string.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binary_string.charCodeAt(i); } return bytes.buffer; }; export async function getSVG ( scene: any, exportSettings: ExportSettings, padding: number, srcFile: TFile|null, //if set, will replace markdown links with obsidian links ): Promise { let elements:ExcalidrawElement[] = scene.elements; if(elements.some(el => el.type === "embeddable")) { elements = JSON.parse(JSON.stringify(elements)); elements.filter(el => el.type === "embeddable").forEach((el:any) => { el.link = generateEmbeddableLink(el.link, scene.appState?.theme ?? "light"); }); } elements = srcFile ? updateElementLinksToObsidianLinks({ elements, hostFile: srcFile, }) : elements; try { let svg: SVGSVGElement; if(exportSettings.isMask) { const cropObject = new CropImage(elements, scene.files); svg = await cropObject.getCroppedSVG(); cropObject.destroy(); } else { svg = await exportToSvg({ elements: elements.filter((el:ExcalidrawElement)=>el.isDeleted !== true), appState: { ...scene.appState, exportBackground: exportSettings.withBackground, exportWithDarkMode: exportSettings.withTheme ? scene.appState?.theme !== "light" : false, ...exportSettings.frameRendering ? {frameRendering: exportSettings.frameRendering} : {}, }, files: scene.files, exportPadding: exportSettings.frameRendering ? 0 : padding, exportingFrame: null, renderEmbeddables: true, skipInliningFonts: exportSettings.skipInliningFonts, }); } if(svg) { svg.addClass("excalidraw-svg"); if(srcFile instanceof TFile) { const cssClasses = getFileCSSClasses(srcFile); cssClasses.forEach((cssClass) => svg.addClass(cssClass)); } } return svg; } catch (error) { return null; } }; export function filterFiles(files: Record): Record { let filteredFiles: Record = {}; Object.entries(files).forEach(([key, value]) => { if (!value.dataURL.startsWith("http")) { filteredFiles[key] = value; } }); return filteredFiles; } export async function getPNG ( scene: any, exportSettings: ExportSettings, padding: number, scale: number = 1, ): Promise { try { if(exportSettings.isMask) { const cropObject = new CropImage(scene.elements, scene.files); const blob = await cropObject.getCroppedPNG(); cropObject.destroy(); return blob; } return await exportToBlob({ elements: scene.elements.filter((el:ExcalidrawElement)=>el.isDeleted !== true), appState: { ...scene.appState, exportBackground: exportSettings.withBackground, exportWithDarkMode: exportSettings.withTheme ? scene.appState?.theme !== "light" : false, ...exportSettings.frameRendering ? {frameRendering: exportSettings.frameRendering} : {}, }, files: filterFiles(scene.files), exportPadding: exportSettings.frameRendering ? 0 : padding, mimeType: "image/png", getDimensions: (width: number, height: number) => ({ width: width * scale, height: height * scale, scale, }), }); } catch (error) { new Notice("Error exporting PNG - PNG too large, try a smaller resolution"); errorlog({ where: "Utils.getPNG", error }); return null; } }; export async function getQuickImagePreview ( plugin: ExcalidrawPlugin, path: string, extension: "png" | "svg", ): Promise { if (!plugin.settings.displayExportedImageIfAvailable) { return null; } const imagePath = getIMGFilename(path, extension); const file = plugin.app.vault.getAbstractFileByPath(imagePath); if (!file || !(file instanceof TFile)) { return null; } switch (extension) { case "png": return await plugin.app.vault.readBinary(file); default: return await plugin.app.vault.read(file); } }; export async function getImageSize ( src: string, ): Promise<{ height: number; width: number }> { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { //console.log({ height: img.naturalHeight, width: img.naturalWidth, img}); resolve({ height: img.naturalHeight, width: img.naturalWidth }); }; img.onerror = reject; img.src = src; }); }; export function addAppendUpdateCustomData (el: Mutable, newData: any): ExcalidrawElement { if(!newData) return el; if(!el.customData) el.customData = {}; for (const key in newData) { if(typeof newData[key] === "undefined") continue; el.customData[key] = newData[key]; } return el; }; export function scaleLoadedImage ( scene: any, files: any ): { dirty: boolean; scene: any } { let dirty = false; if (!files || !scene) { return { dirty, scene }; } for (const f of files.filter((f:any)=>{ if(!Boolean(EXCALIDRAW_PLUGIN)) return true; //this should never happen const ef = EXCALIDRAW_PLUGIN.filesMaster.get(f.id); if(!ef) return true; //mermaid SVG or equation const file = EXCALIDRAW_PLUGIN.app.vault.getAbstractFileByPath(ef.path.replace(/#.*$/,"").replace(/\|.*$/,"")); if(!file || (file instanceof TFolder)) return false; 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; scene.elements .filter((e: any) => e.type === "image" && e.fileId === f.id) .forEach((el: any) => { const [w_old, h_old] = [el.width, el.height]; if(el.customData?.isAnchored && f.shouldScale || !el.customData?.isAnchored && !f.shouldScale) { addAppendUpdateCustomData(el, f.shouldScale ? {isAnchored: false} : {isAnchored: true}); dirty = true; } if(f.shouldScale) { const elementAspectRatio = w_old / h_old; if (imageAspectRatio !== elementAspectRatio) { dirty = true; const h_new = Math.sqrt((w_old * h_old * h_image) / w_image); const w_new = Math.sqrt((w_old * h_old * w_image) / h_image); el.height = h_new; el.width = w_new; el.y += (h_old - h_new) / 2; el.x += (w_old - w_new) / 2; } } else { if(w_old !== w_image || h_old !== h_image) { dirty = true; el.height = h_image; el.width = w_image; el.y += (h_old - h_image) / 2; el.x += (w_old - w_image) / 2; } } }); } return { dirty, scene }; }; export function setDocLeftHandedMode(isLeftHanded: boolean, ownerDocument:Document) { const newStylesheet = ownerDocument.createElement("style"); newStylesheet.id = "excalidraw-left-handed"; newStylesheet.textContent = `.excalidraw .App-bottom-bar{justify-content:flex-end;}`; const oldStylesheet = ownerDocument.getElementById(newStylesheet.id); if (oldStylesheet) { ownerDocument.head.removeChild(oldStylesheet); } if (isLeftHanded) { ownerDocument.head.appendChild(newStylesheet); } } export function setLeftHandedMode (isLeftHanded: boolean) { const visitedDocs = new Set(); app.workspace.iterateAllLeaves((leaf) => { const ownerDocument = DEVICE.isMobile?document:leaf.view.containerEl.ownerDocument; if(!ownerDocument) return; if(visitedDocs.has(ownerDocument)) return; visitedDocs.add(ownerDocument); setDocLeftHandedMode(isLeftHanded,ownerDocument); }) }; export type LinkParts = { original: string; path: string; isBlockRef: boolean; ref: string; width: number; height: number; page: number; }; export function getLinkParts (fname: string, file?: TFile): LinkParts { // 1 2 3 4 5 const REG = /(^[^#\|]*)#?(\^)?([^\|]*)?\|?(\d*)x?(\d*)/; const parts = fname.match(REG); const isBlockRef = parts[2] === "^"; return { original: fname, path: file && (parts[1] === "") ? file.path : parts[1], isBlockRef, ref: parts[3]?.match(/^page=\d*$/i) ? parts[3] : isBlockRef ? cleanBlockRef(parts[3]) : cleanSectionHeading(parts[3]), width: parts[4] ? parseInt(parts[4]) : undefined, height: parts[5] ? parseInt(parts[5]) : undefined, page: parseInt(parts[3]?.match(/page=(\d*)/)?.[1]) }; }; export async function compressAsync (data: string): Promise { return await runCompressionWorker(data, "compress"); } export function compress (data: string): string { const compressed = LZString.compressToBase64(data); let result = ''; const chunkSize = 256; for (let i = 0; i < compressed.length; i += chunkSize) { result += compressed.slice(i, i + chunkSize) + '\n\n'; } return result.trim(); }; export async function decompressAsync (data: string): Promise { return await runCompressionWorker(data, "decompress"); }; export function decompress (data: string, isAsync:boolean = false): string { let cleanedData = ''; const length = data.length; for (let i = 0; i < length; i++) { const char = data[i]; if (char !== '\n' && char !== '\r') { cleanedData += char; } } return LZString.decompressFromBase64(cleanedData); }; export function isMaskFile ( plugin: ExcalidrawPlugin, file: TFile, ): boolean { if (file) { const fileCache = plugin.app.metadataCache.getFileCache(file); if ( fileCache?.frontmatter && fileCache.frontmatter[FRONTMATTER_KEYS["mask"].name] !== null && (typeof fileCache.frontmatter[FRONTMATTER_KEYS["mask"].name] !== "undefined") ) { return Boolean(fileCache.frontmatter[FRONTMATTER_KEYS["mask"].name]); } } return false; }; export function hasExportTheme ( plugin: ExcalidrawPlugin, file: TFile, ): boolean { if (file) { const fileCache = plugin.app.metadataCache.getFileCache(file); if ( fileCache?.frontmatter && fileCache.frontmatter[FRONTMATTER_KEYS["export-dark"].name] !== null && (typeof fileCache.frontmatter[FRONTMATTER_KEYS["export-dark"].name] !== "undefined") ) { return true; } } return false; }; export function getExportTheme ( plugin: ExcalidrawPlugin, file: TFile, theme: string, ): string { if (file) { const fileCache = plugin.app.metadataCache.getFileCache(file); if ( fileCache?.frontmatter && fileCache.frontmatter[FRONTMATTER_KEYS["export-dark"].name] !== null && (typeof fileCache.frontmatter[FRONTMATTER_KEYS["export-dark"].name] !== "undefined") ) { return fileCache.frontmatter[FRONTMATTER_KEYS["export-dark"].name] ? "dark" : "light"; } } return plugin.settings.exportWithTheme ? theme : "light"; }; export function shouldEmbedScene ( plugin: ExcalidrawPlugin, file: TFile ): boolean { if (file) { const fileCache = plugin.app.metadataCache.getFileCache(file); if ( fileCache?.frontmatter && fileCache.frontmatter[FRONTMATTER_KEYS["export-embed-scene"].name] !== null && (typeof fileCache.frontmatter[FRONTMATTER_KEYS["export-embed-scene"].name] !== "undefined") ) { return fileCache.frontmatter[FRONTMATTER_KEYS["export-embed-scene"].name]; } } return plugin.settings.exportEmbedScene; }; export function hasExportBackground ( plugin: ExcalidrawPlugin, file: TFile, ): boolean { if (file) { const fileCache = plugin.app.metadataCache.getFileCache(file); if ( fileCache?.frontmatter && fileCache.frontmatter[FRONTMATTER_KEYS["export-transparent"].name] !== null && (typeof fileCache.frontmatter[FRONTMATTER_KEYS["export-transparent"].name] !== "undefined") ) { return true; } } return false; }; export function getWithBackground ( plugin: ExcalidrawPlugin, file: TFile, ): boolean { if (file) { const fileCache = plugin.app.metadataCache.getFileCache(file); if ( fileCache?.frontmatter && fileCache.frontmatter[FRONTMATTER_KEYS["export-transparent"].name] !== null && (typeof fileCache.frontmatter[FRONTMATTER_KEYS["export-transparent"].name] !== "undefined") ) { return !fileCache.frontmatter[FRONTMATTER_KEYS["export-transparent"].name]; } } return plugin.settings.exportWithBackground; }; export function getExportPadding ( plugin: ExcalidrawPlugin, file: TFile, ): number { if (file) { const fileCache = plugin.app.metadataCache.getFileCache(file); if(!fileCache?.frontmatter) return plugin.settings.exportPaddingSVG; if ( fileCache.frontmatter[FRONTMATTER_KEYS["export-padding"].name] !== null && (typeof fileCache.frontmatter[FRONTMATTER_KEYS["export-padding"].name] !== "undefined") ) { const val = parseInt( fileCache.frontmatter[FRONTMATTER_KEYS["export-padding"].name], ); if (!isNaN(val)) { return val; } } //deprecated. Retained for backward compatibility if ( fileCache.frontmatter[FRONTMATTER_KEYS["export-svgpadding"].name] !== null && (typeof fileCache.frontmatter[FRONTMATTER_KEYS["export-svgpadding"].name] !== "undefined") ) { const val = parseInt( fileCache.frontmatter[FRONTMATTER_KEYS["export-svgpadding"].name], ); if (!isNaN(val)) { return val; } } } return plugin.settings.exportPaddingSVG; }; export function getPNGScale (plugin: ExcalidrawPlugin, file: TFile): number { if (file) { const fileCache = plugin.app.metadataCache.getFileCache(file); if ( fileCache?.frontmatter && fileCache.frontmatter[FRONTMATTER_KEYS["export-pngscale"].name] !== null && (typeof fileCache.frontmatter[FRONTMATTER_KEYS["export-pngscale"].name] !== "undefined") ) { const val = parseFloat( fileCache.frontmatter[FRONTMATTER_KEYS["export-pngscale"].name], ); if (!isNaN(val) && val > 0) { return val; } } } return plugin.settings.pngExportScale; }; export function isVersionNewerThanOther (version: string, otherVersion: string): boolean { const v = version.match(/(\d*)\.(\d*)\.(\d*)/); const o = otherVersion.match(/(\d*)\.(\d*)\.(\d*)/); return Boolean(v && v.length === 4 && o && o.length === 4 && !(isNaN(parseInt(v[1])) || isNaN(parseInt(v[2])) || isNaN(parseInt(v[3]))) && !(isNaN(parseInt(o[1])) || isNaN(parseInt(o[2])) || isNaN(parseInt(o[3]))) && ( parseInt(v[1])>parseInt(o[1]) || (parseInt(v[1]) >= parseInt(o[1]) && parseInt(v[2]) > parseInt(o[2])) || (parseInt(v[1]) >= parseInt(o[1]) && parseInt(v[2]) >= parseInt(o[2]) && parseInt(v[3]) > parseInt(o[3])) ) ) } export function getEmbeddedFilenameParts (fname:string): FILENAMEPARTS { // 0 1 23 4 5 6 7 8 9 const parts = fname?.match(/([^#\^]*)((#\^)(group=|area=|frame=|clippedframe=|taskbone)?([^\|]*)|(#)(group=|area=|frame=|clippedframe=|taskbone)?([^\^\|]*))(.*)/); if(!parts) { return { filepath: fname, hasBlockref: false, hasGroupref: false, hasTaskbone: false, hasArearef: false, hasFrameref: false, hasClippedFrameref: false, blockref: "", hasSectionref: false, sectionref: "", linkpartReference: "", linkpartAlias: "" } } return { filepath: parts[1], hasBlockref: Boolean(parts[3]), hasGroupref: (parts[4]==="group=") || (parts[7]==="group="), hasTaskbone: (parts[4]==="taskbone") || (parts[7]==="taskbone"), hasArearef: (parts[4]==="area=") || (parts[7]==="area="), hasFrameref: (parts[4]==="frame=") || (parts[7]==="frame="), hasClippedFrameref: (parts[4]==="clippedframe=") || (parts[7]==="clippedframe="), blockref: parts[5], hasSectionref: Boolean(parts[6]), sectionref: parts[8], linkpartReference: parts[2], linkpartAlias: parts[9] } } export function isImagePartRef (parts: FILENAMEPARTS): boolean { return (parts.hasGroupref || parts.hasArearef || parts.hasFrameref || parts.hasClippedFrameref); } export function fragWithHTML (html: string) { return createFragment((frag) => (frag.createDiv().innerHTML = html)); } export function errorlog (data: {}) { console.error({ plugin: "Excalidraw", ...data }); }; export async function sleep (ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } /**REACT 18 //see also: https://github.com/zsviczian/obsidian-excalidraw-plugin/commit/b67d70c5196f30e2968f9da919d106ee66f2a5eb //https://github.com/zsviczian/obsidian-excalidraw-plugin/commit/cc9d7828c7ee7755c1ef942519c43df32eae249f export const awaitNextAnimationFrame = async () => new Promise(requestAnimationFrame); */ //export const debug = function(){}; export function _getContainerElement ( element: | (ExcalidrawElement & { containerId: ExcalidrawElement["id"] | null }) | null, scene: any, ) { if (!element || !scene?.elements || element.type !== "text") { return null; } if (element.containerId) { return getContainerElement(element as ExcalidrawTextElement, arrayToMap(scene.elements)) //return scene.elements.find((el:ExcalidrawElement)=>el.id === element.containerId) ?? null; } return null; }; /** * Transforms array of objects containing `id` attribute, * or array of ids (strings), into a Map, keyd by `id`. */ export function arrayToMap ( items: readonly T[] | Map, ) { if (items instanceof Map) { return items; } return items.reduce((acc: Map, element) => { acc.set(typeof element === "string" ? element : element.id, element); return acc; }, new Map()); }; export function updateFrontmatterInString(data:string, keyValuePairs?: [string,string][]):string { if(!data || !keyValuePairs) return data; for(const kvp of keyValuePairs) { const r = new RegExp(`${kvp[0]}:\\s.*\\n`,"g"); data = data.match(r) ? data.replaceAll(r,`${kvp[0]}: ${kvp[1]}\n`) : data.replace(/^---\n/,`---\n${kvp[0]}: ${kvp[1]}\n`); } return data; } function isHyperLink (link:string) { return link && !link.includes("\n") && !link.includes("\r") && link.match(/^https?:(\d*)?\/\/[^\s]*$/); } export function isContainer (el: ExcalidrawElement) { return el.type!=="arrow" && el.boundElements?.map((e) => e.type).includes("text"); } export function hyperlinkIsImage (data: string):boolean { if(!isHyperLink(data)) false; const corelink = data.split("?")[0]; return IMAGE_TYPES.contains(corelink.substring(corelink.lastIndexOf(".")+1)); } export function hyperlinkIsYouTubeLink (link:string): boolean { return isHyperLink(link) && (link.startsWith("https://youtu.be") || link.startsWith("https://www.youtube.com") || link.startsWith("https://youtube.com") || link.startsWith("https//www.youtu.be")) && link.match(/(youtu.be\/|v=)([^?\/\&]*)/)!==null } export async function getYouTubeThumbnailLink (youtubelink: string):Promise { //https://stackoverflow.com/questions/2068344/how-do-i-get-a-youtube-video-thumbnail-from-the-youtube-api //https://youtu.be/z8UkHGpykYU?t=60 //https://www.youtube.com/watch?v=z8UkHGpykYU&ab_channel=VerbaltoVisual const parsed = youtubelink.match(/(youtu.be\/|v=)([^?\/\&]*)/); if(!parsed || !parsed[2]) return null; const videoId = parsed[2]; let url = `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`; let response = await requestUrl({url, method: "get", contentType: "image/jpeg", throw: false }); if(response && response.status === 200) return url; url = `https://i.ytimg.com/vi/${videoId}/hq720.jpg`; response = await requestUrl({url, method: "get", contentType: "image/jpeg", throw: false }); if(response && response.status === 200) return url; url = `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`; response = await requestUrl({url, method: "get", contentType: "image/jpeg", throw: false }); if(response && response.status === 200) return url; return `https://i.ytimg.com/vi/${videoId}/default.jpg`; } export function isCallerFromTemplaterPlugin (stackTrace:string) { const lines = stackTrace.split("\n"); for (const line of lines) { if (line.trim().startsWith("at Templater.")) { return true; } } return false; } export function convertSVGStringToElement (svg: string): SVGSVGElement { const divElement = document.createElement("div"); divElement.innerHTML = svg; const firstChild = divElement.firstChild; if (firstChild instanceof SVGSVGElement) { return firstChild; } return; } export function escapeRegExp (str:string) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } export function addIframe (containerEl: HTMLElement, link:string, startAt?: number, style:string = "settings") { const wrapper = containerEl.createDiv({cls: `excalidraw-videoWrapper ${style}`}) wrapper.createEl("iframe", { attr: { allowfullscreen: true, allow: "encrypted-media;picture-in-picture", frameborder: "0", title: "YouTube video player", src: "https://www.youtube.com/embed/" + link + (startAt ? "?start=" + startAt : ""), sandbox: "allow-forms allow-presentation allow-same-origin allow-scripts allow-modals", }, }); } export interface FontMetrics { unitsPerEm: number; ascender: number; descender: number; lineHeight: number; fontName: string; } export async function getFontMetrics(fontUrl: string, name: string): Promise { try { const font = await opentype.load(fontUrl); const unitsPerEm = font.unitsPerEm; const ascender = font.ascender; const descender = font.descender; const lineHeight = (ascender - descender) / unitsPerEm; const fontName = font.names.fontFamily.en ?? name; return { unitsPerEm, ascender, descender, lineHeight, fontName, }; } catch (error) { console.error('Error loading font:', error); return null; } } // Thanks https://stackoverflow.com/a/54555834 export function cropCanvas( srcCanvas: HTMLCanvasElement, crop: { left: number, top: number, width: number, height: number }, output: { width: number, height: number } = { width: crop.width, height: crop.height }) { const dstCanvas = createEl('canvas'); dstCanvas.width = output.width; dstCanvas.height = output.height; dstCanvas.getContext('2d')!.drawImage( srcCanvas, crop.left, crop.top, crop.width, crop.height, 0, 0, output.width, output.height ); return dstCanvas; }