diff --git a/e2e/screenshot.spec.ts b/e2e/screenshot.spec.ts
index 5a2a8e4c..9892349a 100644
--- a/e2e/screenshot.spec.ts
+++ b/e2e/screenshot.spec.ts
@@ -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 })
+ }
+})
diff --git a/src/App.tsx b/src/App.tsx
index aa31fc59..44aca661 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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}
/>
{!inspectorCollapsed && }
diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx
index 134da751..980d236c 100644
--- a/src/components/Editor.tsx
+++ b/src/components/Editor.tsx
@@ -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(null)
const viewRef = useRef(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,
],
diff --git a/src/components/wikilinks.ts b/src/components/wikilinks.ts
new file mode 100644
index 00000000..3a7c39ef
--- /dev/null
+++ b/src/components/wikilinks.ts
@@ -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[] = []
+ 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]
+}