Compare commits

...

8 Commits

Author SHA1 Message Date
zsviczian
e0111e264c Merge branch 'master' of https://github.com/zsviczian/obsidian-excalidraw-plugin 2022-11-13 20:32:00 +01:00
zsviczian
c6196a86a9 1.7.28 beta 2022-11-13 20:31:57 +01:00
zsviczian
3926e5c30b Update issue templates 2022-11-02 13:59:55 +01:00
zsviczian
a1256422fa Update issue templates 2022-11-02 13:58:09 +01:00
zsviczian
eeb47d4912 Update issue templates 2022-11-02 13:56:50 +01:00
zsviczian
8ceac4ab31 1.7.27 2022-11-01 11:42:39 +01:00
zsviczian
225c6305d1 added SVG import 2022-10-30 15:44:14 +01:00
zsviczian
ba9ab61cc9 override zoomToFit for large drawings 2022-10-29 19:54:06 +02:00
39 changed files with 2216 additions and 110 deletions

View File

@@ -1,32 +1,30 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):**
- OS including version: [e.g. iOS 15.1, Android 9, Windows 11, etc]
- Plugin version:
- Obsidian version:
**Additional context**
Add any other context about the problem here.
---
name: Bug report
about: Create a report to help me improve Excalidraw
title: 'BUG: '
labels: ''
assignees: ''
---
**Your environment**
Please run `Command Palette/Show Debug info` in Obsidian and paste the result here.
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.

View File

