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>
156 lines
4.4 KiB
TypeScript
156 lines
4.4 KiB
TypeScript
import { useEffect, useRef } from 'react'
|
||
import { EditorState } from '@codemirror/state'
|
||
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from '@codemirror/view'
|
||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||
import { markdown } from '@codemirror/lang-markdown'
|
||
import { syntaxHighlighting, defaultHighlightStyle, bracketMatching } from '@codemirror/language'
|
||
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'
|
||
|
||
interface Tab {
|
||
entry: VaultEntry
|
||
content: string
|
||
}
|
||
|
||
interface EditorProps {
|
||
tabs: Tab[]
|
||
activeTabPath: string | null
|
||
onSwitchTab: (path: string) => void
|
||
onCloseTab: (path: string) => void
|
||
onNavigateWikilink: (target: string) => void
|
||
}
|
||
|
||
const editorTheme = EditorView.theme({
|
||
'&': {
|
||
height: '100%',
|
||
fontSize: '14px',
|
||
},
|
||
'.cm-scroller': {
|
||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||
padding: '16px 0',
|
||
},
|
||
'.cm-content': {
|
||
padding: '0 24px',
|
||
maxWidth: '800px',
|
||
},
|
||
'.cm-gutters': {
|
||
background: '#0f0f1a',
|
||
border: 'none',
|
||
color: '#444',
|
||
},
|
||
'.cm-activeLineGutter': {
|
||
background: '#1a1a2e',
|
||
},
|
||
'.cm-activeLine': {
|
||
background: '#1a1a2e',
|
||
},
|
||
'.cm-cursor': {
|
||
borderLeftColor: '#e0e0e0',
|
||
},
|
||
'.cm-selectionBackground': {
|
||
background: '#2a2a5a !important',
|
||
},
|
||
'&.cm-focused .cm-selectionBackground': {
|
||
background: '#2a2a5a !important',
|
||
},
|
||
})
|
||
|
||
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
|
||
|
||
// Create/destroy editor view when active tab changes
|
||
useEffect(() => {
|
||
if (!containerRef.current || !activeTab) return
|
||
|
||
// If view already exists for this tab, skip
|
||
if (viewRef.current) {
|
||
viewRef.current.destroy()
|
||
viewRef.current = null
|
||
}
|
||
|
||
// Place cursor after frontmatter so it starts hidden
|
||
const fmRange = findFrontmatter(activeTab.content)
|
||
const initialCursor = fmRange ? fmRange[1] : 0
|
||
|
||
const state = EditorState.create({
|
||
doc: activeTab.content,
|
||
selection: { anchor: initialCursor },
|
||
extensions: [
|
||
lineNumbers(),
|
||
highlightActiveLine(),
|
||
highlightActiveLineGutter(),
|
||
history(),
|
||
bracketMatching(),
|
||
markdown(),
|
||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||
oneDark,
|
||
editorTheme,
|
||
livePreview(),
|
||
frontmatterHide(),
|
||
wikilinks((target) => navigateRef.current(target)),
|
||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||
EditorView.lineWrapping,
|
||
],
|
||
})
|
||
|
||
const view = new EditorView({
|
||
state,
|
||
parent: containerRef.current,
|
||
})
|
||
|
||
viewRef.current = view
|
||
|
||
return () => {
|
||
view.destroy()
|
||
viewRef.current = null
|
||
}
|
||
// Re-create when active tab path changes OR when tab data becomes available
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [activeTabPath, activeTab?.content])
|
||
|
||
if (tabs.length === 0) {
|
||
return (
|
||
<div className="editor">
|
||
<div className="editor__placeholder">
|
||
<p>Select a note to start editing</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="editor">
|
||
<div className="editor__tab-bar">
|
||
{tabs.map((tab) => (
|
||
<div
|
||
key={tab.entry.path}
|
||
className={`editor__tab${tab.entry.path === activeTabPath ? ' editor__tab--active' : ''}`}
|
||
onClick={() => onSwitchTab(tab.entry.path)}
|
||
>
|
||
<span className="editor__tab-title">{tab.entry.title}</span>
|
||
<button
|
||
className="editor__tab-close"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onCloseTab(tab.entry.path)
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="editor__cm-container" ref={containerRef} />
|
||
</div>
|
||
)
|
||
}
|