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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user