fix: make starter vault images portable
This commit is contained in:
@@ -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\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\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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<typeof useCreateBlockNote>,
|
||||
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<string | null>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ function useHandleFlushPending({
|
||||
pendingRawRestoreRef,
|
||||
pendingRoundTripRawRestoreRef,
|
||||
setRawModeContentOverride,
|
||||
vaultPath,
|
||||
}: {
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
activeTabPath: string | null
|
||||
@@ -145,6 +146,7 @@ function useHandleFlushPending({
|
||||
pendingRawRestoreRef: React.MutableRefObject<CodeMirrorRestoreState | null>
|
||||
pendingRoundTripRawRestoreRef: React.MutableRefObject<PendingRoundTripRawRestore | null>
|
||||
setRawModeContentOverride: React.Dispatch<React.SetStateAction<PendingRawExitContent | null>>
|
||||
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<string | null>
|
||||
setRawModeContentOverride: React.Dispatch<React.SetStateAction<PendingRawExitContent | null>>
|
||||
}) {
|
||||
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<string | null>(null)
|
||||
const rawInitialContentRef = useRef<string | null>(null)
|
||||
@@ -304,6 +304,7 @@ export function useRawModeWithFlush(
|
||||
pendingRawRestoreRef,
|
||||
pendingRoundTripRawRestoreRef,
|
||||
setRawModeContentOverride,
|
||||
vaultPath,
|
||||
})
|
||||
const handleBeforeRawEnd = useHandleBeforeRawEnd({
|
||||
activeTabPath,
|
||||
|
||||
59
src/hooks/editorTabContent.ts
Normal file
59
src/hooks/editorTabContent.ts
Normal file
@@ -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-')
|
||||
}
|
||||
@@ -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<typeof useCreateBlockNote>,
|
||||
cache: Map<string, CachedTabState>,
|
||||
targetPath: string,
|
||||
content: string,
|
||||
options: {
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
cache: Map<string, CachedTabState>
|
||||
targetPath: string
|
||||
content: string
|
||||
vaultPath?: string
|
||||
},
|
||||
): Promise<CachedTabState> {
|
||||
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<boolean>
|
||||
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
vaultPathRef: MutableRefObject<string | undefined>
|
||||
}) {
|
||||
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<string | null>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
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<string | null>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
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<boolean>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
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<boolean>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
vaultPathRef: MutableRefObject<string | undefined>
|
||||
}) {
|
||||
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<Map<string, CachedTabState>>(new Map())
|
||||
const pendingLocalContentRef = useRef<PendingLocalContent | null>(null)
|
||||
const prevActivePathRef = useRef<string | null>(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 }
|
||||
|
||||
152
src/utils/vaultImages.test.ts
Normal file
152
src/utils/vaultImages.test.ts
Normal file
@@ -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 = ''
|
||||
|
||||
expect(resolveImageUrls(markdown, '/vault')).toBe(markdown)
|
||||
})
|
||||
|
||||
it('is a no-op when vaultPath is empty', () => {
|
||||
tauriMode = true
|
||||
const markdown = ''
|
||||
|
||||
expect(resolveImageUrls(markdown, '')).toBe(markdown)
|
||||
})
|
||||
|
||||
it('converts relative attachment paths to asset URLs', () => {
|
||||
tauriMode = true
|
||||
const markdown = ''
|
||||
|
||||
expect(resolveImageUrls(markdown, '/vault')).toBe(
|
||||
`})`,
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves already-correct asset URLs unchanged', () => {
|
||||
tauriMode = true
|
||||
const url = assetUrl('/vault/attachments/file.png')
|
||||
const markdown = ``
|
||||
|
||||
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 = ``
|
||||
|
||||
expect(resolveImageUrls(markdown, '/Users/john/Documents/Getting Started')).toBe(
|
||||
`})`,
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves external URLs unchanged', () => {
|
||||
tauriMode = true
|
||||
const httpImage = ''
|
||||
const dataImage = ''
|
||||
|
||||
expect(resolveImageUrls(httpImage, '/vault')).toBe(httpImage)
|
||||
expect(resolveImageUrls(dataImage, '/vault')).toBe(dataImage)
|
||||
})
|
||||
|
||||
it('handles multiple images in one document', () => {
|
||||
tauriMode = true
|
||||
const markdown = `})\n\n`
|
||||
|
||||
const result = resolveImageUrls(markdown, '/vault')
|
||||
|
||||
expect(result).toContain(`})`)
|
||||
expect(result).toContain(`})`)
|
||||
})
|
||||
|
||||
it('preserves alt text and title attributes', () => {
|
||||
tauriMode = true
|
||||
const markdown = ''
|
||||
|
||||
expect(resolveImageUrls(markdown, '/vault')).toBe(
|
||||
`} "starter vault")`,
|
||||
)
|
||||
})
|
||||
|
||||
it('skips unknown asset URLs without an attachments segment', () => {
|
||||
tauriMode = true
|
||||
const url = assetUrl('/some/other/path/file.png')
|
||||
const markdown = ``
|
||||
|
||||
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 = ``
|
||||
|
||||
expect(portableImageUrls(markdown, '/vault')).toBe(
|
||||
'',
|
||||
)
|
||||
})
|
||||
|
||||
it('is a no-op when vaultPath is empty', () => {
|
||||
const url = assetUrl('/vault/attachments/file.png')
|
||||
const markdown = ``
|
||||
|
||||
expect(portableImageUrls(markdown, '')).toBe(markdown)
|
||||
})
|
||||
|
||||
it('leaves asset URLs from other vaults unchanged', () => {
|
||||
const url = assetUrl('/other-vault/attachments/file.png')
|
||||
const markdown = ``
|
||||
|
||||
expect(portableImageUrls(markdown, '/vault')).toBe(markdown)
|
||||
})
|
||||
|
||||
it('leaves relative and external paths unchanged', () => {
|
||||
const relativeImage = ''
|
||||
const httpImage = ''
|
||||
|
||||
expect(portableImageUrls(relativeImage, '/vault')).toBe(relativeImage)
|
||||
expect(portableImageUrls(httpImage, '/vault')).toBe(httpImage)
|
||||
})
|
||||
|
||||
it('handles multiple images', () => {
|
||||
const markdown = `})\n\n})`
|
||||
|
||||
const result = portableImageUrls(markdown, '/vault')
|
||||
|
||||
expect(result).toContain('')
|
||||
expect(result).toContain('')
|
||||
})
|
||||
|
||||
it('preserves title attributes when converting to portable paths', () => {
|
||||
const markdown = `} "starter vault")`
|
||||
|
||||
expect(portableImageUrls(markdown, '/vault')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveImageUrls / portableImageUrls round-trip', () => {
|
||||
it('keeps relative attachment markdown stable', () => {
|
||||
tauriMode = true
|
||||
const markdown = ''
|
||||
|
||||
expect(portableImageUrls(resolveImageUrls(markdown, '/vault'), '/vault')).toBe(markdown)
|
||||
})
|
||||
})
|
||||
82
src/utils/vaultImages.ts
Normal file
82
src/utils/vaultImages.ts
Normal file
@@ -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:  or .
|
||||
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 ? `` : 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)}`
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user