fix: resolve relative page links from source notes

This commit is contained in:
冰朔 2026-07-17 11:26:04 +08:00
parent 59ce6261d3
commit 63a544d9fb
3 changed files with 57 additions and 11 deletions

View File

@ -1208,7 +1208,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
handleMouseMove: handleCodeBlockCopyMouseMove, handleMouseMove: handleCodeBlockCopyMouseMove,
} = useCodeBlockCopyTarget(containerRef) } = useCodeBlockCopyTarget(containerRef)
useBlockNoteSideMenuHoverGuard(containerRef) useBlockNoteSideMenuHoverGuard(containerRef)
useEditorLinkActivation(containerRef, onNavigateWikilink, vaultPath) useEditorLinkActivation(containerRef, onNavigateWikilink, vaultPath, sourceEntry?.path)
useEffect(() => { useEffect(() => {
_wikilinkEntriesRef.current = entries _wikilinkEntriesRef.current = entries

View File

@ -20,17 +20,19 @@ const mockOpenLocalFile = vi.mocked(openLocalFile)
function Harness({ function Harness({
onNavigateWikilink, onNavigateWikilink,
vaultPath, vaultPath,
sourcePath,
}: { }: {
onNavigateWikilink: (target: string) => void onNavigateWikilink: (target: string) => void
vaultPath?: string vaultPath?: string
sourcePath?: string
}) { }) {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
useEditorLinkActivation(containerRef, onNavigateWikilink, vaultPath) useEditorLinkActivation(containerRef, onNavigateWikilink, vaultPath, sourcePath)
return <div ref={containerRef} data-testid="editor-link-container" /> return <div ref={containerRef} data-testid="editor-link-container" />
} }
function renderHarness(onNavigateWikilink = vi.fn(), vaultPath?: string) { function renderHarness(onNavigateWikilink = vi.fn(), vaultPath?: string, sourcePath?: string) {
render(<Harness onNavigateWikilink={onNavigateWikilink} vaultPath={vaultPath} />) render(<Harness onNavigateWikilink={onNavigateWikilink} vaultPath={vaultPath} sourcePath={sourcePath} />)
return { return {
container: screen.getByTestId('editor-link-container') as HTMLDivElement, container: screen.getByTestId('editor-link-container') as HTMLDivElement,
onNavigateWikilink, onNavigateWikilink,
@ -129,7 +131,7 @@ describe('useEditorLinkActivation', () => {
}) })
it('opens relative Markdown note links inside Tolaria on a plain click', async () => { 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 link = appendUrl(container, '../Migration/Related%20Page%20abc123.md#current-status')
const plainClick = dispatchMouseEvent(link, 'click') const plainClick = dispatchMouseEvent(link, 'click')
@ -137,7 +139,17 @@ describe('useEditorLinkActivation', () => {
expect(plainClick.defaultPrevented).toBe(true) expect(plainClick.defaultPrevented).toBe(true)
expect(mockOpenLocalFile).not.toHaveBeenCalled() expect(mockOpenLocalFile).not.toHaveBeenCalled()
await Promise.resolve() 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 () => { it('consumes Markdown note link mousedown before editor internals can select it', async () => {

View File

@ -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() ?? '' const path = href.split(/[?#]/u, 1)[0]?.trim() ?? ''
if (!path || path.startsWith('//') || /^[a-z][a-z\d+.-]*:/iu.test(path)) return null if (!path || path.startsWith('//') || /^[a-z][a-z\d+.-]*:/iu.test(path)) return null
const decodedPath = decodeLinkPath(path).replace(/\\/gu, '/') const decodedPath = decodeLinkPath(path).replace(/\\/gu, '/')
if (!/\.md(?:own)?$/iu.test(decodedPath)) return null 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) { function blurActiveEditable(container: HTMLElement) {
@ -91,6 +123,7 @@ function handleEditorLinkClick(
container: HTMLElement, container: HTMLElement,
onNavigateWikilink: (target: string) => void, onNavigateWikilink: (target: string) => void,
vaultPath?: string, vaultPath?: string,
sourcePath?: string,
) { ) {
const target = elementFromEventTarget(event.target) const target = elementFromEventTarget(event.target)
if (!target || isInsideCodeContext(target)) return if (!target || isInsideCodeContext(target)) return
@ -104,7 +137,7 @@ function handleEditorLinkClick(
const href = resolveAnchorHref(target) const href = resolveAnchorHref(target)
if (!href) return if (!href) return
const markdownTarget = resolveMarkdownNoteTarget(href) const markdownTarget = resolveMarkdownNoteTarget(href, vaultPath, sourcePath)
if (markdownTarget) { if (markdownTarget) {
activateWikilink(event, container, markdownTarget, onNavigateWikilink) activateWikilink(event, container, markdownTarget, onNavigateWikilink)
return return
@ -146,6 +179,7 @@ export function useEditorLinkActivation(
containerRef: RefObject<HTMLDivElement | null>, containerRef: RefObject<HTMLDivElement | null>,
onNavigateWikilink: (target: string) => void, onNavigateWikilink: (target: string) => void,
vaultPath?: string, vaultPath?: string,
sourcePath?: string,
) { ) {
useEffect(() => { useEffect(() => {
const container = containerRef.current const container = containerRef.current
@ -187,7 +221,7 @@ export function useEditorLinkActivation(
} }
clearHandledMouseDownUrl() clearHandledMouseDownUrl()
handleEditorLinkClick(event, container, onNavigateWikilink, vaultPath) handleEditorLinkClick(event, container, onNavigateWikilink, vaultPath, sourcePath)
} }
container.addEventListener('mousedown', handleMouseDown, true) container.addEventListener('mousedown', handleMouseDown, true)
@ -207,5 +241,5 @@ export function useEditorLinkActivation(
clearHandledMouseDownUrl() clearHandledMouseDownUrl()
resetModifierState() resetModifierState()
} }
}, [containerRef, onNavigateWikilink, vaultPath]) }, [containerRef, onNavigateWikilink, sourcePath, vaultPath])
} }