mirror of
https://github.com/zsviczian/obsidian-excalidraw-plugin.git
synced 2025-08-06 05:46:28 +00:00
1828 lines
57 KiB
TypeScript
1828 lines
57 KiB
TypeScript
import ExcalidrawPlugin from "./main";
|
|
import {
|
|
FillStyle,
|
|
StrokeStyle,
|
|
StrokeSharpness,
|
|
ExcalidrawElement,
|
|
ExcalidrawBindableElement,
|
|
} from "@zsviczian/excalidraw/types/element/types";
|
|
import { normalizePath, TFile, WorkspaceLeaf } from "obsidian";
|
|
import ExcalidrawView, { ExportSettings, TextMode } from "./ExcalidrawView";
|
|
import { ExcalidrawData } from "./ExcalidrawData";
|
|
import {
|
|
FRONTMATTER,
|
|
nanoid,
|
|
VIEW_TYPE_EXCALIDRAW,
|
|
MAX_IMAGE_SIZE,
|
|
PLUGIN_ID,
|
|
COLOR_NAMES,
|
|
} from "./constants";
|
|
import {
|
|
//debug,
|
|
embedFontsInSVG,
|
|
errorlog,
|
|
getNewOrAdjacentLeaf,
|
|
getPNG,
|
|
getSVG,
|
|
isObsidianThemeDark,
|
|
scaleLoadedImage,
|
|
wrapText,
|
|
} from "./Utils";
|
|
import { AppState, Point } from "@zsviczian/excalidraw/types/types";
|
|
import { EmbeddedFilesLoader, FileData } from "./EmbeddedFileLoader";
|
|
import { tex2dataURL } from "./LaTeX";
|
|
import {
|
|
determineFocusDistance,
|
|
getCommonBoundingBox,
|
|
getMaximumGroups,
|
|
intersectElementWithLine,
|
|
} from "@zsviczian/excalidraw";
|
|
|
|
declare type ConnectionPoint = "top" | "bottom" | "left" | "right" | null;
|
|
const GAP = 4;
|
|
|
|
export interface ExcalidrawAutomate {
|
|
plugin: ExcalidrawPlugin;
|
|
elementsDict: {}; //contains the ExcalidrawElements currently edited in Automate indexed by el.id
|
|
imagesDict: {}; //the images files including DataURL, indexed by fileId
|
|
style: {
|
|
strokeColor: string; //https://www.w3schools.com/colors/default.asp
|
|
backgroundColor: string;
|
|
angle: number; //radian
|
|
fillStyle: FillStyle; //type FillStyle = "hachure" | "cross-hatch" | "solid"
|
|
strokeWidth: number;
|
|
strokeStyle: StrokeStyle; //type StrokeStyle = "solid" | "dashed" | "dotted"
|
|
roughness: number;
|
|
opacity: number;
|
|
strokeSharpness: StrokeSharpness; //type StrokeSharpness = "round" | "sharp"
|
|
fontFamily: number; //1: Virgil, 2:Helvetica, 3:Cascadia, 4:LocalFont
|
|
fontSize: number;
|
|
textAlign: string; //"left"|"right"|"center"
|
|
verticalAlign: string; //"top"|"bottom"|"middle" :for future use, has no effect currently
|
|
startArrowHead: string; //"triangle"|"dot"|"arrow"|"bar"|null
|
|
endArrowHead: string;
|
|
};
|
|
canvas: {
|
|
theme: string; //"dark"|"light"
|
|
viewBackgroundColor: string;
|
|
gridSize: number;
|
|
};
|
|
setFillStyle(val: number): void; //0:"hachure", 1:"cross-hatch" 2:"solid"
|
|
setStrokeStyle(val: number): void; //0:"solid", 1:"dashed", 2:"dotted"
|
|
setStrokeSharpness(val: number): void; //0:"round", 1:"sharp"
|
|
setFontFamily(val: number): void; //1: Virgil, 2:Helvetica, 3:Cascadia
|
|
setTheme(val: number): void; //0:"light", 1:"dark"
|
|
addToGroup(objectIds: []): string;
|
|
toClipboard(templatePath?: string): void;
|
|
getElements(): ExcalidrawElement[]; //get all elements from ExcalidrawAutomate elementsDict
|
|
getElement(id: string): ExcalidrawElement; //get single element from ExcalidrawAutomate elementsDict
|
|
create(params?: {
|
|
//create a drawing and save it to filename
|
|
filename?: string; //if null: default filename as defined in Excalidraw settings
|
|
foldername?: string; //if null: default folder as defined in Excalidraw settings
|
|
templatePath?: string;
|
|
onNewPane?: boolean;
|
|
frontmatterKeys?: {
|
|
"excalidraw-plugin"?: "raw" | "parsed";
|
|
"excalidraw-link-prefix"?: string;
|
|
"excalidraw-link-brackets"?: boolean;
|
|
"excalidraw-url-prefix"?: string;
|
|
};
|
|
}): Promise<string>;
|
|
createSVG(
|
|
templatePath?: string,
|
|
embedFont?: boolean,
|
|
exportSettings?: ExportSettings, //use ExcalidrawAutomate.getExportSettings(boolean,boolean)
|
|
loader?: EmbeddedFilesLoader, //use ExcalidrawAutomate.getEmbeddedFilesLoader(boolean?)
|
|
theme?: string,
|
|
): Promise<SVGSVGElement>;
|
|
createPNG(
|
|
templatePath?: string,
|
|
scale?: number,
|
|
exportSettings?: ExportSettings, //use ExcalidrawAutomate.getExportSettings(boolean,boolean)
|
|
loader?: EmbeddedFilesLoader, //use ExcalidrawAutomate.getEmbeddedFilesLoader(boolean?)
|
|
theme?: string,
|
|
): Promise<any>;
|
|
wrapText(text: string, lineLen: number): string;
|
|
addRect(topX: number, topY: number, width: number, height: number): string;
|
|
addDiamond(topX: number, topY: number, width: number, height: number): string;
|
|
addEllipse(topX: number, topY: number, width: number, height: number): string;
|
|
addBlob(topX: number, topY: number, width: number, height: number): string;
|
|
addText(
|
|
topX: number,
|
|
topY: number,
|
|
text: string,
|
|
formatting?: {
|
|
wrapAt?: number;
|
|
width?: number;
|
|
height?: number;
|
|
textAlign?: string;
|
|
box?: boolean | "box" | "blob" | "ellipse" | "diamond"; //if !null, text will be boxed
|
|
boxPadding?: number;
|
|
},
|
|
id?: string,
|
|
): string;
|
|
addLine(points: [[x: number, y: number]]): string;
|
|
addArrow(
|
|
points: [[x: number, y: number]],
|
|
formatting?: {
|
|
startArrowHead?: string;
|
|
endArrowHead?: string;
|
|
startObjectId?: string;
|
|
endObjectId?: string;
|
|
},
|
|
): string;
|
|
addImage(topX: number, topY: number, imageFile: TFile): Promise<string>;
|
|
addLaTex(topX: number, topY: number, tex: string): Promise<string>;
|
|
connectObjects(
|
|
objectA: string,
|
|
connectionA: ConnectionPoint, //type ConnectionPoint = "top" | "bottom" | "left" | "right" | null
|
|
objectB: string,
|
|
connectionB: ConnectionPoint, //when passed null, Excalidraw will automatically decide
|
|
formatting?: {
|
|
numberOfPoints?: number; //points on the line. Default is 0 ie. line will only have a start and end point
|
|
startArrowHead?: string; //"triangle"|"dot"|"arrow"|"bar"|null
|
|
endArrowHead?: string; //"triangle"|"dot"|"arrow"|"bar"|null
|
|
padding?: number;
|
|
},
|
|
): void;
|
|
clear(): void; //clear elementsDict and imagesDict only
|
|
reset(): void; //clear() + reset all style values to default
|
|
isExcalidrawFile(f: TFile): boolean; //returns true if MD file is an Excalidraw file
|
|
//view manipulation
|
|
targetView: ExcalidrawView; //the view currently edited
|
|
setView(view: ExcalidrawView | "first" | "active"): ExcalidrawView;
|
|
getExcalidrawAPI(): any; //https://github.com/excalidraw/excalidraw/tree/master/src/packages/excalidraw#ref
|
|
getViewElements(): ExcalidrawElement[]; //get elements in View
|
|
deleteViewElements(el: ExcalidrawElement[]): boolean;
|
|
getViewSelectedElement(): ExcalidrawElement; //get the selected element in the view, if more are selected, get the first
|
|
getViewSelectedElements(): ExcalidrawElement[];
|
|
getViewFileForImageElement(el: ExcalidrawElement): TFile | null; //Returns the TFile file handle for the image element
|
|
copyViewElementsToEAforEditing(elements: ExcalidrawElement[]): void; //copies elements from view to elementsDict for editing
|
|
viewToggleFullScreen(forceViewMode?: boolean): void;
|
|
connectObjectWithViewSelectedElement( //connect an object to the selected element in the view
|
|
objectA: string, //see connectObjects
|
|
connectionA: ConnectionPoint,
|
|
connectionB: ConnectionPoint,
|
|
formatting?: {
|
|
numberOfPoints?: number;
|
|
startArrowHead?: string;
|
|
endArrowHead?: string;
|
|
padding?: number;
|
|
},
|
|
): boolean;
|
|
addElementsToView( //Adds elements from elementsDict to the current view
|
|
repositionToCursor?: boolean, //default is false
|
|
save?: boolean, //default is true
|
|
//newElementsOnTop controls whether elements created with ExcalidrawAutomate
|
|
//are added at the bottom of the stack or the top of the stack of elements already in the view
|
|
//Note that elements copied to the view with copyViewElementsToEAforEditing retain their
|
|
//position in the stack of elements in the view even if modified using EA
|
|
newElementsOnTop?: boolean, //default is false, i.e. the new elements get to the bottom of the stack
|
|
): Promise<boolean>;
|
|
onDropHook(data: {
|
|
//if set Excalidraw will call this function onDrop events
|
|
ea: ExcalidrawAutomate;
|
|
event: React.DragEvent<HTMLDivElement>;
|
|
draggable: any; //Obsidian draggable object
|
|
type: "file" | "text" | "unknown";
|
|
payload: {
|
|
files: TFile[]; //TFile[] array of dropped files
|
|
text: string; //string
|
|
};
|
|
excalidrawFile: TFile; //the file receiving the drop event
|
|
view: ExcalidrawView; //the excalidraw view receiving the drop
|
|
pointerPosition: { x: number; y: number }; //the pointer position on canvas at the time of drop
|
|
}): boolean; //a return of true will stop the default onDrop processing in Excalidraw
|
|
mostRecentMarkdownSVG: SVGSVGElement; //Markdown renderer will drop a copy of the most recent SVG here for debugging purposes
|
|
getEmbeddedFilesLoader(isDark?: boolean): EmbeddedFilesLoader; //utility function to generate EmbeddedFilesLoader object
|
|
getExportSettings( //utility function to generate ExportSettings object
|
|
withBackground: boolean,
|
|
withTheme: boolean,
|
|
): ExportSettings;
|
|
getBoundingBox(elements: ExcalidrawElement[]): {
|
|
//get bounding box of elements
|
|
topX: number; //bounding box is the box encapsulating all of the elements completely
|
|
topY: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
//elements grouped by the highest level groups
|
|
getMaximumGroups(elements: ExcalidrawElement[]): ExcalidrawElement[][];
|
|
//gets the largest element from a group. useful when a text element is grouped with a box, and you want to connect an arrow to the box
|
|
getLargestElement(elements: ExcalidrawElement[]): ExcalidrawElement;
|
|
// Returns 2 or 0 intersection points between line going through `a` and `b`
|
|
// and the `element`, in ascending order of distance from `a`.
|
|
intersectElementWithLine(
|
|
element: ExcalidrawBindableElement,
|
|
a: readonly [number, number],
|
|
b: readonly [number, number],
|
|
gap?: number, //if given, element is inflated by this value
|
|
): Point[];
|
|
|
|
//See OCR plugin for example on how to use scriptSettings
|
|
activeScript: string; //Set automatically by the ScriptEngine
|
|
getScriptSettings(): {}; //Returns script settings. Saves settings in plugin settings, under the activeScript key
|
|
setScriptSettings(settings: any): Promise<void>; //sets script settings.
|
|
openFileInNewOrAdjacentLeaf(file: TFile): WorkspaceLeaf; //Open a file in a new workspaceleaf or reuse an existing adjacent leaf depending on Excalidraw Plugin Settings
|
|
measureText(text: string): { width: number; height: number }; //measure text size based on current style settings
|
|
//verifyMinimumPluginVersion returns true if plugin version is >= than required
|
|
//recommended use:
|
|
//if(!ea.verifyMinimumPluginVersion || !ea.verifyMinimumPluginVersion("1.5.20")) {new Notice("message");return;}
|
|
verifyMinimumPluginVersion(requiredVersion: string): boolean;
|
|
selectElementsInView(elements: ExcalidrawElement[]): void; //sets selection in view
|
|
generateElementId(): string; //returns an 8 character long random id
|
|
cloneElement(element: ExcalidrawElement): ExcalidrawElement; //Returns a clone of the element with a new id
|
|
moveViewElementToZIndex(elementId:number, newZIndex:number): void; //Moves the element to a specific position in the z-index
|
|
hexStringToRgb(color: string):number[];
|
|
rgbToHexString(color: number[]):string;
|
|
hslToRgb(color: number[]):number[];
|
|
rgbToHsl(color:number[]):number[];
|
|
colorNameToHex(color:string):string;
|
|
}
|
|
|
|
declare let window: any;
|
|
|
|
export async function initExcalidrawAutomate(
|
|
plugin: ExcalidrawPlugin,
|
|
): Promise<ExcalidrawAutomate> {
|
|
window.ExcalidrawAutomate = {
|
|
plugin,
|
|
elementsDict: {},
|
|
imagesDict: {},
|
|
style: {
|
|
strokeColor: "#000000",
|
|
backgroundColor: "transparent",
|
|
angle: 0,
|
|
fillStyle: "hachure",
|
|
strokeWidth: 1,
|
|
strokeStyle: "solid",
|
|
roughness: 1,
|
|
opacity: 100,
|
|
strokeSharpness: "sharp",
|
|
fontFamily: 1,
|
|
fontSize: 20,
|
|
textAlign: "left",
|
|
verticalAlign: "top",
|
|
startArrowHead: null,
|
|
endArrowHead: "arrow",
|
|
},
|
|
canvas: { theme: "light", viewBackgroundColor: "#FFFFFF", gridSize: 0 },
|
|
setFillStyle(val: number) {
|
|
switch (val) {
|
|
case 0:
|
|
this.style.fillStyle = "hachure";
|
|
return "hachure";
|
|
case 1:
|
|
this.style.fillStyle = "cross-hatch";
|
|
return "cross-hatch";
|
|
default:
|
|
this.style.fillStyle = "solid";
|
|
return "solid";
|
|
}
|
|
},
|
|
setStrokeStyle(val: number) {
|
|
switch (val) {
|
|
case 0:
|
|
this.style.strokeStyle = "solid";
|
|
return "solid";
|
|
case 1:
|
|
this.style.strokeStyle = "dashed";
|
|
return "dashed";
|
|
default:
|
|
this.style.strokeStyle = "dotted";
|
|
return "dotted";
|
|
}
|
|
},
|
|
setStrokeSharpness(val: number) {
|
|
switch (val) {
|
|
case 0:
|
|
this.style.strokeSharpness = "round";
|
|
return "round";
|
|
default:
|
|
this.style.strokeSharpness = "sharp";
|
|
return "sharp";
|
|
}
|
|
},
|
|
setFontFamily(val: number) {
|
|
switch (val) {
|
|
case 1:
|
|
this.style.fontFamily = 4;
|
|
return getFontFamily(4);
|
|
case 2:
|
|
this.style.fontFamily = 2;
|
|
return getFontFamily(2);
|
|
case 3:
|
|
this.style.strokeSharpness = 3;
|
|
return getFontFamily(3);
|
|
default:
|
|
this.style.strokeSharpness = 1;
|
|
return getFontFamily(1);
|
|
}
|
|
},
|
|
setTheme(val: number) {
|
|
switch (val) {
|
|
case 0:
|
|
this.canvas.theme = "light";
|
|
return "light";
|
|
default:
|
|
this.canvas.theme = "dark";
|
|
return "dark";
|
|
}
|
|
},
|
|
addToGroup(objectIds: []): string {
|
|
const id = nanoid();
|
|
objectIds.forEach((objectId) => {
|
|
this.elementsDict[objectId]?.groupIds?.push(id);
|
|
});
|
|
return id;
|
|
},
|
|
async toClipboard(templatePath?: string) {
|
|
const template = templatePath
|
|
? await getTemplate(
|
|
this.plugin,
|
|
templatePath,
|
|
false,
|
|
new EmbeddedFilesLoader(this.plugin),
|
|
)
|
|
: null;
|
|
let elements = template ? template.elements : [];
|
|
elements = elements.concat(this.getElements());
|
|
navigator.clipboard.writeText(
|
|
JSON.stringify({
|
|
type: "excalidraw/clipboard",
|
|
elements,
|
|
}),
|
|
);
|
|
},
|
|
getElements(): ExcalidrawElement[] {
|
|
const elements = [];
|
|
const elementIds = Object.keys(this.elementsDict);
|
|
for (let i = 0; i < elementIds.length; i++) {
|
|
elements.push(this.elementsDict[elementIds[i]]);
|
|
}
|
|
return elements;
|
|
},
|
|
getElement(id: string): ExcalidrawElement {
|
|
return this.elementsDict[id];
|
|
},
|
|
async create(params?: {
|
|
filename?: string;
|
|
foldername?: string;
|
|
templatePath?: string;
|
|
onNewPane?: boolean;
|
|
frontmatterKeys?: {
|
|
"excalidraw-plugin"?: "raw" | "parsed";
|
|
"excalidraw-link-prefix"?: string;
|
|
"excalidraw-link-brackets"?: boolean;
|
|
"excalidraw-url-prefix"?: string;
|
|
};
|
|
}): Promise<string> {
|
|
const template = params?.templatePath
|
|
? await getTemplate(
|
|
this.plugin,
|
|
params.templatePath,
|
|
true,
|
|
new EmbeddedFilesLoader(this.plugin),
|
|
)
|
|
: null;
|
|
let elements = template ? template.elements : [];
|
|
elements = elements.concat(this.getElements());
|
|
let frontmatter: string;
|
|
if (params?.frontmatterKeys) {
|
|
const keys = Object.keys(params.frontmatterKeys);
|
|
if (!keys.includes("excalidraw-plugin")) {
|
|
params.frontmatterKeys["excalidraw-plugin"] = "parsed";
|
|
}
|
|
frontmatter = "---\n\n";
|
|
for (const key of Object.keys(params.frontmatterKeys)) {
|
|
frontmatter += `${key}: ${
|
|
//@ts-ignore
|
|
params.frontmatterKeys[key] === ""
|
|
? '""'
|
|
: //@ts-ignore
|
|
params.frontmatterKeys[key]
|
|
}\n`;
|
|
}
|
|
frontmatter += "\n---\n";
|
|
} else {
|
|
frontmatter = template?.frontmatter
|
|
? template.frontmatter
|
|
: FRONTMATTER;
|
|
}
|
|
|
|
const scene = {
|
|
type: "excalidraw",
|
|
version: 2,
|
|
source: "https://excalidraw.com",
|
|
elements,
|
|
appState: {
|
|
theme: template?.appState?.theme ?? this.canvas.theme,
|
|
viewBackgroundColor:
|
|
template?.appState?.viewBackgroundColor ??
|
|
this.canvas.viewBackgroundColor,
|
|
currentItemStrokeColor:
|
|
template?.appState?.currentItemStrokeColor ??
|
|
this.style.strokeColor,
|
|
currentItemBackgroundColor:
|
|
template?.appState?.currentItemBackgroundColor ??
|
|
this.style.backgroundColor,
|
|
currentItemFillStyle:
|
|
template?.appState?.currentItemFillStyle ?? this.style.fillStyle,
|
|
currentItemStrokeWidth:
|
|
template?.appState?.currentItemStrokeWidth ??
|
|
this.style.strokeWidth,
|
|
currentItemStrokeStyle:
|
|
template?.appState?.currentItemStrokeStyle ??
|
|
this.style.strokeStyle,
|
|
currentItemRoughness:
|
|
template?.appState?.currentItemRoughness ?? this.style.roughness,
|
|
currentItemOpacity:
|
|
template?.appState?.currentItemOpacity ?? this.style.opacity,
|
|
currentItemFontFamily:
|
|
template?.appState?.currentItemFontFamily ?? this.style.fontFamily,
|
|
currentItemFontSize:
|
|
template?.appState?.currentItemFontSize ?? this.style.fontSize,
|
|
currentItemTextAlign:
|
|
template?.appState?.currentItemTextAlign ?? this.style.textAlign,
|
|
currentItemStrokeSharpness:
|
|
template?.appState?.currentItemStrokeSharpness ??
|
|
this.style.strokeSharpness,
|
|
currentItemStartArrowhead:
|
|
template?.appState?.currentItemStartArrowhead ??
|
|
this.style.startArrowHead,
|
|
currentItemEndArrowhead:
|
|
template?.appState?.currentItemEndArrowhead ??
|
|
this.style.endArrowHead,
|
|
currentItemLinearStrokeSharpness:
|
|
template?.appState?.currentItemLinearStrokeSharpness ??
|
|
this.style.strokeSharpness,
|
|
gridSize: template?.appState?.gridSize ?? this.canvas.gridSize,
|
|
},
|
|
files: template?.files ?? {},
|
|
};
|
|
|
|
return plugin.createAndOpenDrawing(
|
|
params?.filename
|
|
? `${params.filename}.excalidraw.md`
|
|
: this.plugin.getNextDefaultFilename(),
|
|
params?.onNewPane ? params.onNewPane : false,
|
|
params?.foldername ? params.foldername : this.plugin.settings.folder,
|
|
this.plugin.settings.compatibilityMode
|
|
? JSON.stringify(scene, null, "\t")
|
|
: frontmatter +
|
|
(await plugin.exportSceneToMD(JSON.stringify(scene, null, "\t"))),
|
|
);
|
|
},
|
|
async createSVG(
|
|
templatePath?: string,
|
|
embedFont: boolean = false,
|
|
exportSettings?: ExportSettings,
|
|
loader?: EmbeddedFilesLoader,
|
|
theme?: string,
|
|
): Promise<SVGSVGElement> {
|
|
if (!theme) {
|
|
theme = this.plugin.settings.previewMatchObsidianTheme
|
|
? isObsidianThemeDark()
|
|
? "dark"
|
|
: "light"
|
|
: !this.plugin.settings.exportWithTheme
|
|
? "light"
|
|
: undefined;
|
|
}
|
|
if (theme && !exportSettings) {
|
|
exportSettings = {
|
|
withBackground: this.plugin.settings.exportBackground,
|
|
withTheme: true,
|
|
};
|
|
}
|
|
if (!loader) {
|
|
loader = new EmbeddedFilesLoader(
|
|
this.plugin,
|
|
theme ? theme === "dark" : undefined,
|
|
);
|
|
}
|
|
|
|
return await createSVG(
|
|
templatePath,
|
|
embedFont,
|
|
exportSettings,
|
|
loader,
|
|
theme,
|
|
this.canvas.theme,
|
|
this.canvas.viewBackgroundColor,
|
|
this.getElements(),
|
|
this.plugin,
|
|
);
|
|
},
|
|
async createPNG(
|
|
templatePath?: string,
|
|
scale: number = 1,
|
|
exportSettings?: ExportSettings,
|
|
loader?: EmbeddedFilesLoader,
|
|
theme?: string,
|
|
) {
|
|
if (!theme) {
|
|
theme = this.plugin.settings.previewMatchObsidianTheme
|
|
? isObsidianThemeDark()
|
|
? "dark"
|
|
: "light"
|
|
: !this.plugin.settings.exportWithTheme
|
|
? "light"
|
|
: undefined;
|
|
}
|
|
if (theme && !exportSettings) {
|
|
exportSettings = {
|
|
withBackground: this.plugin.settings.exportBackground,
|
|
withTheme: true,
|
|
};
|
|
}
|
|
if (!loader) {
|
|
loader = new EmbeddedFilesLoader(
|
|
this.plugin,
|
|
theme ? theme === "dark" : undefined,
|
|
);
|
|
}
|
|
|
|
return await createPNG(
|
|
templatePath,
|
|
scale,
|
|
exportSettings,
|
|
loader,
|
|
theme,
|
|
this.canvas.theme,
|
|
this.canvas.viewBackgroundColor,
|
|
this.getElements(),
|
|
this.plugin,
|
|
);
|
|
},
|
|
wrapText(text: string, lineLen: number): string {
|
|
return wrapText(text, lineLen, this.plugin.settings.forceWrap);
|
|
},
|
|
addRect(topX: number, topY: number, width: number, height: number): string {
|
|
const id = nanoid();
|
|
this.elementsDict[id] = boxedElement(
|
|
id,
|
|
"rectangle",
|
|
topX,
|
|
topY,
|
|
width,
|
|
height,
|
|
);
|
|
return id;
|
|
},
|
|
addDiamond(
|
|
topX: number,
|
|
topY: number,
|
|
width: number,
|
|
height: number,
|
|
): string {
|
|
const id = nanoid();
|
|
this.elementsDict[id] = boxedElement(
|
|
id,
|
|
"diamond",
|
|
topX,
|
|
topY,
|
|
width,
|
|
height,
|
|
);
|
|
return id;
|
|
},
|
|
addEllipse(
|
|
topX: number,
|
|
topY: number,
|
|
width: number,
|
|
height: number,
|
|
): string {
|
|
const id = nanoid();
|
|
this.elementsDict[id] = boxedElement(
|
|
id,
|
|
"ellipse",
|
|
topX,
|
|
topY,
|
|
width,
|
|
height,
|
|
);
|
|
return id;
|
|
},
|
|
addBlob(topX: number, topY: number, width: number, height: number): string {
|
|
const b = height * 0.5; //minor axis of the ellipsis
|
|
const a = width * 0.5; //major axis of the ellipsis
|
|
const sx = a / 9;
|
|
const sy = b * 0.8;
|
|
const step = 6;
|
|
const p: any = [];
|
|
const pushPoint = (i: number, dir: number) => {
|
|
const x = i + Math.random() * sx - sx / 2;
|
|
p.push([
|
|
x + Math.random() * sx - sx / 2 + ((i % 2) * sx) / 6 + topX,
|
|
dir * Math.sqrt(b * b * (1 - (x * x) / (a * a))) +
|
|
Math.random() * sy -
|
|
sy / 2 +
|
|
((i % 2) * sy) / 6 +
|
|
topY,
|
|
]);
|
|
};
|
|
let i: number;
|
|
for (i = -a + sx / 2; i <= a - sx / 2; i += a / step) {
|
|
pushPoint(i, 1);
|
|
}
|
|
for (i = a - sx / 2; i >= -a + sx / 2; i -= a / step) {
|
|
pushPoint(i, -1);
|
|
}
|
|
p.push(p[0]);
|
|
const scale = (p: [[x: number, y: number]]): [[x: number, y: number]] => {
|
|
const box = getLineBox(p);
|
|
const scaleX = width / box.w;
|
|
const scaleY = height / box.h;
|
|
let i;
|
|
for (i = 0; i < p.length; i++) {
|
|
let [x, y] = p[i];
|
|
x = (x - box.x) * scaleX + box.x;
|
|
y = (y - box.y) * scaleY + box.y;
|
|
p[i] = [x, y];
|
|
}
|
|
return p;
|
|
};
|
|
const id = this.addLine(scale(p));
|
|
this.elementsDict[id] = repositionElementsToCursor(
|
|
[this.getElement(id)],
|
|
{ x: topX, y: topY },
|
|
false,
|
|
)[0];
|
|
return id;
|
|
},
|
|
addText(
|
|
topX: number,
|
|
topY: number,
|
|
text: string,
|
|
formatting?: {
|
|
wrapAt?: number;
|
|
width?: number;
|
|
height?: number;
|
|
textAlign?: string;
|
|
box?: boolean | "box" | "blob" | "ellipse" | "diamond";
|
|
boxPadding?: number;
|
|
},
|
|
id?: string,
|
|
): string {
|
|
id = id ?? nanoid();
|
|
const originalText = text;
|
|
text = formatting?.wrapAt ? this.wrapText(text, formatting.wrapAt) : text;
|
|
const { w, h, baseline } = measureText(
|
|
text,
|
|
this.style.fontSize,
|
|
this.style.fontFamily,
|
|
);
|
|
const width = formatting?.width ? formatting.width : w;
|
|
const height = formatting?.height ? formatting.height : h;
|
|
|
|
let boxId: string = null;
|
|
const boxPadding = formatting?.boxPadding ?? 30;
|
|
if (formatting?.box) {
|
|
switch (formatting?.box) {
|
|
case "ellipse":
|
|
boxId = this.addEllipse(
|
|
topX - boxPadding,
|
|
topY - boxPadding,
|
|
width + 2 * boxPadding,
|
|
height + 2 * boxPadding,
|
|
);
|
|
break;
|
|
case "diamond":
|
|
boxId = this.addDiamond(
|
|
topX - boxPadding,
|
|
topY - boxPadding,
|
|
width + 2 * boxPadding,
|
|
height + 2 * boxPadding,
|
|
);
|
|
break;
|
|
case "blob":
|
|
boxId = this.addBlob(
|
|
topX - boxPadding,
|
|
topY - boxPadding,
|
|
width + 2 * boxPadding,
|
|
height + 2 * boxPadding,
|
|
);
|
|
break;
|
|
default:
|
|
boxId = this.addRect(
|
|
topX - boxPadding,
|
|
topY - boxPadding,
|
|
width + 2 * boxPadding,
|
|
height + 2 * boxPadding,
|
|
);
|
|
}
|
|
}
|
|
//const ea = window.ExcalidrawAutomate;
|
|
const isContainerBound = formatting?.box && formatting.box !== "blob";
|
|
this.elementsDict[id] = {
|
|
text,
|
|
fontSize: this.style.fontSize,
|
|
fontFamily: this.style.fontFamily,
|
|
textAlign: formatting?.textAlign
|
|
? formatting.textAlign
|
|
: this.style.textAlign,
|
|
verticalAlign: this.style.verticalAlign,
|
|
baseline,
|
|
...boxedElement(id, "text", topX, topY, width, height),
|
|
containerId: isContainerBound ? boxId : null,
|
|
originalText: isContainerBound ? originalText : text,
|
|
rawText: isContainerBound ? originalText : text,
|
|
};
|
|
if (boxId && formatting?.box === "blob") {
|
|
this.addToGroup([id, boxId]);
|
|
}
|
|
if (boxId && formatting?.box !== "blob") {
|
|
const box = this.elementsDict[boxId];
|
|
if (!box.boundElements) {
|
|
box.boundElements = [];
|
|
}
|
|
box.boundElements.push({ type: "text", id });
|
|
}
|
|
return boxId ?? id;
|
|
},
|
|
addLine(points: [[x: number, y: number]]): string {
|
|
const box = getLineBox(points);
|
|
const id = nanoid();
|
|
//this.elementIds.push(id);
|
|
this.elementsDict[id] = {
|
|
points: normalizeLinePoints(points),
|
|
lastCommittedPoint: null,
|
|
startBinding: null,
|
|
endBinding: null,
|
|
startArrowhead: null,
|
|
endArrowhead: null,
|
|
...boxedElement(id, "line", points[0][0], points[0][1], box.w, box.h),
|
|
};
|
|
return id;
|
|
},
|
|
addArrow(
|
|
points: [[x: number, y: number]],
|
|
formatting?: {
|
|
startArrowHead?: string;
|
|
endArrowHead?: string;
|
|
startObjectId?: string;
|
|
endObjectId?: string;
|
|
},
|
|
): string {
|
|
const box = getLineBox(points);
|
|
const id = nanoid();
|
|
const startPoint = points[0];
|
|
const endPoint = points[points.length - 1];
|
|
//this.elementIds.push(id);
|
|
this.elementsDict[id] = {
|
|
points: normalizeLinePoints(points),
|
|
lastCommittedPoint: null,
|
|
startBinding: {
|
|
elementId: formatting?.startObjectId,
|
|
focus: formatting?.startObjectId
|
|
? determineFocusDistance(
|
|
this.getElement(formatting?.startObjectId),
|
|
endPoint,
|
|
startPoint,
|
|
)
|
|
: 0.1,
|
|
gap: GAP,
|
|
},
|
|
endBinding: {
|
|
elementId: formatting?.endObjectId,
|
|
focus: formatting?.endObjectId
|
|
? determineFocusDistance(
|
|
this.getElement(formatting?.endObjectId),
|
|
startPoint,
|
|
endPoint,
|
|
)
|
|
: 0.1,
|
|
gap: GAP,
|
|
},
|
|
//https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/388
|
|
startArrowhead:
|
|
typeof formatting?.startArrowHead !== "undefined"
|
|
? formatting.startArrowHead
|
|
: this.style.startArrowHead,
|
|
endArrowhead:
|
|
typeof formatting?.endArrowHead !== "undefined"
|
|
? formatting.endArrowHead
|
|
: this.style.endArrowHead,
|
|
...boxedElement(id, "arrow", points[0][0], points[0][1], box.w, box.h),
|
|
};
|
|
if (formatting?.startObjectId) {
|
|
if (!this.elementsDict[formatting.startObjectId].boundElements) {
|
|
this.elementsDict[formatting.startObjectId].boundElements = [];
|
|
}
|
|
this.elementsDict[formatting.startObjectId].boundElements.push({
|
|
type: "arrow",
|
|
id,
|
|
});
|
|
}
|
|
if (formatting?.endObjectId) {
|
|
if (!this.elementsDict[formatting.endObjectId].boundElements) {
|
|
this.elementsDict[formatting.endObjectId].boundElements = [];
|
|
}
|
|
this.elementsDict[formatting.endObjectId].boundElements.push({
|
|
type: "arrow",
|
|
id,
|
|
});
|
|
}
|
|
return id;
|
|
},
|
|
async addImage(
|
|
topX: number,
|
|
topY: number,
|
|
imageFile: TFile,
|
|
): Promise<string> {
|
|
const id = nanoid();
|
|
const loader = new EmbeddedFilesLoader(
|
|
this.plugin,
|
|
this.canvas.theme === "dark",
|
|
);
|
|
const image = await loader.getObsidianImage(imageFile);
|
|
if (!image) {
|
|
return null;
|
|
}
|
|
this.imagesDict[image.fileId] = {
|
|
mimeType: image.mimeType,
|
|
id: image.fileId,
|
|
dataURL: image.dataURL,
|
|
created: image.created,
|
|
file: imageFile.path,
|
|
hasSVGwithBitmap: image.hasSVGwithBitmap,
|
|
latex: null,
|
|
};
|
|
if (Math.max(image.size.width, image.size.height) > MAX_IMAGE_SIZE) {
|
|
const scale =
|
|
MAX_IMAGE_SIZE / Math.max(image.size.width, image.size.height);
|
|
image.size.width = scale * image.size.width;
|
|
image.size.height = scale * image.size.height;
|
|
}
|
|
this.elementsDict[id] = boxedElement(
|
|
id,
|
|
"image",
|
|
topX,
|
|
topY,
|
|
image.size.width,
|
|
image.size.height,
|
|
);
|
|
this.elementsDict[id].fileId = image.fileId;
|
|
this.elementsDict[id].scale = [1, 1];
|
|
return id;
|
|
},
|
|
async addLaTex(topX: number, topY: number, tex: string): Promise<string> {
|
|
const id = nanoid();
|
|
const image = await tex2dataURL(tex, this.plugin);
|
|
if (!image) {
|
|
return null;
|
|
}
|
|
this.imagesDict[image.fileId] = {
|
|
mimeType: image.mimeType,
|
|
id: image.fileId,
|
|
dataURL: image.dataURL,
|
|
created: image.created,
|
|
file: null,
|
|
hasSVGwithBitmap: false,
|
|
latex: tex,
|
|
};
|
|
this.elementsDict[id] = boxedElement(
|
|
id,
|
|
"image",
|
|
topX,
|
|
topY,
|
|
image.size.width,
|
|
image.size.height,
|
|
);
|
|
this.elementsDict[id].fileId = image.fileId;
|
|
this.elementsDict[id].scale = [1, 1];
|
|
return id;
|
|
},
|
|
connectObjects(
|
|
objectA: string,
|
|
connectionA: ConnectionPoint,
|
|
objectB: string,
|
|
connectionB: ConnectionPoint,
|
|
formatting?: {
|
|
numberOfPoints?: number;
|
|
startArrowHead?: string;
|
|
endArrowHead?: string;
|
|
padding?: number;
|
|
},
|
|
): void {
|
|
if (!(this.elementsDict[objectA] && this.elementsDict[objectB])) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
["line", "arrow", "freedraw"].includes(
|
|
this.elementsDict[objectA].type,
|
|
) ||
|
|
["line", "arrow", "freedraw"].includes(this.elementsDict[objectB].type)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const padding = formatting?.padding ? formatting.padding : 10;
|
|
const numberOfPoints = formatting?.numberOfPoints
|
|
? formatting.numberOfPoints
|
|
: 0;
|
|
const getSidePoints = (side: string, el: any) => {
|
|
switch (side) {
|
|
case "bottom":
|
|
return [(el.x + (el.x + el.width)) / 2, el.y + el.height + padding];
|
|
case "left":
|
|
return [el.x - padding, (el.y + (el.y + el.height)) / 2];
|
|
case "right":
|
|
return [el.x + el.width + padding, (el.y + (el.y + el.height)) / 2];
|
|
default:
|
|
//"top"
|
|
return [(el.x + (el.x + el.width)) / 2, el.y - padding];
|
|
}
|
|
};
|
|
let aX;
|
|
let aY;
|
|
let bX;
|
|
let bY;
|
|
const elA = this.elementsDict[objectA];
|
|
const elB = this.elementsDict[objectB];
|
|
if (!connectionA || !connectionB) {
|
|
const aCenterX = elA.x + elA.width / 2;
|
|
const bCenterX = elB.x + elB.width / 2;
|
|
const aCenterY = elA.y + elA.height / 2;
|
|
const bCenterY = elB.y + elB.height / 2;
|
|
if (!connectionA) {
|
|
const intersect = intersectElementWithLine(
|
|
elA,
|
|
[bCenterX, bCenterY],
|
|
[aCenterX, aCenterY],
|
|
GAP,
|
|
);
|
|
if (intersect.length === 0) {
|
|
[aX, aY] = [aCenterX, aCenterY];
|
|
} else {
|
|
[aX, aY] = intersect[0];
|
|
}
|
|
}
|
|
|
|
if (!connectionB) {
|
|
const intersect = intersectElementWithLine(
|
|
elB,
|
|
[aCenterX, aCenterY],
|
|
[bCenterX, bCenterY],
|
|
GAP,
|
|
);
|
|
if (intersect.length === 0) {
|
|
[bX, bY] = [bCenterX, bCenterY];
|
|
} else {
|
|
[bX, bY] = intersect[0];
|
|
}
|
|
}
|
|
}
|
|
if (connectionA) {
|
|
[aX, aY] = getSidePoints(connectionA, this.elementsDict[objectA]);
|
|
}
|
|
if (connectionB) {
|
|
[bX, bY] = getSidePoints(connectionB, this.elementsDict[objectB]);
|
|
}
|
|
const numAP = numberOfPoints + 2; //number of break points plus the beginning and the end
|
|
const points = [];
|
|
for (let i = 0; i < numAP; i++) {
|
|
points.push([
|
|
aX + (i * (bX - aX)) / (numAP - 1),
|
|
aY + (i * (bY - aY)) / (numAP - 1),
|
|
]);
|
|
}
|
|
this.addArrow(points, {
|
|
startArrowHead: formatting?.startArrowHead,
|
|
endArrowHead: formatting?.endArrowHead,
|
|
startObjectId: objectA,
|
|
endObjectId: objectB,
|
|
});
|
|
},
|
|
clear() {
|
|
this.elementsDict = {};
|
|
this.imagesDict = {};
|
|
},
|
|
reset() {
|
|
this.clear();
|
|
this.activeScript = null;
|
|
this.style.strokeColor = "#000000";
|
|
this.style.backgroundColor = "transparent";
|
|
this.style.angle = 0;
|
|
this.style.fillStyle = "hachure";
|
|
this.style.strokeWidth = 1;
|
|
this.style.strokeStyle = "solid";
|
|
this.style.roughness = 1;
|
|
this.style.opacity = 100;
|
|
this.style.strokeSharpness = "sharp";
|
|
this.style.fontFamily = 1;
|
|
this.style.fontSize = 20;
|
|
this.style.textAlign = "left";
|
|
this.style.verticalAlign = "top";
|
|
this.style.startArrowHead = null;
|
|
this.style.endArrowHead = "arrow";
|
|
this.canvas.theme = "light";
|
|
this.canvas.viewBackgroundColor = "#FFFFFF";
|
|
this.canvas.gridSize = 0;
|
|
},
|
|
isExcalidrawFile(f: TFile): boolean {
|
|
return this.plugin.isExcalidrawFile(f);
|
|
},
|
|
targetView: null,
|
|
setView(view: ExcalidrawView | "first" | "active"): ExcalidrawView {
|
|
if (view == "active") {
|
|
const v = this.plugin.app.workspace.activeLeaf.view;
|
|
if (!(v instanceof ExcalidrawView)) {
|
|
return;
|
|
}
|
|
this.targetView = v;
|
|
}
|
|
if (view == "first") {
|
|
const leaves =
|
|
this.plugin.app.workspace.getLeavesOfType(VIEW_TYPE_EXCALIDRAW);
|
|
if (!leaves || leaves.length == 0) {
|
|
return;
|
|
}
|
|
this.targetView = leaves[0].view as ExcalidrawView;
|
|
}
|
|
if (view instanceof ExcalidrawView) {
|
|
this.targetView = view;
|
|
}
|
|
return this.targetView;
|
|
},
|
|
getExcalidrawAPI(): any {
|
|
if (!this.targetView || !this.targetView?._loaded) {
|
|
errorMessage("targetView not set", "getExcalidrawAPI()");
|
|
return null;
|
|
}
|
|
return (this.targetView as ExcalidrawView).excalidrawAPI;
|
|
},
|
|
getViewElements(): ExcalidrawElement[] {
|
|
if (!this.targetView || !this.targetView?._loaded) {
|
|
errorMessage("targetView not set", "getViewSelectedElements()");
|
|
return [];
|
|
}
|
|
const current = this.targetView?.excalidrawRef?.current;
|
|
if (!current) {
|
|
return [];
|
|
}
|
|
return current?.getSceneElements();
|
|
},
|
|
deleteViewElements(elToDelete: ExcalidrawElement[]): boolean {
|
|
if (!this.targetView || !this.targetView?._loaded) {
|
|
errorMessage("targetView not set", "getViewSelectedElements()");
|
|
return false;
|
|
}
|
|
const current = this.targetView?.excalidrawRef?.current;
|
|
if (!current) {
|
|
return false;
|
|
}
|
|
const el: ExcalidrawElement[] = current.getSceneElements();
|
|
const st: AppState = current.getAppState();
|
|
current.updateScene({
|
|
elements: el.filter((e: ExcalidrawElement) => !elToDelete.includes(e)),
|
|
appState: st,
|
|
commitToHistory: true,
|
|
});
|
|
//this.targetView.save();
|
|
return true;
|
|
},
|
|
getViewSelectedElement(): any {
|
|
const elements = this.getViewSelectedElements();
|
|
return elements ? elements[0] : null;
|
|
},
|
|
getViewSelectedElements(): any[] {
|
|
if (!this.targetView || !this.targetView?._loaded) {
|
|
errorMessage("targetView not set", "getViewSelectedElements()");
|
|
return [];
|
|
}
|
|
const excalidrawAPI = this.targetView?.excalidrawAPI;
|
|
const selectedElements = excalidrawAPI.getAppState()?.selectedElementIds;
|
|
if (!selectedElements) {
|
|
return [];
|
|
}
|
|
const selectedElementsKeys = Object.keys(selectedElements);
|
|
if (!selectedElementsKeys) {
|
|
return [];
|
|
}
|
|
const elements: ExcalidrawElement[] = excalidrawAPI
|
|
.getSceneElements()
|
|
.filter((e: any) => selectedElementsKeys.includes(e.id));
|
|
|
|
const containerBoundTextElmenetsReferencedInElements = elements
|
|
.filter(
|
|
(el) =>
|
|
el.boundElements &&
|
|
el.boundElements.filter((be) => be.type === "text").length > 0,
|
|
)
|
|
.map(
|
|
(el) =>
|
|
el.boundElements
|
|
.filter((be) => be.type === "text")
|
|
.map((be) => be.id)[0],
|
|
);
|
|
|
|
const elementIDs = elements
|
|
.map((el) => el.id)
|
|
.concat(containerBoundTextElmenetsReferencedInElements);
|
|
|
|
return this.getViewElements().filter((el: ExcalidrawElement) =>
|
|
elementIDs.contains(el.id),
|
|
);
|
|
},
|
|
getViewFileForImageElement(el: ExcalidrawElement): TFile | null {
|
|
if (!this.targetView || !this.targetView?._loaded) {
|
|
errorMessage("targetView not set", "getViewSelectedElements()");
|
|
return null;
|
|
}
|
|
if (!el || el.type !== "image") {
|
|
errorMessage(
|
|
"Must provide an image element as input",
|
|
"getViewFileForImageElement()",
|
|
);
|
|
return null;
|
|
}
|
|
return (this.targetView as ExcalidrawView)?.excalidrawData?.getFile(
|
|
el.fileId,
|
|
)?.file;
|
|
},
|
|
copyViewElementsToEAforEditing(elements: ExcalidrawElement[]): void {
|
|
elements.forEach((el) => {
|
|
this.elementsDict[el.id] = {
|
|
...el,
|
|
version: el.version + 1,
|
|
updated: Date.now(),
|
|
versionNonce: Math.floor(Math.random()*1000000000),
|
|
};});
|
|
},
|
|
viewToggleFullScreen(forceViewMode: boolean = false): void {
|
|
if (this.plugin.app.isMobile) {
|
|
errorMessage("mobile not supported", "viewToggleFullScreen()");
|
|
return;
|
|
}
|
|
if (!this.targetView || !this.targetView?._loaded) {
|
|
errorMessage("targetView not set", "viewToggleFullScreen()");
|
|
return;
|
|
}
|
|
if (forceViewMode) {
|
|
const ref = this.getExcalidrawAPI();
|
|
ref.updateScene({
|
|
elements: ref.getSceneElements(),
|
|
appState: {
|
|
viewModeEnabled: true,
|
|
...ref.appState,
|
|
},
|
|
commitToHistory: false,
|
|
});
|
|
}
|
|
const view = this.targetView as ExcalidrawView;
|
|
if(view.isFullscreen()) view.exitFullscreen();
|
|
else view.gotoFullscreen();
|
|
},
|
|
connectObjectWithViewSelectedElement(
|
|
objectA: string,
|
|
connectionA: ConnectionPoint,
|
|
connectionB: ConnectionPoint,
|
|
formatting?: {
|
|
numberOfPoints?: number;
|
|
startArrowHead?: string;
|
|
endArrowHead?: string;
|
|
padding?: number;
|
|
},
|
|
): boolean {
|
|
const el = this.getViewSelectedElement();
|
|
if (!el) {
|
|
return false;
|
|
}
|
|
const id = el.id;
|
|
this.elementsDict[id] = el;
|
|
this.connectObjects(objectA, connectionA, id, connectionB, formatting);
|
|
delete this.elementsDict[id];
|
|
return true;
|
|
},
|
|
async addElementsToView(
|
|
repositionToCursor: boolean = false,
|
|
save: boolean = true,
|
|
newElementsOnTop: boolean = false,
|
|
): Promise<boolean> {
|
|
if (!this.targetView || !this.targetView?._loaded) {
|
|
errorMessage("targetView not set", "addElementsToView()");
|
|
return false;
|
|
}
|
|
const elements = this.getElements();
|
|
return await this.targetView.addElements(
|
|
elements,
|
|
repositionToCursor,
|
|
save,
|
|
this.imagesDict,
|
|
newElementsOnTop,
|
|
);
|
|
},
|
|
onDropHook: null,
|
|
mostRecentMarkdownSVG: null,
|
|
getEmbeddedFilesLoader(isDark?: boolean): EmbeddedFilesLoader {
|
|
return new EmbeddedFilesLoader(this.plugin, isDark);
|
|
},
|
|
getExportSettings(
|
|
withBackground: boolean,
|
|
withTheme: boolean,
|
|
): ExportSettings {
|
|
return { withBackground, withTheme };
|
|
},
|
|
getBoundingBox(elements: ExcalidrawElement[]): {
|
|
topX: number;
|
|
topY: number;
|
|
width: number;
|
|
height: number;
|
|
} {
|
|
const bb = getCommonBoundingBox(elements);
|
|
return {
|
|
topX: bb.minX,
|
|
topY: bb.minY,
|
|
width: bb.maxX - bb.minX,
|
|
height: bb.maxY - bb.minY,
|
|
};
|
|
},
|
|
getMaximumGroups(elements: ExcalidrawElement[]): ExcalidrawElement[][] {
|
|
return getMaximumGroups(elements);
|
|
},
|
|
getLargestElement(elements: ExcalidrawElement[]): ExcalidrawElement {
|
|
if (!elements || elements.length === 0) {
|
|
return null;
|
|
}
|
|
let largestElement = elements[0];
|
|
const getSize = (el: ExcalidrawElement): Number => {
|
|
return el.height * el.width;
|
|
};
|
|
let largetstSize = getSize(elements[0]);
|
|
for (let i = 1; i < elements.length; i++) {
|
|
const size = getSize(elements[i]);
|
|
if (size > largetstSize) {
|
|
largetstSize = size;
|
|
largestElement = elements[i];
|
|
}
|
|
}
|
|
return largestElement;
|
|
},
|
|
intersectElementWithLine(
|
|
element: ExcalidrawBindableElement,
|
|
a: readonly [number, number],
|
|
b: readonly [number, number],
|
|
gap?: number,
|
|
): Point[] {
|
|
return intersectElementWithLine(element, a, b, gap);
|
|
},
|
|
activeScript: null,
|
|
getScriptSettings(): {} {
|
|
if (!this.activeScript) {
|
|
return null;
|
|
}
|
|
return this.plugin.settings.scriptEngineSettings[this.activeScript] ?? {};
|
|
},
|
|
async setScriptSettings(settings: any): Promise<void> {
|
|
if (!this.activeScript) {
|
|
return null;
|
|
}
|
|
this.plugin.settings.scriptEngineSettings[this.activeScript] = settings;
|
|
await this.plugin.saveSettings();
|
|
},
|
|
openFileInNewOrAdjacentLeaf(file: TFile): WorkspaceLeaf {
|
|
if (!file || !(file instanceof TFile)) {
|
|
return null;
|
|
}
|
|
if (!this.targetView) {
|
|
return null;
|
|
}
|
|
const leaf = getNewOrAdjacentLeaf(this.plugin, this.targetView.leaf);
|
|
leaf.openFile(file);
|
|
return leaf;
|
|
},
|
|
measureText(text: string): { width: number; height: number } {
|
|
const size = measureText(
|
|
text,
|
|
this.style.fontSize,
|
|
this.style.fontFamily,
|
|
);
|
|
return { width: size.w, height: size.h };
|
|
},
|
|
verifyMinimumPluginVersion(requiredVersion: string): boolean {
|
|
const manifest = this.plugin.app.plugins.manifests[PLUGIN_ID];
|
|
return manifest.version >= requiredVersion;
|
|
},
|
|
selectElementsInView(elements: ExcalidrawElement[]):void {
|
|
if (!this.targetView || !this.targetView?._loaded) {
|
|
errorMessage("targetView not set", "selectElementsInView()");
|
|
return;
|
|
}
|
|
if (!elements || elements.length===0) {
|
|
return;
|
|
}
|
|
const API = this.getExcalidrawAPI();
|
|
API.selectElements(elements);
|
|
},
|
|
generateElementId(): string {
|
|
return nanoid();
|
|
},
|
|
cloneElement(element: ExcalidrawElement): ExcalidrawElement{
|
|
const newEl = JSON.parse(JSON.stringify(element));
|
|
newEl.id = nanoid();
|
|
return newEl;
|
|
},
|
|
moveViewElementToZIndex(elementId:number, newZIndex:number): void {
|
|
if (!this.targetView || !this.targetView?._loaded) {
|
|
errorMessage("targetView not set", "moveViewElementToZIndex()");
|
|
return;
|
|
}
|
|
const API = this.getExcalidrawAPI();
|
|
const elements = this.getViewElements();
|
|
const elementToMove = elements.filter((el:any)=>el.id===elementId);
|
|
if (elementToMove.length === 0) {
|
|
errorMessage(`Element (id: ${elementId}) not found`, "moveViewElementToZIndex");
|
|
return;
|
|
}
|
|
if (newZIndex >= elements.length) {
|
|
API.bringToFront(elementToMove);
|
|
return;
|
|
}
|
|
if (newZIndex < 0) {
|
|
API.sendToBack(elementToMove);
|
|
return;
|
|
}
|
|
|
|
const oldZIndex = elements.indexOf(elementToMove[0]);
|
|
elements.splice(newZIndex, 0, elements.splice(oldZIndex, 1)[0]);
|
|
API.updateScene({
|
|
elements: elements,
|
|
commitToHistory: true,
|
|
});
|
|
},
|
|
hexStringToRgb(color: string):number[] {
|
|
const res = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
|
|
return [parseInt(res[1], 16), parseInt(res[2], 16), parseInt(res[3], 16)];
|
|
},
|
|
rgbToHexString(color: number[]):string {
|
|
const colorInt =
|
|
((Math.round(color[0]) & 0xff) << 16) +
|
|
((Math.round(color[1]) & 0xff) << 8) +
|
|
(Math.round(color[2]) & 0xff);
|
|
const colorStr = colorInt.toString(16).toLowerCase();
|
|
return "#"+"000000".substring(colorStr.length) + colorStr;
|
|
},
|
|
hslToRgb(color: number[]):number[] {
|
|
const h = color[0] / 360;
|
|
const s = color[1] / 100;
|
|
const l = color[2] / 100;
|
|
let t2;
|
|
let t3;
|
|
let val;
|
|
|
|
if (s === 0) {
|
|
val = l * 255;
|
|
return [val, val, val];
|
|
}
|
|
|
|
if (l < 0.5) {
|
|
t2 = l * (1 + s);
|
|
} else {
|
|
t2 = l + s - l * s;
|
|
}
|
|
|
|
const t1 = 2 * l - t2;
|
|
|
|
const rgb = [0, 0, 0];
|
|
for (let i = 0; i < 3; i++) {
|
|
t3 = h + (1 / 3) * -(i - 1);
|
|
if (t3 < 0) {
|
|
t3++;
|
|
}
|
|
|
|
if (t3 > 1) {
|
|
t3--;
|
|
}
|
|
|
|
if (6 * t3 < 1) {
|
|
val = t1 + (t2 - t1) * 6 * t3;
|
|
} else if (2 * t3 < 1) {
|
|
val = t2;
|
|
} else if (3 * t3 < 2) {
|
|
val = t1 + (t2 - t1) * (2 / 3 - t3) * 6;
|
|
} else {
|
|
val = t1;
|
|
}
|
|
|
|
rgb[i] = val * 255;
|
|
}
|
|
return rgb;
|
|
},
|
|
rgbToHsl(color:number[]):number[] {
|
|
const r = color[0] / 255;
|
|
const g = color[1] / 255;
|
|
const b = color[2] / 255;
|
|
const min = Math.min(r, g, b);
|
|
const max = Math.max(r, g, b);
|
|
const delta = max - min;
|
|
let h;
|
|
let s;
|
|
|
|
if (max === min) {
|
|
h = 0;
|
|
} else if (r === max) {
|
|
h = (g - b) / delta;
|
|
} else if (g === max) {
|
|
h = 2 + (b - r) / delta;
|
|
} else if (b === max) {
|
|
h = 4 + (r - g) / delta;
|
|
}
|
|
|
|
h = Math.min(h * 60, 360);
|
|
|
|
if (h < 0) {
|
|
h += 360;
|
|
}
|
|
|
|
const l = (min + max) / 2;
|
|
|
|
if (max === min) {
|
|
s = 0;
|
|
} else if (l <= 0.5) {
|
|
s = delta / (max + min);
|
|
} else {
|
|
s = delta / (2 - max - min);
|
|
}
|
|
|
|
return [h, s * 100, l * 100];
|
|
},
|
|
colorNameToHex(color:string):string {
|
|
if (COLOR_NAMES.has(color.toLowerCase().trim())) {
|
|
return COLOR_NAMES.get(color.toLowerCase().trim());
|
|
}
|
|
return color.trim();
|
|
}
|
|
};
|
|
await initFonts();
|
|
return window.ExcalidrawAutomate;
|
|
}
|
|
|
|
export function destroyExcalidrawAutomate() {
|
|
delete window.ExcalidrawAutomate;
|
|
}
|
|
|
|
function normalizeLinePoints(
|
|
points: [[x: number, y: number]],
|
|
//box: { x: number; y: number; w: number; h: number },
|
|
) {
|
|
const p = [];
|
|
const [x, y] = points[0];
|
|
for (let i = 0; i < points.length; i++) {
|
|
p.push([points[i][0] - x, points[i][1] - y]);
|
|
}
|
|
return p;
|
|
}
|
|
|
|
function boxedElement(
|
|
id: string,
|
|
eltype: any,
|
|
x: number,
|
|
y: number,
|
|
w: number,
|
|
h: number,
|
|
) {
|
|
const ea = window.ExcalidrawAutomate;
|
|
return {
|
|
id,
|
|
type: eltype,
|
|
x,
|
|
y,
|
|
width: w,
|
|
height: h,
|
|
angle: ea.style.angle,
|
|
strokeColor: ea.style.strokeColor,
|
|
backgroundColor: ea.style.backgroundColor,
|
|
fillStyle: ea.style.fillStyle,
|
|
strokeWidth: ea.style.strokeWidth,
|
|
strokeStyle: ea.style.strokeStyle,
|
|
roughness: ea.style.roughness,
|
|
opacity: ea.style.opacity,
|
|
strokeSharpness: ea.style.strokeSharpness,
|
|
seed: Math.floor(Math.random() * 100000),
|
|
version: 1,
|
|
versionNonce: Math.floor(Math.random()*1000000000),
|
|
updated: Date.now(),
|
|
isDeleted: false,
|
|
groupIds: [] as any,
|
|
boundElements: [] as any,
|
|
link: null as string,
|
|
};
|
|
}
|
|
|
|
function getLineBox(points: [[x: number, y: number]]) {
|
|
const [x1, y1, x2, y2] = estimateLineBound(points);
|
|
return {
|
|
x: x1,
|
|
y: y1,
|
|
w: x2 - x1, //Math.abs(points[points.length-1][0]-points[0][0]),
|
|
h: y2 - y1, //Math.abs(points[points.length-1][1]-points[0][1])
|
|
};
|
|
}
|
|
|
|
function getFontFamily(id: number) {
|
|
switch (id) {
|
|
case 1:
|
|
return "Virgil, Segoe UI Emoji";
|
|
case 2:
|
|
return "Helvetica, Segoe UI Emoji";
|
|
case 3:
|
|
return "Cascadia, Segoe UI Emoji";
|
|
case 4:
|
|
return "LocalFont";
|
|
}
|
|
}
|
|
|
|
async function initFonts() {
|
|
for (let i = 1; i <= 3; i++) {
|
|
await (document as any).fonts.load(`20px ${getFontFamily(i)}`);
|
|
}
|
|
}
|
|
|
|
export function measureText(
|
|
newText: string,
|
|
fontSize: number,
|
|
fontFamily: number,
|
|
) {
|
|
//following odd error with mindmap on iPad while synchornizing with desktop.
|
|
if(!fontSize) fontSize = 20;
|
|
if(!fontFamily) fontFamily = 1;
|
|
const line = document.createElement("div");
|
|
const body = document.body;
|
|
line.style.position = "absolute";
|
|
line.style.whiteSpace = "pre";
|
|
line.style.font = `${fontSize.toString()}px ${getFontFamily(fontFamily)}`;
|
|
body.appendChild(line);
|
|
line.innerText = newText
|
|
.split("\n")
|
|
// replace empty lines with single space because leading/trailing empty
|
|
// lines would be stripped from computation
|
|
.map((x) => x || " ")
|
|
.join("\n");
|
|
const width = line.offsetWidth;
|
|
const height = line.offsetHeight;
|
|
// Now creating 1px sized item that will be aligned to baseline
|
|
// to calculate baseline shift
|
|
const span = document.createElement("span");
|
|
span.style.display = "inline-block";
|
|
span.style.overflow = "hidden";
|
|
span.style.width = "1px";
|
|
span.style.height = "1px";
|
|
line.appendChild(span);
|
|
// Baseline is important for positioning text on canvas
|
|
const baseline = span.offsetTop + span.offsetHeight;
|
|
document.body.removeChild(line);
|
|
return { w: width, h: height, baseline };
|
|
}
|
|
|
|
async function getTemplate(
|
|
plugin: ExcalidrawPlugin,
|
|
fileWithPath: string,
|
|
loadFiles: boolean = false,
|
|
loader: EmbeddedFilesLoader,
|
|
): Promise<{
|
|
elements: any;
|
|
appState: any;
|
|
frontmatter: string;
|
|
files: any;
|
|
hasSVGwithBitmap: boolean;
|
|
}> {
|
|
const app = plugin.app;
|
|
const vault = app.vault;
|
|
const templatePath = normalizePath(fileWithPath);
|
|
const file = app.metadataCache.getFirstLinkpathDest(templatePath, "");
|
|
let hasSVGwithBitmap = false;
|
|
if (file && file instanceof TFile) {
|
|
const data = (await vault.read(file))
|
|
.replaceAll("\r\n", "\n")
|
|
.replaceAll("\r", "\n");
|
|
const excalidrawData: ExcalidrawData = new ExcalidrawData(plugin);
|
|
|
|
if (file.extension === "excalidraw") {
|
|
await excalidrawData.loadLegacyData(data, file);
|
|
return {
|
|
elements: excalidrawData.scene.elements,
|
|
appState: excalidrawData.scene.appState,
|
|
frontmatter: "",
|
|
files: excalidrawData.scene.files,
|
|
hasSVGwithBitmap,
|
|
};
|
|
}
|
|
|
|
const parsed =
|
|
data.search("excalidraw-plugin: parsed\n") > -1 ||
|
|
data.search("excalidraw-plugin: locked\n") > -1; //locked for backward compatibility
|
|
await excalidrawData.loadData(
|
|
data,
|
|
file,
|
|
parsed ? TextMode.parsed : TextMode.raw,
|
|
);
|
|
|
|
let trimLocation = data.search("# Text Elements\n");
|
|
if (trimLocation == -1) {
|
|
trimLocation = data.search("# Drawing\n");
|
|
}
|
|
|
|
let scene = excalidrawData.scene;
|
|
if (loadFiles) {
|
|
//debug({where:"getTemplate",template:file.name,loader:loader.uid});
|
|
await loader.loadSceneFiles(excalidrawData, (fileArray: FileData[]) => {
|
|
//, isDark: boolean) => {
|
|
if (!fileArray || fileArray.length === 0) {
|
|
return;
|
|
}
|
|
for (const f of fileArray) {
|
|
if (f.hasSVGwithBitmap) {
|
|
hasSVGwithBitmap = true;
|
|
}
|
|
excalidrawData.scene.files[f.id] = {
|
|
mimeType: f.mimeType,
|
|
id: f.id,
|
|
dataURL: f.dataURL,
|
|
created: f.created,
|
|
};
|
|
}
|
|
scene = scaleLoadedImage(excalidrawData.scene, fileArray).scene;
|
|
});
|
|
}
|
|
|
|
return {
|
|
elements: scene.elements,
|
|
appState: scene.appState,
|
|
frontmatter: data.substring(0, trimLocation),
|
|
files: scene.files,
|
|
hasSVGwithBitmap,
|
|
};
|
|
}
|
|
return {
|
|
elements: [],
|
|
appState: {},
|
|
frontmatter: null,
|
|
files: [],
|
|
hasSVGwithBitmap,
|
|
};
|
|
}
|
|
|
|
export async function createPNG(
|
|
templatePath: string = undefined,
|
|
scale: number = 1,
|
|
exportSettings: ExportSettings,
|
|
loader: EmbeddedFilesLoader,
|
|
forceTheme: string = undefined,
|
|
canvasTheme: string = undefined,
|
|
canvasBackgroundColor: string = undefined,
|
|
automateElements: ExcalidrawElement[] = [],
|
|
plugin: ExcalidrawPlugin,
|
|
) {
|
|
if (!loader) {
|
|
loader = new EmbeddedFilesLoader(plugin);
|
|
}
|
|
const template = templatePath
|
|
? await getTemplate(plugin, templatePath, true, loader)
|
|
: null;
|
|
let elements = template?.elements ?? [];
|
|
elements = elements.concat(automateElements);
|
|
return await getPNG(
|
|
{
|
|
type: "excalidraw",
|
|
version: 2,
|
|
source: "https://excalidraw.com",
|
|
elements,
|
|
appState: {
|
|
theme: forceTheme ?? template?.appState?.theme ?? canvasTheme,
|
|
viewBackgroundColor:
|
|
template?.appState?.viewBackgroundColor ?? canvasBackgroundColor,
|
|
},
|
|
files: template?.files ?? {},
|
|
},
|
|
{
|
|
withBackground:
|
|
exportSettings?.withBackground ?? plugin.settings.exportWithBackground,
|
|
withTheme: exportSettings?.withTheme ?? plugin.settings.exportWithTheme,
|
|
},
|
|
scale,
|
|
);
|
|
}
|
|
|
|
export async function createSVG(
|
|
templatePath: string = undefined,
|
|
embedFont: boolean = false,
|
|
exportSettings: ExportSettings,
|
|
loader: EmbeddedFilesLoader,
|
|
forceTheme: string = undefined,
|
|
canvasTheme: string = undefined,
|
|
canvasBackgroundColor: string = undefined,
|
|
automateElements: ExcalidrawElement[] = [],
|
|
plugin: ExcalidrawPlugin,
|
|
): Promise<SVGSVGElement> {
|
|
if (!loader) {
|
|
loader = new EmbeddedFilesLoader(plugin);
|
|
}
|
|
const template = templatePath
|
|
? await getTemplate(plugin, templatePath, true, loader)
|
|
: null;
|
|
let elements = template?.elements ?? [];
|
|
elements = elements.concat(automateElements);
|
|
const svg = await getSVG(
|
|
{
|
|
//createAndOpenDrawing
|
|
type: "excalidraw",
|
|
version: 2,
|
|
source: "https://excalidraw.com",
|
|
elements,
|
|
appState: {
|
|
theme: forceTheme ?? template?.appState?.theme ?? canvasTheme,
|
|
viewBackgroundColor:
|
|
template?.appState?.viewBackgroundColor ?? canvasBackgroundColor,
|
|
},
|
|
files: template?.files ?? {},
|
|
},
|
|
{
|
|
withBackground:
|
|
exportSettings?.withBackground ?? plugin.settings.exportWithBackground,
|
|
withTheme: exportSettings?.withTheme ?? plugin.settings.exportWithTheme,
|
|
},
|
|
plugin.settings.exportPaddingSVG,
|
|
);
|
|
if (template?.hasSVGwithBitmap) {
|
|
svg.setAttribute("hasbitmap", "true");
|
|
}
|
|
return embedFont ? embedFontsInSVG(svg, plugin) : svg;
|
|
}
|
|
|
|
function estimateLineBound(points: any): [number, number, number, number] {
|
|
let minX = Infinity;
|
|
let minY = Infinity;
|
|
let maxX = -Infinity;
|
|
let maxY = -Infinity;
|
|
|
|
for (const [x, y] of points) {
|
|
minX = Math.min(minX, x);
|
|
minY = Math.min(minY, y);
|
|
maxX = Math.max(maxX, x);
|
|
maxY = Math.max(maxY, y);
|
|
}
|
|
|
|
return [minX, minY, maxX, maxY];
|
|
}
|
|
|
|
export function estimateBounds(
|
|
elements: ExcalidrawElement[],
|
|
): [number, number, number, number] {
|
|
const bb = getCommonBoundingBox(elements);
|
|
return [bb.minX, bb.minY, bb.maxX, bb.maxY];
|
|
}
|
|
|
|
export function repositionElementsToCursor(
|
|
elements: ExcalidrawElement[],
|
|
newPosition: { x: number; y: number },
|
|
center: boolean = false,
|
|
): ExcalidrawElement[] {
|
|
const [x1, y1, x2, y2] = estimateBounds(elements);
|
|
let [offsetX, offsetY] = [0, 0];
|
|
if (center) {
|
|
[offsetX, offsetY] = [
|
|
newPosition.x - (x1 + x2) / 2,
|
|
newPosition.y - (y1 + y2) / 2,
|
|
];
|
|
} else {
|
|
[offsetX, offsetY] = [newPosition.x - x1, newPosition.y - y1];
|
|
}
|
|
|
|
elements.forEach((element: any) => {
|
|
//using any so I can write read-only propery x & y
|
|
element.x = element.x + offsetX;
|
|
element.y = element.y + offsetY;
|
|
});
|
|
return elements;
|
|
}
|
|
|
|
function errorMessage(message: string, source: string) {
|
|
switch (message) {
|
|
case "targetView not set":
|
|
errorlog({
|
|
where: "ExcalidrawAutomate",
|
|
source,
|
|
message:
|
|
"targetView not set, or no longer active. Use setView before calling this function",
|
|
});
|
|
break;
|
|
case "mobile not supported":
|
|
errorlog({
|
|
where: "ExcalidrawAutomate",
|
|
source,
|
|
message: "this function is not avalable on Obsidian Mobile",
|
|
});
|
|
break;
|
|
default:
|
|
errorlog({
|
|
where: "ExcalidrawAutomate",
|
|
source,
|
|
message: "unknown error",
|
|
});
|
|
}
|
|
}
|