import { useRef, useEffect } from 'react' import { Decoration, type DecorationSet, EditorView, lineNumbers, highlightActiveLine, keymap, ViewPlugin, type ViewUpdate, } from '@codemirror/view' import { EditorState, Prec } from '@codemirror/state' import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' import { frontmatterHighlightPlugin, frontmatterHighlightTheme } from '../extensions/frontmatterHighlight' import { markdownLanguage } from '../extensions/markdownHighlight' import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce' import { resolveArrowLigatureInput } from '../utils/arrowLigatures' import { zoomCursorFix } from '../extensions/zoomCursorFix' import { nativeTextAssistanceDisabledAttributes } from '../lib/nativeTextAssistance' const FONT_FAMILY = '"JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' const RAW_EDITOR_COLORS = { activeLineBackground: 'var(--state-hover-subtle)', background: 'var(--surface-editor)', foreground: 'var(--text-primary)', gutterBackground: 'var(--surface-editor)', gutterBorder: 'var(--border-subtle)', gutterText: 'var(--text-muted)', } const AUTO_TEXT_DIRECTION_LINE = Decoration.line({ attributes: { dir: 'auto' }, }) interface MarkdownFence { character: '`' | '~' length: number } export interface CodeMirrorCallbacks { onDocChange: (doc: string) => void onCursorActivity: (view: EditorView) => void onSave: () => void onEscape: () => boolean } function readMarkdownFence(line: string): MarkdownFence | null { const match = /^( {0,3})(`{3,}|~{3,})/.exec(line) if (!match) return null const fence = match[2] return { character: fence[0] as MarkdownFence['character'], length: fence.length, } } function isClosingMarkdownFence(line: string, opening: MarkdownFence): boolean { const match = /^( {0,3})(`{3,}|~{3,})[ \t]*$/.exec(line) if (!match) return false const fence = match[2] return fence[0] === opening.character && fence.length >= opening.length } function isInsideMarkdownFence(markdownBeforeCursor: string): boolean { const lines = markdownBeforeCursor.split(/\r?\n/) let opening: MarkdownFence | null = null for (const line of lines) { if (opening) { if (isClosingMarkdownFence(line, opening)) opening = null continue } opening = readMarkdownFence(line) } return opening !== null } function buildBaseTheme() { return EditorView.theme({ '&': { fontSize: '13px', fontFamily: FONT_FAMILY, backgroundColor: RAW_EDITOR_COLORS.background, color: RAW_EDITOR_COLORS.foreground, flex: '1', minHeight: '0', }, '.cm-scroller': { fontFamily: FONT_FAMILY, lineHeight: '1.6', padding: '0', overflow: 'auto', }, '.cm-content': { padding: '16px 32px 16px 12px', caretColor: RAW_EDITOR_COLORS.foreground, }, '.cm-gutters': { backgroundColor: RAW_EDITOR_COLORS.gutterBackground, color: RAW_EDITOR_COLORS.gutterText, borderRight: `1px solid ${RAW_EDITOR_COLORS.gutterBorder}`, minHeight: '100%', paddingTop: '0', paddingLeft: '6px', }, '.cm-lineNumbers .cm-gutterElement': { paddingRight: '12px', minWidth: '28px', textAlign: 'right', }, '.cm-activeLine': { backgroundColor: RAW_EDITOR_COLORS.activeLineBackground, }, '.cm-activeLineGutter': { backgroundColor: RAW_EDITOR_COLORS.activeLineBackground, }, '&.cm-focused': { outline: 'none' }, '.cm-line': { padding: '0', unicodeBidi: 'plaintext', textAlign: 'start', }, }) } function buildAutoTextDirectionDecorations(view: EditorView): DecorationSet { const ranges = [] for (const visibleRange of view.visibleRanges) { for (let pos = visibleRange.from; pos <= visibleRange.to;) { const line = view.state.doc.lineAt(pos) ranges.push(AUTO_TEXT_DIRECTION_LINE.range(line.from)) pos = line.to + 1 } } return Decoration.set(ranges, true) } function buildAutoTextDirectionExtension() { return [ EditorView.perLineTextDirection.of(true), ViewPlugin.fromClass(class { decorations: DecorationSet constructor(view: EditorView) { this.decorations = buildAutoTextDirectionDecorations(view) } update(update: ViewUpdate) { if (update.docChanged || update.viewportChanged) { this.decorations = buildAutoTextDirectionDecorations(update.view) } } }, { decorations: plugin => plugin.decorations, }), ] } function buildSaveKeymap(callbacks: { current: CodeMirrorCallbacks }) { return Prec.highest(keymap.of([{ key: 'Mod-s', run: () => { callbacks.current.onSave(); return true }, }, { key: 'Escape', run: () => callbacks.current.onEscape(), }])) } function buildArrowLigaturesExtension() { let literalAsciiCursor: number | null = null return EditorView.inputHandler.of((view, from, _to, text) => { if (isInsideMarkdownFence(view.state.doc.sliceString(0, from))) { literalAsciiCursor = null return false } const beforeText = view.state.doc.sliceString(Math.max(0, from - 2), from) const resolution = resolveArrowLigatureInput({ beforeText, cursor: from, inputText: text, literalAsciiCursor, }) literalAsciiCursor = resolution.nextLiteralAsciiCursor if (!resolution.change) { return false } view.dispatch({ changes: { from: resolution.change.from, to: resolution.change.to, insert: resolution.change.insert, }, selection: { anchor: resolution.change.from + resolution.change.insert.length, }, userEvent: 'input.type', }) return true }) } 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, buildAutoTextDirectionExtension(), history(), buildArrowLigaturesExtension(), keymap.of([...defaultKeymap, ...historyKeymap]), buildSaveKeymap(callbacksRef), buildBaseTheme(), EditorView.cspNonce.of(RUNTIME_STYLE_NONCE), EditorView.contentAttributes.of(nativeTextAssistanceDisabledAttributes), 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 }