Add wikilinks: render [[links]] as styled clickable elements

Wikilinks are detected via regex, rendered as styled spans with hidden
brackets, and navigate to the linked note on click. Uses mousedown
instead of click to catch the event before CM6 removes the widget.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-14 20:30:45 +01:00
parent f3b6b4ed4a
commit daa7d4d0c1
4 changed files with 161 additions and 1 deletions

View File

@@ -70,3 +70,24 @@ test('frontmatter hidden from editor view', async ({ page }) => {
// Frontmatter should be hidden — editor starts with content, not ---
await page.screenshot({ path: 'test-results/frontmatter-hidden.png', fullPage: true })
})
test('wikilinks: rendered as styled elements and clickable', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
// Open "Manage Sponsorships" which contains [[Matteo Cellini]] wikilink
await page.locator('.note-list__item', { hasText: 'Manage Sponsorships' }).click()
await page.waitForTimeout(500)
// Screenshot showing wikilink rendered as styled text
await page.screenshot({ path: 'test-results/wikilinks-styled.png', fullPage: true })
// Click the wikilink to navigate to Matteo Cellini
const wikilink = page.locator('.cm-wikilink', { hasText: 'Matteo Cellini' })
if (await wikilink.isVisible({ timeout: 3000 }).catch(() => false)) {
await wikilink.click()
await page.waitForTimeout(500)
// Should now have a new tab for Matteo Cellini
await page.screenshot({ path: 'test-results/wikilinks-navigated.png', fullPage: true })
}
})

View File

@@ -105,6 +105,18 @@ function App() {
setActiveTabPath(path)
}, [])
const handleNavigateWikilink = useCallback((target: string) => {
// Find entry by title (case-insensitive) or alias
const found = entries.find(
(e) =>
e.title.toLowerCase() === target.toLowerCase() ||
e.aliases.some((a) => a.toLowerCase() === target.toLowerCase())
)
if (found) {
handleSelectNote(found)
}
}, [entries, handleSelectNote])
const handleSidebarResize = useCallback((delta: number) => {
setSidebarWidth((w) => Math.max(150, Math.min(400, w + delta)))
}, [])
@@ -136,6 +148,7 @@ function App() {
activeTabPath={activeTabPath}
onSwitchTab={handleSwitchTab}
onCloseTab={handleCloseTab}
onNavigateWikilink={handleNavigateWikilink}
/>
</div>
{!inspectorCollapsed && <ResizeHandle onResize={handleInspectorResize} />}

View File

@@ -7,6 +7,7 @@ import { syntaxHighlighting, defaultHighlightStyle, bracketMatching } from '@cod
import { oneDark } from '@codemirror/theme-one-dark'
import { livePreview } from './livePreview'
import { frontmatterHide, findFrontmatter } from './frontmatterHide'
import { wikilinks } from './wikilinks'
import type { VaultEntry } from '../types'
import './Editor.css'
@@ -20,6 +21,7 @@ interface EditorProps {
activeTabPath: string | null
onSwitchTab: (path: string) => void
onCloseTab: (path: string) => void
onNavigateWikilink: (target: string) => void
}
const editorTheme = EditorView.theme({
@@ -57,9 +59,11 @@ const editorTheme = EditorView.theme({
},
})
export function Editor({ tabs, activeTabPath, onSwitchTab, onCloseTab }: EditorProps) {
export function Editor({ tabs, activeTabPath, onSwitchTab, onCloseTab, onNavigateWikilink }: EditorProps) {
const containerRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
const navigateRef = useRef(onNavigateWikilink)
navigateRef.current = onNavigateWikilink
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
@@ -92,6 +96,7 @@ export function Editor({ tabs, activeTabPath, onSwitchTab, onCloseTab }: EditorP
editorTheme,
livePreview(),
frontmatterHide(),
wikilinks((target) => navigateRef.current(target)),
keymap.of([...defaultKeymap, ...historyKeymap]),
EditorView.lineWrapping,
],

121
src/components/wikilinks.ts Normal file
View File

@@ -0,0 +1,121 @@
import {
ViewPlugin, Decoration, EditorView, WidgetType,
type DecorationSet, type ViewUpdate,
} from '@codemirror/view'
import { type Range, type Extension } from '@codemirror/state'
/**
* Wikilink extension — renders [[links]] as styled clickable elements.
* Hides [[ and ]] when cursor is not on that line, reveals on cursor line.
* Clicking a wikilink triggers a callback to navigate to the linked note.
*/
const WIKILINK_RE = /\[\[([^\]]+)\]\]/g
function isOnCursorLine(view: EditorView, from: number, to: number): boolean {
for (const range of view.state.selection.ranges) {
const cursorLine = view.state.doc.lineAt(range.head).number
const fromLine = view.state.doc.lineAt(from).number
const toLine = view.state.doc.lineAt(Math.min(to, view.state.doc.length)).number
if (cursorLine >= fromLine && cursorLine <= toLine) return true
}
return false
}
class WikilinkWidget extends WidgetType {
title: string
constructor(title: string) {
super()
this.title = title
}
toDOM() {
const span = document.createElement('span')
span.className = 'cm-wikilink'
span.textContent = this.title
span.dataset.wikilinkTarget = this.title
return span
}
eq(other: WikilinkWidget) {
return this.title === other.title
}
}
function buildDecorations(view: EditorView): DecorationSet {
const decs: Range<Decoration>[] = []
const doc = view.state.doc.toString()
let match
WIKILINK_RE.lastIndex = 0
while ((match = WIKILINK_RE.exec(doc)) !== null) {
const from = match.index
const to = from + match[0].length
const title = match[1]
if (isOnCursorLine(view, from, to)) continue
// Replace [[title]] with styled widget
decs.push(
Decoration.replace({ widget: new WikilinkWidget(title) }).range(from, to)
)
}
return Decoration.set(decs, true)
}
const wikilinkPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet
constructor(view: EditorView) {
this.decorations = buildDecorations(view)
}
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet || update.viewportChanged) {
this.decorations = buildDecorations(update.view)
}
}
},
{
decorations: (v) => v.decorations,
}
)
const wikilinkTheme = EditorView.theme({
'.cm-wikilink': {
color: '#4a9eff',
cursor: 'pointer',
borderBottom: '1px dotted #4a9eff50',
padding: '0 1px',
},
'.cm-wikilink:hover': {
borderBottomColor: '#4a9eff',
background: '#4a9eff15',
},
})
/**
* Create the wikilinks extension.
* @param onNavigate Callback when a wikilink is clicked (receives the link title)
*/
export function wikilinks(onNavigate: (target: string) => void): Extension {
// Use mousedown (not click) to catch the event before CM6 moves the cursor,
// which would remove the widget decoration and lose the click target.
const clickHandler = EditorView.domEventHandlers({
mousedown(event, _view) {
const el = event.target as HTMLElement
console.log('[wikilink] mousedown on:', el.className, el.textContent?.substring(0, 30))
if (el.classList.contains('cm-wikilink') && el.dataset.wikilinkTarget) {
event.preventDefault()
console.log('[wikilink] navigating to:', el.dataset.wikilinkTarget)
onNavigate(el.dataset.wikilinkTarget)
return true
}
return false
},
})
return [wikilinkPlugin, wikilinkTheme, clickHandler]
}