mirror of
https://github.com/zsviczian/obsidian-excalidraw-plugin.git
synced 2025-08-06 05:46:28 +00:00
Compare commits
2 Commits
1.7.26
...
shape-arit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
225c6305d1 | ||
|
|
ba9ab61cc9 |
@@ -26,7 +26,9 @@
|
||||
"react-dom": "^17.0.2",
|
||||
"react-scripts": "^5.0.1",
|
||||
"roughjs": "^4.5.2",
|
||||
"colormaster": "1.2.1"
|
||||
"colormaster": "1.2.1",
|
||||
"chroma-js": "^2.4.2",
|
||||
"gl-matrix": "^3.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.16.12",
|
||||
@@ -41,6 +43,7 @@
|
||||
"@rollup/plugin-replace": "^3.0.1",
|
||||
"@rollup/plugin-typescript": "^8.3.0",
|
||||
"@types/js-beautify": "^1.13.3",
|
||||
"@types/chroma-js": "^2.1.4",
|
||||
"@types/node": "^15.12.4",
|
||||
"@types/react-dom": "^17.0.2",
|
||||
"@zerollup/ts-transform-paths": "^1.7.18",
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
NonDeletedExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
} from "@zsviczian/excalidraw/types/element/types";
|
||||
import { normalizePath, TFile, WorkspaceLeaf } from "obsidian";
|
||||
import { normalizePath, Notice, TFile, WorkspaceLeaf } from "obsidian";
|
||||
import ExcalidrawView, { ExportSettings, TextMode } from "./ExcalidrawView";
|
||||
import { ExcalidrawData } from "./ExcalidrawData";
|
||||
import {
|
||||
@@ -58,6 +58,7 @@ import HSVPlugin from "colormaster/plugins/hsv";
|
||||
import RYBPlugin from "colormaster/plugins/ryb";
|
||||
import CMYKPlugin from "colormaster/plugins/cmyk";
|
||||
import { TInput } from "colormaster/types";
|
||||
import {ConversionResult, svgToExcalidraw} from "./svgToExcalidraw/parser"
|
||||
|
||||
extendPlugins([
|
||||
HarmonyPlugin,
|
||||
@@ -1873,6 +1874,16 @@ export class ExcalidrawAutomate implements ExcalidrawAutomateInterface {
|
||||
|
||||
return CM(color);
|
||||
}
|
||||
|
||||
importSVG(svgString:string):boolean {
|
||||
const res:ConversionResult = svgToExcalidraw(svgString);
|
||||
if(res.hasErrors) {
|
||||
new Notice (`There were errors while parsing the given SVG:\n${[...res.errors].map((el) => el.innerHTML)}`);
|
||||
return false;
|
||||
}
|
||||
this.copyViewElementsToEAforEditing(res.content);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export async function initExcalidrawAutomate(
|
||||
|
||||
@@ -306,7 +306,6 @@ export default class ExcalidrawView extends TextFileView {
|
||||
if (!this.getScene || !this.file) {
|
||||
return;
|
||||
}
|
||||
//@ts-ignore
|
||||
if (app.isMobile) {
|
||||
const prompt = new Prompt(
|
||||
app,
|
||||
@@ -651,7 +650,7 @@ export default class ExcalidrawView extends TextFileView {
|
||||
if (this.toolsPanelRef && this.toolsPanelRef.current) {
|
||||
this.toolsPanelRef.current.setFullscreen(true);
|
||||
}
|
||||
if (app.isMobile) {
|
||||
if (this.plugin.device.isPhone) {
|
||||
if(Platform.isIosApp) {
|
||||
this.restoreMobileLeaves();
|
||||
app.workspace.getLayout().main.children
|
||||
@@ -735,7 +734,7 @@ export default class ExcalidrawView extends TextFileView {
|
||||
if (this.toolsPanelRef && this.toolsPanelRef.current) {
|
||||
this.toolsPanelRef.current.setFullscreen(false);
|
||||
}
|
||||
if (app.isMobile) {
|
||||
if (this.plugin.device.isPhone) {
|
||||
this.restoreMobileLeaves();
|
||||
const oldStylesheet = document.getElementById("excalidraw-full-screen");
|
||||
if (oldStylesheet) {
|
||||
@@ -2354,7 +2353,7 @@ export default class ExcalidrawView extends TextFileView {
|
||||
elements,
|
||||
commitToHistory: true,
|
||||
},
|
||||
false,
|
||||
true, //set to true because svtToExcalidraw generates a legacy Excalidraw object
|
||||
true
|
||||
);
|
||||
|
||||
@@ -2796,7 +2795,7 @@ export default class ExcalidrawView extends TextFileView {
|
||||
if (this.semaphores.justLoaded) {
|
||||
this.semaphores.justLoaded = false;
|
||||
if (!this.semaphores.preventAutozoom) {
|
||||
this.zoomToFit(false);
|
||||
this.zoomToFit(false,true);
|
||||
}
|
||||
this.previousSceneVersion = this.getSceneVersion(et);
|
||||
this.previousBackgroundColor = st.viewBackgroundColor;
|
||||
@@ -3396,13 +3395,17 @@ export default class ExcalidrawView extends TextFileView {
|
||||
}
|
||||
}
|
||||
|
||||
public zoomToFit(delay: boolean = true) {
|
||||
public zoomToFit(delay: boolean = true, justLoaded: boolean = false) {
|
||||
const api = this.excalidrawAPI;
|
||||
if (!api || !this.excalidrawRef || this.semaphores.isEditingText) {
|
||||
return;
|
||||
}
|
||||
const maxZoom = this.plugin.settings.zoomToFitMaxLevel;
|
||||
const elements = api.getSceneElements().filter((el:ExcalidrawElement)=>el.width<10000 && el.height<10000);
|
||||
if((app.isMobile && elements.length>1000) || elements.length>2500) {
|
||||
if(justLoaded) api.scrollToContent();
|
||||
return;
|
||||
}
|
||||
if (delay) {
|
||||
//time for the DOM to render, I am sure there is a more elegant solution
|
||||
setTimeout(
|
||||
|
||||
54
src/dialogs/ImportSVGDialog.ts
Normal file
54
src/dialogs/ImportSVGDialog.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { App, FuzzySuggestModal, TFile } from "obsidian";
|
||||
import { REG_LINKINDEX_INVALIDCHARS } from "../Constants";
|
||||
import ExcalidrawView from "../ExcalidrawView";
|
||||
import { t } from "../lang/helpers";
|
||||
import ExcalidrawPlugin from "../main";
|
||||
|
||||
export class ImportSVGDialog extends FuzzySuggestModal<TFile> {
|
||||
public app: App;
|
||||
public plugin: ExcalidrawPlugin;
|
||||
private view: ExcalidrawView;
|
||||
|
||||
constructor(plugin: ExcalidrawPlugin) {
|
||||
super(plugin.app);
|
||||
this.plugin = plugin;
|
||||
this.app = plugin.app;
|
||||
this.limit = 20;
|
||||
this.setInstructions([
|
||||
{
|
||||
command: t("SELECT_FILE"),
|
||||
purpose: "",
|
||||
},
|
||||
]);
|
||||
this.setPlaceholder(t("SELECT_DRAWING"));
|
||||
this.emptyStateText = t("NO_MATCH");
|
||||
}
|
||||
|
||||
getItems(): TFile[] {
|
||||
return (this.app.vault.getFiles() || []).filter(
|
||||
(f: TFile) => f.extension === "svg" &&
|
||||
//https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/422
|
||||
!f.path.match(REG_LINKINDEX_INVALIDCHARS),
|
||||
);
|
||||
}
|
||||
|
||||
getItemText(item: TFile): string {
|
||||
return item.path;
|
||||
}
|
||||
|
||||
async onChooseItem(item: TFile, event: KeyboardEvent): Promise<void> {
|
||||
if(!item) return;
|
||||
const ea = this.plugin.ea;
|
||||
ea.reset();
|
||||
ea.setView(this.view);
|
||||
const svg = await app.vault.read(item);
|
||||
if(!svg || svg === "") return;
|
||||
ea.importSVG(svg);
|
||||
ea.addElementsToView(true, true, true);
|
||||
}
|
||||
|
||||
public start(view: ExcalidrawView) {
|
||||
this.view = view;
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export default {
|
||||
INSERT_LINK_TO_ELEMENT_READY: "Link is READY and available on the clipboard",
|
||||
INSERT_LINK: "Insert link to file",
|
||||
INSERT_IMAGE: "Insert image or Excalidraw drawing from your vault",
|
||||
IMPORT_SVG: "Import an SVG file as Excalidraw strokes (limited SVG support, TEXT currently not supported)",
|
||||
INSERT_MD: "Insert markdown file from vault",
|
||||
INSERT_LATEX:
|
||||
"Insert LaTeX formula (e.g. \\binom{n}{k} = \\frac{n!}{k!(n-k)!})",
|
||||
|
||||
44
src/main.ts
44
src/main.ts
@@ -58,6 +58,7 @@ import {
|
||||
import { openDialogAction, OpenFileDialog } from "./dialogs/OpenDrawing";
|
||||
import { InsertLinkDialog } from "./dialogs/InsertLinkDialog";
|
||||
import { InsertImageDialog } from "./dialogs/InsertImageDialog";
|
||||
import { ImportSVGDialog } from "./dialogs/ImportSVGDialog";
|
||||
import { InsertMDDialog } from "./dialogs/InsertMDDialog";
|
||||
import {
|
||||
initExcalidrawAutomate,
|
||||
@@ -139,6 +140,7 @@ export default class ExcalidrawPlugin extends Plugin {
|
||||
private openDialog: OpenFileDialog;
|
||||
public insertLinkDialog: InsertLinkDialog;
|
||||
public insertImageDialog: InsertImageDialog;
|
||||
public importSVGDialog: ImportSVGDialog;
|
||||
public insertMDDialog: InsertMDDialog;
|
||||
public activeExcalidrawView: ExcalidrawView = null;
|
||||
public lastActiveExcalidrawFilePath: string = null;
|
||||
@@ -166,6 +168,17 @@ export default class ExcalidrawPlugin extends Plugin {
|
||||
private packageMap: WeakMap<Window,Packages> = new WeakMap<Window,Packages>();
|
||||
public leafChangeTimeout: NodeJS.Timeout = null;
|
||||
private forceSaveCommand:Command;
|
||||
public device: {
|
||||
isDesktop: boolean,
|
||||
isPhone: boolean,
|
||||
isTablet: boolean,
|
||||
isMobile: boolean,
|
||||
isLinux: boolean,
|
||||
isMacOS: boolean,
|
||||
isWindows: boolean,
|
||||
isIOS: boolean,
|
||||
isAndroid: boolean
|
||||
};
|
||||
|
||||
constructor(app: App, manifest: PluginManifest) {
|
||||
super(app, manifest);
|
||||
@@ -176,8 +189,6 @@ export default class ExcalidrawPlugin extends Plugin {
|
||||
this.equationsMaster = new Map<FileId, string>();
|
||||
}
|
||||
|
||||
|
||||
|
||||
public getPackage(win:Window):Packages {
|
||||
if(win===window) {
|
||||
return {react, reactDOM, excalidrawLib};
|
||||
@@ -197,6 +208,18 @@ export default class ExcalidrawPlugin extends Plugin {
|
||||
}
|
||||
|
||||
async onload() {
|
||||
this.device = {
|
||||
isDesktop: !document.body.hasClass("is-tablet") && !document.body.hasClass("is-mobile"),
|
||||
isPhone: document.body.hasClass("is-phone"),
|
||||
isTablet: document.body.hasClass("is-tablet"),
|
||||
isMobile: document.body.hasClass("is-mobile"), //running Obsidian Mobile, need to also check isTablet
|
||||
isLinux: document.body.hasClass("mod-linux") && ! document.body.hasClass("is-android"),
|
||||
isMacOS: document.body.hasClass("mod-macos") && ! document.body.hasClass("is-ios"),
|
||||
isWindows: document.body.hasClass("mod-windows"),
|
||||
isIOS: document.body.hasClass("is-ios"),
|
||||
isAndroid: document.body.hasClass("is-android")
|
||||
}
|
||||
|
||||
addIcon(ICON_NAME, EXCALIDRAW_ICON);
|
||||
addIcon(SCRIPTENGINE_ICON_NAME, SCRIPTENGINE_ICON);
|
||||
addIcon(DISK_ICON_NAME, DISK_ICON);
|
||||
@@ -691,6 +714,7 @@ export default class ExcalidrawPlugin extends Plugin {
|
||||
this.openDialog = new OpenFileDialog(this.app, this);
|
||||
this.insertLinkDialog = new InsertLinkDialog(this.app);
|
||||
this.insertImageDialog = new InsertImageDialog(this);
|
||||
this.importSVGDialog = new ImportSVGDialog(this);
|
||||
this.insertMDDialog = new InsertMDDialog(this);
|
||||
|
||||
this.addRibbonIcon(ICON_NAME, t("CREATE_NEW"), async (e) => {
|
||||
@@ -1229,6 +1253,22 @@ export default class ExcalidrawPlugin extends Plugin {
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "import-svg",
|
||||
name: t("IMPORT_SVG"),
|
||||
checkCallback: (checking: boolean) => {
|
||||
if (checking) {
|
||||
return Boolean(this.app.workspace.getActiveViewOfType(ExcalidrawView))
|
||||
}
|
||||
const view = this.app.workspace.getActiveViewOfType(ExcalidrawView);
|
||||
if (view) {
|
||||
this.importSVGDialog.start(view);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "release-notes",
|
||||
name: t("READ_RELEASE_NOTES"),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -473,6 +473,15 @@ export class ToolsPanel extends React.Component<PanelProps, PanelState> {
|
||||
icon={ICONS.copyElementLink}
|
||||
view={this.props.view}
|
||||
/>
|
||||
<ActionButton
|
||||
key={"import-svg"}
|
||||
title={t("IMPORT_SVG")}
|
||||
action={(e:React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
this.props.view.plugin.importSVGDialog.start(this.props.view);
|
||||
}}
|
||||
icon={ICONS.importSVG}
|
||||
view={this.props.view}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
{this.renderScriptButtons(false)}
|
||||
|
||||
133
src/svgToExcalidraw/attributes.ts
Normal file
133
src/svgToExcalidraw/attributes.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import chroma from "chroma-js";
|
||||
import { ExcalidrawElementBase } from "./elements/ExcalidrawElement";
|
||||
|
||||
export function hexWithAlpha(color: string, alpha: number): string {
|
||||
return chroma(color).alpha(alpha).css();
|
||||
}
|
||||
|
||||
export function has(el: Element, attr: string): boolean {
|
||||
return el.hasAttribute(attr);
|
||||
}
|
||||
|
||||
export function get(el: Element, attr: string, backup?: string): string {
|
||||
return el.getAttribute(attr) || backup || "";
|
||||
}
|
||||
|
||||
export function getNum(el: Element, attr: string, backup?: number): number {
|
||||
const numVal = Number(get(el, attr));
|
||||
return numVal === NaN ? backup || 0 : numVal;
|
||||
}
|
||||
|
||||
const presAttrs = {
|
||||
stroke: "stroke",
|
||||
"stroke-opacity": "stroke-opacity",
|
||||
"stroke-width": "stroke-width",
|
||||
fill: "fill",
|
||||
"fill-opacity": "fill-opacity",
|
||||
opacity: "opacity",
|
||||
} as const;
|
||||
|
||||
type ExPartialElement = Partial<ExcalidrawElementBase>;
|
||||
|
||||
type AttrHandlerArgs = {
|
||||
el: Element;
|
||||
exVals: ExPartialElement;
|
||||
};
|
||||
|
||||
type PresAttrHandlers = {
|
||||
[key in keyof typeof presAttrs]: (args: AttrHandlerArgs) => void;
|
||||
};
|
||||
|
||||
const attrHandlers: PresAttrHandlers = {
|
||||
stroke: ({ el, exVals }) => {
|
||||
const strokeColor = get(el, "stroke");
|
||||
|
||||
exVals.strokeColor = has(el, "stroke-opacity")
|
||||
? hexWithAlpha(strokeColor, getNum(el, "stroke-opacity"))
|
||||
: strokeColor;
|
||||
},
|
||||
|
||||
"stroke-opacity": ({ el, exVals }) => {
|
||||
exVals.strokeColor = hexWithAlpha(
|
||||
get(el, "stroke", "#000000"),
|
||||
getNum(el, "stroke-opacity"),
|
||||
);
|
||||
},
|
||||
|
||||
"stroke-width": ({ el, exVals }) => {
|
||||
exVals.strokeWidth = getNum(el, "stroke-width");
|
||||
},
|
||||
|
||||
fill: ({ el, exVals }) => {
|
||||
const fill = get(el, `fill`);
|
||||
|
||||
exVals.backgroundColor = fill === "none" ? "#00000000" : fill;
|
||||
},
|
||||
|
||||
"fill-opacity": ({ el, exVals }) => {
|
||||
exVals.backgroundColor = hexWithAlpha(
|
||||
get(el, "fill", "#000000"),
|
||||
getNum(el, "fill-opacity"),
|
||||
);
|
||||
},
|
||||
|
||||
opacity: ({ el, exVals }) => {
|
||||
exVals.opacity = getNum(el, "opacity", 100);
|
||||
},
|
||||
};
|
||||
|
||||
// Presentation Attributes for SVG Elements:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/Presentation
|
||||
export function presAttrsToElementValues(
|
||||
el: Element,
|
||||
): Partial<ExcalidrawElementBase> {
|
||||
const exVals = [...el.attributes].reduce((exVals, attr) => {
|
||||
const name = attr.name;
|
||||
|
||||
if (Object.keys(attrHandlers).includes(name)) {
|
||||
attrHandlers[name as keyof PresAttrHandlers]({ el, exVals });
|
||||
}
|
||||
|
||||
return exVals;
|
||||
}, {} as ExPartialElement);
|
||||
|
||||
return exVals;
|
||||
}
|
||||
|
||||
type FilterAttrs = Partial<
|
||||
Pick<ExcalidrawElementBase, "x" | "y" | "width" | "height">
|
||||
>;
|
||||
|
||||
export function filterAttrsToElementValues(el: Element): FilterAttrs {
|
||||
const filterVals: FilterAttrs = {};
|
||||
|
||||
if (has(el, "x")) {
|
||||
filterVals.x = getNum(el, "x");
|
||||
}
|
||||
|
||||
if (has(el, "y")) {
|
||||
filterVals.y = getNum(el, "y");
|
||||
}
|
||||
|
||||
if (has(el, "width")) {
|
||||
filterVals.width = getNum(el, "width");
|
||||
}
|
||||
|
||||
if (has(el, "height")) {
|
||||
filterVals.height = getNum(el, "height");
|
||||
}
|
||||
|
||||
return filterVals;
|
||||
}
|
||||
|
||||
export function pointsAttrToPoints(el: Element): number[][] {
|
||||
let points: number[][] = [];
|
||||
|
||||
if (has(el, "points")) {
|
||||
points = get(el, "points")
|
||||
.split(" ")
|
||||
.map((p) => p.split(",").map(parseFloat));
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
117
src/svgToExcalidraw/elements/ExcalidrawElement.ts
Normal file
117
src/svgToExcalidraw/elements/ExcalidrawElement.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { randomId, randomInteger } from "../utils";
|
||||
|
||||
import { ExcalidrawLinearElement, FillStyle, GroupId, StrokeSharpness, StrokeStyle } from "@zsviczian/excalidraw/types/element/types";
|
||||
|
||||
export type Point = [number, number];
|
||||
|
||||
export type ExcalidrawElementBase = {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
strokeColor: string;
|
||||
backgroundColor: string;
|
||||
fillStyle: FillStyle;
|
||||
strokeWidth: number;
|
||||
strokeStyle: StrokeStyle;
|
||||
strokeSharpness: StrokeSharpness;
|
||||
roughness: number;
|
||||
opacity: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle: number;
|
||||
/** Random integer used to seed shape generation so that the roughjs shape
|
||||
doesn't differ across renders. */
|
||||
seed: number;
|
||||
/** Integer that is sequentially incremented on each change. Used to reconcile
|
||||
elements during collaboration or when saving to server. */
|
||||
version: number;
|
||||
/** Random integer that is regenerated on each change.
|
||||
Used for deterministic reconciliation of updates during collaboration,
|
||||
in case the versions (see above) are identical. */
|
||||
versionNonce: number;
|
||||
isDeleted: boolean;
|
||||
/** List of groups the element belongs to.
|
||||
Ordered from deepest to shallowest. */
|
||||
groupIds: GroupId[];
|
||||
/** Ids of (linear) elements that are bound to this element. */
|
||||
boundElementIds: ExcalidrawLinearElement["id"][] | null;
|
||||
};
|
||||
|
||||
export type ExcalidrawRectangle = ExcalidrawElementBase & {
|
||||
type: "rectangle";
|
||||
};
|
||||
|
||||
export type ExcalidrawLine = ExcalidrawElementBase & {
|
||||
type: "line";
|
||||
points: readonly Point[];
|
||||
};
|
||||
|
||||
export type ExcalidrawEllipse = ExcalidrawElementBase & {
|
||||
type: "ellipse";
|
||||
};
|
||||
|
||||
export type ExcalidrawGenericElement =
|
||||
| ExcalidrawRectangle
|
||||
| ExcalidrawEllipse
|
||||
| ExcalidrawLine
|
||||
| ExcalidrawDraw;
|
||||
|
||||
export type ExcalidrawDraw = ExcalidrawElementBase & {
|
||||
type: "line";
|
||||
points: readonly Point[];
|
||||
};
|
||||
|
||||
export function createExElement(): ExcalidrawElementBase {
|
||||
return {
|
||||
id: randomId(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "#000000",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
strokeSharpness: "sharp",
|
||||
roughness: 0,
|
||||
opacity: 100,
|
||||
width: 0,
|
||||
height: 0,
|
||||
angle: 0,
|
||||
seed: randomInteger(),
|
||||
version: 0,
|
||||
versionNonce: 0,
|
||||
isDeleted: false,
|
||||
groupIds: [],
|
||||
boundElementIds: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createExRect(): ExcalidrawRectangle {
|
||||
return {
|
||||
...createExElement(),
|
||||
type: "rectangle",
|
||||
};
|
||||
}
|
||||
|
||||
export function createExLine(): ExcalidrawLine {
|
||||
return {
|
||||
...createExElement(),
|
||||
type: "line",
|
||||
points: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createExEllipse(): ExcalidrawEllipse {
|
||||
return {
|
||||
...createExElement(),
|
||||
type: "ellipse",
|
||||
};
|
||||
}
|
||||
|
||||
export function createExDraw(): ExcalidrawDraw {
|
||||
return {
|
||||
...createExElement(),
|
||||
type: "line",
|
||||
points: [],
|
||||
};
|
||||
}
|
||||
21
src/svgToExcalidraw/elements/ExcalidrawScene.ts
Normal file
21
src/svgToExcalidraw/elements/ExcalidrawScene.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ExcalidrawGenericElement } from "./ExcalidrawElement";
|
||||
|
||||
class ExcalidrawScene {
|
||||
type = "excalidraw";
|
||||
version = 2;
|
||||
source = "https://excalidraw.com";
|
||||
elements: ExcalidrawGenericElement[] = [];
|
||||
|
||||
constructor(elements:any = []) {
|
||||
this.elements = elements;
|
||||
}
|
||||
|
||||
toExJSON(): any {
|
||||
return {
|
||||
...this,
|
||||
elements: this.elements.map((el) => ({ ...el })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default ExcalidrawScene;
|
||||
23
src/svgToExcalidraw/elements/Group.ts
Normal file
23
src/svgToExcalidraw/elements/Group.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { randomId } from "../utils";
|
||||
import { presAttrsToElementValues } from "../attributes";
|
||||
import { ExcalidrawElementBase } from "../elements/ExcalidrawElement";
|
||||
|
||||
export function getGroupAttrs(groups: Group[]): any {
|
||||
return groups.reduce((acc, { element }) => {
|
||||
const elVals = presAttrsToElementValues(element);
|
||||
|
||||
return { ...acc, ...elVals };
|
||||
}, {} as Partial<ExcalidrawElementBase>);
|
||||
}
|
||||
|
||||
class Group {
|
||||
id = randomId();
|
||||
|
||||
element: Element;
|
||||
|
||||
constructor(element: Element) {
|
||||
this.element = element;
|
||||
}
|
||||
}
|
||||
|
||||
export default Group;
|
||||
5
src/svgToExcalidraw/elements/index.ts
Normal file
5
src/svgToExcalidraw/elements/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import * as path from "./path";
|
||||
|
||||
export default {
|
||||
path,
|
||||
};
|
||||
35
src/svgToExcalidraw/elements/path/index.ts
Normal file
35
src/svgToExcalidraw/elements/path/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { RawElement } from "../../types";
|
||||
import { getElementBoundaries } from "../utils";
|
||||
import pathToPoints from "./utils/path-to-points";
|
||||
|
||||
const parse = (node: Element) => {
|
||||
const data = node.getAttribute("d");
|
||||
const backgroundColor = node.getAttribute("fill");
|
||||
const strokeColor = node.getAttribute("stroke");
|
||||
|
||||
return {
|
||||
data: data || "",
|
||||
backgroundColor:
|
||||
(backgroundColor !== "currentColor" && backgroundColor) || "transparent",
|
||||
strokeColor: (strokeColor !== "currentColor" && strokeColor) || "#000000",
|
||||
};
|
||||
};
|
||||
|
||||
export const convert = (node: Element): RawElement[] => {
|
||||
const { data, backgroundColor, strokeColor } = parse(node);
|
||||
const elementsPoints = pathToPoints(data);
|
||||
|
||||
return elementsPoints.map((points) => {
|
||||
const boundaries = getElementBoundaries(points);
|
||||
|
||||
return {
|
||||
type: "line",
|
||||
roughness: 0,
|
||||
strokeSharpness: "sharp",
|
||||
points,
|
||||
backgroundColor,
|
||||
strokeColor,
|
||||
...boundaries,
|
||||
};
|
||||
});
|
||||
};
|
||||
66
src/svgToExcalidraw/elements/path/utils/bezier.ts
Normal file
66
src/svgToExcalidraw/elements/path/utils/bezier.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { safeNumber } from "../../../utils";
|
||||
|
||||
/**
|
||||
* Get a point at a given section of a cubic bezier curve.
|
||||
* This function only supports two dimensions curves
|
||||
* @see https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Cubic_B%C3%A9zier_curves
|
||||
*/
|
||||
const getPointOfCubicCurve = (
|
||||
controlPoints: number[][],
|
||||
section: number,
|
||||
): number[] =>
|
||||
Array.from({ length: 2 }).map((v, i) => {
|
||||
const point =
|
||||
controlPoints[0][i] * (1 - section) ** 3 +
|
||||
3 * controlPoints[1][i] * section * (1 - section) ** 2 +
|
||||
3 * controlPoints[2][i] * section ** 2 * (1 - section) +
|
||||
controlPoints[3][i] * section ** 3;
|
||||
|
||||
return safeNumber(point);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a point at a given section of a quadratic bezier curve.
|
||||
* This function only supports two dimensions curves
|
||||
* @see https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Quadratic_B%C3%A9zier_curves
|
||||
*/
|
||||
const getPointOfQuadraticCurve = (
|
||||
controlPoints: number[][],
|
||||
section: number,
|
||||
): number[] =>
|
||||
Array.from({ length: 2 }).map((v, i) => {
|
||||
const point =
|
||||
controlPoints[0][i] * (1 - section) ** 2 +
|
||||
2 * controlPoints[1][i] * section * (1 - section) +
|
||||
controlPoints[2][i] * section ** 2;
|
||||
|
||||
return safeNumber(point);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get list of points for a cubic bézier curve.
|
||||
* Starting point is not returned
|
||||
*/
|
||||
export const curveToPoints = (
|
||||
type: "cubic" | "quadratic",
|
||||
controlPoints: number[][],
|
||||
nbPoints = 10,
|
||||
): number[][] => {
|
||||
if (nbPoints <= 0) {
|
||||
throw new Error("Requested amount of points must be positive");
|
||||
} else if (nbPoints > 100) {
|
||||
nbPoints = 100;
|
||||
}
|
||||
|
||||
return Array.from({ length: nbPoints }, (value, index) => {
|
||||
const section = safeNumber(((100 / nbPoints) * (index + 1)) / 100);
|
||||
|
||||
if (type === "cubic") {
|
||||
return getPointOfCubicCurve(controlPoints, section);
|
||||
} else if (type === "quadratic") {
|
||||
return getPointOfQuadraticCurve(controlPoints, section);
|
||||
}
|
||||
|
||||
throw new Error("Invalid bézier curve type requested");
|
||||
});
|
||||
};
|
||||
133
src/svgToExcalidraw/elements/path/utils/ellipse.ts
Normal file
133
src/svgToExcalidraw/elements/path/utils/ellipse.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
const degreeToRadian = (degree: number): number => (degree * Math.PI) / 180;
|
||||
|
||||
/**
|
||||
* Get each possible ellipses center points given two points and ellipse radius
|
||||
* @see https://math.stackexchange.com/questions/2240031/solving-an-equation-for-an-ellipse
|
||||
*/
|
||||
export const getEllipsesCenter = (
|
||||
curX: number,
|
||||
curY: number,
|
||||
destX: number,
|
||||
destY: number,
|
||||
radiusX: number,
|
||||
radiusY: number,
|
||||
): number[][] => [
|
||||
[
|
||||
(curX + destX) / 2 +
|
||||
((radiusX * (curY - destY)) / (2 * radiusY)) *
|
||||
Math.sqrt(
|
||||
4 /
|
||||
((curX - destX) ** 2 / radiusX ** 2 +
|
||||
(curY - destY) ** 2 / radiusY ** 2) -
|
||||
1,
|
||||
),
|
||||
(curY + destY) / 2 -
|
||||
((radiusY * (curX - destX)) / (2 * radiusX)) *
|
||||
Math.sqrt(
|
||||
4 /
|
||||
((curX - destX) ** 2 / radiusX ** 2 +
|
||||
(curY - destY) ** 2 / radiusY ** 2) -
|
||||
1,
|
||||
),
|
||||
],
|
||||
[
|
||||
(curX + destX) / 2 -
|
||||
((radiusX * (curY - destY)) / (2 * radiusY)) *
|
||||
Math.sqrt(
|
||||
4 /
|
||||
((curX - destX) ** 2 / radiusX ** 2 +
|
||||
(curY - destY) ** 2 / radiusY ** 2) -
|
||||
1,
|
||||
),
|
||||
(curY + destY) / 2 +
|
||||
((radiusY * (curX - destX)) / (2 * radiusX)) *
|
||||
Math.sqrt(
|
||||
4 /
|
||||
((curX - destX) ** 2 / radiusX ** 2 +
|
||||
(curY - destY) ** 2 / radiusY ** 2) -
|
||||
1,
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Get point of ellipse at given degree
|
||||
*/
|
||||
const getPointAtDegree = (
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
radiusX: number,
|
||||
radiusY: number,
|
||||
degree: number,
|
||||
): number[] => [
|
||||
Math.round(radiusX * Math.cos(degreeToRadian(degree)) + centerX),
|
||||
Math.round(radiusY * Math.sin(degreeToRadian(degree)) + centerY),
|
||||
];
|
||||
|
||||
/**
|
||||
* Get all points of a given ellipse
|
||||
*/
|
||||
export const getEllipsePoints = (
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
radiusX: number,
|
||||
radiusY: number,
|
||||
): number[][] => {
|
||||
const points: number[][] = [];
|
||||
|
||||
for (let i = 0; i < 360; i += 1) {
|
||||
const pointAtDegree = getPointAtDegree(
|
||||
centerX,
|
||||
centerY,
|
||||
radiusX,
|
||||
radiusY,
|
||||
i,
|
||||
);
|
||||
const existingPoint = points.find(
|
||||
([x, y]) => x === pointAtDegree[0] && y === pointAtDegree[1],
|
||||
);
|
||||
|
||||
if (!existingPoint) {
|
||||
points.push(pointAtDegree);
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find ellipse arc given sweep parameter
|
||||
*/
|
||||
export const findArc = (
|
||||
points: number[][],
|
||||
sweep: boolean,
|
||||
curX: number,
|
||||
curY: number,
|
||||
destX: number,
|
||||
destY: number,
|
||||
): number[][] => {
|
||||
const indexCur = points.findIndex(
|
||||
([x, y]) => x === Math.round(curX) && y === Math.round(curY),
|
||||
);
|
||||
const indexDest = points.findIndex(
|
||||
([x, y]) => x === Math.round(destX) && y === Math.round(destY),
|
||||
);
|
||||
const arc = [];
|
||||
const step = sweep ? -1 : 1;
|
||||
|
||||
for (let i = indexDest; true; i += step) {
|
||||
arc.push(points[i]);
|
||||
|
||||
if (i === indexCur) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (sweep && i === 0) {
|
||||
i = points.length;
|
||||
} else if (!sweep && i === points.length - 1) {
|
||||
i = -1;
|
||||
}
|
||||
}
|
||||
|
||||
return arc.reverse();
|
||||
};
|
||||
313
src/svgToExcalidraw/elements/path/utils/path-to-points.ts
Normal file
313
src/svgToExcalidraw/elements/path/utils/path-to-points.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { PathCommand } from "../../../types";
|
||||
import { safeNumber } from "../../../utils";
|
||||
import { curveToPoints } from "./bezier";
|
||||
import { findArc, getEllipsePoints, getEllipsesCenter } from "./ellipse";
|
||||
|
||||
const PATH_COMMANDS_REGEX =
|
||||
/(?:([HhVv] *-?\d*(?:\.\d+)?)|([MmLlTt](?: *-?\d*(?:\.\d+)?(?:,| *)?){2})|([Cc](?: *-?\d*(?:\.\d+)?(?:,| *)?){6})|([QqSs](?: *-?\d*(?:\.\d+)?(?:,| *)?){4})|([Aa](?: *-?\d*(?:\.\d+)?(?:,| *)?){7})|(z|Z))/g;
|
||||
const COMMAND_REGEX = /(?:[MmLlHhVvCcSsQqTtAaZz]|(-?\d+(?:\.\d+)?))/g;
|
||||
|
||||
const handleMoveToAndLineTo = (
|
||||
currentPosition: number[],
|
||||
parameters: number[],
|
||||
isRelative: boolean,
|
||||
): number[] => {
|
||||
if (isRelative) {
|
||||
return [
|
||||
currentPosition[0] + parameters[0],
|
||||
currentPosition[1] + parameters[1],
|
||||
];
|
||||
}
|
||||
|
||||
return parameters;
|
||||
};
|
||||
|
||||
const handleHorizontalLineTo = (
|
||||
currentPosition: number[],
|
||||
x: number,
|
||||
isRelative: boolean,
|
||||
): number[] => {
|
||||
if (isRelative) {
|
||||
return [currentPosition[0] + x, currentPosition[1]];
|
||||
}
|
||||
|
||||
return [x, currentPosition[1]];
|
||||
};
|
||||
|
||||
const handleVerticalLineTo = (
|
||||
currentPosition: number[],
|
||||
y: number,
|
||||
isRelative: boolean,
|
||||
): number[] => {
|
||||
if (isRelative) {
|
||||
return [currentPosition[0], currentPosition[1] + y];
|
||||
}
|
||||
|
||||
return [currentPosition[0], y];
|
||||
};
|
||||
|
||||
const handleCubicCurveTo = (
|
||||
currentPosition: number[],
|
||||
parameters: number[],
|
||||
lastCommand: PathCommand,
|
||||
isSimpleForm: boolean,
|
||||
isRelative: boolean,
|
||||
): number[][] => {
|
||||
const controlPoints = [currentPosition];
|
||||
let inferredControlPoint;
|
||||
|
||||
if (isSimpleForm) {
|
||||
inferredControlPoint = ["C", "c"].includes(lastCommand?.type)
|
||||
? [
|
||||
currentPosition[0] - (lastCommand.parameters[2] - currentPosition[0]),
|
||||
currentPosition[1] - (lastCommand.parameters[3] - currentPosition[1]),
|
||||
]
|
||||
: currentPosition;
|
||||
}
|
||||
|
||||
if (isRelative) {
|
||||
controlPoints.push(
|
||||
inferredControlPoint || [
|
||||
currentPosition[0] + parameters[0],
|
||||
currentPosition[1] + parameters[1],
|
||||
],
|
||||
[currentPosition[0] + parameters[2], currentPosition[1] + parameters[3]],
|
||||
[currentPosition[0] + parameters[4], currentPosition[1] + parameters[5]],
|
||||
);
|
||||
} else {
|
||||
controlPoints.push(
|
||||
inferredControlPoint || [parameters[0], parameters[1]],
|
||||
[parameters[2], parameters[3]],
|
||||
[parameters[4], parameters[5]],
|
||||
);
|
||||
}
|
||||
|
||||
return curveToPoints("cubic", controlPoints);
|
||||
};
|
||||
|
||||
const handleQuadraticCurveTo = (
|
||||
currentPosition: number[],
|
||||
parameters: number[],
|
||||
lastCommand: PathCommand,
|
||||
isSimpleForm: boolean,
|
||||
isRelative: boolean,
|
||||
): number[][] => {
|
||||
const controlPoints = [currentPosition];
|
||||
let inferredControlPoint;
|
||||
|
||||
if (isSimpleForm) {
|
||||
inferredControlPoint = ["Q", "q"].includes(lastCommand?.type)
|
||||
? [
|
||||
currentPosition[0] - (lastCommand.parameters[0] - currentPosition[0]),
|
||||
currentPosition[1] - (lastCommand.parameters[1] - currentPosition[1]),
|
||||
]
|
||||
: currentPosition;
|
||||
}
|
||||
|
||||
if (isRelative) {
|
||||
controlPoints.push(
|
||||
inferredControlPoint || [
|
||||
currentPosition[0] + parameters[0],
|
||||
currentPosition[1] + parameters[1],
|
||||
],
|
||||
[currentPosition[0] + parameters[2], currentPosition[1] + parameters[3]],
|
||||
);
|
||||
} else {
|
||||
controlPoints.push(inferredControlPoint || [parameters[0], parameters[1]], [
|
||||
parameters[2],
|
||||
parameters[3],
|
||||
]);
|
||||
}
|
||||
|
||||
return curveToPoints("quadratic", controlPoints);
|
||||
};
|
||||
|
||||
/**
|
||||
* @todo handle arcs rotation
|
||||
* @todo handle specific cases where only one ellipse can exist
|
||||
*/
|
||||
const handleArcTo = (
|
||||
currentPosition: number[],
|
||||
[radiusX, radiusY, , large, sweep, destX, destY]: number[],
|
||||
isRelative: boolean,
|
||||
): number[][] => {
|
||||
destX = isRelative ? currentPosition[0] + destX : destX;
|
||||
destY = isRelative ? currentPosition[1] + destY : destY;
|
||||
|
||||
const ellipsesCenter = getEllipsesCenter(
|
||||
currentPosition[0],
|
||||
currentPosition[1],
|
||||
destX,
|
||||
destY,
|
||||
radiusX,
|
||||
radiusY,
|
||||
);
|
||||
|
||||
const ellipsesPoints = [
|
||||
getEllipsePoints(
|
||||
ellipsesCenter[0][0],
|
||||
ellipsesCenter[0][1],
|
||||
radiusX,
|
||||
radiusY,
|
||||
),
|
||||
getEllipsePoints(
|
||||
ellipsesCenter[1][0],
|
||||
ellipsesCenter[1][1],
|
||||
radiusX,
|
||||
radiusY,
|
||||
),
|
||||
];
|
||||
|
||||
const arcs = [
|
||||
findArc(
|
||||
ellipsesPoints[0],
|
||||
!!sweep,
|
||||
currentPosition[0],
|
||||
currentPosition[1],
|
||||
destX,
|
||||
destY,
|
||||
),
|
||||
findArc(
|
||||
ellipsesPoints[1],
|
||||
!!sweep,
|
||||
currentPosition[0],
|
||||
currentPosition[1],
|
||||
destX,
|
||||
destY,
|
||||
),
|
||||
];
|
||||
|
||||
const finalArc = arcs.reduce(
|
||||
(arc, curArc) =>
|
||||
(large && curArc.length > arc.length) ||
|
||||
(!large && (!arc.length || curArc.length < arc.length))
|
||||
? curArc
|
||||
: arc,
|
||||
[],
|
||||
);
|
||||
|
||||
return finalArc;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a SVG path data to list of points
|
||||
*/
|
||||
const pathToPoints = (path: string): number[][][] => {
|
||||
const commands = path.match(PATH_COMMANDS_REGEX);
|
||||
const elements = [];
|
||||
const commandsHistory = [];
|
||||
let currentPosition = [0, 0];
|
||||
let points = [];
|
||||
|
||||
if (!commands?.length) {
|
||||
throw new Error("No commands found in given path");
|
||||
}
|
||||
|
||||
for (const command of commands) {
|
||||
const lastCommand = commandsHistory[commandsHistory.length - 2];
|
||||
const commandMatch = command.match(COMMAND_REGEX);
|
||||
|
||||
currentPosition = points[points.length - 1] || currentPosition;
|
||||
|
||||
if (commandMatch?.length) {
|
||||
const commandType = commandMatch[0];
|
||||
const parameters = commandMatch
|
||||
.slice(1, commandMatch.length)
|
||||
.map((parameter) => safeNumber(Number(parameter)));
|
||||
const isRelative = commandType.toLowerCase() === commandType;
|
||||
|
||||
commandsHistory.push({
|
||||
type: commandType,
|
||||
parameters,
|
||||
isRelative,
|
||||
});
|
||||
|
||||
switch (commandType) {
|
||||
case "M":
|
||||
case "m":
|
||||
case "L":
|
||||
case "l":
|
||||
points.push(
|
||||
handleMoveToAndLineTo(currentPosition, parameters, isRelative),
|
||||
);
|
||||
|
||||
break;
|
||||
case "H":
|
||||
case "h":
|
||||
points.push(
|
||||
handleHorizontalLineTo(currentPosition, parameters[0], isRelative),
|
||||
);
|
||||
|
||||
break;
|
||||
case "V":
|
||||
case "v":
|
||||
points.push(
|
||||
handleVerticalLineTo(currentPosition, parameters[0], isRelative),
|
||||
);
|
||||
|
||||
break;
|
||||
case "C":
|
||||
case "c":
|
||||
case "S":
|
||||
case "s":
|
||||
points.push(
|
||||
...handleCubicCurveTo(
|
||||
currentPosition,
|
||||
parameters,
|
||||
lastCommand,
|
||||
["S", "s"].includes(commandType),
|
||||
isRelative,
|
||||
),
|
||||
);
|
||||
|
||||
break;
|
||||
case "Q":
|
||||
case "q":
|
||||
case "T":
|
||||
case "t":
|
||||
points.push(
|
||||
...handleQuadraticCurveTo(
|
||||
currentPosition,
|
||||
parameters,
|
||||
lastCommand,
|
||||
["T", "t"].includes(commandType),
|
||||
isRelative,
|
||||
),
|
||||
);
|
||||
|
||||
break;
|
||||
case "A":
|
||||
case "a":
|
||||
points.push(...handleArcTo(currentPosition, parameters, isRelative));
|
||||
|
||||
break;
|
||||
case "Z":
|
||||
case "z":
|
||||
if (points.length) {
|
||||
if (
|
||||
currentPosition[0] !== points[0][0] ||
|
||||
currentPosition[1] !== points[0][1]
|
||||
) {
|
||||
points.push(points[0]);
|
||||
}
|
||||
|
||||
elements.push(points);
|
||||
}
|
||||
|
||||
points = [];
|
||||
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// console.error("Unsupported command provided will be ignored:", command);
|
||||
}
|
||||
}
|
||||
|
||||
if (elements.length === 0 && points.length) {
|
||||
elements.push(points);
|
||||
}
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
export default pathToPoints;
|
||||
39
src/svgToExcalidraw/elements/utils.ts
Normal file
39
src/svgToExcalidraw/elements/utils.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ElementBoundaries } from "../types";
|
||||
|
||||
export const getElementBoundaries = (points: number[][]): ElementBoundaries => {
|
||||
const { x, y } = points.reduce(
|
||||
(boundaries, [x, y]) => {
|
||||
if (x < boundaries.x.min) {
|
||||
boundaries.x.min = x;
|
||||
}
|
||||
if (x > boundaries.x.max) {
|
||||
boundaries.x.max = x;
|
||||
}
|
||||
if (y < boundaries.y.min) {
|
||||
boundaries.y.min = y;
|
||||
}
|
||||
if (y > boundaries.y.max) {
|
||||
boundaries.y.max = y;
|
||||
}
|
||||
|
||||
return boundaries;
|
||||
},
|
||||
{
|
||||
x: {
|
||||
min: Infinity,
|
||||
max: 0,
|
||||
},
|
||||
y: {
|
||||
min: Infinity,
|
||||
max: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
x: x.min,
|
||||
y: y.min,
|
||||
width: x.max - x.min,
|
||||
height: y.max - y.min,
|
||||
};
|
||||
};
|
||||
40
src/svgToExcalidraw/parser.ts
Normal file
40
src/svgToExcalidraw/parser.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import ExcalidrawScene from "./elements/ExcalidrawScene";
|
||||
import Group from "./elements/Group";
|
||||
import { createTreeWalker, walk } from "./walker";
|
||||
|
||||
export type ConversionResult = {
|
||||
hasErrors: boolean;
|
||||
errors: NodeListOf<Element> | null;
|
||||
content: any; // Serialized Excalidraw JSON
|
||||
};
|
||||
|
||||
export const svgToExcalidraw = (svgString: string): ConversionResult => {
|
||||
const parser = new DOMParser();
|
||||
const svgDOM = parser.parseFromString(svgString, "image/svg+xml");
|
||||
|
||||
// was there a parsing error?
|
||||
const errorsElements = svgDOM.querySelectorAll("parsererror");
|
||||
const hasErrors = errorsElements.length > 0;
|
||||
let content = null;
|
||||
|
||||
if (hasErrors) {
|
||||
console.error(
|
||||
"There were errors while parsing the given SVG: ",
|
||||
[...errorsElements].map((el) => el.innerHTML),
|
||||
);
|
||||
} else {
|
||||
const tw = createTreeWalker(svgDOM);
|
||||
const scene = new ExcalidrawScene();
|
||||
const groups: Group[] = [];
|
||||
|
||||
walk({ tw, scene, groups, root: svgDOM }, tw.nextNode());
|
||||
|
||||
content = scene.elements; //scene.toExJSON();
|
||||
}
|
||||
|
||||
return {
|
||||
hasErrors,
|
||||
errors: hasErrors ? errorsElements : null,
|
||||
content,
|
||||
};
|
||||
};
|
||||
2
src/svgToExcalidraw/readme.md
Normal file
2
src/svgToExcalidraw/readme.md
Normal file
@@ -0,0 +1,2 @@
|
||||
Original source https://github.com/excalidraw/svg-to-excalidraw. Last commit: https://github.com/excalidraw/svg-to-excalidraw/commit/6f6e4b7269c4194b56cf7517a8357ba73be12a3a
|
||||
Embedded into the project instead of using an import because compiled file size difference (smaller this way). Also the svg-to-excalidraw package has not been maintained for over a year, thus I don't expect to miss out on frequent updates
|
||||
173
src/svgToExcalidraw/transform.ts
Normal file
173
src/svgToExcalidraw/transform.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import Group from "./elements/Group";
|
||||
import { vec3, mat4 } from "gl-matrix";
|
||||
|
||||
/*
|
||||
SVG transform attr is a bit strange in that it can accept traditional
|
||||
css transform string (at least per spec) as well as a it's own "unitless"
|
||||
version of transform functions.
|
||||
|
||||
https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform
|
||||
*/
|
||||
|
||||
const transformFunctions = {
|
||||
matrix: "matrix",
|
||||
matrix3d: "matrix3d",
|
||||
perspective: "perspective",
|
||||
rotate: "rotate",
|
||||
rotate3d: "rotate3d",
|
||||
rotateX: "rotateX",
|
||||
rotateY: "rotateY",
|
||||
rotateZ: "rotateZ",
|
||||
scale: "scale",
|
||||
scale3d: "scale3d",
|
||||
scaleX: "scaleX",
|
||||
scaleY: "scaleY",
|
||||
scaleZ: "scaleZ",
|
||||
skew: "skew",
|
||||
skewX: "skewX",
|
||||
skewY: "skewY",
|
||||
translate: "translate",
|
||||
translate3d: "translate3d",
|
||||
translateX: "translateX",
|
||||
translateY: "translateY",
|
||||
translateZ: "translateZ",
|
||||
} as const;
|
||||
|
||||
const transformFunctionsArr = Object.keys(transformFunctions);
|
||||
|
||||
// type Transform
|
||||
|
||||
type TransformFuncValue = {
|
||||
value: string;
|
||||
unit: string;
|
||||
};
|
||||
|
||||
type TransformFunc = {
|
||||
type: keyof typeof transformFunctions;
|
||||
values: TransformFuncValue[];
|
||||
};
|
||||
|
||||
const defaultUnits = {
|
||||
matrix: "",
|
||||
matrix3d: "",
|
||||
perspective: "perspective",
|
||||
rotate: "deg",
|
||||
rotate3d: "deg",
|
||||
rotateX: "deg",
|
||||
rotateY: "deg",
|
||||
rotateZ: "deg",
|
||||
scale: "",
|
||||
scale3d: "",
|
||||
scaleX: "",
|
||||
scaleY: "",
|
||||
scaleZ: "",
|
||||
skew: "skew",
|
||||
skewX: "deg",
|
||||
skewY: "deg",
|
||||
translate: "px",
|
||||
translate3d: "px",
|
||||
translateX: "px",
|
||||
translateY: "px",
|
||||
translateZ: "px",
|
||||
};
|
||||
|
||||
// Convert between possible svg transform attribute values to css transform attribute values.
|
||||
const svgTransformToCSSTransform = (svgTransformStr: string): string => {
|
||||
// Create transform function string "chunks", e.g "rotate(90deg)"
|
||||
const tFuncs = svgTransformStr.match(/(\w+)\(([^)]*)\)/g);
|
||||
if (!tFuncs) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const tFuncValues: TransformFunc[] = tFuncs.map((tFuncStr): TransformFunc => {
|
||||
const type = tFuncStr.split("(")[0] as keyof typeof transformFunctions;
|
||||
if (!type) {
|
||||
throw new Error("Unable to find transform name");
|
||||
}
|
||||
if (!transformFunctionsArr.includes(type)) {
|
||||
throw new Error(`transform function name "${type}" is not valid`);
|
||||
}
|
||||
|
||||
// get the arg/props of the transform function, e.g "90deg".
|
||||
const tFuncParts = tFuncStr.match(/([-+]?[0-9]*\.?[0-9]+)([a-z])*/g);
|
||||
if (!tFuncParts) {
|
||||
return { type, values: [] };
|
||||
}
|
||||
|
||||
let values = tFuncParts.map((a): TransformFuncValue => {
|
||||
// Separate the arg value and unit. e.g ["90", "deg"]
|
||||
const [value, unit] = a.matchAll(/([-+]?[0-9]*\.?[0-9]+)|([a-z])*/g);
|
||||
|
||||
return {
|
||||
unit: unit[0] || defaultUnits[type],
|
||||
value: value[0],
|
||||
};
|
||||
});
|
||||
|
||||
// Not supporting x, y args of svg rotate transform yet...
|
||||
if (values && type === "rotate" && values?.length > 1) {
|
||||
values = [values[0]];
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
values,
|
||||
};
|
||||
});
|
||||
|
||||
// Generate a string of transform functions that can be set as a CSS Transform.
|
||||
const csstransformStr = tFuncValues
|
||||
.map(({ type, values }) => {
|
||||
const valStr = values
|
||||
.map(({ unit, value }) => `${value}${unit}`)
|
||||
.join(", ");
|
||||
return `${type}(${valStr})`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
return csstransformStr;
|
||||
};
|
||||
|
||||
export const createDOMMatrixFromSVGStr = (
|
||||
svgTransformStr: string,
|
||||
): DOMMatrix => {
|
||||
const cssTransformStr = svgTransformToCSSTransform(svgTransformStr);
|
||||
|
||||
return new DOMMatrix(cssTransformStr);
|
||||
};
|
||||
|
||||
export function getElementMatrix(el: Element): mat4 {
|
||||
if (el.hasAttribute("transform")) {
|
||||
const elMat = new DOMMatrix(
|
||||
svgTransformToCSSTransform(el.getAttribute("transform") || ""),
|
||||
);
|
||||
|
||||
return mat4.multiply(mat4.create(), mat4.create(), elMat.toFloat32Array());
|
||||
}
|
||||
|
||||
return mat4.create();
|
||||
}
|
||||
|
||||
export function getTransformMatrix(el: Element, groups: Group[]): mat4 {
|
||||
const accumMat = groups
|
||||
.map(({ element }) => getElementMatrix(element))
|
||||
.concat([getElementMatrix(el)])
|
||||
.reduce((acc, mat) => mat4.multiply(acc, acc, mat), mat4.create());
|
||||
|
||||
return accumMat;
|
||||
}
|
||||
|
||||
export function transformPoints(
|
||||
points: number[][],
|
||||
transform: mat4,
|
||||
): [number, number][] {
|
||||
return points.map(([x, y]) => {
|
||||
const [newX, newY] = vec3.transformMat4(
|
||||
vec3.create(),
|
||||
vec3.fromValues(x, y, 1),
|
||||
transform,
|
||||
);
|
||||
|
||||
return [newX, newY];
|
||||
});
|
||||
}
|
||||
118
src/svgToExcalidraw/types.ts
Normal file
118
src/svgToExcalidraw/types.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { ExcalidrawElement, ExcalidrawLinearElement, ExcalidrawTextElement, FillStyle, GroupId, StrokeSharpness, StrokeStyle } from "@zsviczian/excalidraw/types/element/types";
|
||||
|
||||
export type PathCommand = {
|
||||
type: string;
|
||||
parameters: number[];
|
||||
isRelative: boolean;
|
||||
};
|
||||
|
||||
export type RawElement = {
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
points: number[][];
|
||||
backgroundColor: string;
|
||||
strokeColor: string;
|
||||
};
|
||||
|
||||
export type ElementBoundaries = {
|
||||
x: number;
|
||||
y: number;
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
/* from Excalidraw codebase */
|
||||
|
||||
// 1-based in case we ever do `if(element.fontFamily)`
|
||||
export const FONT_FAMILY = {
|
||||
1: "Virgil",
|
||||
2: "Helvetica",
|
||||
3: "Cascadia",
|
||||
} as const;
|
||||
|
||||
export declare type RoughPoint = [number, number];
|
||||
export type Point = Readonly<RoughPoint>;
|
||||
|
||||
export declare type Line = [Point, Point];
|
||||
export interface Rectangle {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type _ExcalidrawElementBase = Readonly<{
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
strokeColor: string;
|
||||
backgroundColor: string;
|
||||
fillStyle: FillStyle;
|
||||
strokeWidth: number;
|
||||
strokeStyle: StrokeStyle;
|
||||
strokeSharpness: StrokeSharpness;
|
||||
roughness: number;
|
||||
opacity: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle: number;
|
||||
/** Random integer used to seed shape generation so that the roughjs shape
|
||||
doesn't differ across renders. */
|
||||
seed: number;
|
||||
/** Integer that is sequentially incremented on each change. Used to reconcile
|
||||
elements during collaboration or when saving to server. */
|
||||
version: number;
|
||||
/** Random integer that is regenerated on each change.
|
||||
Used for deterministic reconciliation of updates during collaboration,
|
||||
in case the versions (see above) are identical. */
|
||||
versionNonce: number;
|
||||
isDeleted: boolean;
|
||||
/** List of groups the element belongs to.
|
||||
Ordered from deepest to shallowest. */
|
||||
groupIds: readonly GroupId[];
|
||||
/** Ids of (linear) elements that are bound to this element. */
|
||||
boundElementIds: readonly ExcalidrawLinearElement["id"][] | null;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
|
||||
type: "selection";
|
||||
};
|
||||
|
||||
export type ExcalidrawRectangleElement = _ExcalidrawElementBase & {
|
||||
type: "rectangle";
|
||||
};
|
||||
|
||||
export type ExcalidrawDiamondElement = _ExcalidrawElementBase & {
|
||||
type: "diamond";
|
||||
};
|
||||
|
||||
export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
|
||||
type: "ellipse";
|
||||
};
|
||||
|
||||
/**
|
||||
* These are elements that don't have any additional properties.
|
||||
*/
|
||||
export type ExcalidrawGenericElement =
|
||||
| ExcalidrawSelectionElement
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement;
|
||||
|
||||
/**
|
||||
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
||||
* no computed data. The list of all ExcalidrawElements should be shareable
|
||||
* between peers and contain no state local to the peer.
|
||||
*/
|
||||
export type _ExcalidrawElement =
|
||||
| ExcalidrawGenericElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawLinearElement;
|
||||
|
||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||
isDeleted: false;
|
||||
};
|
||||
|
||||
40
src/svgToExcalidraw/utils.ts
Normal file
40
src/svgToExcalidraw/utils.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Random } from "roughjs/bin/math";
|
||||
import { nanoid } from "nanoid";
|
||||
import { Point } from "./elements/ExcalidrawElement";
|
||||
|
||||
const random = new Random(Date.now());
|
||||
|
||||
export const randomInteger = (): number => Math.floor(random.next() * 2 ** 31);
|
||||
|
||||
export const randomId = (): string => nanoid();
|
||||
|
||||
export const safeNumber = (number: number): number => Number(number.toFixed(2));
|
||||
|
||||
export function dimensionsFromPoints(points: number[][]): number[] {
|
||||
const xCoords = points.map(([x]) => x);
|
||||
const yCoords = points.map(([, y]) => y);
|
||||
|
||||
const minX = Math.min(...xCoords);
|
||||
const minY = Math.min(...yCoords);
|
||||
const maxX = Math.max(...xCoords);
|
||||
const maxY = Math.max(...yCoords);
|
||||
|
||||
return [maxX - minX, maxY - minY];
|
||||
}
|
||||
|
||||
// winding order is clockwise values is positive, counter clockwise if negative.
|
||||
export function getWindingOrder(
|
||||
points: Point[],
|
||||
): "clockwise" | "counterclockwise" {
|
||||
const total = points.reduce((acc, [x1, y1], idx, arr) => {
|
||||
const p2 = arr[idx + 1];
|
||||
const x2 = p2 ? p2[0] : 0;
|
||||
const y2 = p2 ? p2[1] : 0;
|
||||
|
||||
const e = (x2 - x1) * (y2 + y1);
|
||||
|
||||
return e + acc;
|
||||
}, 0);
|
||||
|
||||
return total > 0 ? "clockwise" : "counterclockwise";
|
||||
}
|
||||
463
src/svgToExcalidraw/walker.ts
Normal file
463
src/svgToExcalidraw/walker.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
import { mat4 } from "gl-matrix";
|
||||
import { dimensionsFromPoints } from "./utils";
|
||||
import ExcalidrawScene from "./elements/ExcalidrawScene";
|
||||
import Group, { getGroupAttrs } from "./elements/Group";
|
||||
import {
|
||||
ExcalidrawElementBase,
|
||||
ExcalidrawRectangle,
|
||||
ExcalidrawEllipse,
|
||||
ExcalidrawLine,
|
||||
ExcalidrawDraw,
|
||||
createExRect,
|
||||
createExEllipse,
|
||||
createExLine,
|
||||
createExDraw,
|
||||
Point,
|
||||
} from "./elements/ExcalidrawElement";
|
||||
import {
|
||||
presAttrsToElementValues,
|
||||
filterAttrsToElementValues,
|
||||
pointsAttrToPoints,
|
||||
has,
|
||||
get,
|
||||
getNum,
|
||||
} from "./attributes";
|
||||
import { getTransformMatrix, transformPoints } from "./transform";
|
||||
import { pointsOnPath } from "points-on-path";
|
||||
import { randomId, getWindingOrder } from "./utils";
|
||||
|
||||
const SUPPORTED_TAGS = [
|
||||
"svg",
|
||||
"path",
|
||||
"g",
|
||||
"use",
|
||||
"circle",
|
||||
"ellipse",
|
||||
"rect",
|
||||
"polyline",
|
||||
"polygon",
|
||||
];
|
||||
|
||||
const nodeValidator = (node: Element): number => {
|
||||
if (SUPPORTED_TAGS.includes(node.tagName)) {
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
};
|
||||
|
||||
export function createTreeWalker(dom: Node): TreeWalker {
|
||||
return document.createTreeWalker(dom, NodeFilter.SHOW_ALL, {
|
||||
acceptNode: nodeValidator,
|
||||
});
|
||||
}
|
||||
|
||||
type WalkerArgs = {
|
||||
root: Document;
|
||||
tw: TreeWalker;
|
||||
scene: ExcalidrawScene;
|
||||
groups: Group[];
|
||||
};
|
||||
|
||||
const presAttrs = (
|
||||
el: Element,
|
||||
groups: Group[],
|
||||
): Partial<ExcalidrawElementBase> => {
|
||||
return {
|
||||
...getGroupAttrs(groups),
|
||||
...presAttrsToElementValues(el),
|
||||
...filterAttrsToElementValues(el),
|
||||
};
|
||||
};
|
||||
|
||||
const skippedUseAttrs = ["id"];
|
||||
const allwaysPassedUseAttrs = [
|
||||
"x",
|
||||
"y",
|
||||
"width",
|
||||
"height",
|
||||
"href",
|
||||
"xlink:href",
|
||||
];
|
||||
|
||||
/*
|
||||
"Most attributes on use do not override those already on the element
|
||||
referenced by use. (This differs from how CSS style attributes override
|
||||
those set 'earlier' in the cascade). Only the attributes x, y, width,
|
||||
height and href on the use element will override those set on the
|
||||
referenced element. However, any other attributes not set on the referenced
|
||||
element will be applied to the use element."
|
||||
|
||||
Situation 1: Attr is set on defEl, NOT on useEl
|
||||
- result: use defEl attr
|
||||
Situation 2: Attr is on useEl, NOT on defEl
|
||||
- result: use the useEl attr
|
||||
Situation 3: Attr is on both useEl and defEl
|
||||
- result: use the defEl attr (Unless x, y, width, height, href, xlink:href)
|
||||
*/
|
||||
const getDefElWithCorrectAttrs = (defEl: Element, useEl: Element): Element => {
|
||||
const finalEl = [...useEl.attributes].reduce((el, attr) => {
|
||||
if (skippedUseAttrs.includes(attr.value)) {
|
||||
return el;
|
||||
}
|
||||
|
||||
// Does defEl have the attr? If so, use it, else use the useEl attr
|
||||
if (
|
||||
!defEl.hasAttribute(attr.name) ||
|
||||
allwaysPassedUseAttrs.includes(attr.name)
|
||||
) {
|
||||
el.setAttribute(attr.name, useEl.getAttribute(attr.name) || "");
|
||||
}
|
||||
return el;
|
||||
}, defEl.cloneNode() as Element);
|
||||
|
||||
return finalEl;
|
||||
};
|
||||
|
||||
const walkers = {
|
||||
svg: (args: WalkerArgs) => {
|
||||
walk(args, args.tw.nextNode());
|
||||
},
|
||||
|
||||
g: (args: WalkerArgs) => {
|
||||
const nextArgs = {
|
||||
...args,
|
||||
tw: createTreeWalker(args.tw.currentNode),
|
||||
groups: [...args.groups, new Group(args.tw.currentNode as Element)],
|
||||
};
|
||||
|
||||
walk(nextArgs, nextArgs.tw.nextNode());
|
||||
|
||||
walk(args, args.tw.nextSibling());
|
||||
},
|
||||
|
||||
use: (args: WalkerArgs) => {
|
||||
const { root, tw, scene } = args;
|
||||
const useEl = tw.currentNode as Element;
|
||||
|
||||
const id = useEl.getAttribute("href") || useEl.getAttribute("xlink:href");
|
||||
|
||||
if (!id) {
|
||||
throw new Error("unable to get id of use element");
|
||||
}
|
||||
|
||||
const defEl = root.querySelector(id);
|
||||
|
||||
if (!defEl) {
|
||||
throw new Error(`unable to find def element with id: ${id}`);
|
||||
}
|
||||
|
||||
const tempScene = new ExcalidrawScene();
|
||||
|
||||
const finalEl = getDefElWithCorrectAttrs(defEl, useEl);
|
||||
|
||||
walk(
|
||||
{
|
||||
...args,
|
||||
scene: tempScene,
|
||||
tw: createTreeWalker(finalEl),
|
||||
},
|
||||
finalEl,
|
||||
);
|
||||
|
||||
const exEl = tempScene.elements.pop();
|
||||
|
||||
if (exEl) {
|
||||
scene.elements.push(exEl);
|
||||
//throw new Error("Unable to create ex element");
|
||||
}
|
||||
|
||||
|
||||
|
||||
walk(args, args.tw.nextNode());
|
||||
},
|
||||
|
||||
circle: (args: WalkerArgs): void => {
|
||||
const { tw, scene, groups } = args;
|
||||
const el = tw.currentNode as Element;
|
||||
|
||||
const r = getNum(el, "r", 0);
|
||||
const d = r * 2;
|
||||
const x = getNum(el, "x", 0) + getNum(el, "cx", 0) - r;
|
||||
const y = getNum(el, "y", 0) + getNum(el, "cy", 0) - r;
|
||||
|
||||
const mat = getTransformMatrix(el, groups);
|
||||
|
||||
// @ts-ignore
|
||||
const m = mat4.fromValues(d, 0, 0, 0, 0, d, 0, 0, 0, 0, 1, 0, x, y, 0, 1);
|
||||
|
||||
const result = mat4.multiply(mat4.create(), mat, m);
|
||||
|
||||
const circle: ExcalidrawEllipse = {
|
||||
...createExEllipse(),
|
||||
...presAttrs(el, groups),
|
||||
x: result[12],
|
||||
y: result[13],
|
||||
width: result[0],
|
||||
height: result[5],
|
||||
groupIds: groups.map((g) => g.id),
|
||||
};
|
||||
|
||||
scene.elements.push(circle);
|
||||
|
||||
walk(args, tw.nextNode());
|
||||
},
|
||||
|
||||
ellipse: (args: WalkerArgs): void => {
|
||||
const { tw, scene, groups } = args;
|
||||
const el = tw.currentNode as Element;
|
||||
|
||||
const rx = getNum(el, "rx", 0);
|
||||
const ry = getNum(el, "ry", 0);
|
||||
const cx = getNum(el, "cx", 0);
|
||||
const cy = getNum(el, "cy", 0);
|
||||
const x = getNum(el, "x", 0) + cx - rx;
|
||||
const y = getNum(el, "y", 0) + cy - ry;
|
||||
const w = rx * 2;
|
||||
const h = ry * 2;
|
||||
|
||||
const mat = getTransformMatrix(el, groups);
|
||||
|
||||
const m = mat4.fromValues(w, 0, 0, 0, 0, h, 0, 0, 0, 0, 1, 0, x, y, 0, 1);
|
||||
|
||||
const result = mat4.multiply(mat4.create(), mat, m);
|
||||
|
||||
const ellipse: ExcalidrawEllipse = {
|
||||
...createExEllipse(),
|
||||
...presAttrs(el, groups),
|
||||
x: result[12],
|
||||
y: result[13],
|
||||
width: result[0],
|
||||
height: result[5],
|
||||
groupIds: groups.map((g) => g.id),
|
||||
};
|
||||
|
||||
scene.elements.push(ellipse);
|
||||
|
||||
walk(args, tw.nextNode());
|
||||
},
|
||||
|
||||
line: (args: WalkerArgs) => {
|
||||
// unimplemented
|
||||
walk(args, args.tw.nextNode());
|
||||
},
|
||||
|
||||
polygon: (args: WalkerArgs) => {
|
||||
const { tw, scene, groups } = args;
|
||||
const el = tw.currentNode as Element;
|
||||
|
||||
const points = pointsAttrToPoints(el);
|
||||
|
||||
const mat = getTransformMatrix(el, groups);
|
||||
|
||||
const transformedPoints = transformPoints(points, mat);
|
||||
|
||||
// The first point needs to be 0, 0, and all following points
|
||||
// are relative to the first point.
|
||||
const x = transformedPoints[0][0];
|
||||
const y = transformedPoints[0][1];
|
||||
|
||||
const relativePoints = transformedPoints.map(([_x, _y]) => [
|
||||
_x - x,
|
||||
_y - y,
|
||||
]);
|
||||
|
||||
const [width, height] = dimensionsFromPoints(relativePoints);
|
||||
|
||||
const line: ExcalidrawLine = {
|
||||
...createExLine(),
|
||||
...getGroupAttrs(groups),
|
||||
...presAttrsToElementValues(el),
|
||||
points: relativePoints.concat([[0, 0]]),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
|
||||
scene.elements.push(line);
|
||||
|
||||
walk(args, args.tw.nextNode());
|
||||
},
|
||||
|
||||
polyline: (args: WalkerArgs) => {
|
||||
const { tw, scene, groups } = args;
|
||||
const el = tw.currentNode as Element;
|
||||
|
||||
const mat = getTransformMatrix(el, groups);
|
||||
|
||||
const points = pointsAttrToPoints(el);
|
||||
const transformedPoints = transformPoints(points, mat);
|
||||
|
||||
// The first point needs to be 0, 0, and all following points
|
||||
// are relative to the first point.
|
||||
const x = transformedPoints[0][0];
|
||||
const y = transformedPoints[0][1];
|
||||
|
||||
const relativePoints = transformedPoints.map(([_x, _y]) => [
|
||||
_x - x,
|
||||
_y - y,
|
||||
]);
|
||||
|
||||
const [width, height] = dimensionsFromPoints(relativePoints);
|
||||
|
||||
const hasFill = has(el, "fill");
|
||||
const fill = get(el, "fill");
|
||||
|
||||
const shouldFill = !hasFill || (hasFill && fill !== "none");
|
||||
|
||||
const line: ExcalidrawLine = {
|
||||
...createExLine(),
|
||||
...getGroupAttrs(groups),
|
||||
...presAttrsToElementValues(el),
|
||||
points: relativePoints.concat(shouldFill ? [[0, 0]] : []),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
|
||||
scene.elements.push(line);
|
||||
|
||||
walk(args, args.tw.nextNode());
|
||||
},
|
||||
|
||||
rect: (args: WalkerArgs) => {
|
||||
const { tw, scene, groups } = args;
|
||||
const el = tw.currentNode as Element;
|
||||
|
||||
const x = getNum(el, "x", 0);
|
||||
const y = getNum(el, "y", 0);
|
||||
const w = getNum(el, "width", 0);
|
||||
const h = getNum(el, "height", 0);
|
||||
|
||||
const mat = getTransformMatrix(el, groups);
|
||||
|
||||
// @ts-ignore
|
||||
const m = mat4.fromValues(w, 0, 0, 0, 0, h, 0, 0, 0, 0, 1, 0, x, y, 0, 1);
|
||||
|
||||
const result = mat4.multiply(mat4.create(), mat, m);
|
||||
|
||||
/*
|
||||
NOTE: Currently there doesn't seem to be a way to specify the border
|
||||
radius of a rect within Excalidraw. This means that attributes
|
||||
rx and ry can't be used.
|
||||
*/
|
||||
const isRound = el.hasAttribute("rx") || el.hasAttribute("ry");
|
||||
|
||||
const rect: ExcalidrawRectangle = {
|
||||
...createExRect(),
|
||||
...presAttrs(el, groups),
|
||||
x: result[12],
|
||||
y: result[13],
|
||||
width: result[0],
|
||||
height: result[5],
|
||||
strokeSharpness: isRound ? "round" : "sharp",
|
||||
};
|
||||
|
||||
scene.elements.push(rect);
|
||||
|
||||
walk(args, args.tw.nextNode());
|
||||
},
|
||||
|
||||
path: (args: WalkerArgs) => {
|
||||
const { tw, scene, groups } = args;
|
||||
const el = tw.currentNode as Element;
|
||||
|
||||
const mat = getTransformMatrix(el, groups);
|
||||
|
||||
const points = pointsOnPath(get(el, "d"));
|
||||
|
||||
const fillColor = get(el, "fill", "black");
|
||||
const fillRule = get(el, "fill-rule", "nonzero");
|
||||
|
||||
let elements: ExcalidrawDraw[] = [];
|
||||
let localGroup = randomId();
|
||||
|
||||
switch (fillRule) {
|
||||
case "nonzero":
|
||||
let initialWindingOrder = "clockwise";
|
||||
|
||||
elements = points.map((pointArr, idx): ExcalidrawDraw => {
|
||||
const tPoints: Point[] = transformPoints(pointArr, mat4.clone(mat));
|
||||
const x = tPoints[0][0];
|
||||
const y = tPoints[0][1];
|
||||
|
||||
const [width, height] = dimensionsFromPoints(tPoints);
|
||||
|
||||
const relativePoints = tPoints.map(
|
||||
([_x, _y]): Point => [_x - x, _y - y],
|
||||
);
|
||||
|
||||
const windingOrder = getWindingOrder(relativePoints);
|
||||
if (idx === 0) {
|
||||
initialWindingOrder = windingOrder;
|
||||
localGroup = randomId();
|
||||
}
|
||||
|
||||
let backgroundColor = fillColor;
|
||||
if (initialWindingOrder !== windingOrder) {
|
||||
backgroundColor = "#FFFFFF";
|
||||
}
|
||||
|
||||
return {
|
||||
...createExDraw(),
|
||||
strokeWidth: 0,
|
||||
strokeColor: "#00000000",
|
||||
...presAttrs(el, groups),
|
||||
points: relativePoints,
|
||||
backgroundColor,
|
||||
width,
|
||||
height,
|
||||
x: x + getNum(el, "x", 0),
|
||||
y: y + getNum(el, "y", 0),
|
||||
groupIds: [localGroup],
|
||||
};
|
||||
});
|
||||
break;
|
||||
case "evenodd":
|
||||
elements = points.map((pointArr, idx): ExcalidrawDraw => {
|
||||
const tPoints: Point[] = transformPoints(pointArr, mat4.clone(mat));
|
||||
const x = tPoints[0][0];
|
||||
const y = tPoints[0][1];
|
||||
|
||||
const [width, height] = dimensionsFromPoints(tPoints);
|
||||
|
||||
const relativePoints = tPoints.map(
|
||||
([_x, _y]): Point => [_x - x, _y - y],
|
||||
);
|
||||
|
||||
if (idx === 0) {
|
||||
localGroup = randomId();
|
||||
}
|
||||
|
||||
return {
|
||||
...createExDraw(),
|
||||
...presAttrs(el, groups),
|
||||
points: relativePoints,
|
||||
width,
|
||||
height,
|
||||
x: x + getNum(el, "x", 0),
|
||||
y: y + getNum(el, "y", 0),
|
||||
};
|
||||
});
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
scene.elements = scene.elements.concat(elements);
|
||||
|
||||
walk(args, tw.nextNode());
|
||||
},
|
||||
};
|
||||
|
||||
export function walk(args: WalkerArgs, nextNode: Node | null): void {
|
||||
if (!nextNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeName = nextNode.nodeName as keyof typeof walkers;
|
||||
if (walkers[nodeName]) {
|
||||
walkers[nodeName](args);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"importHelpers": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
|
||||
15
yarn.lock
15
yarn.lock
@@ -1676,6 +1676,11 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/chroma-js@^2.1.4":
|
||||
"integrity" "sha512-l9hWzP7cp7yleJUI7P2acmpllTJNYf5uU6wh50JzSIZt3fFHe+w2FM6w9oZGBTYzjjm2qHdnQvI+fF/JF/E5jQ=="
|
||||
"resolved" "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.1.4.tgz"
|
||||
"version" "2.1.4"
|
||||
|
||||
"@types/codemirror@0.0.108":
|
||||
"integrity" "sha512-3FGFcus0P7C2UOGCNUVENqObEb4SFk+S8Dnxq7K6aIsLVs/vDtlangl3PEO0ykaKXyK56swVF6Nho7VsA44uhw=="
|
||||
"resolved" "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.108.tgz"
|
||||
@@ -2964,6 +2969,11 @@
|
||||
optionalDependencies:
|
||||
"fsevents" "~2.3.2"
|
||||
|
||||
"chroma-js@^2.4.2":
|
||||
"integrity" "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A=="
|
||||
"resolved" "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz"
|
||||
"version" "2.4.2"
|
||||
|
||||
"chrome-trace-event@^1.0.2":
|
||||
"integrity" "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg=="
|
||||
"resolved" "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz"
|
||||
@@ -4628,6 +4638,11 @@
|
||||
"call-bind" "^1.0.2"
|
||||
"get-intrinsic" "^1.1.1"
|
||||
|
||||
"gl-matrix@^3.4.3":
|
||||
"integrity" "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA=="
|
||||
"resolved" "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz"
|
||||
"version" "3.4.3"
|
||||
|
||||
"glob-parent@^5.1.2", "glob-parent@~5.1.2":
|
||||
"integrity" "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="
|
||||
"resolved" "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
|
||||
|
||||
Reference in New Issue
Block a user