← 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 }}
    />
  );
}