From 63a544d9fbb30bdcdb7377947fe22947a5276005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Fri, 17 Jul 2026 11:26:04 +0800 Subject: [PATCH] fix: resolve relative page links from source notes --- src/components/SingleEditorView.tsx | 2 +- .../useEditorLinkActivation.test.tsx | 22 +++++++--- src/components/useEditorLinkActivation.ts | 44 ++++++++++++++++--- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index df43bd1..0440e5a 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -1208,7 +1208,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange handleMouseMove: handleCodeBlockCopyMouseMove, } = useCodeBlockCopyTarget(containerRef) useBlockNoteSideMenuHoverGuard(containerRef) - useEditorLinkActivation(containerRef, onNavigateWikilink, vaultPath) + useEditorLinkActivation(containerRef, onNavigateWikilink, vaultPath, sourceEntry?.path) useEffect(() => { _wikilinkEntriesRef.current = entries diff --git a/src/components/useEditorLinkActivation.test.tsx b/src/components/useEditorLinkActivation.test.tsx index bbfcaee..321bc00 100644 --- a/src/components/useEditorLinkActivation.test.tsx +++ b/src/components/useEditorLinkActivation.test.tsx @@ -20,17 +20,19 @@ const mockOpenLocalFile = vi.mocked(openLocalFile) function Harness({ onNavigateWikilink, vaultPath, + sourcePath, }: { onNavigateWikilink: (target: string) => void vaultPath?: string + sourcePath?: string }) { const containerRef = useRef(null) - useEditorLinkActivation(containerRef, onNavigateWikilink, vaultPath) + useEditorLinkActivation(containerRef, onNavigateWikilink, vaultPath, sourcePath) return
} -function renderHarness(onNavigateWikilink = vi.fn(), vaultPath?: string) { - render() +function renderHarness(onNavigateWikilink = vi.fn(), vaultPath?: string, sourcePath?: string) { + render() return { container: screen.getByTestId('editor-link-container') as HTMLDivElement, onNavigateWikilink, @@ -129,7 +131,7 @@ describe('useEditorLinkActivation', () => { }) it('opens relative Markdown note links inside Tolaria on a plain click', async () => { - const { container, onNavigateWikilink } = renderHarness() + const { container, onNavigateWikilink } = renderHarness(vi.fn(), '/vault', '/vault/docs/NEXT.md') const link = appendUrl(container, '../Migration/Related%20Page%20abc123.md#current-status') const plainClick = dispatchMouseEvent(link, 'click') @@ -137,7 +139,17 @@ describe('useEditorLinkActivation', () => { expect(plainClick.defaultPrevented).toBe(true) expect(mockOpenLocalFile).not.toHaveBeenCalled() await Promise.resolve() - expect(onNavigateWikilink).toHaveBeenCalledWith('../Migration/Related Page abc123') + expect(onNavigateWikilink).toHaveBeenCalledWith('Migration/Related Page abc123') + }) + + it('resolves duplicate README links from the current note directory', async () => { + const { container, onNavigateWikilink } = renderHarness(vi.fn(), '/vault', '/vault/docs/NEXT.md') + const link = appendUrl(container, '../apps/tolaria/modules/02-knowledge-rendering/README.md') + + dispatchMouseEvent(link, 'click') + + await Promise.resolve() + expect(onNavigateWikilink).toHaveBeenCalledWith('apps/tolaria/modules/02-knowledge-rendering/README') }) it('consumes Markdown note link mousedown before editor internals can select it', async () => { diff --git a/src/components/useEditorLinkActivation.ts b/src/components/useEditorLinkActivation.ts index 79a2dad..31c49e0 100644 --- a/src/components/useEditorLinkActivation.ts +++ b/src/components/useEditorLinkActivation.ts @@ -34,14 +34,46 @@ function decodeLinkPath(path: string) { } } -function resolveMarkdownNoteTarget(href: string) { +function normalizePathSegments(path: string) { + const segments: string[] = [] + for (const segment of path.split('/')) { + if (!segment || segment === '.') continue + if (segment === '..') { + segments.pop() + continue + } + segments.push(segment) + } + return segments.join('/') +} + +function sourceRelativeDirectory(vaultPath?: string, sourcePath?: string) { + if (!vaultPath || !sourcePath) return null + + const normalizedVault = vaultPath.replace(/\\/gu, '/').replace(/\/+$/u, '') + const normalizedSource = sourcePath.replace(/\\/gu, '/') + const prefix = `${normalizedVault}/` + if (!normalizedSource.toLowerCase().startsWith(prefix.toLowerCase())) return null + + const relativeSource = normalizedSource.slice(prefix.length) + const slashIndex = relativeSource.lastIndexOf('/') + return slashIndex === -1 ? '' : relativeSource.slice(0, slashIndex) +} + +function resolveMarkdownNoteTarget(href: string, vaultPath?: string, sourcePath?: string) { const path = href.split(/[?#]/u, 1)[0]?.trim() ?? '' if (!path || path.startsWith('//') || /^[a-z][a-z\d+.-]*:/iu.test(path)) return null const decodedPath = decodeLinkPath(path).replace(/\\/gu, '/') if (!/\.md(?:own)?$/iu.test(decodedPath)) return null - return decodedPath.replace(/\.md(?:own)?$/iu, '') + const pathWithoutExtension = decodedPath.replace(/\.md(?:own)?$/iu, '') + if (pathWithoutExtension.startsWith('/')) return normalizePathSegments(pathWithoutExtension) + + const sourceDirectory = sourceRelativeDirectory(vaultPath, sourcePath) + return normalizePathSegments( + sourceDirectory === null ? pathWithoutExtension : `${sourceDirectory}/${pathWithoutExtension}`, + ) } function blurActiveEditable(container: HTMLElement) { @@ -91,6 +123,7 @@ function handleEditorLinkClick( container: HTMLElement, onNavigateWikilink: (target: string) => void, vaultPath?: string, + sourcePath?: string, ) { const target = elementFromEventTarget(event.target) if (!target || isInsideCodeContext(target)) return @@ -104,7 +137,7 @@ function handleEditorLinkClick( const href = resolveAnchorHref(target) if (!href) return - const markdownTarget = resolveMarkdownNoteTarget(href) + const markdownTarget = resolveMarkdownNoteTarget(href, vaultPath, sourcePath) if (markdownTarget) { activateWikilink(event, container, markdownTarget, onNavigateWikilink) return @@ -146,6 +179,7 @@ export function useEditorLinkActivation( containerRef: RefObject, onNavigateWikilink: (target: string) => void, vaultPath?: string, + sourcePath?: string, ) { useEffect(() => { const container = containerRef.current @@ -187,7 +221,7 @@ export function useEditorLinkActivation( } clearHandledMouseDownUrl() - handleEditorLinkClick(event, container, onNavigateWikilink, vaultPath) + handleEditorLinkClick(event, container, onNavigateWikilink, vaultPath, sourcePath) } container.addEventListener('mousedown', handleMouseDown, true) @@ -207,5 +241,5 @@ export function useEditorLinkActivation( clearHandledMouseDownUrl() resetModifierState() } - }, [containerRef, onNavigateWikilink, vaultPath]) + }, [containerRef, onNavigateWikilink, sourcePath, vaultPath]) }