← back to Norma
components/editor/HtmlSourceEditorClient.tsx
116 lines
'use client';
import { useEffect, useRef } from 'react';
import { EditorState } from '@codemirror/state';
import { EditorView, lineNumbers, keymap } from '@codemirror/view';
import { defaultKeymap, historyKeymap, history } from '@codemirror/commands';
import { bracketMatching } from '@codemirror/language';
import { html } from '@codemirror/lang-html';
import { oneDark } from '@codemirror/theme-one-dark';
interface HtmlSourceEditorClientProps {
value: string;
onChange: (value: string) => void;
}
export default function HtmlSourceEditorClient({ value, onChange }: HtmlSourceEditorClientProps) {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
const externalUpdateRef = useRef(false);
/* Keep callback ref up-to-date without re-creating the editor */
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
/* Build the editor once on mount */
useEffect(() => {
if (!containerRef.current || viewRef.current) return;
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged && !externalUpdateRef.current) {
onChangeRef.current(update.state.doc.toString());
}
});
const state = EditorState.create({
doc: value,
extensions: [
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
lineNumbers(),
bracketMatching(),
html(),
oneDark,
updateListener,
EditorView.theme({
'&': {
backgroundColor: 'var(--color-surface-el)',
fontSize: '0.875rem',
},
'.cm-scroller': {
fontFamily:
'ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace',
lineHeight: '1.6',
},
'.cm-content': {
minHeight: '300px',
caretColor: 'var(--color-primary)',
},
'.cm-gutters': {
backgroundColor: 'rgba(0,0,0,0.2)',
borderRight: '1px solid var(--color-border)',
color: 'var(--color-text-muted)',
},
'.cm-activeLineGutter': {
backgroundColor: 'rgba(16, 185, 129, 0.12)',
},
'.cm-activeLine': {
backgroundColor: 'rgba(16, 185, 129, 0.06)',
},
'.cm-selectionBackground': {
backgroundColor: 'rgba(16, 185, 129, 0.3) !important',
},
'.cm-cursor': {
borderLeftColor: 'var(--color-primary)',
},
}),
EditorView.lineWrapping,
],
});
const view = new EditorView({ state, parent: containerRef.current });
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/* Sync external value changes into CodeMirror without clobbering undo stack */
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const current = view.state.doc.toString();
if (current === value) return;
externalUpdateRef.current = true;
view.dispatch({
changes: { from: 0, to: current.length, insert: value },
});
externalUpdateRef.current = false;
}, [value]);
return (
<div
ref={containerRef}
className="cm-container"
aria-label="HTML source editor"
style={{ minHeight: 300 }}
/>
);
}