Files
obsidian-excalidraw-plugin/src/Utils.ts
Zsolt Viczian 7055f08c35 1.5.21
2022-01-23 15:22:30 +01:00

556 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { exportToSvg, exportToBlob } from "@zsviczian/excalidraw";
import {
App,
normalizePath,
Notice,
request,
TAbstractFile,
TFolder,
Vault,
WorkspaceLeaf,
} from "obsidian";
import { Random } from "roughjs/bin/math";
import { DataURL, Zoom } from "@zsviczian/excalidraw/types/types";
import {
CASCADIA_FONT,
REG_BLOCK_REF_CLEAN,
VIRGIL_FONT,
PLUGIN_ID,
} from "./constants";
import ExcalidrawPlugin from "./main";
import { ExcalidrawElement } from "@zsviczian/excalidraw/types/element/types";
import { ExportSettings } from "./ExcalidrawView";
declare module "obsidian" {
interface Workspace {
getAdjacentLeafInDirection(
leaf: WorkspaceLeaf,
direction: string,
): WorkspaceLeaf;
}
interface Vault {
getConfig(option: "attachmentFolderPath"): string;
}
}
let versionUpdateChecked = false;
export const checkExcalidrawVersion = async (app: App) => {
if (versionUpdateChecked) {
return;
}
versionUpdateChecked = true;
//@ts-ignore
const manifest = app.plugins.manifests[PLUGIN_ID];
try {
const gitAPIrequest = async () => {
return JSON.parse(
await request({
url: `https://api.github.com/repos/zsviczian/obsidian-excalidraw-plugin/releases?per_page=5&page=1`,
}),
);
};
const latestVersion = (await gitAPIrequest())
.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 (latestVersion > manifest.version) {
new Notice(
`A newer version of Excalidraw is available in Community Plugins.\n\nYou are using ${manifest.version}.\nThe latest is ${latestVersion}`,
);
}
} catch (e) {
errorlog({ where: "Utils/checkExcalidrawVersion", error: e });
}
setTimeout(() => (versionUpdateChecked = false), 28800000); //reset after 8 hours
};
/**
* Splits a full path including a folderpath and a filename into separate folderpath and filename components
* @param filepath
*/
export function splitFolderAndFilename(filepath: string): {
folderpath: string;
filename: string;
basename: string;
} {
const lastIndex = filepath.lastIndexOf("/");
const filename = lastIndex == -1 ? filepath : filepath.substr(lastIndex + 1);
return {
folderpath: normalizePath(filepath.substr(0, lastIndex)),
filename,
basename: filename.replace(/\.[^/.]+$/, ""),
};
}
/**
* Download data as file from Obsidian, to store on local device
* @param encoding
* @param data
* @param filename
*/
export function download(encoding: string, data: any, filename: string) {
const element = document.createElement("a");
element.setAttribute("href", (encoding ? `${encoding},` : "") + data);
element.setAttribute("download", filename);
element.style.display = "none";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
/**
* Generates the image filename based on the excalidraw filename
* @param excalidrawPath - Full filepath of ExclidrawFile
* @param newExtension - extension of IMG file in ".extension" format
* @returns
*/
export function getIMGPathFromExcalidrawFile(
excalidrawPath: string,
newExtension: string,
): string {
const isLegacyFile: boolean = excalidrawPath.endsWith(".excalidraw");
const replaceExtension: string = isLegacyFile ? ".excalidraw" : ".md";
return (
excalidrawPath.substring(0, excalidrawPath.lastIndexOf(replaceExtension)) +
newExtension
);
}
/**
* Create new file, if file already exists find first unique filename by adding a number to the end of the filename
* @param filename
* @param folderpath
* @returns
*/
export function getNewUniqueFilepath(
vault: Vault,
filename: string,
folderpath: string,
): string {
let fname = normalizePath(`${folderpath}/${filename}`);
let file: TAbstractFile = vault.getAbstractFileByPath(fname);
let i = 0;
while (file) {
fname = normalizePath(
`${folderpath}/${filename.slice(
0,
filename.lastIndexOf("."),
)}_${i}${filename.slice(filename.lastIndexOf("."))}`,
);
i++;
file = vault.getAbstractFileByPath(fname);
}
return fname;
}
/**
* Open or create a folderpath if it does not exist
* @param folderpath
*/
export async function checkAndCreateFolder(vault: Vault, folderpath: string) {
folderpath = normalizePath(folderpath);
const folder = vault.getAbstractFileByPath(folderpath);
if (folder && folder instanceof TFolder) {
return;
}
await vault.createFolder(folderpath);
}
const random = new Random(Date.now());
export const randomInteger = () => Math.floor(random.next() * 2 ** 31);
//https://macromates.com/blog/2006/wrapping-text-with-regular-expressions/
export function wrapText(
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 const rotatedDimensions = (
element: ExcalidrawElement,
): [number, number, number, number] => {
if (element.angle === 0) {
return [element.x, element.y, element.width, element.height];
}
const centerX = element.x + element.width / 2;
const centerY = element.y + element.height / 2;
const [left, top] = rotate(
element.x,
element.y,
centerX,
centerY,
element.angle,
);
const [right, bottom] = rotate(
element.x + element.width,
element.y + element.height,
centerX,
centerY,
element.angle,
);
return [
left < right ? left : right,
top < bottom ? top : bottom,
Math.abs(left - right),
Math.abs(top - bottom),
];
};
export const viewportCoordsToSceneCoords = (
{ clientX, clientY }: { clientX: number; clientY: number },
{
zoom,
offsetLeft,
offsetTop,
scrollX,
scrollY,
}: {
zoom: Zoom;
offsetLeft: number;
offsetTop: number;
scrollX: number;
scrollY: number;
},
) => {
const invScale = 1 / zoom.value;
const x = (clientX - zoom.translation.x - offsetLeft) * invScale - scrollX;
const y = (clientY - zoom.translation.y - offsetTop) * invScale - scrollY;
return { x, y };
};
export const getNewOrAdjacentLeaf = (
plugin: ExcalidrawPlugin,
leaf: WorkspaceLeaf,
): WorkspaceLeaf => {
if (plugin.settings.openInAdjacentPane) {
let leafToUse = plugin.app.workspace.getAdjacentLeafInDirection(
leaf,
"right",
);
if (!leafToUse) {
leafToUse = plugin.app.workspace.getAdjacentLeafInDirection(leaf, "left");
}
if (!leafToUse) {
leafToUse = plugin.app.workspace.getAdjacentLeafInDirection(
leaf,
"bottom",
);
}
if (!leafToUse) {
leafToUse = plugin.app.workspace.getAdjacentLeafInDirection(leaf, "top");
}
if (!leafToUse) {
leafToUse = plugin.app.workspace.createLeafBySplit(leaf);
}
return leafToUse;
}
return plugin.app.workspace.createLeafBySplit(leaf);
};
export const getDataURL = async (
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 const getFontDataURL = async (
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);
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 const svgToBase64 = (svg: string): string => {
return `data:image/svg+xml;base64,${btoa(
unescape(encodeURIComponent(svg.replaceAll("&nbsp;", " "))),
)}`;
};
export const getBinaryFileFromDataURL = (dataURL: string): ArrayBuffer => {
if (!dataURL) {
return null;
}
const parts = dataURL.matchAll(/base64,(.*)/g).next();
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 const getAttachmentsFolderAndFilePath = async (
app: App,
activeViewFilePath: string,
newFileName: string,
): Promise<{ folder: string; filepath: string }> => {
let folder = app.vault.getConfig("attachmentFolderPath");
// folder == null: save to vault root
// folder == "./" save to same folder as current file
// folder == "folder" save to specific folder in vault
// folder == "./folder" save to specific subfolder of current active folder
if (folder && folder.startsWith("./")) {
// folder relative to current file
const activeFileFolder = `${
splitFolderAndFilename(activeViewFilePath).folderpath
}/`;
folder = normalizePath(activeFileFolder + folder.substring(2));
}
if (!folder) {
folder = "";
}
await checkAndCreateFolder(app.vault, folder);
return {
folder,
filepath: normalizePath(
folder === "" ? newFileName : `${folder}/${newFileName}`,
),
};
};
export const getSVG = async (
scene: any,
exportSettings: ExportSettings,
): Promise<SVGSVGElement> => {
try {
return await exportToSvg({
elements: scene.elements,
appState: {
exportBackground: exportSettings.withBackground,
exportWithDarkMode: exportSettings.withTheme
? scene.appState?.theme != "light"
: false,
...scene.appState,
},
files: scene.files,
exportPadding: 10,
});
} catch (error) {
return null;
}
};
export const getPNG = async (
scene: any,
exportSettings: ExportSettings,
scale: number = 1,
) => {
try {
return await exportToBlob({
elements: scene.elements,
appState: {
exportBackground: exportSettings.withBackground,
exportWithDarkMode: exportSettings.withTheme
? scene.appState?.theme != "light"
: false,
...scene.appState,
},
files: scene.files,
mimeType: "image/png",
getDimensions: (width: number, height: number) => ({
width: width * scale,
height: height * scale,
scale,
}),
});
} catch (error) {
errorlog({ where: "Utils.getPNG", error });
return null;
}
};
export const embedFontsInSVG = (
svg: SVGSVGElement,
plugin: ExcalidrawPlugin,
): SVGSVGElement => {
//replace font references with base64 fonts
const includesVirgil =
svg.querySelector("text[font-family^='Virgil']") != null;
const includesCascadia =
svg.querySelector("text[font-family^='Cascadia']") != null;
const includesLocalFont =
svg.querySelector("text[font-family^='LocalFont']") != null;
const defs = svg.querySelector("defs");
if (defs && (includesCascadia || includesVirgil || includesLocalFont)) {
defs.innerHTML = `<style>${includesVirgil ? VIRGIL_FONT : ""}${
includesCascadia ? CASCADIA_FONT : ""
}${includesLocalFont ? plugin.fourthFontDef : ""}</style>`;
}
return svg;
};
export const getImageSize = async (
src: string,
): Promise<{ height: number; width: number }> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve({ height: img.height, width: img.width });
img.onerror = reject;
img.src = src;
});
};
export const scaleLoadedImage = (
scene: any,
files: any,
): { dirty: boolean; scene: any } => {
let dirty = false;
if (!files || !scene) {
return { dirty, scene };
}
for (const f of files) {
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];
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;
}
});
return { dirty, scene };
}
};
export const isObsidianThemeDark = () =>
document.body.classList.contains("theme-dark");
export function getIMGFilename(path: string, extension: string): string {
return `${path.substring(0, path.lastIndexOf("."))}.${extension}`;
}
export type LinkParts = {
original: string;
path: string;
isBlockRef: boolean;
ref: string;
width: number;
height: number;
};
export const getLinkParts = (fname: string): LinkParts => {
const REG = /(^[^#\|]+)#?(\^)?([^\|]*)?\|?(\d*)x?(\d*)/;
const parts = fname.match(REG);
return {
original: fname,
path: parts[1],
isBlockRef: parts[2] === "^",
ref: parts[3]?.replaceAll(REG_BLOCK_REF_CLEAN, ""),
width: parts[4] ? parseInt(parts[4]) : undefined,
height: parts[5] ? parseInt(parts[5]) : undefined,
};
};
export const errorlog = (data: {}) => {
console.error({ plugin: "Excalidraw", ...data });
};
export const sleep = async (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const log = console.log.bind(window.console);
export const debug = console.log.bind(window.console);
//export const debug = function(){};