From fb0d550eb25d529df0765defedf8bd085a520924 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 23 Apr 2026 16:31:36 +0200 Subject: [PATCH] fix: make starter vault images portable --- src/components/Editor.test.tsx | 21 ++++ src/components/Editor.tsx | 3 +- src/components/editorRawModeSync.ts | 10 +- src/components/useRawModeWithFlush.ts | 21 ++-- src/hooks/editorTabContent.ts | 59 ++++++++++ src/hooks/useEditorTabSwap.ts | 110 ++++++++----------- src/utils/vaultImages.test.ts | 152 ++++++++++++++++++++++++++ src/utils/vaultImages.ts | 82 ++++++++++++++ 8 files changed, 378 insertions(+), 80 deletions(-) create mode 100644 src/hooks/editorTabContent.ts create mode 100644 src/utils/vaultImages.test.ts create mode 100644 src/utils/vaultImages.ts diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index 2f3c859d..9a6fb7b0 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -514,6 +514,27 @@ describe('raw-mode sync content guards', () => { expect(rawLatestContentRef.current).toBe(result) }) + it('keeps raw-mode serialization portable for vault attachment images', () => { + const rawLatestContentRef = { current: null as string | null } + + mockEditor.blocksToMarkdownLossy.mockReturnValueOnce( + '# Test Project\n\n![shot](asset://localhost/%2Fvault%2Fattachments%2Fshot.png)\n', + ) + + const result = syncActiveTabIntoRawBuffer({ + editor: mockEditor as never, + activeTabPath: mockEntry.path, + activeTabContent: mockContent, + rawLatestContentRef, + vaultPath: '/vault', + }) + + expect(result).toBe( + '---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\n# Test Project\n\n![shot](attachments/shot.png)\n', + ) + expect(rawLatestContentRef.current).toBe(result) + }) + it('does not emit a content change when leaving raw mode without user edits', () => { const onContentChange = vi.fn() const normalizedContent = '---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\n# Test Project\n\nThis is a test note with some words to count.\n' diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index e2de9d7f..b9e43839 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -184,6 +184,7 @@ function useEditorSetup({ activeTabPath, activeTab?.content ?? null, onContentChange, + vaultPath, ) const tabsForEditorSwap = applyPendingRawExitContent(tabs, pendingRawExitContent) const rawModeContent = resolveRawModeContent({ activeTab, rawModeContentOverride }) @@ -197,7 +198,7 @@ function useEditorSetup({ }, [activeTabPath, setPendingRawExitContent, tabs]) const { handleEditorChange, editorMountedRef } = useEditorTabSwap({ - tabs: tabsForEditorSwap, activeTabPath, editor, onContentChange, rawMode, + tabs: tabsForEditorSwap, activeTabPath, editor, onContentChange, rawMode, vaultPath, }) useEditorFocus(editor, editorMountedRef) diff --git a/src/components/editorRawModeSync.ts b/src/components/editorRawModeSync.ts index e0d9db12..e1f15c66 100644 --- a/src/components/editorRawModeSync.ts +++ b/src/components/editorRawModeSync.ts @@ -2,6 +2,7 @@ import type { useCreateBlockNote } from '@blocknote/react' import type { VaultEntry } from '../types' import { splitFrontmatter, restoreWikilinksInBlocks } from '../utils/wikilinks' import { compactMarkdown } from '../utils/compact-markdown' +import { portableImageUrls } from '../utils/vaultImages' interface Tab { entry: VaultEntry @@ -24,10 +25,12 @@ export function buildPendingRawExitContent( export function serializeEditorDocumentToMarkdown( editor: ReturnType, tabContent: string, + vaultPath?: string, ): string { const blocks = editor.document const restored = restoreWikilinksInBlocks(blocks) - const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks)) + const rawBodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks)) + const bodyMarkdown = vaultPath ? portableImageUrls(rawBodyMarkdown, vaultPath) : rawBodyMarkdown const [frontmatter] = splitFrontmatter(tabContent) return `${frontmatter}${bodyMarkdown}` } @@ -74,11 +77,12 @@ export function syncActiveTabIntoRawBuffer(options: { activeTabPath: string | null activeTabContent: string | null rawLatestContentRef: React.MutableRefObject + vaultPath?: string }) { - const { editor, activeTabPath, activeTabContent, rawLatestContentRef } = options + const { editor, activeTabPath, activeTabContent, rawLatestContentRef, vaultPath } = options if (!activeTabPath || activeTabContent === null) return null - const syncedContent = serializeEditorDocumentToMarkdown(editor, activeTabContent) + const syncedContent = serializeEditorDocumentToMarkdown(editor, activeTabContent, vaultPath) rawLatestContentRef.current = syncedContent return syncedContent } diff --git a/src/components/useRawModeWithFlush.ts b/src/components/useRawModeWithFlush.ts index b6d46e7a..75d7956b 100644 --- a/src/components/useRawModeWithFlush.ts +++ b/src/components/useRawModeWithFlush.ts @@ -135,6 +135,7 @@ function useHandleFlushPending({ pendingRawRestoreRef, pendingRoundTripRawRestoreRef, setRawModeContentOverride, + vaultPath, }: { editor: ReturnType activeTabPath: string | null @@ -145,6 +146,7 @@ function useHandleFlushPending({ pendingRawRestoreRef: React.MutableRefObject pendingRoundTripRawRestoreRef: React.MutableRefObject setRawModeContentOverride: React.Dispatch> + vaultPath?: string }) { return useCallback(async () => { rawSourceContentRef.current = activeTabContent @@ -153,6 +155,7 @@ function useHandleFlushPending({ activeTabPath, activeTabContent, rawLatestContentRef, + vaultPath, }) rawInitialContentRef.current = syncedContent ?? activeTabContent pendingRawRestoreRef.current = buildPendingRawRestore({ @@ -176,6 +179,7 @@ function useHandleFlushPending({ rawLatestContentRef, rawSourceContentRef, setRawModeContentOverride, + vaultPath, ]) } @@ -246,21 +250,16 @@ function useSyncRawModeContentOverride({ rawSourceContentRef: React.MutableRefObject setRawModeContentOverride: React.Dispatch> }) { - const syncRawModeContentOverride = ( - current: PendingRawExitContent | null, - nextContent: string, - ) => { - if (!current) return current - if (current.path !== activeTabPath || current.content === nextContent) return current - return { path: activeTabPath, content: nextContent } - } - useLayoutEffect(() => { if (!activeTabPath || activeTabContent === null) return if (rawSourceContentRef.current === null || activeTabContent === rawSourceContentRef.current) return const nextContent = activeTabContent - setRawModeContentOverride((current) => syncRawModeContentOverride(current, nextContent)) + setRawModeContentOverride((current) => { + if (!current) return current + if (current.path !== activeTabPath || current.content === nextContent) return current + return { path: activeTabPath, content: nextContent } + }) }, [activeTabContent, activeTabPath, rawSourceContentRef, setRawModeContentOverride]) } @@ -269,6 +268,7 @@ export function useRawModeWithFlush( activeTabPath: string | null, activeTabContent: string | null, onContentChange?: (path: string, content: string) => void, + vaultPath?: string, ) { const rawLatestContentRef = useRef(null) const rawInitialContentRef = useRef(null) @@ -304,6 +304,7 @@ export function useRawModeWithFlush( pendingRawRestoreRef, pendingRoundTripRawRestoreRef, setRawModeContentOverride, + vaultPath, }) const handleBeforeRawEnd = useHandleBeforeRawEnd({ activeTabPath, diff --git a/src/hooks/editorTabContent.ts b/src/hooks/editorTabContent.ts new file mode 100644 index 00000000..2b358624 --- /dev/null +++ b/src/hooks/editorTabContent.ts @@ -0,0 +1,59 @@ +import { splitFrontmatter } from '../utils/wikilinks' + +type MarkdownContent = string +type FilePath = string +type Frontmatter = string +type NoteTitle = string +type PathStem = string +type HeadingTextInline = { type?: string; text?: string } + +export function extractEditorBody(rawFileContent: MarkdownContent): MarkdownContent { + const [, rawBody] = splitFrontmatter(rawFileContent) + return rawBody.trimStart() +} + +function extractH1Content(blocks: unknown[]): HeadingTextInline[] | null { + const first = blocks?.[0] as { + type?: string + props?: { level?: number } + content?: HeadingTextInline[] + } | undefined + + if (!first) return null + if (first.type !== 'heading') return null + if (first.props?.level !== 1) return null + if (!Array.isArray(first.content)) return null + return first.content +} + +export function getH1TextFromBlocks(blocks: unknown[]): NoteTitle | null { + const content = extractH1Content(blocks) + if (!content) return null + + let text = '' + for (const item of content) { + if (item.type === 'text') { + text += item.text || '' + } + } + + const trimmed = text.trim() + return trimmed || null +} + +export function replaceTitleInFrontmatter(frontmatter: Frontmatter, newTitle: NoteTitle): Frontmatter { + return frontmatter.replace(/^(title:\s*).+$/m, `$1${newTitle}`) +} + +export function pathStem(path: FilePath): PathStem { + const filename = path.split('/').pop() ?? path + return filename.replace(/\.md$/, '') +} + +export function slugifyPathStem(title: NoteTitle): PathStem { + return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') +} + +export function isUntitledPath(path: FilePath): boolean { + return pathStem(path).startsWith('untitled-') +} diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index b079a871..c16f3cca 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -4,6 +4,15 @@ import type { VaultEntry } from '../types' import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks } from '../utils/wikilinks' import { compactMarkdown } from '../utils/compact-markdown' import { failNoteOpenTrace, finishNoteOpenTrace } from '../utils/noteOpenPerformance' +import { resolveImageUrls, portableImageUrls } from '../utils/vaultImages' +import { + extractEditorBody, + getH1TextFromBlocks, + isUntitledPath, + pathStem, + slugifyPathStem, +} from './editorTabContent' +export { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './editorTabContent' interface Tab { entry: VaultEntry @@ -32,6 +41,7 @@ interface UseEditorTabSwapOptions { onContentChange?: (path: string, content: string) => void /** When true, the BlockNote editor is hidden (raw/CodeMirror mode active). */ rawMode?: boolean + vaultPath?: string } function signalEditorTabSwapped(path: string): void { @@ -41,63 +51,6 @@ function signalEditorTabSwapped(path: string): void { finishNoteOpenTrace(path) } -/** Strip the YAML frontmatter from raw file content, returning the body - * (including any H1 heading) that should appear in the editor. */ -export function extractEditorBody(rawFileContent: string): string { - const [, rawBody] = splitFrontmatter(rawFileContent) - return rawBody.trimStart() -} - -type HeadingTextInline = { type?: string; text?: string } - -function extractH1Content(blocks: unknown[]): HeadingTextInline[] | null { - const first = blocks?.[0] as { - type?: string - props?: { level?: number } - content?: HeadingTextInline[] - } | undefined - - if (!first) return null - if (first.type !== 'heading') return null - if (first.props?.level !== 1) return null - if (!Array.isArray(first.content)) return null - return first.content -} - -/** Extract H1 text from the editor's first block, or null if not an H1. */ -export function getH1TextFromBlocks(blocks: unknown[]): string | null { - const content = extractH1Content(blocks) - if (!content) return null - - let text = '' - for (const item of content) { - if (item.type === 'text') { - text += item.text || '' - } - } - - const trimmed = text.trim() - return trimmed || null -} - -/** Replace the title: line in YAML frontmatter with a new title value. */ -export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string): string { - return frontmatter.replace(/^(title:\s*).+$/m, `$1${newTitle}`) -} - -function pathStem(path: string): string { - const filename = path.split('/').pop() ?? path - return filename.replace(/\.md$/, '') -} - -function slugifyPathStem(title: string): string { - return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') -} - -function isUntitledPath(path: string): boolean { - return pathStem(path).startsWith('untitled-') -} - function readEditorScrollTop(): number { const scrollEl = document.querySelector('.editor__blocknote-container') return scrollEl?.scrollTop ?? 0 @@ -179,16 +132,21 @@ async function parseMarkdownBlocks( } async function resolveBlocksForTarget( - editor: ReturnType, - cache: Map, - targetPath: string, - content: string, + options: { + editor: ReturnType + cache: Map + targetPath: string + content: string + vaultPath?: string + }, ): Promise { + const { editor, cache, targetPath, content, vaultPath } = options const cached = cache.get(targetPath) if (cached?.sourceContent === content) return cached const body = extractEditorBody(content) - const preprocessed = preProcessWikilinks(body) + const withImages = vaultPath ? resolveImageUrls(body, vaultPath) : body + const preprocessed = preProcessWikilinks(withImages) const fastPathBlocks = buildFastPathBlocks({ preprocessed }) if (fastPathBlocks) { const nextState = { blocks: fastPathBlocks, scrollTop: 0, sourceContent: content } @@ -375,6 +333,7 @@ function useEditorChangeHandler(options: { suppressChangeRef: MutableRefObject tabCacheRef: MutableRefObject> pendingLocalContentRef: MutableRefObject + vaultPathRef: MutableRefObject }) { const { editor, @@ -384,6 +343,7 @@ function useEditorChangeHandler(options: { suppressChangeRef, tabCacheRef, pendingLocalContentRef, + vaultPathRef, } = options return useCallback(() => { @@ -396,7 +356,10 @@ function useEditorChangeHandler(options: { const blocks = editor.document const restored = restoreWikilinksInBlocks(blocks) - const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks)) + const rawBodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks)) + const bodyMarkdown = vaultPathRef.current + ? portableImageUrls(rawBodyMarkdown, vaultPathRef.current) + : rawBodyMarkdown const [frontmatter] = splitFrontmatter(tab.content) const nextContent = `${frontmatter}${bodyMarkdown}` pendingLocalContentRef.current = { path, content: nextContent } @@ -406,7 +369,7 @@ function useEditorChangeHandler(options: { sourceContent: nextContent, }) onContentChangeRef.current?.(path, nextContent) - }, [editor, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, suppressChangeRef, tabCacheRef, tabsRef]) + }, [editor, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, suppressChangeRef, tabCacheRef, tabsRef, vaultPathRef]) } function consumeRawModeTransition( @@ -781,6 +744,7 @@ function scheduleEmptyHeadingSwap(options: { content: string prevActivePathRef: MutableRefObject suppressChangeRef: MutableRefObject + vaultPath?: string }) { const { editor, @@ -822,9 +786,10 @@ function scheduleParsedBlockSwap(options: { content, prevActivePathRef, suppressChangeRef, + vaultPath, } = options - void resolveBlocksForTarget(editor, cache, targetPath, content) + void resolveBlocksForTarget({ editor, cache, targetPath, content, vaultPath }) .then(({ blocks, scrollTop }) => { if (prevActivePathRef.current !== targetPath) return applyBlocksToEditor(editor, blocks, scrollTop, suppressChangeRef) @@ -846,6 +811,7 @@ function scheduleTabSwap(options: { prevActivePathRef: MutableRefObject rawSwapPendingRef: MutableRefObject suppressChangeRef: MutableRefObject + vaultPath?: string }) { const { editor, @@ -856,6 +822,7 @@ function scheduleTabSwap(options: { prevActivePathRef, rawSwapPendingRef, suppressChangeRef, + vaultPath, } = options suppressChangeRef.current = true @@ -892,6 +859,7 @@ function scheduleTabSwap(options: { content: activeTab.content, prevActivePathRef, suppressChangeRef, + vaultPath, }) } @@ -991,6 +959,7 @@ function runTabSwapEffect(options: { rawSwapPendingRef: MutableRefObject suppressChangeRef: MutableRefObject pendingLocalContentRef: MutableRefObject + vaultPath?: string }) { const { tabs, @@ -1005,6 +974,7 @@ function runTabSwapEffect(options: { rawSwapPendingRef, suppressChangeRef, pendingLocalContentRef, + vaultPath, } = options const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode) @@ -1040,6 +1010,7 @@ function runTabSwapEffect(options: { prevActivePathRef, rawSwapPendingRef, suppressChangeRef, + vaultPath, }) } @@ -1056,6 +1027,7 @@ function useTabSwapEffect(options: { rawSwapPendingRef: MutableRefObject suppressChangeRef: MutableRefObject pendingLocalContentRef: MutableRefObject + vaultPathRef: MutableRefObject }) { const { tabs, @@ -1070,6 +1042,7 @@ function useTabSwapEffect(options: { rawSwapPendingRef, suppressChangeRef, pendingLocalContentRef, + vaultPathRef, } = options useEffect(() => { @@ -1086,6 +1059,7 @@ function useTabSwapEffect(options: { rawSwapPendingRef, suppressChangeRef, pendingLocalContentRef, + vaultPath: vaultPathRef.current, }) }, [ activeTabPath, @@ -1100,6 +1074,7 @@ function useTabSwapEffect(options: { tabCacheRef, tabs, pendingLocalContentRef, + vaultPathRef, ]) } @@ -1114,7 +1089,7 @@ function useTabSwapEffect(options: { * * Returns `handleEditorChange`, the onChange callback for SingleEditorView. */ -export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, rawMode }: UseEditorTabSwapOptions) { +export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, rawMode, vaultPath }: UseEditorTabSwapOptions) { const tabCacheRef = useRef>(new Map()) const pendingLocalContentRef = useRef(null) const prevActivePathRef = useRef(null) @@ -1125,6 +1100,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, const suppressChangeRef = useRef(false) const onContentChangeRef = useLatestRef(onContentChange) const tabsRef = useLatestRef(tabs) + const vaultPathRef = useLatestRef(vaultPath) const handleEditorChange = useEditorChangeHandler({ editor, tabsRef, @@ -1133,6 +1109,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, suppressChangeRef, tabCacheRef, pendingLocalContentRef, + vaultPathRef, }) useEditorMountState(editor, editorMountedRef, pendingSwapRef) @@ -1149,6 +1126,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, rawSwapPendingRef, suppressChangeRef, pendingLocalContentRef, + vaultPathRef, }) return { handleEditorChange, editorMountedRef } diff --git a/src/utils/vaultImages.test.ts b/src/utils/vaultImages.test.ts new file mode 100644 index 00000000..a6add6fa --- /dev/null +++ b/src/utils/vaultImages.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi } from 'vitest' +import { resolveImageUrls, portableImageUrls } from './vaultImages' + +let tauriMode = false + +vi.mock('@tauri-apps/api/core', () => ({ + convertFileSrc: vi.fn((path: string) => `asset://localhost/${encodeURIComponent(path)}`), +})) + +vi.mock('../mock-tauri', () => ({ + isTauri: () => tauriMode, +})) + +function assetUrl(path: string): string { + return `asset://localhost/${encodeURIComponent(path)}` +} + +describe('resolveImageUrls', () => { + it('is a no-op outside Tauri', () => { + tauriMode = false + const markdown = '![alt](attachments/file.png)' + + expect(resolveImageUrls(markdown, '/vault')).toBe(markdown) + }) + + it('is a no-op when vaultPath is empty', () => { + tauriMode = true + const markdown = '![alt](attachments/file.png)' + + expect(resolveImageUrls(markdown, '')).toBe(markdown) + }) + + it('converts relative attachment paths to asset URLs', () => { + tauriMode = true + const markdown = '![screenshot](attachments/1776369786040-CleanShot_2026-04-16.png)' + + expect(resolveImageUrls(markdown, '/vault')).toBe( + `![screenshot](${assetUrl('/vault/attachments/1776369786040-CleanShot_2026-04-16.png')})`, + ) + }) + + it('leaves already-correct asset URLs unchanged', () => { + tauriMode = true + const url = assetUrl('/vault/attachments/file.png') + const markdown = `![alt](${url})` + + expect(resolveImageUrls(markdown, '/vault')).toBe(markdown) + }) + + it('rewrites legacy asset URLs from a different vault', () => { + tauriMode = true + const legacyUrl = assetUrl('/Users/luca/Workspace/tolaria-getting-started/attachments/CleanShot.png') + const markdown = `![CleanShot](${legacyUrl})` + + expect(resolveImageUrls(markdown, '/Users/john/Documents/Getting Started')).toBe( + `![CleanShot](${assetUrl('/Users/john/Documents/Getting Started/attachments/CleanShot.png')})`, + ) + }) + + it('leaves external URLs unchanged', () => { + tauriMode = true + const httpImage = '![logo](https://example.com/logo.png)' + const dataImage = '![icon](data:image/png;base64,abc123)' + + expect(resolveImageUrls(httpImage, '/vault')).toBe(httpImage) + expect(resolveImageUrls(dataImage, '/vault')).toBe(dataImage) + }) + + it('handles multiple images in one document', () => { + tauriMode = true + const markdown = `![a](${assetUrl('/old/attachments/a.png')})\n\n![b](attachments/b.png)` + + const result = resolveImageUrls(markdown, '/vault') + + expect(result).toContain(`![a](${assetUrl('/vault/attachments/a.png')})`) + expect(result).toContain(`![b](${assetUrl('/vault/attachments/b.png')})`) + }) + + it('preserves alt text and title attributes', () => { + tauriMode = true + const markdown = '![my screenshot](attachments/file.png "starter vault")' + + expect(resolveImageUrls(markdown, '/vault')).toBe( + `![my screenshot](${assetUrl('/vault/attachments/file.png')} "starter vault")`, + ) + }) + + it('skips unknown asset URLs without an attachments segment', () => { + tauriMode = true + const url = assetUrl('/some/other/path/file.png') + const markdown = `![alt](${url})` + + expect(resolveImageUrls(markdown, '/vault')).toBe(markdown) + }) +}) + +describe('portableImageUrls', () => { + it('converts vault attachment asset URLs to relative paths', () => { + const url = assetUrl('/vault/attachments/1776369786040-CleanShot.png') + const markdown = `![screenshot](${url})` + + expect(portableImageUrls(markdown, '/vault')).toBe( + '![screenshot](attachments/1776369786040-CleanShot.png)', + ) + }) + + it('is a no-op when vaultPath is empty', () => { + const url = assetUrl('/vault/attachments/file.png') + const markdown = `![alt](${url})` + + expect(portableImageUrls(markdown, '')).toBe(markdown) + }) + + it('leaves asset URLs from other vaults unchanged', () => { + const url = assetUrl('/other-vault/attachments/file.png') + const markdown = `![alt](${url})` + + expect(portableImageUrls(markdown, '/vault')).toBe(markdown) + }) + + it('leaves relative and external paths unchanged', () => { + const relativeImage = '![alt](attachments/file.png)' + const httpImage = '![logo](https://example.com/logo.png)' + + expect(portableImageUrls(relativeImage, '/vault')).toBe(relativeImage) + expect(portableImageUrls(httpImage, '/vault')).toBe(httpImage) + }) + + it('handles multiple images', () => { + const markdown = `![a](${assetUrl('/vault/attachments/a.png')})\n\n![b](${assetUrl('/vault/attachments/b.png')})` + + const result = portableImageUrls(markdown, '/vault') + + expect(result).toContain('![a](attachments/a.png)') + expect(result).toContain('![b](attachments/b.png)') + }) + + it('preserves title attributes when converting to portable paths', () => { + const markdown = `![shot](${assetUrl('/vault/attachments/a.png')} "starter vault")` + + expect(portableImageUrls(markdown, '/vault')).toBe('![shot](attachments/a.png "starter vault")') + }) +}) + +describe('resolveImageUrls / portableImageUrls round-trip', () => { + it('keeps relative attachment markdown stable', () => { + tauriMode = true + const markdown = '![shot](attachments/file.png)' + + expect(portableImageUrls(resolveImageUrls(markdown, '/vault'), '/vault')).toBe(markdown) + }) +}) diff --git a/src/utils/vaultImages.ts b/src/utils/vaultImages.ts new file mode 100644 index 00000000..3f85f95b --- /dev/null +++ b/src/utils/vaultImages.ts @@ -0,0 +1,82 @@ +import { convertFileSrc } from '@tauri-apps/api/core' +import { isTauri } from '../mock-tauri' + +const ASSET_URL_PREFIX = 'asset://localhost/' +const ATTACHMENTS_SEGMENT = '/attachments/' +const RELATIVE_ATTACHMENTS_PREFIX = 'attachments/' + +type Markdown = string +type VaultPath = string +type AttachmentPath = string +type AbsolutePath = string +type MarkdownImageUrl = string + +// Matches markdown image syntax: ![alt](url) or ![alt](url "title"). +const MD_IMAGE_PATTERN = /!\[([^\]]*)\]\(([^)\s"]+)(\s+"[^"]*")?\)/g + +function assetUrl(path: AbsolutePath): MarkdownImageUrl { + return convertFileSrc(path) +} + +function vaultAttachmentPath(vaultPath: VaultPath, attachmentPath: AttachmentPath): AbsolutePath { + return `${vaultPath}/${attachmentPath}` +} + +function extractAttachmentPath(absolutePath: AbsolutePath): AttachmentPath | null { + const index = absolutePath.lastIndexOf(ATTACHMENTS_SEGMENT) + if (index === -1) return null + + const filename = absolutePath.slice(index + ATTACHMENTS_SEGMENT.length) + return filename ? `${RELATIVE_ATTACHMENTS_PREFIX}${filename}` : null +} + +function decodeAssetPath(url: MarkdownImageUrl): AbsolutePath { + return decodeURIComponent(url.slice(ASSET_URL_PREFIX.length)) +} + +function isCurrentVaultAsset(url: MarkdownImageUrl, vaultPath: VaultPath): boolean { + const absolutePath = decodeAssetPath(url) + return absolutePath === vaultPath || absolutePath.startsWith(`${vaultPath}/`) +} + +function rewriteMarkdownImages( + markdown: Markdown, + transformUrl: (url: MarkdownImageUrl) => MarkdownImageUrl | null, +): Markdown { + return markdown.replace(MD_IMAGE_PATTERN, (match, alt, url, title = '') => { + const nextUrl = transformUrl(url) + return nextUrl ? `![${alt}](${nextUrl}${title})` : match + }) +} + +export function resolveImageUrls(markdown: Markdown, vaultPath: VaultPath): Markdown { + if (!isTauri() || !vaultPath) return markdown + + return rewriteMarkdownImages(markdown, (url) => { + if (url.startsWith(RELATIVE_ATTACHMENTS_PREFIX)) { + return assetUrl(vaultAttachmentPath(vaultPath, url)) + } + + if (!url.startsWith(ASSET_URL_PREFIX) || isCurrentVaultAsset(url, vaultPath)) { + return null + } + + const attachmentPath = extractAttachmentPath(decodeAssetPath(url)) + return attachmentPath ? assetUrl(vaultAttachmentPath(vaultPath, attachmentPath)) : null + }) +} + +export function portableImageUrls(markdown: Markdown, vaultPath: VaultPath): Markdown { + if (!vaultPath) return markdown + + const attachmentsPrefix = `${vaultPath}/${RELATIVE_ATTACHMENTS_PREFIX}` + + return rewriteMarkdownImages(markdown, (url) => { + if (!url.startsWith(ASSET_URL_PREFIX)) return null + + const absolutePath = decodeAssetPath(url) + if (!absolutePath.startsWith(attachmentsPrefix)) return null + + return `${RELATIVE_ATTACHMENTS_PREFIX}${absolutePath.slice(attachmentsPrefix.length)}` + }) +}