From 0866ebcc34f506ffafcc1866451a24f61c75899a Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Sun, 4 Jan 2026 20:10:55 -0300 Subject: [PATCH 1/7] feat: resizable layout, image embeds in editor, and responsive preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add resizable sidebar and notelist panels with drag handles - Persist panel widths to localStorage - Replace react-resizable-panels with pure CSS flexbox + custom hook - Add inline image preview in CodeMirror editor for embeds - Add ImageLightbox component for fullscreen image viewing - Make preview metadata header responsive with container queries - Reduce typography and spacing for narrow preview panels 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/package.json | 6 +- apps/desktop/src/main/index.ts | 172 +++++++++++++++++- apps/desktop/src/preload/index.ts | 19 ++ apps/desktop/src/renderer/App.tsx | 58 +++--- .../src/renderer/components/ImageLightbox.tsx | 123 +++++++++++++ .../renderer/components/MarkdownEditor.tsx | 82 ++++++++- .../src/renderer/components/NoteEditor.tsx | 62 +++++++ .../components/editor/MarkdownPreview.tsx | 167 ++++++++++++----- .../src/renderer/hooks/useResizableLayout.ts | 113 ++++++++++++ apps/desktop/src/renderer/index.html | 2 +- apps/desktop/src/renderer/styles/global.css | 67 ++++--- apps/desktop/src/renderer/styles/lightbox.css | 95 ++++++++++ apps/desktop/src/renderer/styles/preview.css | 74 +++++--- packages/embeds/package.json | 14 ++ .../embeds/src/adapters/codemirror/index.ts | 5 + .../src/adapters/codemirror/inline-preview.ts | 111 +++++++++++ .../src/adapters/remark/remark-embed.ts | 128 ++----------- packages/storage-core/src/data/DataPaths.ts | 4 + .../src/adapters/remark/remark-wikilink.ts | 3 +- pnpm-lock.yaml | 103 ++++++++++- 20 files changed, 1166 insertions(+), 242 deletions(-) create mode 100644 apps/desktop/src/renderer/components/ImageLightbox.tsx create mode 100644 apps/desktop/src/renderer/hooks/useResizableLayout.ts create mode 100644 apps/desktop/src/renderer/styles/lightbox.css create mode 100644 packages/embeds/src/adapters/codemirror/index.ts create mode 100644 packages/embeds/src/adapters/codemirror/inline-preview.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 219686f..7996bf3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -39,12 +39,12 @@ "@readied/commands": "workspace:*", "@readied/core": "workspace:*", "@readied/embeds": "workspace:*", - "@readied/tasks": "workspace:*", - "@readied/wikilinks": "workspace:*", "@readied/licensing": "workspace:*", "@readied/product-config": "workspace:*", "@readied/storage-core": "workspace:*", "@readied/storage-sqlite": "workspace:*", + "@readied/tasks": "workspace:*", + "@readied/wikilinks": "workspace:*", "@tanstack/react-query": "^5.90.16", "better-sqlite3": "^11.7.0", "electron-updater": "^6.6.2", @@ -65,10 +65,12 @@ "@vitejs/plugin-react": "^4.2.1", "electron": "^29.1.4", "electron-builder": "^26.0.12", + "electron-devtools-installer": "^4.0.0", "electron-vite": "^2.1.0", "pino-pretty": "^13.1.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "rehype-raw": "^7.0.0", "typescript": "^5.7.2", "vite": "^5.4.11", "vitest": "^2.1.8" diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index b225b5f..6e2e3fa 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -4,11 +4,12 @@ * Initializes the app, database, and IPC handlers. */ -import { join } from 'path'; -import { readFile, writeFile, unlink } from 'fs/promises'; +import { join, normalize } from 'path'; +import { readFile, writeFile, unlink, mkdir } from 'fs/promises'; import { existsSync } from 'fs'; -import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'; +import { app, BrowserWindow, ipcMain, dialog, shell, protocol } from 'electron'; import { autoUpdater } from 'electron-updater'; +import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer'; import { runMigrations, createDataPaths, @@ -434,6 +435,118 @@ function registerIpcHandlers(): void { return repo.getOutgoingLinks(createNoteId(noteId)); }); + // ═══════════════════════════════════════════════════════════════════════════ + // Embeds (File Resolution) + // ═══════════════════════════════════════════════════════════════════════════ + + // Resolve embed target to asset:// URL + ipcMain.handle('embeds:resolve', async (_event, target: string, noteId: string) => { + if (!dataPaths) return null; + + // Build path to note's assets folder: /assets/{noteId}/{target} + const assetPath = join(dataPaths.assets, noteId, target); + + // Check if file exists + if (existsSync(assetPath)) { + // Return asset:// URL with host (required for browser to recognize protocol) + return `asset://local/${noteId}/${target}`; + } + + // File not found + return null; + }); + + // Batch resolve multiple embed targets (more efficient) + ipcMain.handle( + 'embeds:resolveBatch', + async (_event, targets: string[], noteId: string): Promise> => { + if (!dataPaths) return {}; + + const result: Record = {}; + for (const target of targets) { + const assetPath = join(dataPaths.assets, noteId, target); + // Return asset:// URL with host (required for browser to recognize protocol) + result[target] = existsSync(assetPath) ? `asset://local/${noteId}/${target}` : null; + } + return result; + } + ); + + // Save asset (image/file) for a note via drag & drop or paste + ipcMain.handle( + 'embeds:saveAsset', + async ( + _event, + noteId: string, + mime: string, + bytes: ArrayBuffer, + originalName?: string + ): Promise<{ ok: true; filename: string; relPath: string } | { ok: false; error: string }> => { + if (!dataPaths) { + return { ok: false, error: 'Data paths not initialized' }; + } + + // Validate noteId (non-empty, alphanumeric with hyphens/underscores) + if (!noteId || !/^[\w-]+$/.test(noteId)) { + return { ok: false, error: 'Invalid noteId' }; + } + + // Validate size (max 20MB) + const MAX_SIZE = 20 * 1024 * 1024; + if (bytes.byteLength > MAX_SIZE) { + return { ok: false, error: 'File too large (max 20MB)' }; + } + + // Derive extension from mime type + const mimeToExt: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg+xml': 'svg', + 'image/bmp': 'bmp', + 'image/ico': 'ico', + 'video/mp4': 'mp4', + 'video/webm': 'webm', + 'video/quicktime': 'mov', + 'audio/mpeg': 'mp3', + 'audio/wav': 'wav', + 'audio/ogg': 'ogg', + 'application/pdf': 'pdf', + }; + + let ext = mimeToExt[mime]; + if (!ext && originalName) { + // Fallback to originalName extension + const match = originalName.match(/\.([a-zA-Z0-9]+)$/); + ext = match?.[1]?.toLowerCase() ?? 'bin'; + } + if (!ext) { + ext = 'bin'; + } + + // Generate unique filename: timestamp-random.ext + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + const filename = `${timestamp}-${random}.${ext}`; + + // Ensure note's assets directory exists + const noteAssetsDir = join(dataPaths.assets, noteId); + await mkdir(noteAssetsDir, { recursive: true }); + + // Write file + const assetPath = join(noteAssetsDir, filename); + await writeFile(assetPath, Buffer.from(bytes)); + + return { + ok: true, + filename, + relPath: `${noteId}/${filename}`, + }; + } + ); + // Count notes ipcMain.handle('notes:count', async () => { // Get all notes to compute counts @@ -960,6 +1073,30 @@ function initAutoUpdater(): void { }, 3000); } +/** + * asset:// protocol + * + * Invariant: + * - Renderer NEVER accesses filesystem paths directly + * - All local assets are resolved via asset:// URLs + * + * Rationale: + * - Avoids file:// which is blocked in dev (http://localhost) + * - Same behavior in dev and production + * - Enables secure embeds (images, video, pdf) + */ +protocol.registerSchemesAsPrivileged([ + { + scheme: 'asset', + privileges: { + secure: true, + standard: true, + supportFetchAPI: true, + stream: true, + }, + }, +]); + // App lifecycle app .whenReady() @@ -967,6 +1104,28 @@ app // Initialize data paths first (creates directories) dataPaths = initDataPaths(); + // Register asset:// protocol handler + protocol.registerFileProtocol('asset', (request, callback) => { + // asset://local/noteId/filename → assets/noteId/filename + // Strip protocol and host (local/) + let urlPath = decodeURIComponent(request.url.slice('asset://'.length)); + if (urlPath.startsWith('local/')) { + urlPath = urlPath.slice('local/'.length); + } + + // Sanitize: prevent path traversal attacks + const safePath = normalize(urlPath).replace(/^(\.\.[/\\])+/, ''); + + const filePath = join(dataPaths!.assets, safePath); + + if (!existsSync(filePath)) { + callback({ error: -6 }); // FILE_NOT_FOUND + return; + } + + callback({ path: filePath }); + }); + // Initialize logger (must be after dataPaths) const log = initLogger({ logsDir: dataPaths.logs, @@ -987,6 +1146,13 @@ app registerLogHandlers(); log.info('All IPC handlers registered'); + // Install React DevTools in development + if (process.env.NODE_ENV === 'development') { + installExtension(REACT_DEVELOPER_TOOLS) + .then(name => log.info({ extension: name }, 'DevTools extension installed')) + .catch(err => log.warn({ error: err.message }, 'Failed to install DevTools extension')); + } + // Create window and start auto-updater createWindow(); initAutoUpdater(); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 040de18..69d6d12 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -282,6 +282,19 @@ export interface ReadiedAPI { /** Get outgoing links (notes this note links TO) */ getOutgoing: (noteId: string) => Promise; }; + embeds: { + /** Resolve embed target to file:// URL (returns null if not found) */ + resolve: (target: string, noteId: string) => Promise; + /** Batch resolve multiple embed targets (more efficient) */ + resolveBatch: (targets: string[], noteId: string) => Promise>; + /** Save asset (image/file) for a note via drag & drop or paste */ + saveAsset: ( + noteId: string, + mime: string, + bytes: ArrayBuffer, + originalName?: string + ) => Promise<{ ok: true; filename: string; relPath: string } | { ok: false; error: string }>; + }; } // Expose the API @@ -361,6 +374,12 @@ const api: ReadiedAPI = { getBacklinks: noteId => ipcRenderer.invoke('links:backlinks', noteId), getOutgoing: noteId => ipcRenderer.invoke('links:outgoing', noteId), }, + embeds: { + resolve: (target, noteId) => ipcRenderer.invoke('embeds:resolve', target, noteId), + resolveBatch: (targets, noteId) => ipcRenderer.invoke('embeds:resolveBatch', targets, noteId), + saveAsset: (noteId, mime, bytes, originalName) => + ipcRenderer.invoke('embeds:saveAsset', noteId, mime, bytes, originalName), + }, }; contextBridge.exposeInMainWorld('readied', api); diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index fe524c5..f767383 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { Group, Panel, Separator, useDefaultLayout } from 'react-resizable-panels'; import type { NoteSnapshot, NoteStatus } from '../preload/index'; import { NoteList } from './components/NoteList'; import { NoteEditor } from './components/NoteEditor'; @@ -22,6 +21,7 @@ import { useSyncLinks } from './hooks/useLinks'; import { useEditorPreferencesStore } from './stores/editorPreferencesStore'; import { useTagColorsStore } from './stores/tagColorsStore'; import { usePerformanceMode } from './hooks/usePerformanceMode'; +import { useResizableLayout } from './hooks/useResizableLayout'; const queryClient = new QueryClient({ defaultOptions: { @@ -34,19 +34,12 @@ const queryClient = new QueryClient({ /** * Main Notes Application - * - * Uses Zustand for navigation state (no Provider needed). - * All filtering is derived via useFilteredNotes hook. */ function NotesApp() { - // Initialize performance mode (glass/blur tuning) usePerformanceMode(); - // Layout persistence - const { defaultLayout, onLayoutChange } = useDefaultLayout({ - id: 'readied-main-layout', - storage: localStorage, - }); + // Resizable layout + const { sidebarWidth, notelistWidth, startResizeSidebar, startResizeNotelist } = useResizableLayout(); // Navigation state from Zustand const navigation = useNavigation(); @@ -302,22 +295,18 @@ function NotesApp() { return (
- - {/* Sidebar Panel */} - +
+ +
+ +
- - - - - {/* Editor Panel - elastic, takes remaining space */} - +
+
+ +
- - +
+
); diff --git a/apps/desktop/src/renderer/components/ImageLightbox.tsx b/apps/desktop/src/renderer/components/ImageLightbox.tsx new file mode 100644 index 0000000..3e397b6 --- /dev/null +++ b/apps/desktop/src/renderer/components/ImageLightbox.tsx @@ -0,0 +1,123 @@ +/** + * ImageLightbox - Fullscreen image viewer + * + * Opens when clicking on embedded images in the preview. + * Supports zoom and pan. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { X, ZoomIn, ZoomOut, RotateCw } from 'lucide-react'; + +interface ImageLightboxProps { + readonly src: string; + readonly alt?: string; + readonly onClose: () => void; +} + +export function ImageLightbox({ src, alt, onClose }: ImageLightboxProps) { + const [scale, setScale] = useState(1); + const [rotation, setRotation] = useState(0); + + // Handle keyboard + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } else if (e.key === '+' || e.key === '=') { + setScale(s => Math.min(s + 0.25, 4)); + } else if (e.key === '-') { + setScale(s => Math.max(s - 0.25, 0.25)); + } else if (e.key === 'r') { + setRotation(r => (r + 90) % 360); + } else if (e.key === '0') { + setScale(1); + setRotation(0); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + // Prevent body scroll while open + useEffect(() => { + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = ''; + }; + }, []); + + const handleBackdropClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }, + [onClose] + ); + + const zoomIn = useCallback(() => setScale(s => Math.min(s + 0.25, 4)), []); + const zoomOut = useCallback(() => setScale(s => Math.max(s - 0.25, 0.25)), []); + const rotate = useCallback(() => setRotation(r => (r + 90) % 360), []); + + return ( +
+ {/* Controls */} +
+ + {Math.round(scale * 100)}% + + + +
+ + {/* Image */} +
+ {alt +
+ + {/* Filename */} + {alt &&
{alt}
} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/MarkdownEditor.tsx b/apps/desktop/src/renderer/components/MarkdownEditor.tsx index 3119a11..f52b4e7 100644 --- a/apps/desktop/src/renderer/components/MarkdownEditor.tsx +++ b/apps/desktop/src/renderer/components/MarkdownEditor.tsx @@ -3,7 +3,7 @@ */ import { useEffect, useRef, useCallback, forwardRef, useImperativeHandle, useMemo } from 'react'; -import { EditorState, type Extension } from '@codemirror/state'; +import { EditorState, EditorSelection, type Extension } from '@codemirror/state'; import { EditorView, keymap, @@ -44,6 +44,7 @@ import { setCurrentNoteId, currentNoteIdField, } from '@readied/wikilinks'; +import { embedInlinePreview } from '@readied/embeds/codemirror'; /** Dark theme matching Readied's design */ const darkTheme = EditorView.theme( @@ -177,6 +178,8 @@ interface MarkdownEditorProps { onReady?: () => void; /** Current note ID (for excluding from wikilink autocomplete) */ noteId?: string; + /** Callback to get resolved embed URL (for inline image preview) */ + getEmbedUrl?: (target: string) => string | null; } /** Imperative handle exposed via ref */ @@ -206,12 +209,14 @@ export interface MarkdownEditorHandle { export const MarkdownEditor = forwardRef( function MarkdownEditor( - { initialContent, onChange, placeholder = 'Start writing...', onReady, noteId }, + { initialContent, onChange, placeholder = 'Start writing...', onReady, noteId, getEmbedUrl }, ref ) { const containerRef = useRef(null); const viewRef = useRef(null); const onChangeRef = useRef(onChange); + const noteIdRef = useRef(noteId); + const getEmbedUrlRef = useRef(getEmbedUrl); // Create wikilink autocomplete extension with injected dependencies const wikilinkAutocomplete = useMemo( @@ -314,8 +319,10 @@ export const MarkdownEditor = forwardRef { @@ -360,6 +367,9 @@ export const MarkdownEditor = forwardRef getEmbedUrlRef.current?.(target) ?? null), + // Dark theme darkTheme, @@ -398,7 +408,73 @@ export const MarkdownEditor = forwardRef { + const file = e.dataTransfer?.files[0]; + if (!file?.type.startsWith('image/')) return; + const currentNoteId = noteIdRef.current; + if (!currentNoteId) return; + + e.preventDefault(); + + const bytes = await file.arrayBuffer(); + const result = await window.readied.embeds.saveAsset( + currentNoteId, + file.type, + bytes, + file.name + ); + if (!result.ok) { + console.error('Failed to save asset:', result.error); + return; + } + + const embed = `![[${result.filename}]]`; + const pos = view.state.selection.main.head; + view.dispatch({ + changes: { from: pos, insert: embed }, + selection: EditorSelection.cursor(pos + embed.length), + userEvent: 'input.drop', + }); + }; + + // Handle image paste (Cmd+V) + const handlePaste = async (e: ClipboardEvent) => { + const items = Array.from(e.clipboardData?.items || []); + const imageItem = items.find(i => i.type.startsWith('image/')); + + if (!imageItem) return; // Let default paste handle text + const currentNoteId = noteIdRef.current; + if (!currentNoteId) return; + + e.preventDefault(); + const blob = imageItem.getAsFile(); + if (!blob) return; + + const bytes = await blob.arrayBuffer(); + const result = await window.readied.embeds.saveAsset(currentNoteId, blob.type, bytes); + if (!result.ok) { + console.error('Failed to save asset:', result.error); + return; + } + + const embed = `![[${result.filename}]]`; + const pos = view.state.selection.main.head; + view.dispatch({ + changes: { from: pos, insert: embed }, + selection: EditorSelection.cursor(pos + embed.length), + userEvent: 'input.paste', + }); + }; + + // Add event listeners to the editor DOM + const dom = view.dom; + dom.addEventListener('drop', handleDrop); + dom.addEventListener('paste', handlePaste); + return () => { + dom.removeEventListener('drop', handleDrop); + dom.removeEventListener('paste', handlePaste); view.destroy(); viewRef.current = null; }; diff --git a/apps/desktop/src/renderer/components/NoteEditor.tsx b/apps/desktop/src/renderer/components/NoteEditor.tsx index dafde9e..6133ab7 100644 --- a/apps/desktop/src/renderer/components/NoteEditor.tsx +++ b/apps/desktop/src/renderer/components/NoteEditor.tsx @@ -10,6 +10,8 @@ import { MarkdownPreview, } from './editor'; import { BacklinksPanel } from './editor/BacklinksPanel'; +import { ImageLightbox } from './ImageLightbox'; +import { extractEmbedTargets } from '@readied/embeds'; import type { MarkdownPreviewHandle, ToolbarVisibility } from './editor'; import type { MarkdownEditorHandle } from './MarkdownEditor'; import type { NoteSnapshot, NoteStatus } from '../../preload/index'; @@ -76,6 +78,12 @@ export function NoteEditor({ const { data: backlinks } = useBacklinks(note?.id ?? null); const backlinksCount = backlinks?.length ?? 0; + // Lightbox state for embedded images + const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null); + + // Resolved embeds state (shared between editor and preview) + const [resolvedEmbeds, setResolvedEmbeds] = useState>({}); + // Toolbar visibility state (for passing to ActionsPanel) const [toolbarVisibility, setToolbarVisibility] = useState({ text: true, @@ -116,6 +124,47 @@ export function NoteEditor({ }; }, [note?.id]); + // Resolve embeds for sharing between editor and preview + useEffect(() => { + if (!note) { + setResolvedEmbeds({}); + return; + } + + const targets = extractEmbedTargets(note.content); + if (targets.length === 0) { + setResolvedEmbeds({}); + return; + } + + // Only resolve local targets (external URLs don't need IPC) + const localTargets = targets.filter( + t => !t.startsWith('http://') && !t.startsWith('https://') + ); + + if (localTargets.length === 0) { + // All external, no IPC needed + setResolvedEmbeds({}); + return; + } + + window.readied.embeds.resolveBatch(localTargets, note.id).then(result => { + setResolvedEmbeds(result); + }); + }, [note?.id, note?.content]); + + // Callback for editor to get resolved embed URLs + const getEmbedUrl = useCallback( + (target: string): string | null => { + // External URLs return themselves + if (target.startsWith('http://') || target.startsWith('https://')) { + return target; + } + return resolvedEmbeds[target] ?? null; + }, + [resolvedEmbeds] + ); + const showEditor = viewMode === 'editor' || viewMode === 'split'; const showPreview = viewMode === 'preview' || viewMode === 'split'; const isSplitMode = viewMode === 'split'; @@ -280,6 +329,7 @@ export function NoteEditor({ onChange={handleChange} onReady={handleEditorReady} noteId={note.id} + getEmbedUrl={getEmbedUrl} />
@@ -294,10 +344,13 @@ export function NoteEditor({ setLightbox({ src: url, alt: target })} + resolvedEmbeds={resolvedEmbeds} />
)} @@ -326,6 +379,15 @@ export function NoteEditor({ noteId={note.id} onNavigateToNote={onNavigateToNote ?? (() => {})} /> + + {/* Image Lightbox */} + {lightbox && ( + setLightbox(null)} + /> + )} ); } diff --git a/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx b/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx index 0b17922..34b09ef 100644 --- a/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx +++ b/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx @@ -1,18 +1,28 @@ -import { useMemo, useRef, useImperativeHandle, forwardRef, useEffect, useCallback } from 'react'; +import { useMemo, useRef, useImperativeHandle, forwardRef, useEffect, useState } from 'react'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; import { Clock, CalendarPlus, ListChecks } from 'lucide-react'; import { remarkWikilink } from '@readied/wikilinks'; -import { remarkEmbed } from '@readied/embeds'; +import { extractEmbedTargets } from '@readied/embeds'; import { countMarkdownTasks } from '@readied/tasks'; import { formatDateTime } from '../../utils/date'; +/** Escape special regex characters in a string */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + interface MarkdownPreviewProps { readonly content: string; + readonly noteId: string; readonly createdAt?: string; readonly updatedAt?: string; readonly onReady?: () => void; readonly onWikilinkClick?: (target: string) => void; + readonly onEmbedClick?: (target: string, url: string) => void; + /** Optional pre-resolved embeds from parent (for sharing with editor) */ + readonly resolvedEmbeds?: Record; } /** Imperative handle for scroll sync */ @@ -23,37 +33,109 @@ export interface MarkdownPreviewHandle { canScroll: () => boolean; } -/** - * MarkdownPreview - Renders markdown content as HTML - * - * Uses react-markdown with GFM (GitHub Flavored Markdown) support. - * Exposes scroll methods via ref for sync with editor. - */ export const MarkdownPreview = forwardRef( - function MarkdownPreview({ content, createdAt, updatedAt, onReady, onWikilinkClick }, ref) { + function MarkdownPreview( + { + content, + noteId, + createdAt, + updatedAt, + onReady, + onWikilinkClick, + onEmbedClick, + resolvedEmbeds: resolvedEmbedsProp, + }, + ref + ) { const containerRef = useRef(null); + const [internalResolvedEmbeds, setInternalResolvedEmbeds] = useState< + Record + >({}); - // Delegated click handler for wikilinks - const handleClick = useCallback( - (e: React.MouseEvent) => { - const target = (e.target as HTMLElement).closest('.wikilink'); - if (target) { - const noteTitle = target.getAttribute('data-target'); - if (noteTitle && onWikilinkClick) { - e.preventDefault(); - onWikilinkClick(noteTitle); - } + // Use prop if provided, otherwise internal state + const resolvedEmbeds = resolvedEmbedsProp ?? internalResolvedEmbeds; + + // Resolve embeds via IPC (only if not using prop) + useEffect(() => { + // Skip if parent is managing resolved embeds + if (resolvedEmbedsProp !== undefined) return; + + const targets = extractEmbedTargets(content); + if (targets.length === 0) { + setInternalResolvedEmbeds({}); + return; + } + window.readied.embeds.resolveBatch(targets, noteId).then(result => { + setInternalResolvedEmbeds(result); + }); + }, [content, noteId, resolvedEmbedsProp]); + + // Invariant: + // Never normalize embeds to markdown images until all URLs are resolved. + // Violating this produces and broken previews. + const resolvedContent = useMemo(() => { + const targets = extractEmbedTargets(content); + if (targets.length === 0) return content; + + // Check if all LOCAL targets are resolved (external URLs don't need IPC) + const localTargets = targets.filter( + (t) => !t.startsWith('http://') && !t.startsWith('https://') + ); + const allLocalResolved = + localTargets.length === 0 || + localTargets.every((t) => resolvedEmbeds[t] != null); + + if (!allLocalResolved) { + return content; // Wait for IPC to resolve local files + } + + let result = content; + for (const target of targets) { + // External URLs use themselves, local files use resolved asset:// URL + const isExternal = target.startsWith('http://') || target.startsWith('https://'); + const url = isExternal ? target : resolvedEmbeds[target]; + + if (!url) continue; + + const pattern = new RegExp( + `!\\[\\[${escapeRegex(target)}(?:\\|([^\\]]+))?\\]\\]`, + 'g' + ); + // Use custom HTML element to bypass rehype URL sanitization + result = result.replace( + pattern, + (_, display) => `` + ); + } + return result; + }, [content, resolvedEmbeds]); + + // Click handler for wikilinks and embeds + const handleClick = (e: React.MouseEvent) => { + const wikilinkEl = (e.target as HTMLElement).closest('.wikilink'); + if (wikilinkEl) { + const noteTitle = wikilinkEl.getAttribute('data-target'); + if (noteTitle && onWikilinkClick) { + e.preventDefault(); + onWikilinkClick(noteTitle); } - }, - [onWikilinkClick] - ); + return; + } + + const imgEl = e.target as HTMLElement; + if (imgEl.tagName === 'IMG') { + const src = imgEl.getAttribute('src'); + if (src?.startsWith('asset://') && onEmbedClick) { + e.preventDefault(); + onEmbedClick(src, src); + } + } + }; - // Notify parent when mounted useEffect(() => { onReady?.(); }, []); - // Expose scroll methods via ref useImperativeHandle(ref, () => ({ getScrollFraction: () => { const el = containerRef.current; @@ -85,18 +167,16 @@ export const MarkdownPreview = forwardRef countMarkdownTasks(content), [content]); const hasProgress = tasks.total > 0; const progressPercent = hasProgress ? (tasks.completed / tasks.total) * 100 : 0; return (
- {/* Metadata header - Inkdrop style */}
{hasProgress && (
-