Wire up @codemirror/lang-markdown with a custom HighlightStyle to highlight headings, bold, italic, strikethrough, links, lists, blockquotes, and inline code in the raw CodeMirror editor. The custom frontmatter plugin is kept for YAML highlighting; its heading decoration is removed in favour of the language-based parser. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
153 lines
4.8 KiB
TypeScript
153 lines
4.8 KiB
TypeScript
import { useRef, useEffect } from 'react'
|
|
import { EditorView, lineNumbers, highlightActiveLine, keymap } from '@codemirror/view'
|
|
import { EditorState } from '@codemirror/state'
|
|
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
|
import { frontmatterHighlightPlugin, frontmatterHighlightTheme } from '../extensions/frontmatterHighlight'
|
|
import { markdownLanguage } from '../extensions/markdownHighlight'
|
|
import { zoomCursorFix } from '../extensions/zoomCursorFix'
|
|
|
|
const FONT_FAMILY = '"JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
|
|
|
export interface CodeMirrorCallbacks {
|
|
onDocChange: (doc: string) => void
|
|
onCursorActivity: (view: EditorView) => void
|
|
onSave: () => void
|
|
onEscape: () => boolean
|
|
}
|
|
|
|
function buildBaseTheme() {
|
|
const bg = '#ffffff'
|
|
const fg = '#1e1e1e'
|
|
const gutterBg = '#ffffff'
|
|
const gutterColor = '#aaa'
|
|
const activeLineBg = 'rgba(0,100,255,0.06)'
|
|
const gutterBorder = '#eee'
|
|
|
|
return EditorView.theme({
|
|
'&': {
|
|
fontSize: '13px',
|
|
fontFamily: FONT_FAMILY,
|
|
backgroundColor: bg,
|
|
color: fg,
|
|
flex: '1',
|
|
minHeight: '0',
|
|
},
|
|
'.cm-scroller': {
|
|
fontFamily: FONT_FAMILY,
|
|
lineHeight: '1.6',
|
|
padding: '16px 0',
|
|
overflow: 'auto',
|
|
},
|
|
'.cm-content': {
|
|
padding: '0 32px 0 16px',
|
|
caretColor: fg,
|
|
},
|
|
'.cm-gutters': {
|
|
backgroundColor: gutterBg,
|
|
color: gutterColor,
|
|
borderRight: `1px solid ${gutterBorder}`,
|
|
paddingLeft: '16px',
|
|
},
|
|
'.cm-lineNumbers .cm-gutterElement': {
|
|
paddingRight: '12px',
|
|
minWidth: '28px',
|
|
textAlign: 'right',
|
|
},
|
|
'.cm-activeLine': {
|
|
backgroundColor: activeLineBg,
|
|
},
|
|
'.cm-activeLineGutter': {
|
|
backgroundColor: activeLineBg,
|
|
},
|
|
'&.cm-focused': { outline: 'none' },
|
|
'.cm-line': { padding: '0' },
|
|
})
|
|
}
|
|
|
|
function buildSaveKeymap(callbacks: { current: CodeMirrorCallbacks }) {
|
|
return keymap.of([{
|
|
key: 'Mod-s',
|
|
run: () => { callbacks.current.onSave(); return true },
|
|
}, {
|
|
key: 'Escape',
|
|
run: () => callbacks.current.onEscape(),
|
|
}])
|
|
}
|
|
|
|
export function useCodeMirror(
|
|
containerRef: React.RefObject<HTMLDivElement | null>,
|
|
content: string,
|
|
callbacks: CodeMirrorCallbacks,
|
|
) {
|
|
const viewRef = useRef<EditorView | null>(null)
|
|
const callbacksRef = useRef(callbacks)
|
|
callbacksRef.current = callbacks
|
|
// Track whether we're dispatching an external sync so the updateListener skips it
|
|
const externalSyncRef = useRef(false)
|
|
|
|
// Sync content prop changes to the editor (e.g. after frontmatter update on disk)
|
|
useEffect(() => {
|
|
const view = viewRef.current
|
|
if (!view) return
|
|
const current = view.state.doc.toString()
|
|
if (current === content) return
|
|
externalSyncRef.current = true
|
|
view.dispatch({ changes: { from: 0, to: current.length, insert: content } })
|
|
externalSyncRef.current = false
|
|
}, [content])
|
|
|
|
useEffect(() => {
|
|
const parent = containerRef.current
|
|
if (!parent) return
|
|
|
|
const state = EditorState.create({
|
|
doc: content,
|
|
extensions: [
|
|
lineNumbers(),
|
|
highlightActiveLine(),
|
|
EditorView.lineWrapping,
|
|
history(),
|
|
keymap.of([...defaultKeymap, ...historyKeymap]),
|
|
buildSaveKeymap(callbacksRef),
|
|
buildBaseTheme(),
|
|
markdownLanguage(),
|
|
frontmatterHighlightTheme(),
|
|
frontmatterHighlightPlugin,
|
|
zoomCursorFix(),
|
|
EditorView.updateListener.of((update) => {
|
|
if (update.docChanged && !externalSyncRef.current) {
|
|
callbacksRef.current.onDocChange(update.state.doc.toString())
|
|
}
|
|
if (update.selectionSet || update.docChanged) {
|
|
callbacksRef.current.onCursorActivity(update.view)
|
|
}
|
|
}),
|
|
],
|
|
})
|
|
|
|
const view = new EditorView({ state, parent })
|
|
viewRef.current = view
|
|
// Expose EditorView on the parent DOM for Playwright test access
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
;(parent as any).__cmView = view
|
|
|
|
// When CSS zoom changes on the document, CodeMirror's cached measurements
|
|
// (scaleX/scaleY, line heights, character widths) become stale because
|
|
// ResizeObserver doesn't fire for ancestor zoom changes. Force a re-measure
|
|
// so cursor placement stays accurate at any zoom level.
|
|
const handleZoomChange = () => { view.requestMeasure() }
|
|
window.addEventListener('laputa-zoom-change', handleZoomChange)
|
|
|
|
return () => {
|
|
window.removeEventListener('laputa-zoom-change', handleZoomChange)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
delete (parent as any).__cmView
|
|
view.destroy()
|
|
viewRef.current = null
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
return viewRef
|
|
}
|