291 lines
8.6 KiB
TypeScript
291 lines
8.6 KiB
TypeScript
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<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,
|
|
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
|
|
}
|