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 React from 'react';
|
||||||
import { createRoot, Root } from 'react-dom/client';
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
import { act } from 'react-dom/test-utils';
|
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
|
/* ---------- DOM test harness (no @testing-library/react in this repo; see
|
||||||
src/ui/AssetPicker.test.tsx / src/ui/Modal.test.tsx for the same
|
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');
|
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';
|
import type { EditorView as EditorViewType } from '@codemirror/view';
|
||||||
|
|
||||||
export type CodeEditorLanguage = 'html' | 'css' | 'javascript' | 'auto';
|
export type CodeEditorLanguage = 'html' | 'css' | 'javascript' | 'auto';
|
||||||
@@ -14,6 +14,15 @@ export interface CodeEditorProps {
|
|||||||
placeholder?: string;
|
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.
|
Lazy-loaded CodeMirror 6.
|
||||||
|
|
||||||
@@ -133,15 +142,16 @@ const fallbackStyle: React.CSSProperties = {
|
|||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CodeEditor: React.FC<CodeEditorProps> = ({
|
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function CodeEditor({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
language = 'html',
|
language = 'html',
|
||||||
height = 320,
|
height = 320,
|
||||||
placeholder,
|
placeholder,
|
||||||
}) => {
|
}: CodeEditorProps, ref) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const viewRef = useRef<EditorViewType | null>(null);
|
const viewRef = useRef<EditorViewType | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const onChangeRef = useRef(onChange);
|
const onChangeRef = useRef(onChange);
|
||||||
onChangeRef.current = onChange;
|
onChangeRef.current = onChange;
|
||||||
// Tracks the last value this component itself emitted, so the
|
// Tracks the last value this component itself emitted, so the
|
||||||
@@ -226,6 +236,41 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
|
|||||||
lastEmittedRef.current = value;
|
lastEmittedRef.current = value;
|
||||||
}, [value, status]);
|
}, [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';
|
const showFallback = status !== 'ready';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -243,6 +288,7 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
|
|||||||
/>
|
/>
|
||||||
{showFallback && (
|
{showFallback && (
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
data-testid="code-editor-fallback"
|
data-testid="code-editor-fallback"
|
||||||
data-language={language}
|
data-language={language}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -254,4 +300,4 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user