Remove all theme switching, creation, and management functionality from the app — backend (Rust theme module, Tauri commands, menu items, startup tasks, vault seeding), frontend (useThemeManager, ThemePropertyEditor, themeSchema, command palette Appearance group, settings panel appearance section, isDarkTheme prop chain), mock data, and all related tests. Editor styling via theme.json/EditorTheme.css is preserved (not user theming). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
151 lines
4.7 KiB
TypeScript
151 lines
4.7 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 { zoomCursorFix } from '../extensions/zoomCursorFix'
|
|
|
|
const FONT_FAMILY = '"Berkeley Mono", "JetBrains Mono", "Fira 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(),
|
|
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
|
|
}
|