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] +}