From 1b4c6c5723d40255c2d04c06191d126c9e7c1656 Mon Sep 17 00:00:00 2001 From: Junerey Date: Fri, 10 Jul 2026 13:34:36 +0700 Subject: [PATCH] app-core: add excalidraw drawing embeds --- packages/app-core/src/App.tsx | 19 +++ .../src/components/CommandPalette.tsx | 9 +- .../src/components/EmbedDrawingPalette.tsx | 130 +++++++++++++++ .../src/components/ExcalidrawPreview.tsx | 78 +++++++++ .../src/components/LazyExcalidrawPreview.tsx | 31 ++++ packages/app-core/src/components/Preview.tsx | 55 ++++++- packages/app-core/src/lib/cm-live-preview.ts | 147 ++++++++++++++++- packages/app-core/src/lib/commands.ts | 23 +++ .../src/lib/excalidraw-preview.test.ts | 75 +++++++++ .../app-core/src/lib/excalidraw-preview.ts | 153 ++++++++++++++++++ packages/app-core/src/lib/help.ts | 16 +- packages/app-core/src/lib/local-assets.ts | 8 +- packages/app-core/src/lib/markdown.test.ts | 23 +++ packages/app-core/src/lib/markdown.ts | 14 ++ packages/app-core/src/store.ts | 61 ++++++- packages/app-core/src/styles/index.css | 41 +++++ 16 files changed, 873 insertions(+), 10 deletions(-) create mode 100644 packages/app-core/src/components/EmbedDrawingPalette.tsx create mode 100644 packages/app-core/src/components/ExcalidrawPreview.tsx create mode 100644 packages/app-core/src/components/LazyExcalidrawPreview.tsx create mode 100644 packages/app-core/src/lib/excalidraw-preview.test.ts create mode 100644 packages/app-core/src/lib/excalidraw-preview.ts diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 2176b047..38214c6b 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -170,6 +170,11 @@ const TemplatePalette = lazy(async () => { return { default: module.TemplatePalette } }) +const EmbedDrawingPalette = lazy(async () => { + const module = await import('./components/EmbedDrawingPalette') + return { default: module.EmbedDrawingPalette } +}) + const SettingsModal = lazy(async () => { const module = await import('./components/SettingsModal') return { default: module.SettingsModal } @@ -256,6 +261,8 @@ function App(): JSX.Element { const setOutlinePaletteOpen = useStore((s) => s.setOutlinePaletteOpen) const templatePaletteOpen = useStore((s) => s.templatePaletteOpen) const setTemplatePaletteOpen = useStore((s) => s.setTemplatePaletteOpen) + const embedDrawingPaletteOpen = useStore((s) => s.embedDrawingPaletteOpen) + const setEmbedDrawingPaletteOpen = useStore((s) => s.setEmbedDrawingPaletteOpen) const sidebarOpen = useStore((s) => s.sidebarOpen) const noteListOpen = useStore((s) => s.noteListOpen) const zenMode = useStore((s) => s.zenMode) @@ -685,6 +692,11 @@ function App(): JSX.Element { focusEditorNormalMode() return } + if (e.key === 'Escape' && state.embedDrawingPaletteOpen) { + setEmbedDrawingPaletteOpen(false) + focusEditorNormalMode() + return + } if (e.key === 'Escape' && state.outlinePaletteOpen) { setOutlinePaletteOpen(false) focusEditorNormalMode() @@ -770,6 +782,7 @@ function App(): JSX.Element { state.commandPaletteOpen || state.bufferPaletteOpen || state.templatePaletteOpen || + state.embedDrawingPaletteOpen || state.outlinePaletteOpen || document.querySelector('[data-ctx-menu]') || document.querySelector('[data-prompt-modal]') || @@ -803,6 +816,7 @@ function App(): JSX.Element { setCommandPaletteOpen, setOutlinePaletteOpen, setTemplatePaletteOpen, + setEmbedDrawingPaletteOpen, setSearchOpen, setVaultTextSearchOpen ]) @@ -925,6 +939,11 @@ function App(): JSX.Element { )} + {embedDrawingPaletteOpen && ( + + + + )} {settingsOpen && ( diff --git a/packages/app-core/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx index 18209962..042837dd 100644 --- a/packages/app-core/src/components/CommandPalette.tsx +++ b/packages/app-core/src/components/CommandPalette.tsx @@ -265,7 +265,14 @@ export function CommandPalette(): JSX.Element { // closePalette's focus restore; the retry wins that race. Skipped when the // command opened the Settings modal so we don't pull focus behind it. const s = useStore.getState() - if (s.focusedPanel === 'editor' && !s.settingsOpen) focusEditorNormalMode() + if ( + s.focusedPanel === 'editor' && + !s.settingsOpen && + !s.embedDrawingPaletteOpen && + !s.templatePaletteOpen && + !s.bufferPaletteOpen + ) + focusEditorNormalMode() } catch (err) { console.error('command failed', cmd.id, err) } diff --git a/packages/app-core/src/components/EmbedDrawingPalette.tsx b/packages/app-core/src/components/EmbedDrawingPalette.tsx new file mode 100644 index 00000000..8ae58268 --- /dev/null +++ b/packages/app-core/src/components/EmbedDrawingPalette.tsx @@ -0,0 +1,130 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useStore } from '../store' +import type { NoteMeta } from '@shared/ipc' +import { isExcalidrawPath, isObsidianExcalidrawPath } from '@shared/excalidraw' +import { rankItems } from '../lib/fuzzy-score' +import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' +import { isImeComposing } from '../lib/ime' +import { focusEditorNormalMode } from '../lib/editor-focus' +import { Modal } from './ui/Modal' + +/** + * "Embed existing drawing" picker. Lists every Excalidraw drawing in the vault + * (native `.excalidraw` plus Obsidian `.excalidraw.md`) and inserts an + * `![[path]]` embed at the cursor in the active note when chosen. + */ +export function EmbedDrawingPalette(): JSX.Element { + const notes = useStore((s) => s.notes) + const setOpen = useStore((s) => s.setEmbedDrawingPaletteOpen) + const insertEmbedAtCursor = useStore((s) => s.insertEmbedAtCursor) + const [query, setQuery] = useState('') + const [active, setActive] = useState(0) + const inputRef = useRef(null) + const listRef = useRef(null) + + const drawings = useMemo( + () => notes.filter((n) => isExcalidrawPath(n.path) || isObsidianExcalidrawPath(n.path)), + [notes] + ) + + const results = useMemo( + () => + rankItems(drawings, query, [ + { get: (n) => n.title, weight: 1 }, + { get: (n) => n.path, weight: 0.7 } + ]).slice(0, 50), + [drawings, query] + ) + + useEffect(() => { + inputRef.current?.focus() + }, []) + + useEffect(() => setActive(0), [query]) + + useEffect(() => { + const el = listRef.current?.querySelector(`[data-embed-idx="${active}"]`) + el?.scrollIntoView({ block: 'nearest' }) + }, [active]) + + const choose = (note: NoteMeta): void => { + setOpen(false) + insertEmbedAtCursor(`![[${note.path}]]\n`) + focusEditorNormalMode() + } + + const close = (): void => { + setOpen(false) + focusEditorNormalMode() + } + + return ( + +
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (isImeComposing(e)) return + if (isPaletteNextKey(e)) { + e.preventDefault() + e.stopPropagation() + setActive((a) => Math.min(results.length - 1, a + 1)) + } else if (isPalettePreviousKey(e)) { + e.preventDefault() + e.stopPropagation() + setActive((a) => Math.max(0, a - 1)) + } else if (e.key === 'Enter') { + e.preventDefault() + const note = results[active] + if (note) choose(note) + } else if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + close() + } + }} + className="w-full bg-transparent text-base text-ink-900 outline-none placeholder:text-ink-400" + /> +
+
+ {results.length === 0 ? ( +
+ {drawings.length === 0 ? 'No drawings in this vault yet.' : 'No matches.'} +
+ ) : ( + results.map((n, i) => ( + + )) + )} +
+
+ + ↑↓ move + + + embed + + + esc close + +
+
+ ) +} diff --git a/packages/app-core/src/components/ExcalidrawPreview.tsx b/packages/app-core/src/components/ExcalidrawPreview.tsx new file mode 100644 index 00000000..c8f58090 --- /dev/null +++ b/packages/app-core/src/components/ExcalidrawPreview.tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react' +import type { CSSProperties } from 'react' +import { getExcalidrawPreview } from '../lib/excalidraw-preview' +import { useStore } from '../store' + +export interface ExcalidrawPreviewProps { + path: string + width?: number + height?: number + className?: string + onClick?: () => void +} + +/** + * Renders an Excalidraw drawing as a PNG image (exported @2x). Used both by + * the editor live-preview widget and the preview-pane hydration to show a + * drawing as an image-like embed inside a note. Re-renders when the vault + * watcher bumps `excalidrawPreviewVersion`. + */ +export function ExcalidrawPreview({ + path, + width, + height, + className, + onClick +}: ExcalidrawPreviewProps): JSX.Element { + const [src, setSrc] = useState(null) + const version = useStore((s) => s.excalidrawPreviewVersion) + + useEffect(() => { + let cancelled = false + setSrc(null) + void getExcalidrawPreview(path).then((url) => { + if (!cancelled) setSrc(url) + }) + return () => { + cancelled = true + } + }, [path, version]) + + const style: CSSProperties = {} + if (width) style.maxWidth = `${width}px` + if (height) style.maxHeight = `${height}px` + + if (!src) { + return ( +
+ ) + } + + return ( + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClick() + } + } + : undefined + } + /> + ) +} diff --git a/packages/app-core/src/components/LazyExcalidrawPreview.tsx b/packages/app-core/src/components/LazyExcalidrawPreview.tsx new file mode 100644 index 00000000..16c68289 --- /dev/null +++ b/packages/app-core/src/components/LazyExcalidrawPreview.tsx @@ -0,0 +1,31 @@ +import { lazy, Suspense } from 'react' +import type { ExcalidrawPreviewProps } from './ExcalidrawPreview' + +const ExcalidrawPreviewImpl = lazy(() => + import('./ExcalidrawPreview').then((mod) => ({ default: mod.ExcalidrawPreview })) +) + +/** + * Lazy boundary for the Excalidraw preview image. Keeps the Excalidraw export + * bundle (pulled in dynamically by excalidraw-preview.ts) out of the main + * editor/preview chunks until a drawing embed is actually shown. + */ +export function LazyExcalidrawPreview({ + path, + width, + height, + className, + onClick +}: ExcalidrawPreviewProps): JSX.Element { + return ( + + + + ) +} diff --git a/packages/app-core/src/components/Preview.tsx b/packages/app-core/src/components/Preview.tsx index 7d8d0e77..8bc57335 100644 --- a/packages/app-core/src/components/Preview.tsx +++ b/packages/app-core/src/components/Preview.tsx @@ -1,5 +1,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; import type { NoteMeta } from "@shared/ipc"; import { renderMarkdown } from "../lib/markdown"; import { expandEmbeds, hasNoteEmbeds } from "../lib/transclusion"; @@ -19,6 +20,9 @@ import { resolveAssetVaultRelativePath, } from "../lib/local-assets"; import { assetTabPath } from "../lib/asset-tabs"; +import { isExcalidrawPath, isObsidianExcalidrawPath } from "@shared/excalidraw"; +import { resolveExcalidrawEmbedPath } from "../lib/excalidraw-preview"; +import { LazyExcalidrawPreview } from "./LazyExcalidrawPreview"; import { enhancePreviewHeadingFolds } from "../lib/preview-heading-fold"; import { renderDiagrams } from "../lib/diagram-renderers"; import { attachInlineDiagramPanZoom } from "../lib/inline-diagram-pan-zoom"; @@ -455,7 +459,9 @@ export const Preview = memo(function Preview({ () => (target: string): { path: string; title: string } | null => { const n = resolveWikilinkTarget(notes, target); - return n ? { path: n.path, title: n.title } : null; + if (!n) return null; + if (isExcalidrawPath(n.path) || isObsidianExcalidrawPath(n.path)) return null; + return { path: n.path, title: n.title }; }, [notes], ); @@ -518,6 +524,9 @@ export const Preview = memo(function Preview({ const openNoteInTabRef = useRef(openNoteInTab); const updateActiveBodyRef = useRef(updateActiveBody); const persistActiveRef = useRef(persistActive); + // React roots for rendered Excalidraw embed placeholders — unmounted on + // every re-render and on component teardown to avoid leaks. + const excalidrawRootsRef = useRef([]); useEffect(() => { notesRef.current = notes; @@ -852,15 +861,59 @@ export const Preview = memo(function Preview({ root.replaceChildren(...Array.from(stage.childNodes)); await renderDiagrams(root, { themeKey: effectiveMode, expanded: false }); if (cancelled) return; + renderExcalidrawEmbeds(root); requestAnimationFrame(() => { if (!cancelled && embedsReadyRef.current) onRenderedRef.current?.(); }); }; + const renderExcalidrawEmbeds = (container: HTMLElement): void => { + // Unmount roots from the previous render before hydrating the new DOM. + for (const r of excalidrawRootsRef.current) { + try { + r.unmount(); + } catch { + /* node already gone */ + } + } + excalidrawRootsRef.current = []; + const notePaths = notes.map((n) => n.path); + container + .querySelectorAll("[data-excalidraw-embed]") + .forEach((host) => { + const target = host.getAttribute("data-excalidraw-embed") || ""; + if (!target.trim()) return; + const wAttr = host.getAttribute("data-embed-width"); + const hAttr = host.getAttribute("data-embed-height"); + const resolved = resolveExcalidrawEmbedPath(notePaths, target) ?? target; + const r = createRoot(host); + excalidrawRootsRef.current.push(r); + r.render( + { + void openNoteInTabRef.current(assetTabPath(resolved)); + }} + />, + ); + }); + }; + void applyRenderedDom(); return () => { cancelled = true; + for (const r of excalidrawRootsRef.current) { + try { + r.unmount(); + } catch { + /* node already gone */ + } + } + excalidrawRootsRef.current = []; }; }, [ assetFilesKey, diff --git a/packages/app-core/src/lib/cm-live-preview.ts b/packages/app-core/src/lib/cm-live-preview.ts index 512672af..ebd5f366 100644 --- a/packages/app-core/src/lib/cm-live-preview.ts +++ b/packages/app-core/src/lib/cm-live-preview.ts @@ -16,6 +16,11 @@ import { } from './local-assets' import { setImageBlockDragPayload } from './image-block-dnd' import { assetTabPath } from './asset-tabs' +import { + getExcalidrawPreview, + parseEmbedSizeHint, + resolveExcalidrawEmbedPath +} from './excalidraw-preview' /** * Live-preview extension: hides markdown syntax markers on lines where @@ -542,6 +547,104 @@ class LocalPdfWidget extends WidgetType { } } +type ParsedExcalidraw = { + href: string + resolvedPath: string + width?: number + height?: number +} + +function parseStandaloneLocalExcalidraw(lineText: string): ParsedExcalidraw | null { + const fromEmbed = lineText.match(STANDALONE_OBSIDIAN_EMBED_RE) + if (!fromEmbed) return null + const href = (fromEmbed[1] ?? '').trim() + if (classifyLocalAssetHref(href) !== 'excalidraw') return null + const state = useStore.getState() + const notePaths = state.notes.map((n) => n.path) + const resolvedPath = resolveExcalidrawEmbedPath(notePaths, href) ?? href + const hint = parseEmbedSizeHint(fromEmbed[2]) + return { + href, + resolvedPath, + width: hint?.width, + height: hint?.height + } +} + +/** Renders a `![[drawing.excalidraw]]` embed as an exported PNG image, like an + * inline image block. Clicking opens the drawing in a dedicated Excalidraw + * tab. The preview is produced lazily by excalidraw-preview.ts (exportToBlob) + * and cached by path + mtime. */ +class LocalExcalidrawWidget extends WidgetType { + constructor( + private readonly notePath: string, + private readonly lineFrom: number, + private readonly lineTo: number, + private readonly href: string, + private readonly resolvedPath: string, + private readonly width?: number, + private readonly height?: number, + private readonly version = 0 + ) { + super() + } + + eq(other: LocalExcalidrawWidget): boolean { + return ( + other.notePath === this.notePath && + other.href === this.href && + other.resolvedPath === this.resolvedPath && + other.width === this.width && + other.height === this.height && + other.version === this.version + ) + } + + toDOM(): HTMLElement { + const figure = document.createElement('figure') + figure.className = 'local-image-embed cm-excalidraw-embed' + + const frame = document.createElement('div') + frame.className = 'local-image-embed-frame' + + const image = document.createElement('img') + image.className = 'local-image-embed-image excalidraw-embed-image' + image.alt = '' + image.loading = 'lazy' + image.draggable = false + const imgStyle = image.style + if (this.width) imgStyle.maxWidth = `${this.width}px` + if (this.height) imgStyle.maxHeight = `${this.height}px` + + // Lazy-render the PNG and swap it in when ready. + void getExcalidrawPreview(this.resolvedPath).then((url) => { + if (url) image.src = url + }) + + // Click anywhere on the image opens the drawing in a new Excalidraw tab. + image.style.cursor = 'pointer' + image.addEventListener('click', (event) => { + event.preventDefault() + event.stopPropagation() + void useStore.getState().openNoteInTab(assetTabPath(this.resolvedPath)) + }) + + frame.append(image) + + const caption = document.createElement('figcaption') + caption.className = 'local-image-embed-caption' + caption.textContent = + decodeURIComponentSafe(this.href.split('/').filter(Boolean).pop()) || 'Drawing' + + figure.append(frame, caption) + return figure + } + + ignoreEvent(): boolean { + return true + } +} + /** Renders a GFM task-list marker (`[ ]` / `[x]` / `[X]`) as a clickable * checkbox. The widget rewrites the underlying markdown when toggled — the * same single-character mutation the Preview pane uses, so the on-disk @@ -704,6 +807,43 @@ function computeDecorations(view: EditorView): DecorationSet { } continue } + const parsedExcalidraw = parseStandaloneLocalExcalidraw(line.text) + if (parsedExcalidraw) { + const st = useStore.getState() + const notePath = st.activeNote?.path + if (!notePath) continue + replacedLines.add(lineNo) + pending.push({ + from: line.to, + to: line.to, + deco: Decoration.widget({ + side: 1, + widget: new LocalExcalidrawWidget( + notePath, + line.from, + line.to, + parsedExcalidraw.href, + parsedExcalidraw.resolvedPath, + parsedExcalidraw.width, + parsedExcalidraw.height, + st.excalidrawPreviewVersion + ) + }) + }) + if (!lineActive) { + pending.push({ + from: line.from, + to: line.to, + deco: imageSourceHide + }) + pending.push({ + from: line.from, + to: line.from, + deco: imageEmbedLine + }) + } + continue + } if (lineActive) continue const parsedPdf = parseStandaloneLocalPdf(line.text) if (parsedPdf) { @@ -916,7 +1056,12 @@ export const livePreviewPlugin = ViewPlugin.fromClass( // (or anywhere other than the note's own directory) bakes in // the wrong URL on the very first decoration pass and stays // broken until you re-trigger a recompute by editing. - state.assetFiles !== prev.assetFiles + state.assetFiles !== prev.assetFiles || + // Notes list arriving late lets Excalidraw embed targets resolve + // to a vault-relative path; and a bumped version means a drawing + // was edited elsewhere and the cached preview must refresh. + state.notes !== prev.notes || + state.excalidrawPreviewVersion !== prev.excalidrawPreviewVersion ) { view.dispatch({ effects: refreshLivePreviewEffect.of(null) }) } diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index edde7e37..ab7e530e 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -164,6 +164,29 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma when: () => !!getState().activeNote, run: () => getState().openTemplatePaletteForInsert() }, + { + id: 'drawing.new', + title: 'New Drawing', + category: 'Note', + keywords: 'excalidraw drawing diagram sketch create new canvas', + run: () => void getState().newDrawing() + }, + { + id: 'embed.drawing.existing', + title: 'Embed Existing Drawing…', + category: 'Note', + keywords: 'excalidraw drawing diagram sketch insert embed image picture canvas', + when: () => !!getState().activeNote, + run: () => getState().setEmbedDrawingPaletteOpen(true) + }, + { + id: 'embed.drawing.new', + title: 'Embed New Drawing', + category: 'Note', + keywords: 'excalidraw drawing diagram sketch create new insert embed canvas', + when: () => !!getState().activeNote, + run: () => void getState().embedNewDrawing() + }, { id: 'template.removeBuiltins', title: 'Remove Built-in Templates', diff --git a/packages/app-core/src/lib/excalidraw-preview.test.ts b/packages/app-core/src/lib/excalidraw-preview.test.ts new file mode 100644 index 00000000..d4af4c55 --- /dev/null +++ b/packages/app-core/src/lib/excalidraw-preview.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest' +import { parseEmbedSizeHint, resolveExcalidrawEmbedPath } from './excalidraw-preview' + +describe('parseEmbedSizeHint', () => { + it('parses a bare width', () => { + expect(parseEmbedSizeHint('600')).toEqual({ width: 600, height: undefined }) + }) + + it('parses width x height', () => { + expect(parseEmbedSizeHint('600x400')).toEqual({ width: 600, height: 400 }) + }) + + it('returns null for empty or undefined input', () => { + expect(parseEmbedSizeHint(null)).toBeNull() + expect(parseEmbedSizeHint(undefined)).toBeNull() + expect(parseEmbedSizeHint('')).toBeNull() + }) + + it('returns null for non-numeric input', () => { + expect(parseEmbedSizeHint('wide')).toBeNull() + expect(parseEmbedSizeHint('abcx123')).toBeNull() + }) + + it('trims whitespace before matching', () => { + expect(parseEmbedSizeHint(' 800 ')).toEqual({ width: 800, height: undefined }) + }) +}) + +describe('resolveExcalidrawEmbedPath', () => { + const notes = [ + 'inbox/My Drawing.excalidraw', + 'Drawings/Architecture.excalidraw', + 'refs/Obsidian Drawing.excalidraw.md', + 'inbox/notes.md' + ] + + it('finds an exact path match', () => { + expect(resolveExcalidrawEmbedPath(notes, 'inbox/My Drawing.excalidraw')).toBe( + 'inbox/My Drawing.excalidraw' + ) + }) + + it('resolves by suffix when the full path is given', () => { + expect(resolveExcalidrawEmbedPath(notes, 'Drawings/Architecture.excalidraw')).toBe( + 'Drawings/Architecture.excalidraw' + ) + }) + + it('resolves a bare filename to its full path', () => { + expect(resolveExcalidrawEmbedPath(notes, 'My Drawing.excalidraw')).toBe( + 'inbox/My Drawing.excalidraw' + ) + }) + + it('resolves by title without extension', () => { + expect(resolveExcalidrawEmbedPath(notes, 'Architecture')).toBe( + 'Drawings/Architecture.excalidraw' + ) + }) + + it('resolves Obsidian .excalidraw.md files', () => { + expect(resolveExcalidrawEmbedPath(notes, 'Obsidian Drawing.excalidraw.md')).toBe( + 'refs/Obsidian Drawing.excalidraw.md' + ) + }) + + it('returns null for an empty target', () => { + expect(resolveExcalidrawEmbedPath(notes, '')).toBeNull() + expect(resolveExcalidrawEmbedPath(notes, ' ')).toBeNull() + }) + + it('returns null when no match exists', () => { + expect(resolveExcalidrawEmbedPath(notes, 'nonexistent.excalidraw')).toBeNull() + }) +}) diff --git a/packages/app-core/src/lib/excalidraw-preview.ts b/packages/app-core/src/lib/excalidraw-preview.ts new file mode 100644 index 00000000..95ac66f1 --- /dev/null +++ b/packages/app-core/src/lib/excalidraw-preview.ts @@ -0,0 +1,153 @@ +import type { ExcalidrawDocument } from '@shared/excalidraw' +import { + isExcalidrawPath, + isObsidianExcalidrawPath, + parseExcalidrawDocument, + extractObsidianExcalidrawScene +} from '@shared/excalidraw' + +export interface EmbedSize { + width?: number + height?: number +} + +/** Parse an Obsidian-style embed size hint: `600`, `600x400`. */ +const SIZE_HINT_RE = /^(\d+)(?:x(\d+))?$/ + +export function parseEmbedSizeHint(hint: string | null | undefined): EmbedSize | null { + if (!hint) return null + const m = hint.trim().match(SIZE_HINT_RE) + if (!m) return null + return { width: Number(m[1]), height: m[2] ? Number(m[2]) : undefined } +} + +interface CacheEntry { + mtime: number + dataUrl: string +} + +/** Path → rendered PNG data URL, keyed by file mtime so edited drawings refresh. */ +const previewCache = new Map() +/** Dedupes concurrent render requests for the same path. */ +const inflight = new Map>() + +function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result)) + reader.onerror = () => reject(reader.error) + reader.readAsDataURL(blob) + }) +} + +async function renderPng(doc: ExcalidrawDocument): Promise { + const { exportToBlob } = await import('@excalidraw/excalidraw') + // Excalidraw keeps deleted elements in the scene for undo history. + // exportToBlob expects only non-deleted elements — including deleted + // ones corrupts the bounding-box calculation and crops the output. + const activeElements = doc.elements.filter((el) => { + if (typeof el !== 'object' || el === null) return false + return !(el as { isDeleted?: boolean }).isDeleted + }) + const blob = await exportToBlob({ + elements: activeElements as never, + appState: { ...doc.appState, exportBackground: true } as never, + files: doc.files as never, + mimeType: 'image/png', + exportPadding: 8, + getDimensions: (width: number, height: number) => ({ + width: width * 2, + height: height * 2, + scale: 2 + }) + } as never) + return blobToDataUrl(blob) +} + +/** Read a `.excalidraw` or Obsidian `.excalidraw.md` file into a renderable scene. */ +async function readScene( + path: string +): Promise<{ doc: ExcalidrawDocument; mtime: number } | null> { + const res = await window.zen.readNote(path) + if (!res) return null + const mtime = res.updatedAt ?? 0 + const doc = isObsidianExcalidrawPath(path) + ? extractObsidianExcalidrawScene(res.body) + : parseExcalidrawDocument(res.body) + if (!doc) return null + return { doc, mtime } +} + +/** + * Render an Excalidraw file to a PNG data URL, cached by path + mtime. + * Returns null if the file can't be read or the export fails (e.g. an + * empty scene that produces no bitmap). + */ +export async function getExcalidrawPreview(path: string): Promise { + const existing = inflight.get(path) + if (existing) return existing + const p = (async () => { + try { + const scene = await readScene(path) + if (!scene) return null + const cached = previewCache.get(path) + if (cached && cached.mtime === scene.mtime) return cached.dataUrl + const dataUrl = await renderPng(scene.doc) + previewCache.set(path, { mtime: scene.mtime, dataUrl }) + return dataUrl + } catch (err) { + console.error('excalidraw preview failed', path, err) + return null + } finally { + inflight.delete(path) + } + })() + inflight.set(path, p) + return p +} + +/** Drop a single cached preview (called by the vault watcher on change). */ +export function invalidateExcalidrawPreview(path: string): void { + previewCache.delete(path) +} + +/** + * Resolve a raw embed target (e.g. `Drawings/foo.excalidraw`, `foo.excalidraw`, + * or bare `foo`) to a real vault-relative note path. Excalidraw drawings live in + * `state.notes`, not `assetFiles`, and `resolveWikilinkTarget` only matches + * `.md` files — so this handles the drawing-specific lookup. + */ +export function resolveExcalidrawEmbedPath( + notePaths: string[], + target: string +): string | null { + const t = target.trim() + if (!t) return null + const stripExt = (name: string): string => + name.replace(/\.(excalidraw\.md|excalidraw)$/i, '') + + if (isObsidianExcalidrawPath(t) || isExcalidrawPath(t)) { + const exact = notePaths.find((p) => p === t) + if (exact) return exact + const suffixMatches = notePaths.filter((p) => p.endsWith('/' + t)) + if (suffixMatches.length === 1) return suffixMatches[0]! + } + + const base = t.split('/').pop() ?? t + const byBase = notePaths.filter((p) => (p.split('/').pop() ?? p) === base) + if (byBase.length === 1) return byBase[0]! + + const titleTarget = stripExt(base) + const byTitle = notePaths.filter((p) => { + const b = p.split('/').pop() ?? p + return stripExt(b) === titleTarget + }) + if (byTitle.length === 1) return byTitle[0]! + + return null +} + +/** Drop all cached previews. */ +export function invalidateAllExcalidrawPreviews(): void { + previewCache.clear() +} diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 420c912d..91e9d265 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -76,7 +76,7 @@ export const HELP_QUICK_START: HelpCard[] = [ { title: 'Use files without leaving ZenNotes', body: - 'Images, SVGs, PDFs, audio, video, and other local files can appear in the vault tree and open in ZenNotes tabs or reference panes. The files stay ordinary vault files, but opening them does not have to bounce you out to another app.' + 'Images, SVGs, PDFs, audio, video, Excalidraw drawings, and other local files can appear in the vault tree and open in ZenNotes tabs or reference panes. The files stay ordinary vault files, but opening them does not have to bounce you out to another app.' }, { title: 'Pick up where you left off', @@ -109,7 +109,12 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'Make and edit your own templates', body: - 'Open Settings → Templates. Press “New template” to author one: a template is just markdown with optional YAML frontmatter (`name`, `description`, `category`, `titleTemplate`, `targetFolder`, `targetSubpath`) and a body. Use the variables `{{title}}`, `{{date}}`, `{{date:YYYY-MM-DD}}` (any moment-style format), `{{time}}`, `{{week}}`, and `{{cursor}}` (where the caret lands). Custom templates are saved as plain `.md` files under `.zennotes/templates/`. You can also fork a built-in by pressing Edit on it — that creates an editable copy that shadows the original, and Reset restores the built-in. From any note, the “Save Current Note as Template…” command captures it as a new template.' + 'Open Settings → Templates. Press "New template" to author one: a template is just markdown with optional YAML frontmatter (`name`, `description`, `category`, `titleTemplate`, `targetFolder`, `targetSubpath`) and a body. Use the variables `{{title}}`, `{{date}}`, `{{date:YYYY-MM-DD}}` (any moment-style format), `{{time}}`, `{{week}}`, and `{{cursor}}` (where the caret lands). Custom templates are saved as plain `.md` files under `.zennotes/templates/`. You can also fork a built-in by pressing Edit on it — that creates an editable copy that shadows the original, and Reset restores the built-in. From any note, the "Save Current Note as Template…" command captures it as a new template.' + }, + { + title: 'Draw diagrams with Excalidraw', + body: + 'Run "New Drawing" from the command palette to create a `.excalidraw` file and open it in a built-in Excalidraw editor tab. Drawings are first-class vault files — they appear in the sidebar, can be moved and archived like notes, and auto-refresh when edited. To embed a drawing inside a note, use "Embed Existing Drawing…" (pick from a searchable list) or "Embed New Drawing" (create one at the cursor and switch to its editor). The embed syntax is `![[name.excalidraw]]`, the same as images, and supports optional size hints like `![[name.excalidraw|600]]` or `![[name.excalidraw|600x400]]`. Clicking an embed opens the drawing in a new tab. Obsidian-style `.excalidraw.md` files are also supported.' }, { title: 'Turn a CSV into a database', @@ -282,7 +287,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Links are actionable', body: - 'Use [[wikilinks]] or markdown links. Following a link — click it, Cmd/Ctrl-click it, or use the follow-link motion (`gd`) in normal mode — opens the note under the cursor and pins PDFs into the reference pane. If the note does not exist yet, following the link offers to create it (after you confirm) rather than leaving a dead link. Prefix a wikilink with `!` to embed rather than link: `![[Note]]` inlines the target note content in the reading view and PDF export — recursively, with cycle protection — so a master note can pull in sub-notes and export to PDF as one document. `![[image.png]]` embeds an image.' + 'Use [[wikilinks]] or markdown links. Following a link — click it, Cmd/Ctrl-click it, or use the follow-link motion (`gd`) in normal mode — opens the note under the cursor and pins PDFs into the reference pane. If the note does not exist yet, following the link offers to create it (after you confirm) rather than leaving a dead link. Prefix a wikilink with `!` to embed rather than link: `![[Note]]` inlines the target note content in the reading view and PDF export — recursively, with cycle protection — so a master note can pull in sub-notes and export to PDF as one document. `![[image.png]]` embeds an image, and `![[drawing.excalidraw]]` embeds an Excalidraw drawing as a PNG preview.' }, { title: 'Files stay local', @@ -299,6 +304,11 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ body: 'The `zn` command-line tool can list, read, search, capture, edit, archive, trash, inspect tasks, and start the MCP server without the app running. Raycast uses it for search, then uses `zennotes://open` and `zennotes://open-window` links to bring the selected note back into ZenNotes. On macOS, Settings → CLI can install the bundled Raycast extension locally so users do not need to wait for the Raycast Store version.' }, + { + title: 'Excalidraw drawings are first-class files', + body: + 'A `.excalidraw` file is a note type alongside Markdown and databases — listed in the sidebar, opened in a dedicated Excalidraw editor tab, and saved back as JSON. Embed one inside a note with `![[drawing.excalidraw]]` (same syntax as images, with optional `|width` or `|WxH` size hints). The embed renders as a PNG preview in both the editor and reading view, refreshes live when the drawing is edited, and opens in a new tab on click. Obsidian `.excalidraw.md` files are also recognized.' + }, { title: 'Math, diagrams, and plots render from plain fences', body: diff --git a/packages/app-core/src/lib/local-assets.ts b/packages/app-core/src/lib/local-assets.ts index 5b99c7f1..ae4b8f23 100644 --- a/packages/app-core/src/lib/local-assets.ts +++ b/packages/app-core/src/lib/local-assets.ts @@ -1,5 +1,6 @@ import { useStore } from '../store' import { externalLinkUrl } from './internal-links' +import { isExcalidrawPath, isObsidianExcalidrawPath } from '@shared/excalidraw' const IMAGE_EXTENSIONS = new Set([ '.apng', @@ -15,7 +16,7 @@ const PDF_EXTENSIONS = new Set(['.pdf']) const AUDIO_EXTENSIONS = new Set(['.aac', '.flac', '.m4a', '.mp3', '.ogg', '.wav']) const VIDEO_EXTENSIONS = new Set(['.m4v', '.mov', '.mp4', '.ogv', '.webm']) -export type LocalAssetKind = 'image' | 'pdf' | 'audio' | 'video' | 'file' +export type LocalAssetKind = 'image' | 'pdf' | 'audio' | 'video' | 'excalidraw' | 'file' function stripQueryAndHash(href: string): string { return href.split('#')[0]?.split('?')[0] ?? href @@ -61,6 +62,7 @@ function assetExtension(href: string): string { export function classifyLocalAssetHref(href: string): LocalAssetKind | null { if (!href || href.startsWith('#') || href.startsWith('//')) return null if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href)) return null + if (isExcalidrawPath(href) || isObsidianExcalidrawPath(href)) return 'excalidraw' const ext = assetExtension(href) if (IMAGE_EXTENSIONS.has(ext)) return 'image' if (PDF_EXTENSIONS.has(ext)) return 'pdf' @@ -232,7 +234,7 @@ function buildImageEmbed( } function buildEmbed( - kind: Exclude, + kind: Exclude, url: string, label: string, href: string, @@ -422,7 +424,7 @@ export function enhanceLocalAssetNodes( }) } - if (kind === 'file' || kind === 'image') return + if (kind === 'file' || kind === 'image' || kind === 'excalidraw') return const paragraph = isStandaloneAnchorParagraph(anchor) if (!paragraph || paragraph.dataset.assetEmbed === 'true') return diff --git a/packages/app-core/src/lib/markdown.test.ts b/packages/app-core/src/lib/markdown.test.ts index 70805c8c..d8eaec3e 100644 --- a/packages/app-core/src/lib/markdown.test.ts +++ b/packages/app-core/src/lib/markdown.test.ts @@ -57,6 +57,29 @@ describe('renderMarkdown', () => { expect(html).toContain('alt="CleanShot 2026-04-13 at 14.31.31@2x.png"') }) + it('renders excalidraw embeds as placeholder divs', () => { + const html = renderMarkdown('![[diagram.excalidraw]]') + + expect(html).toContain('data-excalidraw-embed="diagram.excalidraw"') + expect(html).toContain('class="excalidraw-embed-host"') + expect(html).not.toContain(' { + const html = renderMarkdown('![[diagram.excalidraw|600x400]]') + + expect(html).toContain('data-excalidraw-embed="diagram.excalidraw"') + expect(html).toContain('data-embed-width="600"') + expect(html).toContain('data-embed-height="400"') + }) + + it('renders excalidraw embeds without size hint when label is the target', () => { + const html = renderMarkdown('![[diagram.excalidraw]]') + + expect(html).not.toContain('data-embed-width') + expect(html).not.toContain('data-embed-height') + }) + it('renders ==text== as (and survives the sanitizer)', () => { expect(renderMarkdown('==highlighted==')).toContain('highlighted') const two = renderMarkdown('==a== and ==b==') diff --git a/packages/app-core/src/lib/markdown.ts b/packages/app-core/src/lib/markdown.ts index 151a9e31..92542122 100644 --- a/packages/app-core/src/lib/markdown.ts +++ b/packages/app-core/src/lib/markdown.ts @@ -15,6 +15,7 @@ import type { Root as MdRoot } from 'mdast' import type { Root as HastRoot, Element as HastElement } from 'hast' import { recordRendererPerf } from './perf' import { classifyLocalAssetHref } from './local-assets' +import { parseEmbedSizeHint } from './excalidraw-preview' import { parseColWidthsComment } from './markdown-table' /** @@ -31,6 +32,9 @@ const ALLOWED_RENDERED_URI_RE = const ALLOWED_RENDERED_DATA_ATTRS = [ 'data-callout', 'data-embed-src', + 'data-embed-height', + 'data-embed-width', + 'data-excalidraw-embed', 'data-function-plot-source', 'data-jsxgraph-source', 'data-local-asset-href', @@ -82,6 +86,16 @@ function remarkWikilinks() { alt: label } } + if (bang === '!' && assetKind === 'excalidraw') { + const size = parseEmbedSizeHint(label) + const w = size?.width ? ` data-embed-width="${size.width}"` : '' + const h = size?.height ? ` data-embed-height="${size.height}"` : '' + const safeTarget = target.replace(/"/g, '"') + return { + type: 'html', + value: `
` + } + } if (bang === '!' && assetKind) { return { type: 'link', diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 93f686d9..b904b839 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -24,7 +24,7 @@ import type { WorkspaceMode } from '@shared/ipc' import type { VaultTask } from '@shared/tasks' -import { isExcalidrawPath } from '@shared/excalidraw' +import { isExcalidrawPath, isObsidianExcalidrawPath } from '@shared/excalidraw' import { TASKS_TAB_PATH, isTasksTabPath, parseTasksFromBody } from '@shared/tasks' import type { DatabaseDoc, DatabaseSidecar } from '@shared/databases' import { @@ -44,6 +44,7 @@ import { TRASH_TAB_PATH, isTrashTabPath } from '@shared/trash' import { ASSETS_VIEW_TAB_PATH, isAssetsViewTabPath } from '@shared/assets-view' import { QUICK_NOTES_TAB_PATH, isQuickNotesTabPath } from '@shared/quick-notes' import { isAssetTabPath, assetPathFromTab, assetTabPath } from './lib/asset-tabs' +import { invalidateExcalidrawPreview } from './lib/excalidraw-preview' import { FENCE_RE, TASK_LINE_RE, @@ -2003,6 +2004,11 @@ interface Store { bufferPaletteOpen: boolean outlinePaletteOpen: boolean templatePaletteOpen: boolean + /** "Embed existing drawing" picker visibility. */ + embedDrawingPaletteOpen: boolean + /** Bumped whenever an Excalidraw drawing changes on disk so embed widgets + * and preview components invalidate their cached PNG and re-render. */ + excalidrawPreviewVersion: number /** 'create' makes a new note from the picked template; 'insert' renders it * into the active note instead. */ templatePaletteMode: 'create' | 'insert' @@ -2458,6 +2464,14 @@ interface Store { openThisWeekWeeklyNote: () => Promise openThisMonthMonthlyNote: () => Promise setTemplatePaletteOpen: (open: boolean) => void + setEmbedDrawingPaletteOpen: (open: boolean) => void + /** Create a new Excalidraw drawing and open it in a dedicated tab. */ + newDrawing: () => Promise + /** Create a new Excalidraw drawing, embed it at the cursor in the active + * note, then switch focus to the new drawing's editor tab. */ + embedNewDrawing: () => Promise + /** Insert a `![[path]]` embed at the cursor in the active note. */ + insertEmbedAtCursor: (embed: string) => void /** Open the template picker scoped to a folder; the chosen template is * created there directly (no destination prompt). */ openTemplatePaletteForFolder: (folder: NoteFolder, subpath: string) => void @@ -3499,6 +3513,8 @@ export const useStore = create((set, get) => { bufferPaletteOpen: false, outlinePaletteOpen: false, templatePaletteOpen: false, + embedDrawingPaletteOpen: false, + excalidrawPreviewVersion: 0, templatePaletteMode: 'create', templatePaletteTarget: null, customTemplates: [], @@ -4678,6 +4694,12 @@ export const useStore = create((set, get) => { await get().refreshAssets() return } + // An Excalidraw drawing changed on disk — drop its cached PNG preview + // and bump the version so editor widgets and preview embeds re-render. + if (isExcalidrawPath(ev.path) || isObsidianExcalidrawPath(ev.path)) { + invalidateExcalidrawPreview(ev.path) + set({ excalidrawPreviewVersion: get().excalidrawPreviewVersion + 1 }) + } await Promise.all([ refreshNotesCoalesced(), ev.scope === 'vault-settings' @@ -4997,6 +5019,42 @@ export const useStore = create((set, get) => { } }, + insertEmbedAtCursor: (embed) => { + const state = get() + const view = state.editorViewRef + if (!view) return + const { from, to } = view.state.selection.main + view.dispatch({ + changes: { from, to, insert: embed }, + selection: { anchor: from + embed.length }, + scrollIntoView: true + }) + view.focus() + }, + + newDrawing: async () => { + try { + const meta = await window.zen.createExcalidraw('inbox', '') + await get().refreshNotes() + await get().openNoteInTab(meta.path) + } catch (err) { + console.error('newDrawing failed', err) + } + }, + + embedNewDrawing: async () => { + try { + const meta = await window.zen.createExcalidraw('inbox', '') + if (get().activeNote) { + get().insertEmbedAtCursor(`![[${meta.path}]]\n`) + } + await get().refreshNotes() + await get().openNoteInTab(meta.path) + } catch (err) { + console.error('embedNewDrawing failed', err) + } + }, + createNoteInChosenFolder: async (opts) => { const state = get() const entered = await promptApp( @@ -5243,6 +5301,7 @@ export const useStore = create((set, get) => { commandPaletteInitialMode: open ? mode : 'main' }), setBufferPaletteOpen: (open) => set({ bufferPaletteOpen: open }), + setEmbedDrawingPaletteOpen: (open) => set({ embedDrawingPaletteOpen: open }), setOutlinePaletteOpen: (open) => set({ outlinePaletteOpen: open }), setQuery: (q) => set({ query: q }), toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })), diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index 78a82380..d39576fd 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -2924,6 +2924,47 @@ textarea, line-height: 1.4; } +/* --- Excalidraw embed (CodeMirror live preview + preview pane) --------- */ +.cm-editor .cm-excalidraw-embed { + display: block; + width: 100%; + max-width: min(100%, 980px); + margin: 0.5rem 0 0.6rem; +} + +:is(.prose-zen, .cm-editor) .excalidraw-embed-image { + max-width: 100%; + height: auto; + border-radius: 0.5rem; + display: block; + object-fit: contain; +} + +.excalidraw-embed-host { + margin: 0.75rem 0; +} + +.excalidraw-embed-preview { + cursor: pointer; +} + +.excalidraw-embed-loading { + min-height: 80px; + border-radius: 0.5rem; + background: theme("colors.paper.200"); + animation: excalidraw-embed-pulse 1.4s ease-in-out infinite; +} + +@keyframes excalidraw-embed-pulse { + 0%, + 100% { + opacity: 0.6; + } + 50% { + opacity: 0.9; + } +} + /* --- PDF embed widget (CodeMirror live preview) ---------------------- */ .cm-editor .cm-local-pdf-embed, .cm-editor .local-pdf-embed {