diff --git a/manifest.json b/manifest.json index baf552c..799911e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "obsidian-excalidraw-plugin", "name": "Excalidraw", - "version": "1.9.13", + "version": "1.9.14", "minAppVersion": "1.1.6", "description": "An Obsidian plugin to edit and view Excalidraw drawings", "author": "Zsolt Viczian", diff --git a/package.json b/package.json index ac61ae2..5aa3e28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-excalidraw-plugin", - "version": "1.9.13", + "version": "1.9.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", @@ -17,7 +17,7 @@ "author": "", "license": "MIT", "dependencies": { - "@zsviczian/excalidraw": "0.15.2-obsidian-10", + "@zsviczian/excalidraw": "0.15.2-obsidian-11", "chroma-js": "^2.4.2", "clsx": "^2.0.0", "colormaster": "^1.2.1", diff --git a/src/EmbeddedFileLoader.ts b/src/EmbeddedFileLoader.ts index 0fd7d1c..d569ad6 100644 --- a/src/EmbeddedFileLoader.ts +++ b/src/EmbeddedFileLoader.ts @@ -1,7 +1,7 @@ //https://stackoverflow.com/questions/2068344/how-do-i-get-a-youtube-video-thumbnail-from-the-youtube-api //https://img.youtube.com/vi/uZz5MgzWXiM/maxresdefault.jpg -import { FileId } from "@zsviczian/excalidraw/types/element/types"; +import { ExcalidrawImageElement, FileId } from "@zsviczian/excalidraw/types/element/types"; import { BinaryFileData, DataURL } from "@zsviczian/excalidraw/types/types"; import { App, MarkdownRenderer, Notice, TFile } from "obsidian"; import { @@ -39,6 +39,7 @@ import { svgToBase64, } from "./utils/Utils"; import { ValueOf } from "./types"; +import { has } from "./svgToExcalidraw/attributes"; //An ugly workaround for the following situation. //File A is a markdown file that has an embedded Excalidraw file B @@ -61,6 +62,15 @@ export const IMAGE_MIME_TYPES = { jfif: "image/jfif", } as const; +type ImgData = { + mimeType: MimeType; + fileId: FileId; + dataURL: DataURL; + created: number; + hasSVGwithBitmap: boolean; + size: { height: number; width: number }; +}; + export declare type MimeType = ValueOf | "application/octet-stream"; export type FileData = BinaryFileData & { @@ -307,14 +317,7 @@ export class EmbeddedFilesLoader { return result; } - private async _getObsidianImage(inFile: TFile | EmbeddedFile, depth: number): Promise<{ - mimeType: MimeType; - fileId: FileId; - dataURL: DataURL; - created: number; - hasSVGwithBitmap: boolean; - size: { height: number; width: number }; - }> { + private async _getObsidianImage(inFile: TFile | EmbeddedFile, depth: number): Promise { if (!this.plugin || !inFile) { return null; } @@ -481,7 +484,7 @@ export class EmbeddedFilesLoader { if (this.isDark === undefined) { this.isDark = excalidrawData?.scene?.appState?.theme === "dark"; } - let entry; + let entry: IteratorResult<[FileId, EmbeddedFile]>; const files: FileData[] = []; while (!this.terminate && !(entry = entries.next()).done) { const embeddedFile: EmbeddedFile = entry.value[1]; diff --git a/src/ExcalidrawAutomate.ts b/src/ExcalidrawAutomate.ts index 39e9f4e..e7ccea7 100644 --- a/src/ExcalidrawAutomate.ts +++ b/src/ExcalidrawAutomate.ts @@ -50,7 +50,7 @@ import { wrapTextAtCharLength, } from "src/utils/Utils"; import { getAttachmentsFolderAndFilePath, getLeaf, getNewOrAdjacentLeaf, isObsidianThemeDark } from "src/utils/ObsidianUtils"; -import { AppState, BinaryFileData, ExcalidrawImperativeAPI, Point } from "@zsviczian/excalidraw/types/types"; +import { AppState, BinaryFileData, DataURL, ExcalidrawImperativeAPI, Point } from "@zsviczian/excalidraw/types/types"; import { EmbeddedFile, EmbeddedFilesLoader, FileData } from "src/EmbeddedFileLoader"; import { tex2dataURL } from "src/LaTeX"; import { NewFileActions, Prompt } from "src/dialogs/Prompt"; @@ -2474,6 +2474,11 @@ async function getTemplate( }; } +export const generatePlaceholderDataURL = (width: number, height: number): DataURL => { + const svgString = `Placeholder`; + return `data:image/svg+xml;base64,${btoa(svgString)}` as DataURL; +}; + export async function createPNG( templatePath: string = undefined, scale: number = 1, @@ -2500,7 +2505,9 @@ export async function createPNG( const files = imagesDict ?? {}; if(template?.files) { Object.values(template.files).forEach((f:any)=>{ - files[f.id]=f; + if(!f.dataURL.startsWith("http")) { + files[f.id]=f; + }; }); } diff --git a/src/ExcalidrawData.ts b/src/ExcalidrawData.ts index f2b11be..d364c1d 100644 --- a/src/ExcalidrawData.ts +++ b/src/ExcalidrawData.ts @@ -13,7 +13,6 @@ import { FRONTMATTER_KEY_CUSTOM_URL_PREFIX, FRONTMATTER_KEY_DEFAULT_MODE, fileid, - REG_BLOCK_REF_CLEAN, FRONTMATTER_KEY_LINKBUTTON_OPACITY, FRONTMATTER_KEY_ONLOAD_SCRIPT, FRONTMATTER_KEY_AUTOEXPORT, @@ -45,7 +44,7 @@ import { LinkParts, wrapTextAtCharLength, } from "./utils/Utils"; -import { getAttachmentsFolderAndFilePath, isObsidianThemeDark } from "./utils/ObsidianUtils"; +import { cleanBlockRef, cleanSectionHeading, getAttachmentsFolderAndFilePath, isObsidianThemeDark } from "./utils/ObsidianUtils"; import { ExcalidrawElement, ExcalidrawImageElement, @@ -1719,18 +1718,19 @@ export const getTransclusion = async ( if (!linkParts.path) { return { contents: linkParts.original.trim(), lineNum: 0 }; } //filename not found + if (!file || !(file instanceof TFile)) { return { contents: linkParts.original.trim(), lineNum: 0 }; } + const contents = await app.vault.read(file); + if (!linkParts.ref) { //no blockreference return charCountLimit ? { contents: contents.substring(0, charCountLimit).trim(), lineNum: 0 } : { contents: contents.trim(), lineNum: 0 }; } - //const isParagraphRef = parts.value[2] ? true : false; //does the reference contain a ^ character? - //const id = parts.value[3]; //the block ID or heading text const blocks = ( await app.metadataCache.blockCache.getForFile( @@ -1741,6 +1741,7 @@ export const getTransclusion = async ( if (!blocks) { return { contents: linkParts.original.trim(), lineNum: 0 }; } + if (linkParts.isBlockRef) { let para = blocks.filter((block: any) => block.node.id == linkParts.ref)[0] ?.node; @@ -1759,6 +1760,7 @@ export const getTransclusion = async ( lineNum, }; } + const headings = blocks.filter( (block: any) => block.display.search(/^#+\s/) === 0, ); // startsWith("#")); @@ -1790,12 +1792,19 @@ export const getTransclusion = async ( //const refNoSpace = linkParts.ref.replaceAll(" ",""); if ( !startPos && - (c?.value?.replaceAll(REG_BLOCK_REF_CLEAN, "") === linkParts.ref || - c?.title?.replaceAll(REG_BLOCK_REF_CLEAN, "") === linkParts.ref || - dataHeading?.replaceAll(REG_BLOCK_REF_CLEAN, "") === linkParts.ref || + ((cleanBlockRef(c?.value) === linkParts.ref || + cleanBlockRef(c?.title) === linkParts.ref || + cleanBlockRef(dataHeading) === linkParts.ref || (cc - ? cc[0]?.value?.replaceAll(REG_BLOCK_REF_CLEAN, "") === linkParts.ref - : false)) + ? cleanBlockRef(cc[0]?.value) === linkParts.ref + : false)) || + (cleanSectionHeading(c?.value) === linkParts.ref || + cleanSectionHeading(c?.title) === linkParts.ref || + cleanSectionHeading(dataHeading) === linkParts.ref || + (cc + ? cleanSectionHeading(cc[0]?.value) === linkParts.ref + : false)) + ) ) { startPos = headings[i].node.children[0]?.position.start.offset; // depth = headings[i].node.depth; diff --git a/src/constants.ts b/src/constants.ts index e91eb07..8f2151d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -74,7 +74,11 @@ export const SCRIPT_INSTALL_CODEBLOCK = "excalidraw-script-install"; export const SCRIPT_INSTALL_FOLDER = "Downloaded"; export const fileid = customAlphabet("1234567890abcdef", 40); export const REG_LINKINDEX_INVALIDCHARS = /[<>:"\\|?*#]/g; -export const REG_BLOCK_REF_CLEAN = /[!"#$%&()*+,.:;<=>?@^`{|}~\/\[\]\\]/g; + +//taken from Obsidian source code +export const REG_SECTION_REF_CLEAN = /([:#|^\\\r\n]|%%|\[\[|]])/g; +export const REG_BLOCK_REF_CLEAN = /[!"#$%&()*+,.:;<=>?@^`{|}~\/\[\]\\\r\n]/g; +// /[!"#$%&()*+,.:;<=>?@^`{|}~\/\[\]\\]/g; // https://discord.com/channels/686053708261228577/989603365606531104/1000128926619816048 // /\+|\/|~|=|%|\(|\)|{|}|,|&|\.|\$|!|\?|;|\[|]|\^|#|\*|<|>|&|@|\||\\|"|:|\s/g; export const IMAGE_TYPES = ["jpeg", "jpg", "png", "gif", "svg", "webp", "bmp", "ico"]; diff --git a/src/dialogs/Messages.ts b/src/dialogs/Messages.ts index 11f0047..94c3107 100644 --- a/src/dialogs/Messages.ts +++ b/src/dialogs/Messages.ts @@ -17,6 +17,19 @@ I develop this plugin as a hobby, spending my free time doing this. If you find
`, +"1.9.14":` +# Fixed +- **Dynamic Styling**: Excalidraw ${String.fromCharCode(96)}Plugin Settings/Display/Dynamic Styling${String.fromCharCode(96)} did not handle theme changes correctly. +- **Section References**: Section Headings that contained a dot (e.g. #2022.01.01) (or other special characters) did not work when focusing markdown embeds to a section. [#1262](https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/1262) +- **PNG Export**: When using images from the web (i.e. based on URL and not a file from your Vault), embedding the Excalidraw file into a markdown document as PNG, or exporting as PNG failed. This is because due to browser cross-origin restrictions, Excalidraw is unable to access the image. In such cases, a placeholder will be included in the export, but the export will not fail, as until now. + +# New in ExcalidrawAutomate +- ${String.fromCharCode(96)}getActiveEmbeddableViewOrEditor${String.fromCharCode(96)} will return the active editor and file in case of a markdown document or the active leaf.view for other files (e.g. PDF, MP4 player, Kanban, Canvas, etc) of the currently active embedded object. This function can be used by plugins to check if an editor is available and obtain the view or editor to perform their actions. Example: [package.json](https://github.com/zsviczian/excalibrain/blob/2056a021af7c3a53ed08203a77f6eae304ca6e39/package.json#L23), [Checking for EA](https://github.com/zsviczian/excalibrain/blob/2056a021af7c3a53ed08203a77f6eae304ca6e39/src/excalibrain-main.ts#L114-L127), and [Running the function](https://github.com/zsviczian/excalibrain/blob/2056a021af7c3a53ed08203a77f6eae304ca6e39/src/excalibrain-main.ts#L362-L399) + +${String.fromCharCode(96,96,96)}typescript +public getActiveEmbeddableViewOrEditor (view?:ExcalidrawView): {view:any}|{file:TFile, editor:Editor}|null; +${String.fromCharCode(96,96,96)} +`, "1.9.13":`
diff --git a/src/dialogs/UniversalInsertFileModal.ts b/src/dialogs/UniversalInsertFileModal.ts index e33c059..ba6718e 100644 --- a/src/dialogs/UniversalInsertFileModal.ts +++ b/src/dialogs/UniversalInsertFileModal.ts @@ -3,13 +3,14 @@ import ExcalidrawView from "../ExcalidrawView"; import ExcalidrawPlugin from "../main"; import { Modal, Setting, TextComponent } from "obsidian"; import { FileSuggestionModal } from "./FolderSuggester"; -import { IMAGE_TYPES, REG_BLOCK_REF_CLEAN, sceneCoordsToViewportCoords, viewportCoordsToSceneCoords } from "src/Constants"; +import { IMAGE_TYPES, sceneCoordsToViewportCoords, viewportCoordsToSceneCoords } from "src/Constants"; import { insertEmbeddableToView, insertImageToView } from "src/utils/ExcalidrawViewUtils"; import { getEA } from "src"; import { InsertPDFModal } from "./InsertPDFModal"; import { ExcalidrawImperativeAPI } from "@zsviczian/excalidraw/types/types"; import { MAX_IMAGE_SIZE } from "src/Constants"; import { ExcalidrawAutomate } from "src/ExcalidrawAutomate"; +import { cleanSectionHeading } from "src/utils/ObsidianUtils"; export class UniversalInsertFileModal extends Modal { private center: { x: number, y: number } = { x: 0, y: 0 }; @@ -96,7 +97,7 @@ export class UniversalInsertFileModal extends Modal { .blocks.filter((b: any) => b.display && b.node?.type === "heading") .forEach((b: any) => { sectionPicker.addOption( - `#${b.display.replaceAll(REG_BLOCK_REF_CLEAN, "").trim()}`, + `#${cleanSectionHeading(b.display)}`, b.display) }); } else { diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts index a3e8c4d..abd84f1 100644 --- a/src/lang/locale/en.ts +++ b/src/lang/locale/en.ts @@ -367,9 +367,9 @@ FILENAME_HEAD: "Filename", "Toggle OFF: Embed drawing as a PNG image. Note, that some of the image block referencing features do not work with PNG embeds.",*/ EMBED_PREVIEW_IMAGETYPE_NAME: "Image type in markdown preview", EMBED_PREVIEW_IMAGETYPE_DESC: - "Native SVG: High Image Quality. Embedded Websites, YouTube videos, Obsidian Links will work in the image. Embedded Obsidian pages will not
" + - "SVG Image: High Image Quality. Embedded elements only have placeholders, links don't work
" + - "PNG Image: Lower Image Quality, but in some cases better performance with large drawings. Some of the image block referencing features do not work with PNG embeds.", + "Native SVG: High Image Quality. Embedded Websites, YouTube videos, Obsidian Links, and external images embedded via a URL will all work. Embedded Obsidian pages will not
" + + "SVG Image: High Image Quality. Embedded elements and images embedded via URL only have placeholders, links don't work
" + + "PNG Image: Lower Image Quality, but in some cases better performance with large drawings. Embedded elements and images embedded via URL only have placeholders, links don't work. Also some of the image block referencing features do not work with PNG embeds.", PREVIEW_MATCH_OBSIDIAN_NAME: "Excalidraw preview to match Obsidian theme", PREVIEW_MATCH_OBSIDIAN_DESC: "Image preview in documents should match the Obsidian theme. If enabled, when Obsidian is in dark mode, Excalidraw images will render in dark mode. " + diff --git a/src/menu/EmbeddableActionsMenu.tsx b/src/menu/EmbeddableActionsMenu.tsx index 6d82df7..ab374db 100644 --- a/src/menu/EmbeddableActionsMenu.tsx +++ b/src/menu/EmbeddableActionsMenu.tsx @@ -7,9 +7,10 @@ import { ActionButton } from "./ActionButton"; import { ICONS } from "./ActionIcons"; import { t } from "src/lang/helpers"; import { ScriptEngine } from "src/Scripts"; -import { REG_BLOCK_REF_CLEAN, ROOTELEMENTSIZE, mutateElement, nanoid, sceneCoordsToViewportCoords } from "src/Constants"; +import { ROOTELEMENTSIZE, mutateElement, nanoid, sceneCoordsToViewportCoords } from "src/Constants"; import { REGEX_LINK, REG_LINKINDEX_HYPERLINK } from "src/ExcalidrawData"; import { processLinkText, useDefaultExcalidrawFrame } from "src/utils/CustomEmbeddableUtils"; +import { cleanSectionHeading } from "src/utils/ObsidianUtils"; export class EmbeddableMenu { @@ -119,7 +120,7 @@ export class EmbeddableMenu { .getForFile({ isCancelled: () => false },file)) .blocks.filter((b: any) => b.display && b.node?.type === "heading"); const values = [""].concat( - sections.map((b: any) => `#${b.display.replaceAll(REG_BLOCK_REF_CLEAN, "").trim()}`) + sections.map((b: any) => `#${cleanSectionHeading(b.display)}`) ); const display = [t("SHOW_ENTIRE_FILE")].concat( sections.map((b: any) => b.display) diff --git a/src/utils/FileUtils.ts b/src/utils/FileUtils.ts index d143949..0c377cb 100644 --- a/src/utils/FileUtils.ts +++ b/src/utils/FileUtils.ts @@ -169,7 +169,6 @@ export const getMimeType = (extension: string):MimeType => { } } - // using fetch API const getFileFromURL = async (url: string, mimeType: MimeType, timeout: number = URLFETCHTIMEOUT): Promise => { try { @@ -222,6 +221,54 @@ export const getDataURLFromURL = async (url: string, mimeType: MimeType, timeout : url as DataURL; }; +/* +const timeoutPromise = (timeout: number) => { + return new Promise((_, reject) => + setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout) + ); +}; + +export const getDataURLFromURL = async ( + url: string, + mimeType: MimeType, + timeout: number = URLFETCHTIMEOUT +): Promise => { + return Promise.race([ + new Promise((resolve, reject) => { + const img = new Image(); + + // Add an 'onload' event listener to handle image loading success + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + + if (!ctx) { + reject(new Error('Canvas context is not supported.')); + return; + } + + // Draw the image on the canvas. + ctx.drawImage(img, 0, 0); + + // Get the image data from the canvas. + const dataURL = canvas.toDataURL(mimeType) as DataURL; + resolve(dataURL); + }; + + // Add an 'onerror' event listener to handle image loading failure + img.onerror = () => { + reject(new Error('Failed to load image: ' + url)); + }; + + // Set the 'src' attribute to the image URL to start loading the image. + img.src = url; + }), + timeoutPromise(timeout) + ]); +};*/ + export const blobToBase64 = async (blob: Blob): Promise => { const arrayBuffer = await blob.arrayBuffer() const bytes = new Uint8Array(arrayBuffer) diff --git a/src/utils/ObsidianUtils.ts b/src/utils/ObsidianUtils.ts index e8c19de..4931e99 100644 --- a/src/utils/ObsidianUtils.ts +++ b/src/utils/ObsidianUtils.ts @@ -5,6 +5,7 @@ import { import ExcalidrawPlugin from "../main"; import { checkAndCreateFolder, splitFolderAndFilename } from "./FileUtils"; import { linkClickModifierType, ModifierKeys } from "./ModifierkeyHelper"; +import { REG_BLOCK_REF_CLEAN, REG_SECTION_REF_CLEAN } from "src/Constants"; export const getParentOfClass = (element: Element, cssClass: string):HTMLElement | null => { let parent = element.parentElement; @@ -197,4 +198,20 @@ export const getContainerForDocument = (doc:Document) => { } } return app.workspace.rootSplit; -}; \ No newline at end of file +}; + +export const cleanSectionHeading = (heading:string) => { + if(!heading) return heading; + return heading.replace(REG_SECTION_REF_CLEAN, "").replace(/\s+/g, " ").trim(); +} + +export const cleanBlockRef = (blockRef:string) => { + if(!blockRef) return blockRef; + return blockRef.replace(REG_BLOCK_REF_CLEAN, "").replace(/\s+/g, " ").trim(); +} + +//needed for backward compatibility +export const legacyCleanBlockRef = (blockRef:string) => { + if(!blockRef) return blockRef; + return blockRef.replace(/[!"#$%&()*+,.:;<=>?@^`{|}~\/\[\]\\]/g, "").replace(/\s+/g, " ").trim(); +} \ No newline at end of file diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 5ef18b8..43b3829 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -7,10 +7,9 @@ import { TFile, } from "obsidian"; import { Random } from "roughjs/bin/math"; -import { DataURL, Zoom } from "@zsviczian/excalidraw/types/types"; +import { BinaryFileData, DataURL} from "@zsviczian/excalidraw/types/types"; import { CASCADIA_FONT, - REG_BLOCK_REF_CLEAN, VIRGIL_FONT, FRONTMATTER_KEY_EXPORT_DARK, FRONTMATTER_KEY_EXPORT_TRANSPARENT, @@ -27,11 +26,10 @@ import { compressToBase64, decompressFromBase64 } from "lz-string"; import { getDataURLFromURL, getIMGFilename, getMimeType, getURLImageExtension } from "./FileUtils"; import { IMAGE_TYPES } from "../Constants"; import { generateEmbeddableLink } from "./CustomEmbeddableUtils"; -import Scene from "@zsviczian/excalidraw/types/scene/Scene"; import ExcalidrawScene from "src/svgToExcalidraw/elements/ExcalidrawScene"; import { FILENAMEPARTS } from "./UtilTypes"; import { Mutable } from "@zsviczian/excalidraw/types/utility-types"; -import { add } from "@zsviczian/excalidraw/types/ga"; +import { cleanBlockRef, cleanSectionHeading } from "./ObsidianUtils"; declare const PLUGIN_VERSION:string; @@ -287,6 +285,18 @@ export const getSVG = async ( } }; +export function filterFiles(files: Record): Record { + let filteredFiles: Record = {}; + + Object.entries(files).forEach(([key, value]) => { + if (!value.dataURL.startsWith("http")) { + filteredFiles[key] = value; + } + }); + + return filteredFiles; +} + export const getPNG = async ( scene: any, exportSettings: ExportSettings, @@ -303,7 +313,7 @@ export const getPNG = async ( : false, ...scene.appState, }, - files: scene.files, + files: filterFiles(scene.files), exportPadding: padding, mimeType: "image/png", getDimensions: (width: number, height: number) => ({ @@ -465,11 +475,14 @@ export const getLinkParts = (fname: string, file?: TFile): LinkParts => { // 1 2 3 4 5 const REG = /(^[^#\|]*)#?(\^)?([^\|]*)?\|?(\d*)x?(\d*)/; const parts = fname.match(REG); + const isBlockRef = parts[2] === "^"; return { original: fname, path: file && (parts[1] === "") ? file.path : parts[1], - isBlockRef: parts[2] === "^", - ref: parts[3]?.match(/^page=\d*$/i) ? parts[3] : parts[3]?.replaceAll(REG_BLOCK_REF_CLEAN, ""), + isBlockRef, + ref: parts[3]?.match(/^page=\d*$/i) + ? parts[3] + : isBlockRef ? cleanBlockRef(parts[3]) : cleanSectionHeading(parts[3]), width: parts[4] ? parseInt(parts[4]) : undefined, height: parts[5] ? parseInt(parts[5]) : undefined, page: parseInt(parts[3]?.match(/page=(\d*)/)?.[1])