mirror of
https://github.com/zsviczian/obsidian-excalidraw-plugin.git
synced 2025-08-06 05:46:28 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dd9d1a056 | ||
|
|
46b03725e9 | ||
|
|
65d6577b28 | ||
|
|
97967f5b70 | ||
|
|
9323e1fad4 |
@@ -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;"/> 1 Getting Started</a><br>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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`,
|
||||
|
||||
142
src/main.ts
142
src/main.ts
@@ -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"),
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
158
src/utils/CarveOut.ts
Normal 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
177
src/utils/CropImage.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
15
styles.css
15
styles.css
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user