feat(site-builder): expose insertAtCursor/getValue handle on CodeEditor

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 19:24:29 -07:00
co-authored by Claude Opus 5
parent 45ee004672
commit d89930e218
2 changed files with 86 additions and 5 deletions
+36 -1
View File
@@ -2,7 +2,7 @@ import { describe, test, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { CodeEditor } from './CodeEditor';
import { CodeEditor, type CodeEditorHandle } from './CodeEditor';
/* ---------- DOM test harness (no @testing-library/react in this repo; see
src/ui/AssetPicker.test.tsx / src/ui/Modal.test.tsx for the same
@@ -88,3 +88,38 @@ describe('CodeEditor', () => {
expect((container.firstElementChild as HTMLElement).style.height).toBe('480px');
});
});
describe('CodeEditor imperative handle (textarea fallback mode)', () => {
test('insertAtCursor replaces the selection and emits onChange', async () => {
const onChange = vi.fn();
const ref = React.createRef<CodeEditorHandle>();
render(<CodeEditor ref={ref} value="<div></div>" onChange={onChange} />);
const ta = container.querySelector('[data-testid="code-editor-fallback"]') as HTMLTextAreaElement;
expect(ta).not.toBeNull();
ta.selectionStart = 5;
ta.selectionEnd = 5;
act(() => {
ref.current!.insertAtCursor('<p></p>');
});
expect(onChange).toHaveBeenCalledWith('<div><p></p></div>');
});
test('getValue returns the current document', () => {
const ref = React.createRef<CodeEditorHandle>();
render(<CodeEditor ref={ref} value="<span>x</span>" onChange={vi.fn()} />);
expect(ref.current!.getValue()).toBe('<span>x</span>');
});
test('caretOffset positions the caret inside the inserted snippet', () => {
const ref = React.createRef<CodeEditorHandle>();
render(<CodeEditor ref={ref} value="" onChange={vi.fn()} />);
const ta = container.querySelector('[data-testid="code-editor-fallback"]') as HTMLTextAreaElement;
act(() => {
ref.current!.insertAtCursor('<p></p>', 3);
});
expect(ta.selectionStart).toBe(3);
});
});
+50 -4
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import type { EditorView as EditorViewType } from '@codemirror/view';
export type CodeEditorLanguage = 'html' | 'css' | 'javascript' | 'auto';
@@ -14,6 +14,15 @@ export interface CodeEditorProps {
placeholder?: string;
}
export interface CodeEditorHandle {
/** Replace the current selection (or insert at the caret) with `text`.
* Emits onChange. If `caretOffset` is given, the caret lands that many
* characters after the insertion start instead of at its end. */
insertAtCursor(text: string, caretOffset?: number): void;
/** Current document text. */
getValue(): string;
}
/* ----------------------------------------------------------------
Lazy-loaded CodeMirror 6.
@@ -133,15 +142,16 @@ const fallbackStyle: React.CSSProperties = {
boxSizing: 'border-box',
};
export const CodeEditor: React.FC<CodeEditorProps> = ({
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function CodeEditor({
value,
onChange,
language = 'html',
height = 320,
placeholder,
}) => {
}: CodeEditorProps, ref) {
const containerRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorViewType | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
// Tracks the last value this component itself emitted, so the
@@ -226,6 +236,41 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
lastEmittedRef.current = value;
}, [value, status]);
useImperativeHandle(ref, (): CodeEditorHandle => ({
getValue: () => {
const view = viewRef.current;
if (view) return view.state.doc.toString();
return textareaRef.current?.value ?? value;
},
insertAtCursor: (text: string, caretOffset?: number) => {
const view = viewRef.current;
if (view) {
const { from, to } = view.state.selection.main;
const caret = from + (caretOffset ?? text.length);
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: caret },
});
view.focus();
return;
}
// Textarea fallback: CodeMirror never mounted (still loading, or its
// lazy chunks failed). The toolbar must keep working either way.
const ta = textareaRef.current;
if (!ta) return;
const from = ta.selectionStart ?? ta.value.length;
const to = ta.selectionEnd ?? from;
const next = ta.value.slice(0, from) + text + ta.value.slice(to);
const caret = from + (caretOffset ?? text.length);
lastEmittedRef.current = next;
onChangeRef.current(next);
ta.value = next;
ta.selectionStart = caret;
ta.selectionEnd = caret;
ta.focus();
},
}), [value]);
const showFallback = status !== 'ready';
return (
@@ -243,6 +288,7 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
/>
{showFallback && (
<textarea
ref={textareaRef}
data-testid="code-editor-fallback"
data-language={language}
value={value}
@@ -254,4 +300,4 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
)}
</div>
);
};
});