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, content: string, callbacks: CodeMirrorCallbacks, ) { const viewRef = useRef(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 }