Compare commits

..

5 Commits

Author SHA1 Message Date
zsviczian
6dd9d1a056 2.0.16 2024-01-07 21:22:56 +01:00
zsviczian
46b03725e9 2.0.15 2024-01-07 10:57:53 +01:00
zsviczian
65d6577b28 Update README.md 2024-01-03 21:35:01 +01:00
zsviczian
97967f5b70 2.0.14 2024-01-03 16:32:07 +01:00
zsviczian
9323e1fad4 minor formatting change in rollup.config.json 2024-01-03 09:51:04 +01:00
22 changed files with 728 additions and 63 deletions

View File

@@ -7,6 +7,9 @@ The Obsidian-Excalidraw plugin integrates [Excalidraw](https://excalidraw.com/),
<a href="https://youtu.be/o0exK-xFP3k" target="_blank"><img src="https://user-images.githubusercontent.com/14358394/156931370-aa4d88de-c4a8-46cc-aeb2-dc09aa0bea39.jpg" width="300"/></a>
<a href="https://youtu.be/QKnQgSjJVuc" target="_blank"><img src="https://raw.githubusercontent.com/zsviczian/obsidian-excalidraw-plugin/master/images/thumbnail-getting-started.jpg" width="300"/></a>
### Here's my complete catalog of videos:
<a href="https://excalidraw-obsidian.online/Hobbies/Excalidraw+Blog/Catalogue+of+Videos"><img width="380" alt="image" src="https://github.com/zsviczian/obsidian-excalidraw-plugin/assets/14358394/2577e5ad-7a21-4c62-acd5-4fe80c8a8a95"></a>
<br>
<details><summary>10 Part (slightly outdated) Video Walkthrough</summary>
<a href="https://youtu.be/sY4FoflGaiM" target="_blank"><img src="https://user-images.githubusercontent.com/14358394/125160304-7f211180-e17c-11eb-8363-c52723de1ffd.jpg" width="100" style="vertical-align: middle;"/>&nbsp;&nbsp;1 Getting Started</a><br>

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "obsidian-excalidraw-plugin",
"version": "1.9.15",
"version": "2.0.14",
"description": "This is an Obsidian.md plugin that lets you view and edit Excalidraw drawings",
"main": "lib/index.js",
"types": "lib/index.d.ts",
@@ -18,7 +18,7 @@
"author": "",
"license": "MIT",
"dependencies": {
"@zsviczian/excalidraw": "0.17.1-obsidian-8",
"@zsviczian/excalidraw": "0.17.1-obsidian-11",
"chroma-js": "^2.4.2",
"clsx": "^2.0.0",
"colormaster": "^1.2.1",

View File

@@ -50,10 +50,10 @@ const manifest = isLib ? {} : JSON.parse(manifestStr);
const packageString = isLib
? ""
: ';' + lzstring_pkg +
'const EXCALIDRAW_PACKAGES = "' + LZString.compressToBase64(react_pkg + reactdom_pkg + excalidraw_pkg) + '";' +
'\nconst EXCALIDRAW_PACKAGES = "' + LZString.compressToBase64(react_pkg + reactdom_pkg + excalidraw_pkg) + '";\n' +
'const {react, reactDOM, excalidrawLib} = window.eval.call(window, `(function() {' +
'${LZString.decompressFromBase64(EXCALIDRAW_PACKAGES)};' +
'return {react:React, reactDOM:ReactDOM, excalidrawLib: ExcalidrawLib};})();`);' +
'return {react:React, reactDOM:ReactDOM, excalidrawLib: ExcalidrawLib};})();`);\n' +
'const PLUGIN_VERSION="'+manifest.version+'";';
const BASE_CONFIG = {

View File

@@ -40,6 +40,7 @@ import {
hasExportTheme,
LinkParts,
svgToBase64,
isMaskFile,
} from "./utils/Utils";
import { ValueOf } from "./types";
import { getMermaidImageElements, getMermaidText, shouldRenderMermaid } from "./utils/MermaidUtils";
@@ -357,6 +358,7 @@ export class EmbeddedFilesLoader {
? getWithBackground(this.plugin, file)
: false,
withTheme: !!forceTheme,
isMask: isMaskFile(this.plugin,file),
};
const svg = replaceSVGColors(
await createSVG(

View File

@@ -10,10 +10,11 @@ import {
ExcalidrawTextElement,
StrokeRoundness,
RoundnessType,
ExcalidrawFrameElement,
} from "@zsviczian/excalidraw/types/excalidraw/element/types";
import { Editor, normalizePath, Notice, OpenViewState, RequestUrlResponse, TFile, TFolder, WorkspaceLeaf } from "obsidian";
import * as obsidian_module from "obsidian";
import ExcalidrawView, { ExportSettings, TextMode } from "src/ExcalidrawView";
import ExcalidrawView, { ExportSettings, TextMode, getTextMode } from "src/ExcalidrawView";
import { ExcalidrawData, getMarkdownDrawingSection, REGEX_LINK } from "src/ExcalidrawData";
import {
FRONTMATTER,
@@ -86,6 +87,7 @@ import {
extractCodeBlocks as _extractCodeBlocks,
} from "./utils/AIUtils";
import { EXCALIDRAW_AUTOMATE_INFO } from "./dialogs/SuggesterInfo";
import { CropImage } from "./utils/CropImage";
extendPlugins([
HarmonyPlugin,
@@ -567,6 +569,7 @@ export class ExcalidrawAutomate {
"excalidraw-onload-script"?: string;
"excalidraw-linkbutton-opacity"?: number;
"excalidraw-autoexport"?: boolean;
"excalidraw-mask"?: boolean;
};
plaintext?: string; //text to insert above the `# Text Elements` section
}): Promise<string> {
@@ -678,7 +681,11 @@ export class ExcalidrawAutomate {
if(item.latex) {
outString += `${key}: $$${item.latex}$$\n`;
} else {
outString += `${key}: [[${item.file}]]\n`;
if(item.file) {
outString += `${key}: [[${item.file}]]\n`;
} else {
outString += `${key}: ${item.hyperlink}\n`;
}
}
})
return outString;
@@ -705,6 +712,14 @@ export class ExcalidrawAutomate {
}
};
getCropImageObject(): CropImage {
const scene = this.targetView.getScene();
return new CropImage(
scene.elements,
scene.files,
);
}
/**
*
* @param templatePath
@@ -735,6 +750,7 @@ export class ExcalidrawAutomate {
exportSettings = {
withBackground: this.plugin.settings.exportWithBackground,
withTheme: true,
isMask: false,
};
}
if (!loader) {
@@ -791,6 +807,7 @@ export class ExcalidrawAutomate {
exportSettings = {
withBackground: this.plugin.settings.exportWithBackground,
withTheme: true,
isMask: false,
};
}
if (!loader) {
@@ -944,6 +961,29 @@ export class ExcalidrawAutomate {
return id;
};
/**
*
* @param topX
* @param topY
* @param width
* @param height
* @param name: the display name of the frame
* @returns
*/
addFrame(topX: number, topY: number, width: number, height: number, name?: string): string {
const id = this.addRect(topX, topY, width, height);
const frame = this.getElement(id) as Mutable<ExcalidrawFrameElement>;
frame.type = "frame";
frame.backgroundColor = "transparent";
frame.strokeColor = "#000";
frame.strokeStyle = "solid";
frame.strokeWidth = 2;
frame.roughness = 0;
frame.roundness = null;
if(name) frame.name = name;
return id;
}
/**
*
* @param topX
@@ -1795,10 +1835,31 @@ export class ExcalidrawAutomate {
elements.forEach((el) => {
this.elementsDict[el.id] = cloneElement(el);
if(el.type === "image") {
this.imagesDict[el.fileId] = sceneFiles?.[el.fileId];
const ef = this.targetView.excalidrawData.getFile(el.fileId);
const equation = this.targetView.excalidrawData.getEquation(el.fileId);
const sceneFile = sceneFiles?.[el.fileId];
this.imagesDict[el.fileId] = {
mimeType: sceneFile.mimeType,
id: el.fileId,
dataURL: sceneFile.dataURL,
created: sceneFile.created,
...ef ? {
isHyperLink: ef.isHyperLink,
hyperlink: ef.hyperlink,
file: ef.file,
hasSVGwithBitmap: ef.isSVGwithBitmap,
latex: null,
} : {},
...equation ? {
file: null,
isHyperLink: false,
hyperlink: null,
hasSVGwithBitmap: false,
latex: equation.latex,
} : {},
};
}
});
} else {
elements.forEach((el) => {
this.elementsDict[el.id] = cloneElement(el);
@@ -2109,8 +2170,9 @@ export class ExcalidrawAutomate {
getExportSettings(
withBackground: boolean,
withTheme: boolean,
isMask: boolean = false,
): ExportSettings {
return { withBackground, withTheme };
return { withBackground, withTheme, isMask };
};
/**
@@ -2622,13 +2684,11 @@ async function getTemplate(
};
}
const parsed =
data.search("excalidraw-plugin: parsed\n") > -1 ||
data.search("excalidraw-plugin: locked\n") > -1; //locked for backward compatibility
const textMode = getTextMode(data);
await excalidrawData.loadData(
data,
file,
parsed ? TextMode.parsed : TextMode.raw,
textMode,
);
let trimLocation = data.search("# Text Elements\n");
@@ -2759,6 +2819,7 @@ export async function createPNG(
withBackground:
exportSettings?.withBackground ?? plugin.settings.exportWithBackground,
withTheme: exportSettings?.withTheme ?? plugin.settings.exportWithTheme,
isMask: exportSettings?.isMask ?? false,
},
padding,
scale,
@@ -2856,6 +2917,7 @@ export async function createSVG(
withBackground:
exportSettings?.withBackground ?? plugin.settings.exportWithBackground,
withTheme,
isMask: exportSettings?.isMask ?? false,
},
padding,
null,

View File

@@ -97,12 +97,12 @@ import {
hasExportTheme,
scaleLoadedImage,
svgToBase64,
updateFrontmatterInString,
hyperlinkIsImage,
hyperlinkIsYouTubeLink,
getYouTubeThumbnailLink,
isContainer,
fragWithHTML,
isMaskFile,
} from "./utils/Utils";
import { getLeaf, getParentOfClass, obsidianPDFQuoteWithRef } from "./utils/ObsidianUtils";
import { splitFolderAndFilename } from "./utils/FileUtils";
@@ -138,6 +138,8 @@ import { nanoid } from "nanoid";
import { CustomMutationObserver, isDebugMode } from "./utils/DebugHelper";
import { extractCodeBlocks, postOpenAI } from "./utils/AIUtils";
import { Mutable } from "@zsviczian/excalidraw/types/excalidraw/utility-types";
import no from "./lang/locale/no";
import { carveOutImage } from "./utils/CarveOut";
declare const PLUGIN_VERSION:string;
@@ -166,6 +168,7 @@ interface WorkspaceItemExt extends WorkspaceItem {
export interface ExportSettings {
withBackground: boolean;
withTheme: boolean;
isMask: boolean;
}
const HIDE = "excalidraw-hidden";
@@ -426,6 +429,7 @@ export default class ExcalidrawView extends TextFileView {
const exportSettings: ExportSettings = {
withBackground: ed ? !ed.transparent : getWithBackground(this.plugin, this.file),
withTheme: true,
isMask: isMaskFile(this.plugin, this.file),
};
return await getSVG(
@@ -504,6 +508,7 @@ export default class ExcalidrawView extends TextFileView {
const exportSettings: ExportSettings = {
withBackground: ed ? !ed.transparent : getWithBackground(this.plugin, this.file),
withTheme: true,
isMask: isMaskFile(this.plugin, this.file),
};
return await getPNG(
{
@@ -806,27 +811,37 @@ export default class ExcalidrawView extends TextFileView {
}
const hide = (el:HTMLElement) => {
while(el && !el.hasClass("workspace-split")) {
let tmpEl = el;
while(tmpEl && !tmpEl.hasClass("workspace-split")) {
el.addClass(SHOW);
el = el.parentElement;
el = tmpEl;
tmpEl = el.parentElement;
}
if(el) {
el.addClass(SHOW);
el.querySelectorAll(`div.workspace-split:not(.${SHOW})`).forEach(el=>el.addClass(SHOW));
el.querySelectorAll(`div.workspace-split:not(.${SHOW})`).forEach(node=>{
if(node !== el) node.addClass(SHOW);
});
el.querySelector(`div.workspace-leaf-content.${SHOW} > .view-header`).addClass(SHOW);
el.querySelectorAll(`div.workspace-tab-container.${SHOW} > div.workspace-leaf:not(.${SHOW})`).forEach(el=>el.addClass(SHOW));
el.querySelectorAll(`div.workspace-tabs.${SHOW} > div.workspace-tab-header-container`).forEach(el=>el.addClass(SHOW));
el.querySelectorAll(`div.workspace-split.${SHOW} > div.workspace-tabs:not(.${SHOW})`).forEach(el=>el.addClass(SHOW));
el.querySelectorAll(`div.workspace-tab-container.${SHOW} > div.workspace-leaf:not(.${SHOW})`).forEach(node=>node.addClass(SHOW));
el.querySelectorAll(`div.workspace-tabs.${SHOW} > div.workspace-tab-header-container`).forEach(node=>node.addClass(SHOW));
el.querySelectorAll(`div.workspace-split.${SHOW} > div.workspace-tabs:not(.${SHOW})`).forEach(node=>node.addClass(SHOW));
}
const doc = this.ownerDocument;
doc.body.querySelectorAll(`div.workspace-split:not(.${SHOW})`).forEach(el=>el.addClass(HIDE));
doc.body.querySelectorAll(`div.workspace-split:not(.${SHOW})`).forEach(node=>{
if(node !== tmpEl) {
node.addClass(HIDE);
} else {
node.addClass(SHOW);
}
});
doc.body.querySelector(`div.workspace-leaf-content.${SHOW} > .view-header`).addClass(HIDE);
doc.body.querySelectorAll(`div.workspace-tab-container.${SHOW} > div.workspace-leaf:not(.${SHOW})`).forEach(el=>el.addClass(HIDE));
doc.body.querySelectorAll(`div.workspace-tabs.${SHOW} > div.workspace-tab-header-container`).forEach(el=>el.addClass(HIDE));
doc.body.querySelectorAll(`div.workspace-split.${SHOW} > div.workspace-tabs:not(.${SHOW})`).forEach(el=>el.addClass(HIDE));
doc.body.querySelectorAll(`div.workspace-ribbon`).forEach(el=>el.addClass(HIDE));
doc.body.querySelectorAll(`div.mobile-navbar`).forEach(el=>el.addClass(HIDE));
doc.body.querySelectorAll(`div.status-bar`).forEach(el=>el.addClass(HIDE));
doc.body.querySelectorAll(`div.workspace-tab-container.${SHOW} > div.workspace-leaf:not(.${SHOW})`).forEach(node=>node.addClass(HIDE));
doc.body.querySelectorAll(`div.workspace-tabs.${SHOW} > div.workspace-tab-header-container`).forEach(node=>node.addClass(HIDE));
doc.body.querySelectorAll(`div.workspace-split.${SHOW} > div.workspace-tabs:not(.${SHOW})`).forEach(node=>node.addClass(HIDE));
doc.body.querySelectorAll(`div.workspace-ribbon`).forEach(node=>node.addClass(HIDE));
doc.body.querySelectorAll(`div.mobile-navbar`).forEach(node=>node.addClass(HIDE));
doc.body.querySelectorAll(`div.status-bar`).forEach(node=>node.addClass(HIDE));
}
hide(this.contentEl);
@@ -1726,6 +1741,19 @@ export default class ExcalidrawView extends TextFileView {
this.isLoaded = false;
if(!this.file) return;
if(this.plugin.settings.showNewVersionNotification) checkExcalidrawVersion(app);
if(isMaskFile(this.plugin,this.file)) {
const notice = new Notice(t("MASK_FILE_NOTICE"), 5000);
//add click and hold event listner to the notice
let noticeTimeout:NodeJS.Timeout = null;
notice.noticeEl.addEventListener("pointerdown", (ev:MouseEvent) => {
noticeTimeout = setTimeout(()=>{
window.open("https://youtu.be/uHFd0XoHRxE");
},1000);
})
notice.noticeEl.addEventListener("pointerup", (ev:TouchEvent) => {
clearTimeout(noticeTimeout);
})
}
if (clear) {
this.clear();
}

View File

@@ -14,6 +14,7 @@ import { getImageSize, svgToBase64 } from "./utils/Utils";
import { fileid } from "./constants/constants";
import { TFile } from "obsidian";
import { MathDocument } from "mathjax-full/js/core/MathDocument";
import { stripVTControlCharacters } from "util";
export const updateEquation = async (
equation: string,
@@ -86,13 +87,16 @@ export async function tex2dataURL(
if(svg.width.baseVal.valueInSpecifiedUnits < 2) {
svg.width.baseVal.valueAsString = `${(svg.width.baseVal.valueInSpecifiedUnits+1).toFixed(3)}ex`;
}
const img = svgToBase64(svg.outerHTML);
svg.width.baseVal.valueAsString = (svg.width.baseVal.valueInSpecifiedUnits * 10).toFixed(3);
svg.height.baseVal.valueAsString = (svg.height.baseVal.valueInSpecifiedUnits * 10).toFixed(3);
const dataURL = svgToBase64(svg.outerHTML);
return {
mimeType: "image/svg+xml",
fileId: fileid() as FileId,
dataURL: dataURL as DataURL,
created: Date.now(),
size: await getImageSize(dataURL),
size: await getImageSize(img),
};
}
} catch (e) {

View File

@@ -19,6 +19,7 @@ import {
getWithBackground,
hasExportTheme,
convertSVGStringToElement,
isMaskFile,
} from "./utils/Utils";
import { getParentOfClass, isObsidianThemeDark, getFileCSSClasses } from "./utils/ObsidianUtils";
import { linkClickModifierType } from "./utils/ModifierkeyHelper";
@@ -272,6 +273,7 @@ const getIMG = async (
const exportSettings: ExportSettings = {
withBackground: getWithBackground(plugin, file),
withTheme: forceTheme ? true : plugin.settings.exportWithTheme,
isMask: isMaskFile(plugin, file),
};
const theme =

View File

@@ -146,6 +146,7 @@ export const MAX_IMAGE_SIZE = 500;
export const FRONTMATTER_KEY = "excalidraw-plugin";
export const FRONTMATTER_KEY_EXPORT_TRANSPARENT =
"excalidraw-export-transparent";
export const FRONTMATTER_KEY_MASK = "excalidraw-mask";
export const FRONTMATTER_KEY_EXPORT_DARK = "excalidraw-export-dark";
export const FRONTMATTER_KEY_EXPORT_SVGPADDING = "excalidraw-export-svgpadding"; //depricated
export const FRONTMATTER_KEY_EXPORT_PADDING = "excalidraw-export-padding";

View File

@@ -280,12 +280,12 @@ function RenderObsidianView(
: "transparent";
canvasNode?.style.setProperty("--canvas-border", color);
canvasNode?.style.setProperty("--canvas-color", color);
canvasNodeContainer?.style.setProperty("border-color", color);
//canvasNodeContainer?.style.setProperty("border-color", color);
} else if(!(mdProps?.borderMatchElement ?? true)) {
const color = ea.getCM(mdProps.borderColor).alphaTo((mdProps.borderOpacity??100)/100).stringHEX();
canvasNode?.style.setProperty("--canvas-border", color);
canvasNode?.style.setProperty("--canvas-color", color);
canvasNodeContainer?.style.setProperty("border-color", color);
//canvasNodeContainer?.style.setProperty("border-color", color);
}
}

View File

@@ -17,6 +17,35 @@ I develop this plugin as a hobby, spending my free time doing this. If you find
<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>
`,
"2.0.16":`
## Fixed
- Image cropping did not work consistently with large image files on lower-powered devices [#1538](https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/1538).
- Mermaid editor was not working when Excalidraw was open in an Obsidian popout window [#1503](https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/1503)
`,
"2.0.15":`
<div class="excalidraw-videoWrapper"><div>
<iframe src="https://www.youtube.com/embed/uHFd0XoHRxE" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
</div></div>
## New
- Crop and Mask Images in Excalidraw, Canvas, and Markdown. (Inspired by @bonecast [#4566](https://github.com/excalidraw/excalidraw/issues/4566))
- Draw metadata around images but hide it on the export.
## Fixed
- Freedraw closed circles (2nd attempt)
- Interactive Markdown embeddable border-color (setting did not have an effect)
`,
"2.0.14":`
## New
- Stylus button now activates the eraser function. Note: This feature is supported for styluses that comply with industry-standard button events. Unfortunately, Samsung SPEN and Apple Pencil do not support this functionality.
## Fixed
- Improved handwriting quality. I have resolved the long-standing issue of closing the loop when ends of the line are close, making an "u" into an "o" ([#1529](https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/1529) and [#6303](https://github.com/excalidraw/excalidraw/issues/6303)).
- Improved Excalidraw's full-screen mode behavior. Access it via the Obsidian Command Palette or the full-screen button on the Obsidian Tools Panel ([#1528](https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/1528)).
- Fixed color picker overlapping with the Obsidian mobile toolbar on Obsidian-Mobile.
- Corrected display issues with alternative font sizes (Fibonacci and Zoom relative) in the element properties panel when editing a text element (refer to [2.0.11 Release Notes](https://github.com/zsviczian/obsidian-excalidraw-plugin/releases/tag/2.0.11) for details about the font-size Easter Egg).
- Resolved the issue where Excalidraw SVG exports containing LaTeX were not loading correctly into Inkscape ([#1519](https://github.com/zsviczian/obsidian-excalidraw-plugin/pull/1519)). Thanks to 🙏@HyunggyuJang for the contribution.
`,
"2.0.13":`
## Fixed
- Excalidraw crashes if you paste an image and right-click on canvas immediately after pasting.

View File

@@ -804,6 +804,10 @@ export const FRONTMATTER_KEYS_INFO: SuggesterInfo[] = [
desc: "Override iFrame theme plugin-settings for this file. 'match' will match the Excalidraw theme, 'default' will match the obsidian theme. Valid values are\ndark\nlight\nauto\ndefault",
after: ": auto",
},
{
field: "mask",
code: null,
desc: "If this key is present the drawing will be handled as a mask to crop an image.",
after: ": true",
},
];

View File

@@ -76,11 +76,13 @@ export default {
RUN_OCR: "OCR: Grab text from freedraw scribble and pictures to clipboard",
TRAY_MODE: "Toggle property-panel tray-mode",
SEARCH: "Search for text in drawing",
CROP_IMAGE: "Crop and mask image",
RESET_IMG_TO_100: "Set selected image element size to 100% of original",
TEMPORARY_DISABLE_AUTOSAVE: "Disable autosave until next time Obsidian starts (only set this if you know what you are doing)",
TEMPORARY_ENABLE_AUTOSAVE: "Enable autosave",
//ExcalidrawView.ts
MASK_FILE_NOTICE: "This is a mask file. It is used to crop images and mask out parts of the image. Press and hold notice to open the help video.",
INSTALL_SCRIPT_BUTTON: "Install or update Excalidraw Scripts",
OPEN_AS_MD: "Open as Markdown",
EXPORT_IMAGE: `Export Image`,

View File

@@ -40,13 +40,11 @@ import {
EXPORT_IMG_ICON_NAME,
EXPORT_IMG_ICON,
LOCALE,
fileid,
IMAGE_TYPES,
} from "./constants/constants";
import {
VIRGIL_FONT,
VIRGIL_DATAURL,
CASCADIA_FONT,
ASSISTANT_FONT,
FONTS_STYLE_ID,
} from "./constants/constFonts";
import ExcalidrawView, { TextMode, getTextMode } from "./ExcalidrawView";
@@ -55,7 +53,6 @@ import {
getMarkdownDrawingSection,
ExcalidrawData,
REGEX_LINK,
getExcalidrawMarkdownHeaderSection
} from "./ExcalidrawData";
import {
ExcalidrawSettings,
@@ -86,6 +83,8 @@ import {
getIMGFilename,
getLink,
getNewUniqueFilepath,
getURLImageExtension,
splitFolderAndFilename,
} from "./utils/FileUtils";
import {
getFontDataURL,
@@ -124,10 +123,10 @@ import { PublishOutOfDateFilesDialog } from "./dialogs/PublishOutOfDateFiles";
import { EmbeddableSettings } from "./dialogs/EmbeddableSettings";
import { processLinkText } from "./utils/CustomEmbeddableUtils";
import { getEA } from "src";
import { BinaryFileData, DataURL, ExcalidrawImperativeAPI } from "@zsviczian/excalidraw/types/excalidraw/types";
import { ExcalidrawImperativeAPI } from "@zsviczian/excalidraw/types/excalidraw/types";
import { Mutable } from "@zsviczian/excalidraw/types/excalidraw/utility-types";
import { CustomMutationObserver, durationTreshold, isDebugMode } from "./utils/DebugHelper";
import de from "./lang/locale/de";
import { carveOutImage, createImageCropperFile, CROPPED_PREFIX } from "./utils/CarveOut";
declare const EXCALIDRAW_PACKAGES:string;
declare const react:any;
@@ -190,6 +189,14 @@ export default class ExcalidrawPlugin extends Plugin {
return LOCALE;
}
get window(): Window {
return window;
};
get document(): Document {
return document;
};
public getPackage(win:Window):Packages {
if(win===window) {
return {react, reactDOM, excalidrawLib};
@@ -1531,6 +1538,129 @@ export default class ExcalidrawPlugin extends Plugin {
}
})
this.addCommand({
id: "crop-image",
name: t("CROP_IMAGE"),
checkCallback: (checking:boolean) => {
const excalidrawView = this.app.workspace.getActiveViewOfType(ExcalidrawView);
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
const canvasView:any = this.app.workspace.activeLeaf?.view;
const isCanvas = canvasView && canvasView.getViewType() === "canvas";
if(!excalidrawView && !markdownView && !isCanvas) return false;
if(excalidrawView) {
if(!excalidrawView.excalidrawAPI) return false;
const els = excalidrawView.getViewSelectedElements().filter(el=>el.type==="image");
if(els.length !== 1) {
if(checking) return false;
new Notice("Select a single image element and try again");
return false;
}
const el = els[0] as ExcalidrawImageElement;
if(el.type !== "image") return false;
if(checking) return true;
(async () => {
let ef = excalidrawView.excalidrawData.getFile(el.fileId);
if(!ef) {
await excalidrawView.save();
await sleep(500);
ef = excalidrawView.excalidrawData.getFile(el.fileId);
if(!ef) {
new Notice("Select a single image element and try again");
return false;
}
}
const ea = new ExcalidrawAutomate(this,excalidrawView);
carveOutImage(ea, el);
})();
}
const carveout = async (isFile: boolean, sourceFile: TFile, imageFile: TFile, imageURL: string, replacer: Function) => {
const ea = getEA() as ExcalidrawAutomate;
const imageID = await ea.addImage(0 , 0, isFile ? imageFile : imageURL, false, false);
if(!imageID) {
new Notice(`Can't load image\n\n${imageURL}`);
return;
}
let fname = "";
let imageLink = "";
if(isFile) {
fname = CROPPED_PREFIX + imageFile.basename + ".md";
imageLink = `[[${imageFile.path}]]`;
} else {
imageLink = imageURL;
const imagename = imageURL.match(/^.*\/([^?]*)\??.*$/)?.[1];
fname = CROPPED_PREFIX + imagename.substring(0,imagename.lastIndexOf(".")) + ".md";
}
const { folderpath } = isFile
? splitFolderAndFilename(imageFile.path)
: {folderpath: ((await getAttachmentsFolderAndFilePath(this.app, sourceFile.path, fname)).folder)};
const newFile = await createImageCropperFile(ea,imageID,imageLink,folderpath,fname);
if(!newFile) return;
const link = this.app.metadataCache.fileToLinktext(newFile,sourceFile.path, true);
replacer(link, newFile);
}
if(isCanvas) {
const selectedNodes:any = [];
canvasView.canvas.nodes.forEach((node:any) => {
if(node.nodeEl.hasClass("is-focused")) selectedNodes.push(node);
})
if(selectedNodes.length !== 1) return false;
const node = selectedNodes[0];
let extension = "";
if(node.file) {
extension = node.file.extension;
}
if(node.url) {
extension = getURLImageExtension(node.url);
}
if(!IMAGE_TYPES.contains(extension)) return false;
if(checking) return true;
const replacer = (link:string, file: TFile) => {
if(node.file) {
node.setFile(file);
}
if(node.url) {
node.canvas.createFileNode({pos:{x:node.x + 20,y: node.y+20}, file});
}
}
carveout(Boolean(node.file), canvasView.file, node.file, node.url, replacer);
}
if (markdownView) {
const editor = markdownView.editor;
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
const parts = REGEX_LINK.getResList(line);
if(parts.length === 0) return false;
const imgpath = REGEX_LINK.getLink(parts[0]);
const imageFile = this.app.metadataCache.getFirstLinkpathDest(imgpath, markdownView.file.path);
const isFile = (imageFile && imageFile instanceof TFile);
let imagepath = isFile ? imageFile.path : "";
let extension = isFile ? imageFile.extension : "";
if(imgpath.match(/^https?|file/)) {
imagepath = imgpath;
extension = getURLImageExtension(imgpath);
}
if(imagepath === "") return false;
if(!IMAGE_TYPES.contains(extension)) return false;
if(checking) return true;
const replacer = (link:string) => {
const lineparts = line.split(parts[0].value[0])
editor.setLine(cursor.line,lineparts[0] + getLink(this ,{embed: true, path:link}) +lineparts[1]);
}
carveout(isFile, markdownView.file, imageFile, imagepath, replacer);
}
}
})
this.addCommand({
id: "insert-image",
name: t("INSERT_IMAGE"),

View File

@@ -1,4 +1,4 @@
import { Copy, Globe, RotateCcw, Scan, Settings } from "lucide-react";
import { Copy, Crop, Globe, RotateCcw, Scan, Settings } from "lucide-react";
import * as React from "react";
import { PenStyle } from "src/PenTypes";
@@ -29,6 +29,7 @@ export const ICONS = {
Reload: (<RotateCcw />),
Copy: (<Copy /> ),
Globe: (<Globe />),
Crop: (<Crop />),
ZoomToSelectedElement: (<Scan />),
Properties: (<Settings />),
ZoomToSection: (

View File

@@ -551,6 +551,16 @@ export class ToolsPanel extends React.Component<PanelProps, PanelState> {
icon={ICONS.importSVG}
view={this.props.view}
/>
<ActionButton
key={"crop-image"}
title={t("CROP_IMAGE")}
action={(e:React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
// @ts-ignore
this.props.view.app.commands.executeCommandById("obsidian-excalidraw-plugin:crop-image")
}}
icon={ICONS.Crop}
view={this.props.view}
/>
</div>
</fieldset>
{this.renderScriptButtons(false)}

View File

@@ -75,6 +75,7 @@ export default class Taskbone {
const exportSettings: ExportSettings = {
withBackground: true,
withTheme: true,
isMask: false,
};
const img =

158
src/utils/CarveOut.ts Normal file
View File

@@ -0,0 +1,158 @@
import { ExcalidrawFrameElement, ExcalidrawImageElement } from "@zsviczian/excalidraw/types/excalidraw/element/types";
import { Mutable } from "@zsviczian/excalidraw/types/excalidraw/utility-types";
import { getEA } from "src";
import { ExcalidrawAutomate } from "src/ExcalidrawAutomate";
import { splitFolderAndFilename } from "./FileUtils";
import { Notice, TFile } from "obsidian";
import ExcalidrawView from "src/ExcalidrawView";
import { ExcalidrawImperativeAPI } from "@zsviczian/excalidraw/types/excalidraw/types";
export const CROPPED_PREFIX = "cropped_";
export const carveOutImage = async (sourceEA: ExcalidrawAutomate, viewImageEl: ExcalidrawImageElement) => {
if(!viewImageEl?.fileId) return;
if(!sourceEA?.targetView) return;
const targetEA = getEA(sourceEA.targetView) as ExcalidrawAutomate;
targetEA.copyViewElementsToEAforEditing([viewImageEl],true);
const {height, width} = await sourceEA.getOriginalImageSize(viewImageEl);
if(!height || !width || height === 0 || width === 0) return;
const newImage = targetEA.getElement(viewImageEl.id) as Mutable<ExcalidrawImageElement>;
newImage.x = 0;
newImage.y = 0;
newImage.width = width;
newImage.height = height;
const ef = sourceEA.targetView.excalidrawData.getFile(viewImageEl.fileId);
let imageLink = "";
let fname = "";
if(ef.file) {
fname = CROPPED_PREFIX + ef.file.basename;
imageLink = `[[${ef.file.path}]]`;
} else {
const imagename = ef.hyperlink?.match(/^.*\/([^?]*)\??.*$/)?.[1];
imageLink = ef.hyperlink;
fname = viewImageEl
? CROPPED_PREFIX + imagename.substring(0,imagename.lastIndexOf("."))
: CROPPED_PREFIX + "_image";
}
const attachmentPath = await sourceEA.getAttachmentFilepath(fname + ".md");
const {folderpath: foldername, filename} = splitFolderAndFilename(attachmentPath);
const file = await createImageCropperFile(targetEA, newImage.id, imageLink, foldername, filename);
if(!file) return;
sourceEA.clear();
sourceEA.copyViewElementsToEAforEditing([viewImageEl]);
const sourceImageEl = sourceEA.getElement(viewImageEl.id) as Mutable<ExcalidrawImageElement>;
sourceImageEl.isDeleted = true;
const replacingImageID = await sourceEA.addImage(sourceImageEl.x, sourceImageEl.y, file, true);
const replacingImage = sourceEA.getElement(replacingImageID) as Mutable<ExcalidrawImageElement>;
replacingImage.width = sourceImageEl.width;
replacingImage.height = sourceImageEl.height;
sourceEA.addElementsToView(false, true, true);
}
export const createImageCropperFile = async (targetEA: ExcalidrawAutomate, imageID: string, imageLink:string, foldername: string, filename: string): Promise<TFile> => {
const workspace = targetEA.plugin.app.workspace;
const vault = targetEA.plugin.app.vault;
const newImage = targetEA.getElement(imageID) as Mutable<ExcalidrawImageElement>;
const { width, height } = newImage;
newImage.opacity = 100;
newImage.locked = true;
const frameID = targetEA.addFrame(0,0,width,height,"Adjust frame to crop image. Add elements for mask: White shows, Black hides.");
const frame = targetEA.getElement(frameID) as Mutable<ExcalidrawFrameElement>;
frame.link = imageLink;
newImage.frameId = frameID;
targetEA.style.opacity = 50;
targetEA.style.fillStyle = "solid";
targetEA.style.strokeStyle = "solid";
targetEA.style.strokeColor = "black";
targetEA.style.backgroundColor = "black";
targetEA.style.roughness = 0;
targetEA.style.roundness = null;
targetEA.canvas.theme = "light";
targetEA.canvas.viewBackgroundColor = "#3d3d3d";
const templateFile = app.vault.getAbstractFileByPath(targetEA.plugin.settings.templateFilePath);
if(templateFile && templateFile instanceof TFile) {
const {appState} = await targetEA.getSceneFromFile(templateFile);
if(appState) {
targetEA.style.fontFamily = appState.currentItemFontFamily;
targetEA.style.fontSize = appState.currentItemFontSize;
}
}
const newPath = await targetEA.create ({
filename,
foldername,
onNewPane: true,
frontmatterKeys: {
"excalidraw-mask": true,
"excalidraw-export-dark": false,
"excalidraw-export-padding": 0,
"excalidraw-export-transparent": true,
}
});
//console.log({newPath});
//wait for file to be created/indexed by Obsidian
let file = vault.getAbstractFileByPath(newPath);
let counter = 0;
while(!file && counter < 50) {
await sleep(100);
file = vault.getAbstractFileByPath(newPath);
counter++;
}
//console.log({counter, file});
if(!file || !(file instanceof TFile)) {
new Notice("File not found. NewExcalidraw Drawing is taking too long to create. Please try again.");
return;
}
//wait for the new ExcalidrawView to open and initialize
counter = 0;
let newView = workspace.getActiveViewOfType(ExcalidrawView) as ExcalidrawView;
while(
(workspace.getActiveFile() !== file ||
newView?.file !== file ||
!newView?.isLoaded ||
!Boolean(newView?.excalidrawAPI)) &&
counter < 100
) {
await sleep(100);
newView = workspace.getActiveViewOfType(ExcalidrawView) as ExcalidrawView;
counter++;
}
//console.log({counter});
if(newView?.file !== file || !newView?.isLoaded ||!Boolean(newView?.excalidrawAPI)) {
new Notice("View did not initialize. NewExcalidraw Drawing is taking too long to open. Please try again.");
return;
}
//wait for the image to load to the new view
const api = newView.excalidrawAPI as ExcalidrawImperativeAPI;
counter = 0;
while(Object.keys(api.getFiles()).length === 0 && counter < 100) {
await sleep(100);
counter++;
}
if(Object.keys(api.getFiles()).length === 0) {
new Notice("Image did not load to the view. NewExcalidraw Drawing is taking too long to load. Please try again.");
return;
}
//console.log({counter, path: workspace.getActiveFile()?.path, newView, files: api.getFiles()});
return file;
}

177
src/utils/CropImage.ts Normal file
View File

@@ -0,0 +1,177 @@
import { ExcalidrawElement, FileId } from "@zsviczian/excalidraw/types/excalidraw/element/types";
import { BinaryFileData } from "@zsviczian/excalidraw/types/excalidraw/types";
import { Mutable } from "@zsviczian/excalidraw/types/excalidraw/utility-types";
import { Notice } from "obsidian";
import { getEA } from "src";
import { ExcalidrawAutomate, cloneElement } from "src/ExcalidrawAutomate";
import { ExportSettings } from "src/ExcalidrawView";
import { embedFontsInSVG } from "./Utils";
import { nanoid } from "src/constants/constants";
export class CropImage {
private imageEA: ExcalidrawAutomate;
private maskEA: ExcalidrawAutomate;
private bbox: {topX: number, topY: number, width: number, height: number};
constructor (
private elements: ExcalidrawElement[],
files: Map<FileId,BinaryFileData>,
) {
const imageEA = getEA() as ExcalidrawAutomate;
this.imageEA = imageEA;
const maskEA = getEA() as ExcalidrawAutomate;
this.maskEA = maskEA;
this.bbox = imageEA.getBoundingBox(elements);
//this makes both the image and the mask the same size
//Adding the bounding element first so it is at the bottom of the layers - does not override the image.
this.setBoundingEl(imageEA, "transparent");
this.setBoundingEl(maskEA, "white"); //the bbox should not mask the image. White lets everything through.
elements.forEach(el => {
const newEl = cloneElement(el) as Mutable<ExcalidrawElement>;
if(el.type !== "image" && el.type !== "frame") {
newEl.opacity = 100;
maskEA.elementsDict[el.id] = newEl;
}
if(el.type === "image") {
imageEA.elementsDict[el.id] = newEl;
}
})
Object.values(files).forEach(file => {
imageEA.imagesDict[file.id] = file;
})
}
private setBoundingEl(ea: ExcalidrawAutomate, bgColor: string) {
const {topX, topY, width, height} = this.bbox;
ea.style.backgroundColor = bgColor;
ea.style.strokeColor = "transparent";
//@ts-ignore: Setting this to string "0" will produce a rectangle with zero stroke width
ea.style.strokeWidth = "0";
ea.style.strokeStyle = "solid";
ea.style.fillStyle = "solid";
ea.style.roughness = 0;
ea.addRect(topX, topY, width, height);
}
private getViewBoxAndSize(): {viewBox: string, vbWidth: number, vbHeight: number, width: number, height: number} {
const frames = this.elements.filter(el=>el.type === "frame");
if(frames.length > 1) {
new Notice("Multiple frames are not supported for image cropping. Discarding frames from mask.");
}
const images = this.imageEA.getElements().filter(el=>el.type === "image");
const {x: frameX, y: frameY, width: frameWidth, height: frameHeight} = frames.length === 1
? frames[0]
: mapToXY(this.imageEA.getBoundingBox(images));
const {topX, topY, width, height} = this.bbox;
return {
viewBox: `${frameX-topX} ${frameY-topY} ${frameWidth} ${frameHeight}`,
vbWidth: frameWidth,
vbHeight: frameHeight,
width,
height,
}
}
private async getMaskSVG():Promise<{style: string, mask: string}> {
const exportSettings:ExportSettings = {
withBackground: false,
withTheme: false,
isMask: false,
}
const maskSVG = await this.maskEA.createSVG(null,false,exportSettings,null,null,0);
const defs = maskSVG.querySelector("defs");
const styleEl = maskSVG.querySelector("style");
const style = styleEl ? styleEl.outerHTML : "";
defs.parentElement.removeChild(defs);
return {style, mask:maskSVG.innerHTML};
}
private async getImageSVG() {
const exportSettings:ExportSettings = {
withBackground: false,
withTheme: false,
isMask: false,
}
const imageSVG = await this.imageEA.createSVG(null,false,exportSettings,null,null,0);
const svgData = new XMLSerializer().serializeToString(imageSVG);
return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}`;
// const blob = new Blob([svgString], { type: 'image/svg+xml' });
// return `data:image/svg+xml;base64,${await blobToBase64(blob)}`;
}
private async buildSVG(): Promise<SVGSVGElement> {
if(this.imageEA.getElements().filter(el=>el.type === "image").length === 0) {
new Notice("No image found. Cannot crop.");
return;
}
const maskID = nanoid();
const {viewBox, vbWidth, vbHeight, width, height} = this.getViewBoxAndSize();
const parser = new DOMParser();
const {style, mask} = await this.getMaskSVG();
const svgString = `<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="${viewBox}" width="${vbWidth}" height="${vbHeight}">\n` +
`<defs>${style}\n<mask id="${maskID}" x="0" y="0" width="${width}" height="${height}" maskUnits="userSpaceOnUse">\n${mask}\n</mask>\n</defs>\n` +
`<image x="0" y="0" width="${width}" height="${height}" mask="url(#${maskID})" mask-type="alpha" href="${await this.getImageSVG()}"/>\n</svg>`;
return parser.parseFromString(
svgString,
"image/svg+xml",
).firstElementChild as SVGSVGElement
}
async getCroppedPNG(): Promise<Blob> {
//@ts-ignore
const PLUGIN = app.plugins.plugins["obsidian-excalidraw-plugin"];
const svg = embedFontsInSVG(await this.buildSVG(), PLUGIN);
return new Promise((resolve, reject) => {
const svgData = new XMLSerializer().serializeToString(svg);
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
reject('Unable to get 2D context');
return;
}
canvas.width = svg.width.baseVal.value;
canvas.height = svg.height.baseVal.value;
const image = new Image();
image.onload = () => {
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0);
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to convert to PNG'));
}
},
'image/png',
1 // image quality (0 - 1)
);
};
image.src = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}`;
});
}
async getCroppedSVG() {
return await this.buildSVG();
}
}
const mapToXY = ({topX, topY, width, height}: {topX: number, topY: number, width: number, height: number}): {x: number, y: number, width: number, height: number} => {
return {
x: topX,
y: topY,
width,
height,
}
}

View File

@@ -20,7 +20,8 @@ import {
FRONTMATTER_KEY_EXPORT_PADDING,
exportToSvg,
exportToBlob,
IMAGE_TYPES
IMAGE_TYPES,
FRONTMATTER_KEY_MASK
} from "../constants/constants";
import ExcalidrawPlugin from "../main";
import { ExcalidrawElement } from "@zsviczian/excalidraw/types/excalidraw/element/types";
@@ -32,6 +33,7 @@ import { FILENAMEPARTS } from "./UtilTypes";
import { Mutable } from "@zsviczian/excalidraw/types/excalidraw/utility-types";
import { cleanBlockRef, cleanSectionHeading, getFileCSSClasses } from "./ObsidianUtils";
import { updateElementLinksToObsidianLinks } from "src/ExcalidrawAutomate";
import { CropImage } from "./CropImage";
declare const PLUGIN_VERSION:string;
@@ -271,26 +273,34 @@ export const getSVG = async (
});
}
elements = srcFile
? updateElementLinksToObsidianLinks({
elements,
hostFile: srcFile,
})
: elements;
try {
const svg = await exportToSvg({
elements: srcFile
? updateElementLinksToObsidianLinks({
elements,
hostFile: srcFile,
})
: elements,
appState: {
exportBackground: exportSettings.withBackground,
exportWithDarkMode: exportSettings.withTheme
? scene.appState?.theme != "light"
: false,
...scene.appState,
},
files: scene.files,
exportPadding: padding,
exportingFrame: null,
renderEmbeddables: true,
});
let svg: SVGSVGElement;
if(exportSettings.isMask) {
const cropObject = new CropImage(elements, scene.files);
svg = await cropObject.getCroppedSVG();
} else {
svg = await exportToSvg({
elements,
appState: {
exportBackground: exportSettings.withBackground,
exportWithDarkMode: exportSettings.withTheme
? scene.appState?.theme != "light"
: false,
...scene.appState,
},
files: scene.files,
exportPadding: padding,
exportingFrame: null,
renderEmbeddables: true,
});
}
if(svg) {
svg.addClass("excalidraw-svg");
if(srcFile instanceof TFile) {
@@ -321,8 +331,13 @@ export const getPNG = async (
exportSettings: ExportSettings,
padding: number,
scale: number = 1,
) => {
): Promise<Blob> => {
try {
if(exportSettings.isMask) {
const cropObject = new CropImage(scene.elements, scene.files);
return await cropObject.getCroppedPNG();
}
return await exportToBlob({
elements: scene.elements,
appState: {
@@ -384,10 +399,15 @@ export const embedFontsInSVG = (
svg.querySelector("text[font-family^='LocalFont']") != null;
const defs = svg.querySelector("defs");
if (defs && (includesCascadia || includesVirgil || includesLocalFont || includesAssistant)) {
defs.innerHTML = `<style>${includesVirgil ? VIRGIL_FONT : ""}${
let style = defs.querySelector("style");
if (!style) {
style = document.createElement("style");
defs.appendChild(style);
}
style.innerHTML = `${includesVirgil ? VIRGIL_FONT : ""}${
includesCascadia ? CASCADIA_FONT : ""}${
includesAssistant ? ASSISTANT_FONT : ""
}${includesLocalFont ? plugin.fourthFontDef : ""}</style>`;
}${includesLocalFont ? plugin.fourthFontDef : ""}`;
}
return svg;
};
@@ -520,6 +540,22 @@ export const decompress = (data: string): string => {
return LZString.decompressFromBase64(data.replaceAll("\n", "").replaceAll("\r", ""));
};
export const isMaskFile = (
plugin: ExcalidrawPlugin,
file: TFile,
): boolean => {
if (file) {
const fileCache = plugin.app.metadataCache.getFileCache(file);
if (
fileCache?.frontmatter &&
fileCache.frontmatter[FRONTMATTER_KEY_MASK] != null
) {
return Boolean(fileCache.frontmatter[FRONTMATTER_KEY_MASK]);
}
}
return false;
};
export const hasExportTheme = (
plugin: ExcalidrawPlugin,
file: TFile,

View File

@@ -7,6 +7,7 @@
height: 100%;
margin: 0px;
background-color: white;
position:relative;
}
.context-menu-option__shortcut {
@@ -531,4 +532,18 @@ hr.excalidraw-setting-hr {
.excalidraw__embeddable-container .workspace-leaf-content .audio-container,
.excalidraw__embeddable-container .workspace-leaf-content .video-container {
display: flex;
}
.excalidraw__embeddable-container .canvas-node-container {
border: 2px solid var(--canvas-color);
}
.excalidraw__embeddable-container .canvas-node {
--shadow-border-themed-inset: inset 0 0 0 1px rgb(var(--canvas-color));;
--shadow-border-themed: 0 0 0 2px rgb(var(--canvas-color));
}
.excalidraw__embeddable-container .canvas-node.is-selected.is-themed .canvas-node-container,
.excalidraw__embeddable-container .canvas-node.is-focused.is-themed .canvas-node-container {
border-color: var(--canvas-color);
}