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:
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
13
src/App.tsx
13
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}
|
||||
/>
|
||||
</div>
|
||||
{!inspectorCollapsed && <ResizeHandle onResize={handleInspectorResize} />}
|
||||
|
||||
@@ -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
121
src/components/wikilinks.ts
Normal 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]
|
||||
}
|
||||
Reference in New Issue
Block a user