diff --git a/craft/src/ui/CodeEditor.test.tsx b/craft/src/ui/CodeEditor.test.tsx index 7c8516f..7fa5b8d 100644 --- a/craft/src/ui/CodeEditor.test.tsx +++ b/craft/src/ui/CodeEditor.test.tsx @@ -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(); + render(); + + 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('

'); + }); + + expect(onChange).toHaveBeenCalledWith('

'); + }); + + test('getValue returns the current document', () => { + const ref = React.createRef(); + render(); + expect(ref.current!.getValue()).toBe('x'); + }); + + test('caretOffset positions the caret inside the inserted snippet', () => { + const ref = React.createRef(); + render(); + const ta = container.querySelector('[data-testid="code-editor-fallback"]') as HTMLTextAreaElement; + act(() => { + ref.current!.insertAtCursor('

', 3); + }); + expect(ta.selectionStart).toBe(3); + }); +}); diff --git a/craft/src/ui/CodeEditor.tsx b/craft/src/ui/CodeEditor.tsx index 44ac16a..d3c16b1 100644 --- a/craft/src/ui/CodeEditor.tsx +++ b/craft/src/ui/CodeEditor.tsx @@ -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 = ({ +export const CodeEditor = forwardRef(function CodeEditor({ value, onChange, language = 'html', height = 320, placeholder, -}) => { +}: CodeEditorProps, ref) { const containerRef = useRef(null); const viewRef = useRef(null); + const textareaRef = useRef(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 = ({ 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 = ({ /> {showFallback && (