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<HTMLDivElement>(null)
-  useEditorLinkActivation(containerRef, onNavigateWikilink, vaultPath)
+  useEditorLinkActivation(containerRef, onNavigateWikilink, vaultPath, sourcePath)
   return <div ref={containerRef} data-testid="editor-link-container" />
 }
 
-function renderHarness(onNavigateWikilink = vi.fn(), vaultPath?: string) {
-  render(<Harness onNavigateWikilink={onNavigateWikilink} vaultPath={vaultPath} />)
+function renderHarness(onNavigateWikilink = vi.fn(), vaultPath?: string, sourcePath?: string) {
+  render(<Harness onNavigateWikilink={onNavigateWikilink} vaultPath={vaultPath} sourcePath={sourcePath} />)
   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<HTMLDivElement | null>,
   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])
 }
-- 
2.50.1 (Apple Git-155)

