mirror of
https://github.com/zsviczian/obsidian-excalidraw-plugin.git
synced 2025-08-06 05:46:28 +00:00
971 lines
31 KiB
TypeScript
971 lines
31 KiB
TypeScript
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<DataURL> {
|
||
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<ArrayBuffer> {
|
||
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<SVGSVGElement> {
|
||
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<ExcalidrawElement["id"], BinaryFileData>): Record<ExcalidrawElement["id"], BinaryFileData> {
|
||
let filteredFiles: Record<ExcalidrawElement["id"], BinaryFileData> = {};
|
||
|
||
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<Blob> {
|
||
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<any> {
|
||
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<ExcalidrawElement>, 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<Document>();
|
||
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<string> {
|
||
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<string> {
|
||
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 <T extends { id: string } | string>(
|
||
items: readonly T[] | Map<string, T>,
|
||
) {
|
||
if (items instanceof Map) {
|
||
return items;
|
||
}
|
||
return items.reduce((acc: Map<string, T>, 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<string> {
|
||
//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<FontMetrics | null> {
|
||
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;
|
||
} |