diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ff50607..baffa35 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1538,6 +1538,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -3563,6 +3569,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "http-range", "jni", "libc", "log", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fee5a0f..6dfab0d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["protocol-asset"] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f46498f..6c593e3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -18,7 +18,13 @@ } ], "security": { - "csp": null + "csp": null, + "assetProtocol": { + "enable": true, + "scope": { + "allow": ["**"] + } + } } }, "bundle": { diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 40fe1da..13df897 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -3,6 +3,7 @@ import { vi } from 'vitest' // Mock @tauri-apps/api/core vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn().mockResolvedValue(''), + convertFileSrc: vi.fn((path: string) => `http://asset.localhost/${path}`), })) // Mock @tauri-apps/plugin-dialog diff --git a/src/__tests__/utils/imagePathResolver.test.ts b/src/__tests__/utils/imagePathResolver.test.ts new file mode 100644 index 0000000..0085ad0 --- /dev/null +++ b/src/__tests__/utils/imagePathResolver.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, vi } from 'vitest' +import { + resolveImagePaths, + unresolveImagePaths, + resolveRelativeSrc, + getDocumentDir, + isAbsoluteSrc, +} from '../../utils/imagePathResolver' + +// Mock convertFileSrc to simulate Tauri's asset protocol +vi.mock('@tauri-apps/api/core', async () => { + const actual = await vi.importActual>('@tauri-apps/api/core') + return { + ...actual, + invoke: vi.fn().mockResolvedValue(''), + convertFileSrc: vi.fn((path: string) => `http://asset.localhost/${path}`), + } +}) + +describe('getDocumentDir', () => { + it('returns directory for a file path', () => { + expect(getDocumentDir('/Users/foo/notes/doc.md')).toBe('/Users/foo/notes') + }) + + it('returns directory for nested path', () => { + expect(getDocumentDir('/a/b/c/file.txt')).toBe('/a/b/c') + }) + + it('returns null for empty string', () => { + expect(getDocumentDir('')).toBeNull() + }) + + it('returns null for null/undefined', () => { + expect(getDocumentDir(null)).toBeNull() + expect(getDocumentDir(undefined)).toBeNull() + }) +}) + +describe('isAbsoluteSrc', () => { + it('returns true for http URLs', () => { + expect(isAbsoluteSrc('http://example.com/img.png')).toBe(true) + expect(isAbsoluteSrc('https://example.com/img.png')).toBe(true) + }) + + it('returns true for data URLs', () => { + expect(isAbsoluteSrc('data:image/png;base64,abc')).toBe(true) + }) + + it('returns true for blob URLs', () => { + expect(isAbsoluteSrc('blob:http://localhost/abc')).toBe(true) + }) + + it('returns true for absolute file paths', () => { + expect(isAbsoluteSrc('/Users/foo/photo.png')).toBe(true) + }) + + it('returns false for relative paths', () => { + expect(isAbsoluteSrc('assets/photo.png')).toBe(false) + expect(isAbsoluteSrc('images/2024/photo.png')).toBe(false) + expect(isAbsoluteSrc('photo.png')).toBe(false) + }) +}) + +describe('resolveRelativeSrc', () => { + const docDir = '/Users/foo/notes' + + it('resolves a relative path to an asset URL', () => { + expect(resolveRelativeSrc('assets/photo.png', docDir)).toBe( + 'http://asset.localhost//Users/foo/notes/assets/photo.png', + ) + }) + + it('returns absolute URLs unchanged', () => { + expect(resolveRelativeSrc('https://example.com/img.png', docDir)).toBe( + 'https://example.com/img.png', + ) + expect(resolveRelativeSrc('data:image/png;base64,abc', docDir)).toBe( + 'data:image/png;base64,abc', + ) + }) + + it('returns empty string unchanged', () => { + expect(resolveRelativeSrc('', docDir)).toBe('') + }) + + it('returns src unchanged when docDir is empty', () => { + expect(resolveRelativeSrc('assets/photo.png', '')).toBe('assets/photo.png') + }) +}) + +describe('resolveImagePaths', () => { + const docDir = '/Users/foo/notes' + + it('resolves relative image src to asset URL', () => { + const html = '' + const result = resolveImagePaths(html, docDir) + expect(result).toBe( + '', + ) + }) + + it('resolves multiple images', () => { + const html = '

text

' + const result = resolveImagePaths(html, docDir) + expect(result).toContain('data-original-src="assets/a.png"') + expect(result).toContain('data-original-src="assets/b.jpg"') + expect(result).toContain('http://asset.localhost//Users/foo/notes/assets/a.png') + expect(result).toContain('http://asset.localhost//Users/foo/notes/assets/b.jpg') + }) + + it('preserves absolute http URLs', () => { + const html = '' + const result = resolveImagePaths(html, docDir) + expect(result).toBe(html) + }) + + it('preserves data URLs', () => { + const html = '' + const result = resolveImagePaths(html, docDir) + expect(result).toBe(html) + }) + + it('preserves already-resolved asset URLs', () => { + const html = '' + const result = resolveImagePaths(html, docDir) + expect(result).toBe(html) + }) + + it('returns html unchanged when docDir is empty', () => { + const html = '' + expect(resolveImagePaths(html, '')).toBe(html) + expect(resolveImagePaths(html, null as unknown as string)).toBe(html) + }) + + it('handles images with alt and title attributes', () => { + const html = 'My photo' + const result = resolveImagePaths(html, docDir) + expect(result).toContain('http://asset.localhost//Users/foo/notes/assets/photo.png') + expect(result).toContain('data-original-src="assets/photo.png"') + expect(result).toContain('alt="My photo"') + expect(result).toContain('title="A photo"') + }) + + it('handles src with subdirectories', () => { + const html = '' + const result = resolveImagePaths(html, docDir) + expect(result).toContain('http://asset.localhost//Users/foo/notes/images/2024/photo.png') + expect(result).toContain('data-original-src="images/2024/photo.png"') + }) + + it('handles src with spaces (encoded)', () => { + const html = '' + const result = resolveImagePaths(html, docDir) + expect(result).toContain('data-original-src="assets/my%20photo.png"') + }) + + it('does not resolve blob URLs', () => { + const html = '' + const result = resolveImagePaths(html, docDir) + expect(result).toBe(html) + }) +}) + +describe('unresolveImagePaths', () => { + const docDir = '/Users/foo/notes' + + it('restores original src from data-original-src', () => { + const html = + '' + const result = unresolveImagePaths(html, docDir) + expect(result).toBe('') + }) + + it('strips asset URL prefix when no data-original-src', () => { + const html = '' + const result = unresolveImagePaths(html, docDir) + expect(result).toBe('') + }) + + it('handles multiple images with mixed strategies', () => { + const html = + '' + + '' + const result = unresolveImagePaths(html, docDir) + expect(result).toContain('src="assets/a.png"') + expect(result).toContain('src="assets/b.jpg"') + expect(result).not.toContain('data-original-src') + expect(result).not.toContain('asset.localhost') + }) + + it('preserves non-asset images unchanged', () => { + const html = '' + const result = unresolveImagePaths(html, docDir) + expect(result).toBe(html) + }) + + it('preserves data URLs unchanged', () => { + const html = '' + const result = unresolveImagePaths(html, docDir) + expect(result).toBe(html) + }) + + it('returns html unchanged when docDir is empty', () => { + const html = + '' + expect(unresolveImagePaths(html, '')).toBe(html) + }) + + it('does not strip asset URLs from a different document dir', () => { + const html = '' + const result = unresolveImagePaths(html, docDir) + // Should not strip because the path doesn't start with docDir + expect(result).toBe(html) + }) +}) + +describe('round-trip', () => { + const docDir = '/Users/foo/notes' + + it('resolve then unresolve preserves original HTML', () => { + const original = '

Hello test world

' + const resolved = resolveImagePaths(original, docDir) + const restored = unresolveImagePaths(resolved, docDir) + expect(restored).toBe(original) + }) + + it('round-trips multiple images with mixed sources', () => { + const original = + '' + + '' + + '' + const resolved = resolveImagePaths(original, docDir) + const restored = unresolveImagePaths(resolved, docDir) + expect(restored).toBe(original) + }) + + it('unresolve handles asset URLs without data-original-src (input rule path)', () => { + // Simulates what happens when input rule resolves a path directly + const html = + 'test' + const result = unresolveImagePaths(html, docDir) + expect(result).toBe('test') + }) +}) diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 5a31f4d..79f4363 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -1,10 +1,17 @@ diff --git a/src/extensions/GdownImage.ts b/src/extensions/GdownImage.ts index b9ebcc7..06c1005 100644 --- a/src/extensions/GdownImage.ts +++ b/src/extensions/GdownImage.ts @@ -1,7 +1,8 @@ -import Image from "@tiptap/extension-image"; -import { markdownImageInputRule } from "./MarkdownImageInputRule"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; -import { invoke } from "@tauri-apps/api/core"; +import Image from '@tiptap/extension-image' +import { markdownImageInputRule } from './MarkdownImageInputRule' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { invoke, convertFileSrc } from '@tauri-apps/api/core' +import { getDocumentDir } from '../utils/imagePathResolver' /** * Get the file path of the currently active document from the tabs store. @@ -9,11 +10,11 @@ import { invoke } from "@tauri-apps/api/core"; */ async function getActiveDocumentPath(): Promise { try { - const { useTabsStore } = await import("../stores/tabs"); - const tabsStore = useTabsStore(); - return tabsStore.activeTab?.filePath ?? null; + const { useTabsStore } = await import('../stores/tabs') + const tabsStore = useTabsStore() + return tabsStore.activeTab?.filePath ?? null } catch { - return null; + return null } } @@ -25,25 +26,23 @@ async function getActiveDocumentPath(): Promise { * - The current document hasn't been saved to disk yet (no filePath) * - The Tauri backend copy command fails */ -async function copyImageToAssets( - file: File -): Promise<{ src: string; isRelative: boolean }> { - const documentPath = await getActiveDocumentPath(); +async function copyImageToAssets(file: File): Promise<{ src: string; isRelative: boolean }> { + const documentPath = await getActiveDocumentPath() // If the document hasn't been saved yet, we can't create a relative assets path. // Check if we have a native file path from drag-and-drop (webkitRelativePath or path property). if (!documentPath) { // Fallback: use base64 data URL return new Promise((resolve) => { - const reader = new FileReader(); + const reader = new FileReader() reader.onload = (e) => { - resolve({ src: e.target?.result as string, isRelative: false }); - }; + resolve({ src: e.target?.result as string, isRelative: false }) + } reader.onerror = () => { - resolve({ src: "", isRelative: false }); - }; - reader.readAsDataURL(file); - }); + resolve({ src: '', isRelative: false }) + } + reader.readAsDataURL(file) + }) } // For dropped files, the browser provides the file object but not the native path. @@ -53,45 +52,58 @@ async function copyImageToAssets( // Strategy: For drag-and-drop from Finder on macOS, the file object has a `.path` // property (non-standard, available in Tauri/Electron). If not available, // we write the file bytes to a temp location first, then copy to assets. - const nativePath = (file as any).path as string | undefined; + const nativePath = (file as any).path as string | undefined if (nativePath) { try { - const relativePath = await invoke("copy_image_to_assets", { + const relativePath = await invoke('copy_image_to_assets', { imagePath: nativePath, documentPath, - }); - return { src: relativePath, isRelative: true }; + }) + return { src: relativePath, isRelative: true } } catch (err) { - console.warn("Failed to copy image to assets via native path:", err); + console.warn('Failed to copy image to assets via native path:', err) } } // Fallback: Read file as raw bytes and write to assets via write_image_to_assets command try { - const arrayBuffer = await file.arrayBuffer(); - const imageBytes = Array.from(new Uint8Array(arrayBuffer)); - const relativePath = await invoke("write_image_to_assets", { + const arrayBuffer = await file.arrayBuffer() + const imageBytes = Array.from(new Uint8Array(arrayBuffer)) + const relativePath = await invoke('write_image_to_assets', { imageBytes, fileName: file.name, documentPath, - }); - return { src: relativePath, isRelative: true }; + }) + return { src: relativePath, isRelative: true } } catch { // Final fallback: base64 data URL return new Promise((resolve) => { - const reader = new FileReader(); + const reader = new FileReader() reader.onload = (e) => { - resolve({ src: e.target?.result as string, isRelative: false }); - }; + resolve({ src: e.target?.result as string, isRelative: false }) + } reader.onerror = () => { - resolve({ src: "", isRelative: false }); - }; - reader.readAsDataURL(file); - }); + resolve({ src: '', isRelative: false }) + } + reader.readAsDataURL(file) + }) } } +/** + * Resolve a relative image src to an asset:// URL for webview display. + * If the src is already absolute (http, data, blob), returns it as-is. + */ +async function resolveInsertedSrc(src: string, isRelative: boolean): Promise { + if (!isRelative || !src) return src + const docPath = await getActiveDocumentPath() + if (!docPath) return src + const docDir = getDocumentDir(docPath) + if (!docDir) return src + return convertFileSrc(`${docDir}/${src}`) +} + /** * GdownImage: Custom TipTap Image extension with Typora-like behavior. * @@ -112,15 +124,15 @@ export const GdownImage = Image.extend({ allowBase64: true, resize: false as const, HTMLAttributes: { - class: "gdown-image", - loading: "lazy", + class: 'gdown-image', + loading: 'lazy', }, - }; + } }, // Override to be inline like Typora inline: true, - group: "inline", + group: 'inline', draggable: true, addAttributes() { @@ -128,176 +140,179 @@ export const GdownImage = Image.extend({ ...this.parent?.(), src: { default: null, - parseHTML: (element: HTMLElement) => element.getAttribute("src"), + parseHTML: (element: HTMLElement) => element.getAttribute('src'), }, alt: { default: null, - parseHTML: (element: HTMLElement) => element.getAttribute("alt"), + parseHTML: (element: HTMLElement) => element.getAttribute('alt'), }, title: { default: null, - parseHTML: (element: HTMLElement) => element.getAttribute("title"), + parseHTML: (element: HTMLElement) => element.getAttribute('title'), }, width: { default: null, - parseHTML: (element: HTMLElement) => element.getAttribute("width"), + parseHTML: (element: HTMLElement) => element.getAttribute('width'), }, height: { default: null, - parseHTML: (element: HTMLElement) => element.getAttribute("height"), + parseHTML: (element: HTMLElement) => element.getAttribute('height'), }, loading: { - default: "lazy", + default: 'lazy', }, - }; + } }, addInputRules() { - return [markdownImageInputRule(this.type)]; + return [markdownImageInputRule(this.type)] }, addKeyboardShortcuts() { return { - "Mod-Shift-i": () => { + 'Mod-Shift-i': () => { // Typora shortcut: Cmd+Shift+I to insert image - window.dispatchEvent(new CustomEvent("gdown:insert-image")); - return true; + window.dispatchEvent(new CustomEvent('gdown:insert-image')) + return true }, - }; + } }, addProseMirrorPlugins() { - const plugins = this.parent?.() || []; + const plugins = this.parent?.() || [] // Plugin for image loading states and click handling plugins.push( new Plugin({ - key: new PluginKey("gdownImageHandler"), + key: new PluginKey('gdownImageHandler'), props: { handleDOMEvents: { // Handle Cmd+click on images to show image info click: (_view, event) => { - const target = event.target as HTMLElement; - if (target.tagName === "IMG" && target.classList.contains("gdown-image")) { + const target = event.target as HTMLElement + if (target.tagName === 'IMG' && target.classList.contains('gdown-image')) { if (event.metaKey || event.ctrlKey) { // Cmd+click: open image in external viewer/browser - const src = target.getAttribute("src"); - if (src && (src.startsWith("http://") || src.startsWith("https://"))) { - window.open(src, "_blank"); + const src = target.getAttribute('src') + if (src && (src.startsWith('http://') || src.startsWith('https://'))) { + window.open(src, '_blank') } - event.preventDefault(); - return true; + event.preventDefault() + return true } } - return false; + return false }, }, }, - }) - ); + }), + ) // Plugin for drag-and-drop and paste image insertion // Copies images to ./assets folder relative to current document plugins.push( new Plugin({ - key: new PluginKey("gdownImageDrop"), + key: new PluginKey('gdownImageDrop'), props: { handleDrop: (view, event) => { - const files = event.dataTransfer?.files; - if (!files || files.length === 0) return false; + const files = event.dataTransfer?.files + if (!files || files.length === 0) return false - const images = Array.from(files).filter((file) => - file.type.startsWith("image/") - ); - if (images.length === 0) return false; + const images = Array.from(files).filter((file) => file.type.startsWith('image/')) + if (images.length === 0) return false - event.preventDefault(); + event.preventDefault() const pos = view.posAtCoords({ left: event.clientX, top: event.clientY, - }); - if (!pos) return false; + }) + if (!pos) return false // Process each dropped image asynchronously images.forEach((imageFile) => { - copyImageToAssets(imageFile).then(({ src }) => { - if (!src) return; - const node = view.state.schema.nodes.image!.create({ - src, - alt: imageFile.name.replace(/\.[^/.]+$/, ""), // Strip extension for alt text - }); - const tr = view.state.tr.insert(pos.pos, node); - view.dispatch(tr); - }).catch((err) => { - console.error("Failed to process dropped image:", err); - // Fallback: insert with base64 data URL - const reader = new FileReader(); - reader.onload = (readerEvent) => { - const fallbackSrc = readerEvent.target?.result as string; + copyImageToAssets(imageFile) + .then(async ({ src, isRelative }) => { + if (!src) return + const resolvedSrc = await resolveInsertedSrc(src, isRelative) const node = view.state.schema.nodes.image!.create({ - src: fallbackSrc, - alt: imageFile.name, - }); - const tr = view.state.tr.insert(pos.pos, node); - view.dispatch(tr); - }; - reader.readAsDataURL(imageFile); - }); - }); - return true; + src: resolvedSrc, + alt: imageFile.name.replace(/\.[^/.]+$/, ''), // Strip extension for alt text + }) + const tr = view.state.tr.insert(pos.pos, node) + view.dispatch(tr) + }) + .catch((err) => { + console.error('Failed to process dropped image:', err) + // Fallback: insert with base64 data URL + const reader = new FileReader() + reader.onload = (readerEvent) => { + const fallbackSrc = readerEvent.target?.result as string + const node = view.state.schema.nodes.image!.create({ + src: fallbackSrc, + alt: imageFile.name, + }) + const tr = view.state.tr.insert(pos.pos, node) + view.dispatch(tr) + } + reader.readAsDataURL(imageFile) + }) + }) + return true }, handlePaste: (view, event) => { - const items = event.clipboardData?.items; - if (!items) return false; + const items = event.clipboardData?.items + if (!items) return false - const images = Array.from(items).filter((item) => - item.type.startsWith("image/") - ); - if (images.length === 0) return false; + const images = Array.from(items).filter((item) => item.type.startsWith('image/')) + if (images.length === 0) return false - event.preventDefault(); + event.preventDefault() images.forEach((item) => { - const file = item.getAsFile(); - if (!file) return; + const file = item.getAsFile() + if (!file) return // Generate a reasonable filename for pasted images (they often lack names) - const pasteFileName = file.name && file.name !== "image.png" - ? file.name - : `pasted-image-${Date.now()}.${file.type.split("/")[1] || "png"}`; + const pasteFileName = + file.name && file.name !== 'image.png' + ? file.name + : `pasted-image-${Date.now()}.${file.type.split('/')[1] || 'png'}` // Create a new File with the generated name if needed - const namedFile = new File([file], pasteFileName, { type: file.type }); + const namedFile = new File([file], pasteFileName, { type: file.type }) - copyImageToAssets(namedFile).then(({ src }) => { - if (!src) return; - const node = view.state.schema.nodes.image!.create({ - src, - alt: pasteFileName.replace(/\.[^/.]+$/, ""), - }); - const tr = view.state.tr.replaceSelectionWith(node); - view.dispatch(tr); - }).catch((err) => { - console.error("Failed to process pasted image:", err); - // Fallback: insert with base64 data URL - const reader = new FileReader(); - reader.onload = (readerEvent) => { - const fallbackSrc = readerEvent.target?.result as string; + copyImageToAssets(namedFile) + .then(async ({ src, isRelative }) => { + if (!src) return + const resolvedSrc = await resolveInsertedSrc(src, isRelative) const node = view.state.schema.nodes.image!.create({ - src: fallbackSrc, - alt: file.name, - }); - const tr = view.state.tr.replaceSelectionWith(node); - view.dispatch(tr); - }; - reader.readAsDataURL(file); - }); - }); - return true; + src: resolvedSrc, + alt: pasteFileName.replace(/\.[^/.]+$/, ''), + }) + const tr = view.state.tr.replaceSelectionWith(node) + view.dispatch(tr) + }) + .catch((err) => { + console.error('Failed to process pasted image:', err) + // Fallback: insert with base64 data URL + const reader = new FileReader() + reader.onload = (readerEvent) => { + const fallbackSrc = readerEvent.target?.result as string + const node = view.state.schema.nodes.image!.create({ + src: fallbackSrc, + alt: file.name, + }) + const tr = view.state.tr.replaceSelectionWith(node) + view.dispatch(tr) + } + reader.readAsDataURL(file) + }) + }) + return true }, }, - }) - ); + }), + ) - return plugins; + return plugins }, -}); +}) diff --git a/src/extensions/MarkdownImageInputRule.ts b/src/extensions/MarkdownImageInputRule.ts index 4191d25..0649058 100644 --- a/src/extensions/MarkdownImageInputRule.ts +++ b/src/extensions/MarkdownImageInputRule.ts @@ -1,29 +1,67 @@ -import { InputRule } from "@tiptap/core"; -import { NodeType } from "@tiptap/pm/model"; +import { InputRule } from '@tiptap/core' +import { NodeType } from '@tiptap/pm/model' +import { getDocumentDir, resolveRelativeSrc } from '../utils/imagePathResolver' +import type { useTabsStore as UseTabsStore } from '../stores/tabs' + +/** + * Cached reference to the tabs store, initialized on first use. + * Avoids circular dependency issues with dynamic import. + */ +let tabsStoreRef: ReturnType | null = null + +async function ensureTabsStore(): Promise { + if (tabsStoreRef) return + try { + const { useTabsStore } = await import('../stores/tabs') + tabsStoreRef = useTabsStore() + } catch { + // Store not available yet + } +} + +/** + * Get the active document's directory synchronously from the cached tabs store. + */ +function getActiveDocDir(): string | null { + if (!tabsStoreRef) return null + return getDocumentDir(tabsStoreRef.activeTab?.filePath ?? null) +} /** * Markdown image input rule: ![alt](src "title") * Matches markdown image syntax and converts to an inline image node. * Typora behavior: as soon as the closing ) is typed, the markdown syntax * disappears and the image renders inline with a preview. + * + * Relative image paths are resolved to Tauri asset protocol URLs so the + * webview can display them. */ export function markdownImageInputRule(imageType: NodeType): InputRule { + // Eagerly initialize the store reference + ensureTabsStore() + // Matches: ![alt text](url) or ![alt text](url "title") - const imageRegex = /!\[([^\]]*)\]\(([^)"]+)(?:\s+"([^"]*)")?\)$/; + const imageRegex = /!\[([^\]]*)\]\(([^)"]+)(?:\s+"([^"]*)")?\)$/ return new InputRule({ find: imageRegex, handler: ({ state, range, match }) => { - const [, alt, src, title] = match; + const [, alt, src, title] = match + if (!src) return + + // Resolve relative paths to asset URLs for webview display + const docDir = getActiveDocDir() + const resolvedSrc = docDir ? resolveRelativeSrc(src, docDir) : src + const attrs: Record = { - src: src ?? null, + src: resolvedSrc, alt: alt ?? null, title: title ?? null, - }; + } - const tr = state.tr; - const node = imageType.create(attrs); - tr.replaceWith(range.from, range.to, node); + const tr = state.tr + const node = imageType.create(attrs) + tr.replaceWith(range.from, range.to, node) }, - }); + }) } diff --git a/src/utils/imagePathResolver.ts b/src/utils/imagePathResolver.ts new file mode 100644 index 0000000..b48eb3c --- /dev/null +++ b/src/utils/imagePathResolver.ts @@ -0,0 +1,120 @@ +/** + * Image path resolution for Tauri webview. + * + * The Tauri webview runs at localhost:1420 (dev) or tauri://localhost (prod), + * so relative image paths like "assets/photo.png" can't resolve to the + * document's directory on disk. This module converts relative paths to + * Tauri asset protocol URLs for rendering, and back to relative paths + * for saving to markdown. + */ + +import { convertFileSrc } from '@tauri-apps/api/core' + +// Re-export for use in extensions +export { convertFileSrc } + +/** + * Extract the directory portion of a file path. + * Returns null if the input is falsy. + */ +export function getDocumentDir(filePath: string | null | undefined): string | null { + if (!filePath) return null + const lastSlash = filePath.lastIndexOf('/') + if (lastSlash === -1) return null + return filePath.substring(0, lastSlash) +} + +/** + * The asset URL prefix that convertFileSrc produces on macOS. + * Used to reverse asset URLs back to filesystem paths. + */ +const ASSET_PREFIX = 'http://asset.localhost/' + +// Matches tags, capturing the full tag for replacement +const IMG_SRC_RE = /(]*?\bsrc=")([^"]+)(")/g + +/** + * Check whether a src value is already absolute (http, https, data, blob, asset). + */ +export function isAbsoluteSrc(src: string): boolean { + return ( + src.startsWith('http://') || + src.startsWith('https://') || + src.startsWith('data:') || + src.startsWith('blob:') || + src.startsWith('asset:') || + src.startsWith('/') + ) +} + +/** + * Resolve a single relative image path to a Tauri asset protocol URL. + * Returns the src unchanged if it's already absolute. + */ +export function resolveRelativeSrc(src: string, documentDir: string): string { + if (!src || !documentDir || isAbsoluteSrc(src)) return src + const absolutePath = `${documentDir}/${src}` + return convertFileSrc(absolutePath) +} + +/** + * Resolve relative image src attributes in HTML to Tauri asset protocol URLs. + * Adds data-original-src attribute to preserve the original relative path + * for round-trip fidelity. + * + * @param html - HTML string (from markdown-it) + * @param documentDir - Absolute path to the document's parent directory + * @returns HTML with resolved image paths + */ +export function resolveImagePaths(html: string, documentDir: string): string { + if (!html || !documentDir) return html + + return html.replace(IMG_SRC_RE, (_match, prefix: string, src: string, suffix: string) => { + if (isAbsoluteSrc(src)) return _match + + // Resolve relative path to absolute filesystem path + const assetUrl = resolveRelativeSrc(src, documentDir) + + // Preserve original src for round-trip back to markdown + return `${prefix}${assetUrl}${suffix} data-original-src="${src}"` + }) +} + +/** + * Restore original relative image paths from resolved asset URLs. + * Handles two cases: + * 1. Images with data-original-src attribute (from markdown load path) + * 2. Images with asset:// URLs but no data-original-src (from input rules, drop, paste) + * + * @param html - HTML string (from Tiptap getHTML()) + * @param documentDir - Absolute path to the document's parent directory + * @returns HTML with relative image paths restored + */ +export function unresolveImagePaths(html: string, documentDir: string): string { + if (!html || !documentDir) return html + + // Strategy 1: Restore from data-original-src attribute + let result = html.replace( + /]*?)\bsrc="[^"]*"([^>]*?)\bdata-original-src="([^"]*)"([^>]*?)>/g, + (_match, before: string, mid: string, originalSrc: string, after: string) => { + const attrs = `${before}src="${originalSrc}"${mid}${after}`.replace(/\s{2,}/g, ' ').trim() + return `` + }, + ) + + // Strategy 2: Strip asset URL prefix for images without data-original-src + // These come from input rules, drag-drop, paste where we resolved the URL + // but didn't add data-original-src to the node attributes + const assetDocPrefix = `${ASSET_PREFIX}${documentDir}/` + if (result.includes(ASSET_PREFIX)) { + result = result.replace(IMG_SRC_RE, (_match, prefix: string, src: string, suffix: string) => { + if (src.startsWith(assetDocPrefix)) { + const relativeSrc = src.substring(assetDocPrefix.length) + return `${prefix}${relativeSrc}${suffix}` + } + return _match + }) + } + + return result +}