@@ -1,20 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
---
name: Feature request
about: Suggest an idea for this project
title: 'FR: '
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -19,7 +19,7 @@ Please upgrade to Obsidian v0.12.19 or higher to get the latest release.
|[![thumbnail](https://user-images.githubusercontent.com/14358394/156931461-0979b821-315a-41dd-86f1-31d169b7c127.jpg)](https://youtu.be/Etskjw7a5zo)|[![Thumbnail](https://user-images.githubusercontent.com/14358394/158008902-12c6a851-237e-4edd-a631-d48e81c904b2.jpg)](https://youtu.be/4N6efq1DtH0)|[![thumbnail](https://user-images.githubusercontent.com/14358394/159369910-6371f08d-b5fa-454d-9c6c-948f7e7a7d26.jpg)](https://youtu.be/U2LkBRBk4LY)|
| [![6 strategies for linking your visual thoughts v4](https://user-images.githubusercontent.com/14358394/171635214-30533c45-94fa-436e-83a9-b2ec99f190e2.jpg)](https://youtu.be/qiKuqMcNWgU)|[![Video thumbnail small](https://user-images.githubusercontent.com/14358394/185791706-3d9983ab-7cb1-4b27-a016-30c039d84e34.jpg)](https://youtu.be/yZQoJg2RCKI)|[![Thumbnail - Colors - Excalidraw Basics (Custom)](https://user-images.githubusercontent.com/14358394/194773147-5418a0ab-6be5-4eb0-a8e4-d6af21b1b483.png)](https://youtu.be/6PLGHBH9VZ4) |
|[![Thumbnail - Excalidraw color palettes (Custom)](https://user-images.githubusercontent.com/14358394/194773211-9e871be7-0795-4dc7-947e-c6c275e690d0.png)](https://youtu.be/epYNx2FSf2w) | [![Thumbnail (Custom)](https://user-images.githubusercontent.com/14358394/194773268-400cfb1b-6bde-45e0-9e4b-91bbaa461cf0.png)](https://youtu.be/Amhlv6r9WvM) | [![Thumbnail - Simple rules for beautiful sketches (Custom) (1)](https://user-images.githubusercontent.com/14358394/194773527-ef35c8b9-1a6d-4415-9c7e-b667fb17535d.png)](https://youtu.be/r9oB1SlK1GU) |
|[![Thumbnail - ColorMaster Scripting (Custom)](https://user-images.githubusercontent.com/14358394/195988535-a133a9b9-d094-45ba-ba64-c994b9a1e0ef.png)](https://youtu.be/7gJDwNgQ6NU) | | |
|[![Thumbnail - ColorMaster Scripting (Custom)](https://user-images.githubusercontent.com/14358394/195988535-a133a9b9-d094-45ba-ba64-c994b9a1e0ef.png)](https://youtu.be/7gJDwNgQ6NU) | [![Thumbnail - 1 7 27 SVG import (Custom)](https://user-images.githubusercontent.com/14358394/199207784-8bbe14e0-7d10-47d7-971d-20dce8dbd659.png)](https://youtu.be/vlC1-iBvIfo) | |

View File

@@ -1,7 +1,7 @@
{
"id": "obsidian-excalidraw-plugin",
"name": "Excalidraw",
"version": "1.7.26",
"version": "1.7.27",
"minAppVersion": "0.15.6",
"description": "An Obsidian plugin to edit and view Excalidraw drawings",
"author": "Zsolt Viczian",

View File

@@ -18,7 +18,7 @@
"license": "MIT",
"dependencies": {
"@types/lz-string": "^1.3.34",
"@zsviczian/excalidraw": "0.13.0-obsidian",
"@zsviczian/excalidraw": "0.13.0-obsidian-1",
"clsx": "^1.1.1",
"lz-string": "^1.4.4",
"monkey-around": "^2.3.0",
@@ -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",

View File

@@ -8,10 +8,11 @@ import {
FileId,
NonDeletedExcalidrawElement,
ExcalidrawImageElement,
ExcalidrawTextElement,
} 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 { ExcalidrawData, getMarkdownDrawingSection } from "./ExcalidrawData";
import {
FRONTMATTER,
nanoid,
@@ -32,7 +33,7 @@ import {
isVersionNewerThanOther,
log,
scaleLoadedImage,
wrapText,
wrapTextAtCharLength,
} from "./utils/Utils";
import { getNewOrAdjacentLeaf, isObsidianThemeDark } from "./utils/ObsidianUtils";
import { AppState, Point } from "@zsviczian/excalidraw/types/types";
@@ -58,6 +59,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,
@@ -122,6 +124,7 @@ export class ExcalidrawAutomate implements ExcalidrawAutomateInterface {
viewBackgroundColor: string;
gridSize: number;
};
colorPalette: {};
constructor(plugin: ExcalidrawPlugin, view?: ExcalidrawView) {
this.plugin = plugin;
@@ -386,10 +389,38 @@ export class ExcalidrawAutomate implements ExcalidrawAutomateInterface {
template?.appState?.currentItemLinearStrokeSharpness ??
this.style.strokeSharpness,
gridSize: template?.appState?.gridSize ?? this.canvas.gridSize,
colorPalette: template?.appState?.colorPalette ?? this.colorPalette,
},
files: template?.files ?? {},
};
const generateMD = ():string => {
const textElements = this.getElements().filter(el => el.type === "text") as ExcalidrawTextElement[];
let outString = "# Text Elements\n";
textElements.forEach(te=> {
outString += `${te.originalText ?? te.text} ^${te.id}\n\n`;
});
const elementsWithLinks = this.getElements().filter( el => el.type !== "text" && el.link)
elementsWithLinks.forEach(el=>{
outString += `${el.link} ^${el.id}\n\n`;
})
outString += Object.keys(this.imagesDict).length > 0
? "\n# Embedded files\n"
: "";
Object.keys(this.imagesDict).forEach((key: FileId)=> {
const item = this.imagesDict[key];
if(item.latex) {
outString += `${key}: $$${item.latex}$$\n`;
} else {
outString += `${key}: [[${item.file}]]\n`;
}
})
return outString;
}
return this.plugin.createAndOpenDrawing(
params?.filename
? params.filename + (params.filename.endsWith(".md") ? "": ".excalidraw.md")
@@ -398,8 +429,8 @@ export class ExcalidrawAutomate implements ExcalidrawAutomateInterface {
params?.foldername ? params.foldername : this.plugin.settings.folder,
this.plugin.settings.compatibilityMode
? JSON.stringify(scene, null, "\t")
: frontmatter +
(await this.plugin.exportSceneToMD(JSON.stringify(scene, null, "\t"))),
: frontmatter + generateMD() +
getMarkdownDrawingSection(JSON.stringify(scene, null, "\t"),this.plugin.settings.compress)
);
};
@@ -518,7 +549,7 @@ export class ExcalidrawAutomate implements ExcalidrawAutomateInterface {
* @returns
*/
wrapText(text: string, lineLen: number): string {
return wrapText(text, lineLen, this.plugin.settings.forceWrap);
return wrapTextAtCharLength(text, lineLen, this.plugin.settings.forceWrap);
};
private boxedElement(
@@ -1873,6 +1904,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(

View File

@@ -27,11 +27,12 @@ import {
decompress,
//getBakPath,
getBinaryFileFromDataURL,
getContainerElement,
getExportTheme,
getLinkParts,
hasExportTheme,
LinkParts,
wrapText,
wrapTextAtCharLength,
} from "./utils/Utils";
import { getAttachmentsFolderAndFilePath, isObsidianThemeDark } from "./utils/ObsidianUtils";
import {
@@ -52,6 +53,13 @@ declare module "obsidian" {
}
}
const {
wrapText,
getFontString,
getMaxContainerWidth,
//@ts-ignore
} = excalidrawLib;
export enum AutoexportPreference {
none,
both,
@@ -210,15 +218,16 @@ const estimateMaxLineLen = (text: string, originalText: string): number => {
return null;
}
for (const line of splitText) {
if (line.length > maxLineLen) {
maxLineLen = line.length;
const l = line.trim();
if (l.length > maxLineLen) {
maxLineLen = l.length;
}
}
return maxLineLen;
};
const wrap = (text: string, lineLen: number) =>
lineLen ? wrapText(text, lineLen, false, 0) : text;
lineLen ? wrapTextAtCharLength(text, lineLen, false, 0) : text;
export class ExcalidrawData {
public textElements: Map<
@@ -638,12 +647,17 @@ export class ExcalidrawData {
//first get scene text elements
const texts = this.scene.elements?.filter((el: any) => el.type === "text");
for (const te of texts) {
const container = getContainerElement(te,this.scene);
const originalText =
(await this.getText(te.id, false)) ?? te.originalText ?? te.text;
(await this.getText(te.id)) ?? te.originalText ?? te.text;
const wrapAt = this.textElements.get(te.id)?.wrapAt;
this.updateTextElement(
te,
wrap(originalText, wrapAt),
wrapAt ? wrapText(
originalText,
getFontString(te.fontSize,te.fontFamily),
getMaxContainerWidth(container)
) : originalText,
originalText,
forceupdate,
); //(await this.getText(te.id))??te.text serves the case when the whole #Text Elements section is deleted by accident
@@ -652,7 +666,6 @@ export class ExcalidrawData {
private async getText(
id: string,
wrapResult: boolean = true,
): Promise<string> {
const text = this.textElements.get(id);
if (!text) {
@@ -667,7 +680,7 @@ export class ExcalidrawData {
});
}
//console.log("parsed",this.textElements.get(id).parsed);
return wrapResult ? wrap(text.parsed, text.wrapAt) : text.parsed;
return text.parsed;
}
//console.log("raw",this.textElements.get(id).raw);
return text.raw;
@@ -794,7 +807,7 @@ export class ExcalidrawData {
if (el.length === 0) {
this.textElements.delete(key); //if no longer in the scene, delete the text element
} else {
const text = await this.getText(key, false);
const text = await this.getText(key);
const raw = this.scene.prevTextMode === TextMode.parsed
? el[0].rawText
: (el[0].originalText ?? el[0].text);
@@ -887,7 +900,7 @@ export class ExcalidrawData {
}
outString +=
text.substring(position, parts.value.index) +
wrapText(
wrapTextAtCharLength(
contents,
REGEX_LINK.getWrapLength(
parts,
@@ -1434,7 +1447,7 @@ export class ExcalidrawData {
const parts = data.linkParts.original.split("#");
this.plugin.filesMaster.set(fileId, {
path:data.file.path,
path:data.file.path + (data.shouldScale()?"":"|100%"),
blockrefData: parts.length === 1
? null
: parts[1],
@@ -1479,16 +1492,18 @@ export class ExcalidrawData {
}
if (this.plugin.filesMaster.has(fileId)) {
const masterFile = this.plugin.filesMaster.get(fileId);
if (!this.app.vault.getAbstractFileByPath(masterFile.path)) {
const path = masterFile.path.split("|")[0].split("#")[0];
if (!this.app.vault.getAbstractFileByPath(path)) {
this.plugin.filesMaster.delete(fileId);
return true;
} // the file no longer exists
const fixScale = masterFile.path.endsWith("100%");
const embeddedFile = new EmbeddedFile(
this.plugin,
this.file.path,
masterFile.blockrefData
? masterFile.path + "#" + masterFile.blockrefData
: masterFile.path
(masterFile.blockrefData
? path + "#" + masterFile.blockrefData
: path) + (fixScale?"|100%":"")
);
this.files.set(fileId, embeddedFile);
return true;

View File

@@ -92,6 +92,7 @@ import { ObsidianMenu } from "./menu/ObsidianMenu";
import { ToolsPanel } from "./menu/ToolsPanel";
import { ScriptEngine } from "./Scripts";
import { getTextElementAtPointer, getImageElementAtPointer, getElementWithLinkAtPointer } from "./utils/GetElementAtPointer";
import { MenuLinks } from "./menu/menuLinks";
export enum TextMode {
parsed = "parsed",
@@ -255,6 +256,7 @@ export default class ExcalidrawView extends TextFileView {
private linkAction_Element: HTMLElement;
public compatibilityMode: boolean = false;
private obsidianMenu: ObsidianMenu;
private menuLinks: MenuLinks;
//https://stackoverflow.com/questions/27132796/is-there-any-javascript-event-fired-when-the-on-screen-keyboard-on-mobile-safari
private isEditingTextResetTimer: NodeJS.Timeout = null;
@@ -306,7 +308,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 +652,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 +736,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) {
@@ -2014,6 +2015,8 @@ export default class ExcalidrawView extends TextFileView {
let currentPosition = { x: 0, y: 0 };
const excalidrawWrapperRef = React.useRef(null);
const toolsPanelRef = React.useRef(null);
const menuLinksRef = React.useRef(null);
const [dimensions, setDimensions] = React.useState({
width: undefined,
height: undefined,
@@ -2028,6 +2031,7 @@ export default class ExcalidrawView extends TextFileView {
this.toolsPanelRef = toolsPanelRef;
this.obsidianMenu = new ObsidianMenu(this.plugin, toolsPanelRef);
this.menuLinks = new MenuLinks(this.plugin, menuLinksRef);
//excalidrawRef readypromise based on
//https://codesandbox.io/s/eexcalidraw-resolvable-promise-d0qg3?file=/src/App.js:167-760
@@ -2354,7 +2358,7 @@ export default class ExcalidrawView extends TextFileView {
elements,
commitToHistory: true,
},
false,
true, //set to true because svtToExcalidraw generates a legacy Excalidraw object
true
);
@@ -2731,7 +2735,7 @@ export default class ExcalidrawView extends TextFileView {
loadScene: false,
saveScene: false,
saveAsScene: false,
export: { saveFileToDisk: false },
export: false,
saveAsImage: false,
saveToActiveFile: false,
},
@@ -2782,6 +2786,8 @@ export default class ExcalidrawView extends TextFileView {
},
libraryReturnUrl: "app://obsidian.md",
autoFocus: true,
hideWelcomeScreen: true,
renderMenuLinks: null, //this.menuLinks.render,
onChange: (et: ExcalidrawElement[], st: AppState) => {
const canvasColorChangeHook = () => {
if(this.plugin.ea.onCanvasColorChangeHook) {
@@ -2796,7 +2802,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 +3402,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(

View File

@@ -210,7 +210,7 @@ COLOR_NAMES.set("white", "#ffffff");
COLOR_NAMES.set("whitesmoke", "#f5f5f5");
COLOR_NAMES.set("yellow", "#ffff00");
COLOR_NAMES.set("yellowgreen", "#9acd32");
export const DEFAULT_MD_EMBED_CSS = `.excalidraw-md-host{padding:0px 10px}.excalidraw-md-footer{height:5px}foreignObject{background-color:transparent}p{display:block;margin-block-start:1em;margin-block-end:1em;margin-inline-start:0px;margin-inline-end:0px;color:inherit}table,tr,th,td{color:inherit;border:1px solid;border-collapse:collapse;padding:3px}th{font-weight:bold;border-bottom:double;background-color:silver}.copy-code-button{display:none}code[class*=language-],pre[class*=language-]{color:#393a34;font-family:"Consolas","Bitstream Vera Sans Mono","Courier New",Courier,monospace;direction:ltr;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;font-size:.9em;line-height:1.2em;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre>code[class*=language-]{font-size:1em}pre[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,code[class*=language-] ::-moz-selection{background:#C1DEF1}pre[class*=language-]::selection,pre[class*=language-] ::selection,code[class*=language-]::selection,code[class*=language-] ::selection{background:#C1DEF1}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto;background-color:#0000001a}:not(pre)>code[class*=language-]{padding:.2em;padding-top:1px;padding-bottom:1px;background:#f8f8f8;border:1px solid #dddddd}.token.comment,.token.prolog,.token.doctype,.token.cdata{color:green;font-style:italic}.token.namespace{opacity:.7}.token.string{color:#a31515}.token.punctuation,.token.operator{color:#393a34}.token.url,.token.symbol,.token.number,.token.boolean,.token.variable,.token.constant,.token.inserted{color:#36acaa}.token.atrule,.token.keyword,.token.attr-value,.language-autohotkey .token.selector,.language-json .token.boolean,.language-json .token.number,code[class*=language-css]{color:#00f}.token.function{color:#393a34}.token.deleted,.language-autohotkey .token.tag{color:#9a050f}.token.selector,.language-autohotkey .token.keyword{color:#00009f}.token.important{color:#e90}.token.important,.token.bold{font-weight:bold}.token.italic{font-style:italic}.token.class-name,.language-json .token.property{color:#2b91af}.token.tag,.token.selector{color:maroon}.token.attr-name,.token.property,.token.regex,.token.entity{color:red}.token.directive.tag .tag{background:#ffff00;color:#393a34}.line-numbers.line-numbers .line-numbers-rows{border-right-color:#a5a5a5}.line-numbers .line-numbers-rows>span:before{color:#2b91af}.line-highlight.line-highlight{background:rgba(193,222,241,.2);background:-webkit-linear-gradient(left,rgba(193,222,241,.2) 70%,rgba(221,222,241,0));background:linear-gradient(to right,rgba(193,222,241,.2) 70%,rgba(221,222,241,0))}blockquote{ font-style:italic;background-color:rgb(46,43,42,0.1);margin:0;margin-left:1em;border-radius:0 4px 4px 0;border:1px solid hsl(0,80%,32%);border-left-width:8px;border-top-width:0px;border-right-width:0px;border-bottom-width:0px;padding:10px 20px;margin-inline-start:30px;margin-inline-end:30px;}`;
export const DEFAULT_MD_EMBED_CSS = `.snw-reference{display: none;}.excalidraw-md-host{padding:0px 10px}.excalidraw-md-footer{height:5px}foreignObject{background-color:transparent}p{display:block;margin-block-start:1em;margin-block-end:1em;margin-inline-start:0px;margin-inline-end:0px;color:inherit}table,tr,th,td{color:inherit;border:1px solid;border-collapse:collapse;padding:3px}th{font-weight:bold;border-bottom:double;background-color:silver}.copy-code-button{display:none}code[class*=language-],pre[class*=language-]{color:#393a34;font-family:"Consolas","Bitstream Vera Sans Mono","Courier New",Courier,monospace;direction:ltr;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;font-size:.9em;line-height:1.2em;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre>code[class*=language-]{font-size:1em}pre[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,code[class*=language-] ::-moz-selection{background:#C1DEF1}pre[class*=language-]::selection,pre[class*=language-] ::selection,code[class*=language-]::selection,code[class*=language-] ::selection{background:#C1DEF1}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto;background-color:#0000001a}:not(pre)>code[class*=language-]{padding:.2em;padding-top:1px;padding-bottom:1px;background:#f8f8f8;border:1px solid #dddddd}.token.comment,.token.prolog,.token.doctype,.token.cdata{color:green;font-style:italic}.token.namespace{opacity:.7}.token.string{color:#a31515}.token.punctuation,.token.operator{color:#393a34}.token.url,.token.symbol,.token.number,.token.boolean,.token.variable,.token.constant,.token.inserted{color:#36acaa}.token.atrule,.token.keyword,.token.attr-value,.language-autohotkey .token.selector,.language-json .token.boolean,.language-json .token.number,code[class*=language-css]{color:#00f}.token.function{color:#393a34}.token.deleted,.language-autohotkey .token.tag{color:#9a050f}.token.selector,.language-autohotkey .token.keyword{color:#00009f}.token.important{color:#e90}.token.important,.token.bold{font-weight:bold}.token.italic{font-style:italic}.token.class-name,.language-json .token.property{color:#2b91af}.token.tag,.token.selector{color:maroon}.token.attr-name,.token.property,.token.regex,.token.entity{color:red}.token.directive.tag .tag{background:#ffff00;color:#393a34}.line-numbers.line-numbers .line-numbers-rows{border-right-color:#a5a5a5}.line-numbers .line-numbers-rows>span:before{color:#2b91af}.line-highlight.line-highlight{background:rgba(193,222,241,.2);background:-webkit-linear-gradient(left,rgba(193,222,241,.2) 70%,rgba(221,222,241,0));background:linear-gradient(to right,rgba(193,222,241,.2) 70%,rgba(221,222,241,0))}blockquote{ font-style:italic;background-color:rgb(46,43,42,0.1);margin:0;margin-left:1em;border-radius:0 4px 4px 0;border:1px solid hsl(0,80%,32%);border-left-width:8px;border-top-width:0px;border-right-width:0px;border-bottom-width:0px;padding:10px 20px;margin-inline-start:30px;margin-inline-end:30px;}`;
export const SCRIPTENGINE_ICON = `<g transform="translate(-8,-8)"><path d="M24.318 37.983c-1.234-1.232-8.433-3.903-7.401-7.387 1.057-3.484 9.893-12.443 13.669-13.517 3.776-1.074 6.142 6.523 9.012 7.073 2.87.55 6.797-1.572 8.207-3.694 1.384-2.148-3.147-7.413.15-9.168 3.298-1.755 16.389-2.646 19.611-1.284 3.247 1.363-1.611 7.335-.151 9.483 1.46 2.148 6.067 3.746 8.836 3.38 2.769-.368 4.154-6.733 7.728-5.633 3.575 1.1 12.36 8.828 13.67 12.233 1.308 3.406-5.186 5.423-5.79 8.2-.58 2.75-.026 6.705 2.265 8.355 2.266 1.65 9.642-1.78 11.404 1.598 1.762 3.38 1.007 15.35-.806 18.651-1.787 3.353-7.753-.367-9.969 1.31-2.215 1.65-3.901 5.92-3.373 8.67.504 2.777 7.754 4.48 6.445 7.885C96.49 87.543 87.15 95.454 83.5 96.685c-3.65 1.231-4.96-4.741-7.577-5.16-2.593-.393-6.57.707-8.03 2.75-1.436 2.017 2.668 7.806-.63 9.483-3.323 1.676-15.759 2.226-19.157.655-3.373-1.598.554-7.964-1.108-10.138-1.687-2.174-6.394-3.431-9.012-2.907-2.643.55-3.273 7.282-6.747 6.103-3.499-1.126-12.788-9.535-14.172-13.019-1.36-3.484 5.437-5.108 5.966-7.858.529-2.777-.68-7.073-2.744-8.697-2.064-1.624-7.93 2.41-9.642-1.126-1.737-3.537-2.441-16.765-.654-20.118 1.787-3.3 9.062 1.598 11.429.183 2.366-1.44 2.316-7.282 2.769-8.749m.126-.104c-1.234-1.232-8.433-3.903-7.401-7.387 1.057-3.484 9.893-12.443 13.669-13.517 3.776-1.074 6.142 6.523 9.012 7.073 2.87.55 6.797-1.572 8.207-3.694 1.384-2.148-3.147-7.413.15-9.168 3.298-1.755 16.389-2.646 19.611-1.284 3.247 1.363-1.611 7.335-.151 9.483 1.46 2.148 6.067 3.746 8.836 3.38 2.769-.368 4.154-6.733 7.728-5.633 3.575 1.1 12.36 8.828 13.67 12.233 1.308 3.406-5.186 5.423-5.79 8.2-.58 2.75-.026 6.705 2.265 8.355 2.266 1.65 9.642-1.78 11.404 1.598 1.762 3.38 1.007 15.35-.806 18.651-1.787 3.353-7.753-.367-9.969 1.31-2.215 1.65-3.901 5.92-3.373 8.67.504 2.777 7.754 4.48 6.445 7.885C96.49 87.543 87.15 95.454 83.5 96.685c-3.65 1.231-4.96-4.741-7.577-5.16-2.593-.393-6.57.707-8.03 2.75-1.436 2.017 2.668 7.806-.63 9.483-3.323 1.676-15.759 2.226-19.157.655-3.373-1.598.554-7.964-1.108-10.138-1.687-2.174-6.394-3.431-9.012-2.907-2.643.55-3.273 7.282-6.747 6.103-3.499-1.126-12.788-9.535-14.172-13.019-1.36-3.484 5.437-5.108 5.966-7.858.529-2.777-.68-7.073-2.744-8.697-2.064-1.624-7.93 2.41-9.642-1.126-1.737-3.537-2.441-16.765-.654-20.118 1.787-3.3 9.062 1.598 11.429.183 2.366-1.44 2.316-7.282 2.769-8.749" fill="none" stroke-width="2" stroke-linecap="round" stroke="currentColor"/><path d="M81.235 56.502a23.3 23.3 0 0 1-1.46 8.068 20.785 20.785 0 0 1-1.762 3.72 24.068 24.068 0 0 1-5.337 6.26 22.575 22.575 0 0 1-3.449 2.358 23.726 23.726 0 0 1-7.803 2.803 24.719 24.719 0 0 1-8.333 0 24.102 24.102 0 0 1-4.028-1.074 23.71 23.71 0 0 1-3.776-1.729 23.259 23.259 0 0 1-6.369-5.265 23.775 23.775 0 0 1-2.416-3.353 24.935 24.935 0 0 1-1.762-3.72 23.765 23.765 0 0 1-1.083-3.981 23.454 23.454 0 0 1 0-8.173c.252-1.336.604-2.698 1.083-3.956a24.935 24.935 0 0 1 1.762-3.72 22.587 22.587 0 0 1 2.416-3.378c.881-1.048 1.888-2.017 2.946-2.908a24.38 24.38 0 0 1 3.423-2.357 23.71 23.71 0 0 1 3.776-1.73 21.74 21.74 0 0 1 4.028-1.047 23.437 23.437 0 0 1 8.333 0 24.282 24.282 0 0 1 7.803 2.777 26.198 26.198 0 0 1 3.45 2.357 24.62 24.62 0 0 1 5.336 6.287 20.785 20.785 0 0 1 1.762 3.72 21.32 21.32 0 0 1 1.083 3.955c.251 1.336.302 3.405.377 4.086.05.681.05-.68 0 0" fill="none" stroke-width="4" stroke-linecap="round" stroke="currentColor"/><path d="M69.404 56.633c-6.596-3.3-13.216-6.6-19.51-9.744m19.51 9.744c-6.747-3.379-13.493-6.758-19.51-9.744m0 0v19.489m0-19.49v19.49m0 0c4.355-2.148 8.71-4.322 19.51-9.745m-19.51 9.745c3.978-1.965 7.93-3.956 19.51-9.745m0 0h0m0 0h0" fill="currentColor" stroke-linecap="round" stroke="currentColor" stroke-width="4"/></g>`;
export const DISK_ICON_NAME = "disk";
export const DISK_ICON = `<path fill="none" stroke="currentColor" fill="#fff" d="M0 0h100v100H0z"/><path fill="none" stroke="currentColor" d="M20.832 4.168c21.824.145 43.645.289 74.68.5m-74.68-.5c17.09.113 34.176.227 74.68.5m0 0c.094 27.3.191 54.602.32 91.164m-.32-91.164c.113 32.633.23 65.27.32 91.164m0 0H4.168m91.664 0H4.168m0 0v-75m0 75v-75m0 0L20.832 4.168M4.168 20.832L20.832 4.168M20.832 4.168h58.336m-58.336 0h58.336m0 0v25m0-25v25m0 0H20.832m58.336 0H20.832m0 0v-25m0 25v-25" stroke-width="1.66668" /><path fill="none" stroke="currentColor" d="M29.168 4.168h16.664v16.664H29.168"/><path fill="none" stroke="currentColor" d="M29.168 4.168h16.664m-16.664 0h16.664m0 0v16.664m0-16.664v16.664m0 0H29.168m16.664 0H29.168m0 0V4.168m0 16.664V4.168M12.5 54.168h75m-75 0h75m0 0v41.664m0-41.664v41.664m0 0h-75m75 0h-75m0 0V54.168m0 41.664V54.168M20.832 62.5c20.11-.18 40.219-.36 55.68-.5m-55.68.5c14.656-.133 29.313-.262 55.68-.5M20.832 71.332c13.098-.117 26.2-.234 55.68-.5m-55.68.5l55.68-.5M21.117 79.582c20.645-.184 41.285-.371 55.68-.5m-55.68.5c18.153-.16 36.301-.324 55.68-.5" stroke-width="1.66668"/>`;

View 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();
}
}

View File

@@ -11,12 +11,23 @@ Thank you & Enjoy!
`;
export const RELEASE_NOTES: { [k: string]: string } = {
Intro: `I want to help you keep up with all the updates. After installing each release, you'll be prompted with a summary of new features and fixes. You can disable these popup messages in plugin settings.
Intro: `After each update you'll be prompted with the release notes. You can disable this in plugin settings.
I develop this plugin as a hobby, spending most of my free time doing this. If you'd like to contribute to the on-going work, I have a simple membership scheme with Bronze, Silver and Gold tiers. Many of you have already bought me a coffee. THANK YOU! It really means a lot to me! If you find this plugin valuable, please consider supporting me.
I develop this plugin as a hobby, spending my free time doing this. If you find it valuable, then please say THANK YOU or...
<div class="ex-coffee-div"><a href="https://ko-fi.com/zsolt"><img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=3" height=45></a></div>
`,
"1.7.27":`## New
- Import SVG drawing as an Excalidraw object. [#679](https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/679)
<div class="excalidraw-videoWrapper"><div>
<iframe src="https://www.youtube.com/embed/vlC1-iBvIfo" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
</div></div>
## Fixed
- Large drawings freeze on the iPad when opening the file. I implemented a workaround whereby Excalidraw will avoid zoom-to-fit drawings with over 1000 elements. [#863](https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/863)
- Reintroduced copy/paste to the context menu
`,
"1.7.26":`## Fixed
- Transcluded block with a parent bullet does not embed sub-bullet [#853](https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/853)
- Transcluded text will now exclude ^block-references at end of lines

View File

@@ -35,7 +35,7 @@ export class ReleaseNotes extends Modal {
const message = this.version
? Object.keys(RELEASE_NOTES)
.filter((key) => key === "Intro" || isVersionNewerThanOther(key,prevRelease))
.map((key: string) => `# ${key}\n${RELEASE_NOTES[key]}`)
.map((key: string) => `${key==="Intro" ? "" : `# ${key}\n`}${RELEASE_NOTES[key]}`)
.slice(0, 10)
.join("\n\n---\n")
: FIRST_RUN;

View File

@@ -224,8 +224,8 @@ export const EXCALIDRAW_AUTOMATE_INFO: SuggesterInfo[] = [
},
{
field: "addImage",
code: "addImage(topX: number, topY: number, imageFile: TFile): Promise<string>;",
desc: null,
code: "addImage(topX: number, topY: number, imageFile: TFile, scale: boolean): Promise<string>;",
desc: "set scale to false if you want to embed the image at 100% of its original size. Default is true which will insert a scaled image",
after: "",
},
{

View File

@@ -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)!})",

View File

@@ -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"),

View File

@@ -26,9 +26,9 @@ export class ActionButton extends React.Component<ButtonProps, ButtonState> {
return (
<button
style={{
width: "fit-content",
padding: "2px",
margin: "4px",
//width: "fit-content",
//padding: "2px",
//margin: "4px",
}}
className="ToolIcon_type_button ToolIcon_size_small ToolIcon_type_button--show ToolIcon"
title={this.props.title}

File diff suppressed because one or more lines are too long

21
src/menu/MenuLinks.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { AppState } from "@zsviczian/excalidraw/types/types";
import clsx from "clsx";
import * as React from "react";
import ExcalidrawPlugin from "../main";
export class MenuLinks {
plugin: ExcalidrawPlugin;
ref: React.MutableRefObject<any>;
constructor(plugin: ExcalidrawPlugin, ref: React.MutableRefObject<any>) {
this.plugin = plugin;
this.ref = ref;
}
render = (isMobile: boolean, appState: AppState) => {
return (
<div>Hello</div>
);
}
}

View File

@@ -257,7 +257,7 @@ export class ToolsPanel extends React.Component<PanelProps, PanelState> {
className="Island App-menu__left scrollbar"
style={{
maxHeight: "350px",
backgroundColor: "transparent",
width: "initial",
//@ts-ignore
"--padding": 2,
display: this.state.minimized ? "none" : "block",
@@ -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)}

View 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;
}

View 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: [],
};
}

View 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;

View 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;

View File

@@ -0,0 +1,5 @@
import * as path from "./path";
export default {
path,
};

View 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,
};
});
};

View 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");
});
};

View 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();
};

View 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;

View 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,
};
};

View 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,
};
};

View 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

View 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];
});
}

View 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;
};

View 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";
}

View 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);
}
}

View File

@@ -22,6 +22,7 @@ import { ExcalidrawElement } from "@zsviczian/excalidraw/types/element/types";
import { ExportSettings } from "../ExcalidrawView";
import { compressToBase64, decompressFromBase64 } from "lz-string";
import { getIMGFilename } from "./FileUtils";
import ExcalidrawScene from "lib/svgToExcalidraw/elements/ExcalidrawScene";
declare const PLUGIN_VERSION:string;
@@ -85,7 +86,7 @@ const random = new Random(Date.now());
export const randomInteger = () => Math.floor(random.next() * 2 ** 31);
//https://macromates.com/blog/2006/wrapping-text-with-regular-expressions/
export function wrapText(
export function wrapTextAtCharLength(
text: string,
lineLen: number,
forceWrap: boolean = false,
@@ -649,3 +650,19 @@ export const awaitNextAnimationFrame = async () => new Promise(requestAnimationF
export const log = console.log.bind(window.console);
export const debug = console.log.bind(window.console);
//export const debug = function(){};
export const getContainerElement = (
element:
| (ExcalidrawElement & { containerId: ExcalidrawElement["id"] | null })
| null,
scene: ExcalidrawScene,
) => {
if (!element) {
return null;
}
if (element.containerId) {
return scene.elements.filter(el=>el.id === element.containerId)[0] ?? null;
}
return null;
};

View File

@@ -96,8 +96,7 @@ li[data-testid] {
.ex-coffee-div {
text-align: center;
margin-bottom: 20px;
margin-bottom: 10px;
}
.excalidraw-scriptengine-install td>img {
@@ -184,9 +183,8 @@ li[data-testid] {
}
.excalidraw-release .modal {
max-height: 90%;
width: auto;
max-width: 130ch;
max-height: 80%;
max-width: 100ch;
}
.excalidraw .Island .scrollbar {
@@ -225,6 +223,65 @@ textarea.excalidraw-wysiwyg {
border-radius: 0;
}
.is-tablet .excalidraw button {
.is-tablet .excalidraw button,
.is-mobile .excalidraw button {
padding: initial;
height: 1.8rem;
}
.excalidraw button,
.ToolIcon button {
box-shadow: none;
justify-content: initial;
}
.excalidraw {
--default-button-size: 2rem !important;
--default-icon-size: 1rem !important;
--lg-button-size: 1.8rem !important;
--lg-icon-size: 1rem !important;
}
.excalidraw .tray-zoom {
pointer-events: initial;
padding-bottom: 0.05rem;
padding-top: 0.05rem;
}
.excalidraw-container.theme--dark {
background-color: #121212;
color: #fff;
}
/* https://discordapp.com/channels/686053708261228577/989603365606531104/1041266507256184863 */
/*.workspace-leaf {
contain: none !important;
}*/
.color-picker-content {
overflow-y: auto;
max-height: 10rem;
}
.excalidraw .FixedSideContainer_side_top {
top: 0.3rem;
}
.excalidraw .ToolIcon__keybinding {
font-size: 0.5rem;
}
.Island > .Stack > .Stack {
padding:0.2rem;
}
label.color-input-container > input {
max-width: 8rem;
}
.excalidraw .FixedSideContainer_side_top {
left: 10px !important;
top: 10px !important;
right: 10px !important;
bottom: 10px !important;
}

View File

@@ -7,6 +7,7 @@
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"esModuleInterop": true,
"importHelpers": true,
"lib": [
"dom",

View File

@@ -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"
@@ -2216,10 +2221,10 @@
dependencies:
"@zerollup/ts-helpers" "^1.7.18"
"@zsviczian/excalidraw@0.13.0-obsidian":
"integrity" "sha512-c4SnBEGKtenLB/1gSjXe3BVA+yZfo8b1p2E7sVcaPG8MTz6cpQsCB2+cv7Zta5ihIxuGfK3ZSepVhMbN7RFY2w=="
"resolved" "https://registry.npmjs.org/@zsviczian/excalidraw/-/excalidraw-0.13.0-obsidian.tgz"
"version" "0.13.0-obsidian"
"@zsviczian/excalidraw@0.13.0-obsidian-1":
"integrity" "sha512-gHfuEX/qrBa+4kolxEkQ/3W5hGfSLoJSXDpuhb8Mvvyyl148hsuWmhUQGFWcNee73YbuQ0arb3hXqwnMUgK0Ig=="
"resolved" "https://registry.npmjs.org/@zsviczian/excalidraw/-/excalidraw-0.13.0-obsidian-1.tgz"
"version" "0.13.0-obsidian-1"
"abab@^2.0.3", "abab@^2.0.5":
"integrity" "sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q=="
@@ -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"