diff --git a/docs/app/docs-infra/hooks/page.mdx b/docs/app/docs-infra/hooks/page.mdx index 5736f8c75..eeca3964c 100644 --- a/docs/app/docs-infra/hooks/page.mdx +++ b/docs/app/docs-infra/hooks/page.mdx @@ -91,10 +91,12 @@ The `useCode` hook provides programmatic access to code display, editing, and tr - File Navigation Issues - Hash Behavior Not Working as Expected - Exports: + - preloadCodeEditor + - Parameters: loader - useCode - Parameters: contentProps, opts - useCodeComponents -- Types: CodeComponentsContext, UseCodeOpts, UseCodeResult +- Types: CodeComponentsContext, CodeEditorProps, UseCodeOpts, UseCodeResult diff --git a/docs/app/docs-infra/hooks/use-code/types.md b/docs/app/docs-infra/hooks/use-code/types.md index 84c769779..700320857 100644 --- a/docs/app/docs-infra/hooks/use-code/types.md +++ b/docs/app/docs-infra/hooks/use-code/types.md @@ -4,6 +4,22 @@ ## API Reference +### preloadCodeEditor + +Warms the editor chunk ahead of first focus. Fails open. + +**Parameters:** + +| Parameter | Type | Default | Description | +| :-------- | :----------------- | :------ | :---------- | +| loader? | `CodeEditorLoader` | - | - | + +**Return Value:** + +```tsx +type ReturnValue = Promise; +``` + ### useCode **useCode Parameters:** @@ -35,6 +51,40 @@ type ReturnValue = Partial | undefined; type CodeComponentsContext = React.Context | undefined>; ``` +### CodeEditorProps + +A transparent textarea laid over an already-highlighted `
`. The textarea
+owns the text, so selection, undo/redo, IME, and spellcheck stay native; the
+`
` beneath it keeps painting, frames and all.
+
+Nothing is highlighted here. An edit goes out through `setSource`, the host
+re-parses, and the `
` re-renders from the new tree — which is what keeps
+emphasis frames, collapse placeholders, and the intersection-driven frame
+hydration working while editing.
+
+Indent and outdent go through `document.execCommand('insertText')` rather than
+a direct value write, which is what keeps them on the browser's native undo
+stack. The `inputType` vocabulary used to classify edits follows the approach
+in Pierre's editor.
+
+```typescript
+type CodeEditorProps = {
+  /** Complete source, matching the text painted by the `
` underneath. */
+  source: string;
+  /** Canonical file name reported back through `setSource`. */
+  fileName?: string;
+  language?: string;
+  /** Spaces inserted by Tab. */
+  tabSize?: number;
+  setSource: SetSource;
+  /** Fired on first focus, so the host can warm the live runtime. */
+  onActivate?: () => void;
+  /** Fired on Escape, so the host can move focus out. */
+  onExit?: () => void;
+  onReady?: (textarea: HTMLTextAreaElement | null) => void;
+};
+```
+
 ### UseCodeOpts
 
 ```typescript
@@ -285,3 +335,25 @@ type SourceEnhancer = (
   fileName: string,
 ) => { data?: unknown | undefined } | Promise;
 ```
+
+### SetSource
+
+```typescript
+type SetSource = (
+  source: string,
+  fileName?: string | undefined,
+  position?:
+    | {
+        position: number;
+        extent: number;
+        content: string;
+        line: number;
+        history?: 'undo' | 'redo' | undefined;
+        historyPivotLine?: number | undefined;
+        deletedFromLineStart?: boolean | undefined;
+        backward?: boolean | undefined;
+      }
+    | undefined,
+  preParsed?: Root | undefined,
+) => void;
+```
diff --git a/packages/docs-infra/src/CodeProvider/CodeContext.tsx b/packages/docs-infra/src/CodeProvider/CodeContext.tsx
index 5dbe1ba31..723e70468 100644
--- a/packages/docs-infra/src/CodeProvider/CodeContext.tsx
+++ b/packages/docs-infra/src/CodeProvider/CodeContext.tsx
@@ -18,6 +18,7 @@ import type {
 import type { ParseSourceAsync } from './createParseSourceWorkerClient';
 import type { PreParsedCacheEntry } from '../CodeHighlighter/CodeHighlighterContext';
 import type { EditingEngineLoader } from '../useCode/editingEngineCache';
+import type { CodeEditorLoader } from '../useCode/codeEditorCache';
 import type { CreateTransformedFiles } from '../useCode/TransformEngine';
 
 // Type definitions for the heavy functions we're moving to context
@@ -125,6 +126,8 @@ export interface CodeContext {
    * `editActivation: 'interaction'`.
    */
   editingEngineLoader?: EditingEngineLoader;
+  /** Lazily loads the textarea editor. A read-only block never calls this. */
+  codeEditorLoader?: CodeEditorLoader;
 }
 
 export const CodeContext = React.createContext({});
diff --git a/packages/docs-infra/src/CodeProvider/CodeProvider.test.tsx b/packages/docs-infra/src/CodeProvider/CodeProvider.test.tsx
index 1ccc5d57b..980a156ff 100644
--- a/packages/docs-infra/src/CodeProvider/CodeProvider.test.tsx
+++ b/packages/docs-infra/src/CodeProvider/CodeProvider.test.tsx
@@ -43,10 +43,9 @@ describe('CodeProvider (eager)', () => {
     await expect(ctx.loadCodeFallbackLoader!()).resolves.toBeTypeOf('function');
     await expect(ctx.computeHastDeltasLoader!()).resolves.toBeTypeOf('function');
     // The editing engine is bundled eagerly here, so its accessor resolves
-    // instantly to ONE module exposing both the contentEditable engine and the
-    // edit-time source-manipulation fns (proving they share a single chunk).
+    // instantly to the edit-time source-manipulation fns. The editing surface
+    // lives in its own chunk, reached through `codeEditorLoader`.
     const editingModule = await ctx.editingEngineLoader!();
-    expect(editingModule.createEditableEngine).toBeTypeOf('function');
     expect(editingModule.analyzeSource).toBeTypeOf('function');
     expect(editingModule.toControlledCode).toBeTypeOf('function');
     // The transform applier (jsondiffpatch path) resolves to `createTransformedFiles`.
diff --git a/packages/docs-infra/src/CodeProvider/CodeProvider.tsx b/packages/docs-infra/src/CodeProvider/CodeProvider.tsx
index 58b8f0738..1f6d9f261 100644
--- a/packages/docs-infra/src/CodeProvider/CodeProvider.tsx
+++ b/packages/docs-infra/src/CodeProvider/CodeProvider.tsx
@@ -26,6 +26,7 @@ import { loadIsomorphicCodeVariant } from '../pipeline/loadIsomorphicCodeVariant
 import { computeHastDeltas } from '../pipeline/loadIsomorphicCodeVariant/computeHastDeltas';
 import * as EditingEngine from '../useCode/EditingEngine';
 import type { EditingEngineLoader } from '../useCode/editingEngineCache';
+import type { CodeEditorLoader } from '../useCode/codeEditorCache';
 import { createTransformedFiles } from '../useCode/TransformEngine';
 // Eager: the emphasis enhancer is bundled so the synchronous editing
 // re-enhancement path has it with no fetch (zero-latency invariant).
@@ -41,6 +42,9 @@ const loadVariantLoaderEager: LoadVariantLoader = () => Promise.resolve(loadIsom
 const computeHastDeltasLoaderEager: ComputeHastDeltasLoader = () =>
   Promise.resolve(computeHastDeltas);
 const editingEngineLoaderEager: EditingEngineLoader = () => Promise.resolve(EditingEngine);
+// The editor stays code-split even in the eager provider: bundling it would pull
+// it into every page that renders a read-only code block.
+const codeEditorLoaderEager: CodeEditorLoader = () => import('../useCode/CodeEditor');
 const transformEngineLoaderEager: TransformEngineLoader = () =>
   Promise.resolve(createTransformedFiles);
 
@@ -77,6 +81,7 @@ export function CodeProvider({
       loadIsomorphicCodeVariantLoader: loadVariantLoaderEager,
       computeHastDeltasLoader: computeHastDeltasLoaderEager,
       editingEngineLoader: editingEngineLoaderEager,
+      codeEditorLoader: codeEditorLoaderEager,
       transformEngineLoader: transformEngineLoaderEager,
       defaultSourceEnhancers: [enhanceCodeEmphasis],
     }),
diff --git a/packages/docs-infra/src/CodeProvider/CodeProviderLazy.test.tsx b/packages/docs-infra/src/CodeProvider/CodeProviderLazy.test.tsx
index 880df5eda..411646bf7 100644
--- a/packages/docs-infra/src/CodeProvider/CodeProviderLazy.test.tsx
+++ b/packages/docs-infra/src/CodeProvider/CodeProviderLazy.test.tsx
@@ -35,13 +35,15 @@ describe('CodeProviderLazy', () => {
     await expect(ctx.loadIsomorphicCodeVariantLoader!()).resolves.toBeTypeOf('function');
     await expect(ctx.loadCodeFallbackLoader!()).resolves.toBeTypeOf('function');
     await expect(ctx.computeHastDeltasLoader!()).resolves.toBeTypeOf('function');
-    // The editing engine is the 4th lazy accessor (dynamic-import-backed), resolving
-    // to ONE module exposing both the contentEditable engine and the edit-time
-    // source-manipulation fns (proving they share a single dynamically-loaded chunk).
+    // The editing engine is the 4th lazy accessor (dynamic-import-backed),
+    // resolving to the edit-time source-manipulation fns.
     const editingModule = await ctx.editingEngineLoader!();
-    expect(editingModule.createEditableEngine).toBeTypeOf('function');
     expect(editingModule.analyzeSource).toBeTypeOf('function');
     expect(editingModule.toControlledCode).toBeTypeOf('function');
+    // The editing surface is a separate chunk so a programmatic-only editor
+    // never pulls it in.
+    const editorModule = await ctx.codeEditorLoader!();
+    expect(editorModule.CodeEditor).toBeTypeOf('function');
     // The transform applier (jsondiffpatch path) is dynamic-import-backed too,
     // resolving to `createTransformedFiles`.
     await expect(ctx.transformEngineLoader!()).resolves.toBeTypeOf('function');
diff --git a/packages/docs-infra/src/CodeProvider/CodeProviderLazy.tsx b/packages/docs-infra/src/CodeProvider/CodeProviderLazy.tsx
index 94c54e0ae..fa2217b79 100644
--- a/packages/docs-infra/src/CodeProvider/CodeProviderLazy.tsx
+++ b/packages/docs-infra/src/CodeProvider/CodeProviderLazy.tsx
@@ -20,11 +20,13 @@ import { enhanceCodeEmphasisLazy } from '../pipeline/enhanceCodeEmphasis/enhance
 import {
   PRELOAD_KEY_COMPUTE_DELTAS,
   PRELOAD_KEY_EDITING,
+  PRELOAD_KEY_CODE_EDITOR,
   PRELOAD_KEY_LOAD_FALLBACK,
   PRELOAD_KEY_LOAD_VARIANT,
   PRELOAD_KEY_TRANSFORM_ENGINE,
   computeHastDeltasFactory,
   editingEngineFactory,
+  codeEditorFactory,
   loadFallbackFactory,
   loadVariantFactory,
   transformEngineFactory,
@@ -121,6 +123,7 @@ function CodeProviderLazyInner({
       loadIsomorphicCodeVariantLoader: () => preload(PRELOAD_KEY_LOAD_VARIANT, loadVariantFactory),
       computeHastDeltasLoader: () => preload(PRELOAD_KEY_COMPUTE_DELTAS, computeHastDeltasFactory),
       editingEngineLoader: () => preload(PRELOAD_KEY_EDITING, editingEngineFactory),
+      codeEditorLoader: () => preload(PRELOAD_KEY_CODE_EDITOR, codeEditorFactory),
       transformEngineLoader: () => preload(PRELOAD_KEY_TRANSFORM_ENGINE, transformEngineFactory),
       defaultSourceEnhancers: [enhanceCodeEmphasisLazy],
     }),
diff --git a/packages/docs-infra/src/CodeProvider/constants.ts b/packages/docs-infra/src/CodeProvider/constants.ts
index 8eb0ef9cf..4ce7ffaba 100644
--- a/packages/docs-infra/src/CodeProvider/constants.ts
+++ b/packages/docs-infra/src/CodeProvider/constants.ts
@@ -1,5 +1,6 @@
 import type { LoadFallbackCodeFn, LoadVariantFn, ComputeHastDeltasFn } from './CodeContext';
 import type { EditingEngineModule } from '../useCode/editingEngineCache';
+import type { CodeEditorModule } from '../useCode/codeEditorCache';
 import type { CreateTransformedFiles } from '../useCode/TransformEngine';
 
 /**
@@ -31,6 +32,7 @@ export const computeHastDeltasFactory = async (): Promise =
   (await import('../pipeline/loadIsomorphicCodeVariant/computeHastDeltas')).computeHastDeltas;
 
 export const PRELOAD_KEY_EDITING = 'docs-infra/editingEngine';
+export const PRELOAD_KEY_CODE_EDITOR = 'docs-infra/codeEditor';
 
 export const editingEngineFactory = async (): Promise =>
   import('../useCode/EditingEngine');
@@ -39,3 +41,6 @@ export const PRELOAD_KEY_TRANSFORM_ENGINE = 'docs-infra/transformEngine';
 
 export const transformEngineFactory = async (): Promise =>
   (await import('../useCode/TransformEngine')).createTransformedFiles;
+
+export const codeEditorFactory = async (): Promise =>
+  import('../useCode/CodeEditor');
diff --git a/packages/docs-infra/src/CodeProvider/useCodeProviderValue.ts b/packages/docs-infra/src/CodeProvider/useCodeProviderValue.ts
index d1e65c2ad..bb25d62ac 100644
--- a/packages/docs-infra/src/CodeProvider/useCodeProviderValue.ts
+++ b/packages/docs-infra/src/CodeProvider/useCodeProviderValue.ts
@@ -19,6 +19,7 @@ import type {
   TransformEngineLoader,
 } from './CodeContext';
 import type { EditingEngineLoader } from '../useCode/editingEngineCache';
+import type { CodeEditorLoader } from '../useCode/codeEditorCache';
 
 /**
  * The host-supplied source loaders. Identical for both providers (passed by the
@@ -47,6 +48,7 @@ export interface CodeProviderHeavyAccessors {
   loadIsomorphicCodeVariantLoader: LoadVariantLoader;
   computeHastDeltasLoader: ComputeHastDeltasLoader;
   editingEngineLoader: EditingEngineLoader;
+  codeEditorLoader: CodeEditorLoader;
   transformEngineLoader: TransformEngineLoader;
   /**
    * Provider-specific default source enhancers. The eager `CodeProvider` passes
diff --git a/packages/docs-infra/src/useCode/CodeEditor.test.tsx b/packages/docs-infra/src/useCode/CodeEditor.test.tsx
new file mode 100644
index 000000000..1879f9ddd
--- /dev/null
+++ b/packages/docs-infra/src/useCode/CodeEditor.test.tsx
@@ -0,0 +1,233 @@
+/**
+ * @vitest-environment jsdom
+ */
+import * as React from 'react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import type { HastRoot } from '../CodeHighlighter/types';
+import { CodeContext } from '../CodeProvider/CodeContext';
+import { CodeEditor } from './CodeEditor';
+
+function textarea() {
+  return screen.getByRole('textbox') as HTMLTextAreaElement;
+}
+
+/** Types into the textarea the way the browser does — value then `input`. */
+function type(element: HTMLTextAreaElement, value: string, caret = value.length) {
+  element.value = value;
+  element.setSelectionRange(caret, caret);
+  fireEvent.input(element);
+}
+
+describe('CodeEditor', () => {
+  it('seeds the textarea from the painted source', () => {
+    render( {}} />);
+    expect(textarea().value).toBe('const value = 1;');
+  });
+
+  it('keeps the source out of its own DOM subtree', () => {
+    // The textarea renders inside the `
`, so a `defaultValue` would become
+    // a child text node and `pre.textContent` would report the source twice.
+    render( {}} />);
+    expect(textarea().textContent).toBe('');
+  });
+
+  it('reports edited source with the caret position', () => {
+    const setSource = vi.fn();
+    render();
+
+    type(textarea(), 'const value = 2;');
+
+    expect(setSource).toHaveBeenCalledWith(
+      'const value = 2;',
+      'App.tsx',
+      expect.objectContaining({ line: 0, position: 16, extent: 0 }),
+    );
+  });
+
+  it('reports the caret line and its text for a multi-line edit', () => {
+    const setSource = vi.fn();
+    render();
+
+    type(textarea(), 'first\nchanged');
+
+    expect(setSource).toHaveBeenCalledWith(
+      'first\nchanged',
+      'App.tsx',
+      expect.objectContaining({ line: 1, content: 'changed' }),
+    );
+  });
+
+  it('adopts source that did not originate in the editor', () => {
+    const { rerender } = render(
+       {}} />,
+    );
+    type(textarea(), 'edited locally');
+    expect(textarea().value).toBe('edited locally');
+
+    rerender( {}} />);
+    expect(textarea().value).toBe('reset externally');
+  });
+
+  it('keeps a local edit when the host echoes it back', () => {
+    const { rerender } = render(
+       {}} />,
+    );
+    type(textarea(), 'edited');
+
+    rerender( {}} />);
+    expect(textarea().value).toBe('edited');
+  });
+
+  it('adopts source on a file switch even when the text is identical', () => {
+    const { rerender } = render(
+       {}} />,
+    );
+    type(textarea(), 'local');
+
+    rerender( {}} />);
+    expect(textarea().value).toBe('shared');
+  });
+
+  it('keeps the textarea out of the tab order so Tab can indent', () => {
+    render( {}} />);
+    expect(textarea().tabIndex).toBe(-1);
+  });
+
+  it('labels the textarea with the edited file', () => {
+    render( {}} />);
+    expect(textarea().getAttribute('aria-label')).toBe('Edit App.tsx');
+  });
+
+  it('indents at the caret on Tab', () => {
+    const setSource = vi.fn();
+    render();
+    const element = textarea();
+    element.setSelectionRange(0, 0);
+
+    fireEvent.keyDown(element, { key: 'Tab' });
+
+    expect(element.value).toBe('  const a = 1;');
+    expect(setSource).toHaveBeenCalledWith('  const a = 1;', 'App.tsx', expect.any(Object));
+  });
+
+  it('emits once per Tab, not twice', () => {
+    // jsdom has no `execCommand`, so stand one in that behaves like a browser's:
+    // it edits the value AND fires `input`. Without the guard the editor reports
+    // the same indent twice — once from that event, once from the key handler —
+    // and reparses it twice.
+    const setSource = vi.fn();
+    render();
+    const element = textarea();
+
+    const execCommand = vi.fn((_command: string, _ui: boolean, text: string) => {
+      const { selectionStart, selectionEnd, value } = element;
+      element.value = `${value.slice(0, selectionStart)}${text}${value.slice(selectionEnd)}`;
+      fireEvent.input(element);
+      return true;
+    });
+    document.execCommand = execCommand as unknown as typeof document.execCommand;
+
+    try {
+      element.setSelectionRange(0, 0);
+      fireEvent.keyDown(element, { key: 'Tab' });
+
+      expect(execCommand).toHaveBeenCalledWith('insertText', false, '  ');
+      expect(element.value).toBe('  const a = 1;');
+      expect(setSource).toHaveBeenCalledTimes(1);
+      expect(setSource).toHaveBeenCalledWith('  const a = 1;', 'App.tsx', expect.any(Object));
+    } finally {
+      // @ts-expect-error -- restoring the jsdom default, which is absent
+      delete document.execCommand;
+    }
+  });
+
+  it('outdents on Shift+Tab', () => {
+    render( {}} />);
+    const element = textarea();
+    element.setSelectionRange(6, 6);
+
+    fireEvent.keyDown(element, { key: 'Tab', shiftKey: true });
+
+    expect(element.value).toBe('  const a = 1;');
+  });
+
+  it('leaves the source alone when there is nothing to outdent', () => {
+    const setSource = vi.fn();
+    render();
+    const element = textarea();
+    element.setSelectionRange(0, 0);
+
+    fireEvent.keyDown(element, { key: 'Tab', shiftKey: true });
+
+    expect(element.value).toBe('const a = 1;');
+    expect(setSource).not.toHaveBeenCalled();
+  });
+
+  it('does not intercept Tab combined with a modifier', () => {
+    render( {}} />);
+    const element = textarea();
+    element.setSelectionRange(0, 0);
+
+    fireEvent.keyDown(element, { key: 'Tab', metaKey: true });
+
+    expect(element.value).toBe('const a = 1;');
+  });
+
+  it('exits editing on Escape', () => {
+    const onExit = vi.fn();
+    render( {}} onExit={onExit} />);
+
+    fireEvent.keyDown(textarea(), { key: 'Escape' });
+
+    expect(onExit).toHaveBeenCalledTimes(1);
+  });
+
+  it('activates the live runtime on focus without editing', () => {
+    const onActivate = vi.fn();
+    const setSource = vi.fn();
+    render(
+      ,
+    );
+
+    fireEvent.focus(textarea());
+
+    expect(onActivate).toHaveBeenCalledTimes(1);
+    expect(setSource).not.toHaveBeenCalled();
+  });
+
+  it('hands the host a pre-parsed tree when a worker parser is available', async () => {
+    const setSource = vi.fn();
+    const hast: HastRoot = { type: 'root', children: [] };
+    const parseSourceAsync = vi.fn(() => Promise.resolve(hast));
+
+    render(
+      
+        
+      ,
+    );
+
+    type(textarea(), 'second');
+
+    await waitFor(() =>
+      expect(setSource).toHaveBeenCalledWith('second', 'App.tsx', expect.any(Object), hast),
+    );
+  });
+
+  it('still reports the edit when the worker parse fails', async () => {
+    const setSource = vi.fn();
+    const parseSourceAsync = vi.fn(() => Promise.reject(new Error('worker died')));
+
+    render(
+      
+        
+      ,
+    );
+
+    type(textarea(), 'second');
+
+    await waitFor(() =>
+      expect(setSource).toHaveBeenCalledWith('second', 'App.tsx', expect.any(Object)),
+    );
+  });
+});
diff --git a/packages/docs-infra/src/useCode/CodeEditor.tsx b/packages/docs-infra/src/useCode/CodeEditor.tsx
new file mode 100644
index 000000000..92c7524da
--- /dev/null
+++ b/packages/docs-infra/src/useCode/CodeEditor.tsx
@@ -0,0 +1,284 @@
+'use client';
+
+import * as React from 'react';
+import type { HastRoot } from '../CodeHighlighter/types';
+import { useCodeContext } from '../CodeProvider/CodeContext';
+import type { SetSource } from './useSourceEditing';
+import type { Position } from './editingTypes';
+import { indentEdit, outdentEdit } from './codeEditorEdits';
+
+/**
+ * A transparent textarea laid over an already-highlighted `
`. The textarea
+ * owns the text, so selection, undo/redo, IME, and spellcheck stay native; the
+ * `
` beneath it keeps painting, frames and all.
+ *
+ * Nothing is highlighted here. An edit goes out through `setSource`, the host
+ * re-parses, and the `
` re-renders from the new tree — which is what keeps
+ * emphasis frames, collapse placeholders, and the intersection-driven frame
+ * hydration working while editing.
+ *
+ * Indent and outdent go through `document.execCommand('insertText')` rather than
+ * a direct value write, which is what keeps them on the browser's native undo
+ * stack. The `inputType` vocabulary used to classify edits follows the approach
+ * in Pierre's editor.
+ */
+export interface CodeEditorProps {
+  /** Complete source, matching the text painted by the `
` underneath. */
+  source: string;
+  /** Canonical file name reported back through `setSource`. */
+  fileName?: string;
+  language?: string;
+  /** Spaces inserted by Tab. */
+  tabSize?: number;
+  setSource: SetSource;
+  /** Fired on first focus, so the host can warm the live runtime. */
+  onActivate?: () => void;
+  /** Fired on Escape, so the host can move focus out. */
+  onExit?: () => void;
+  onReady?: (textarea: HTMLTextAreaElement | null) => void;
+}
+
+function getCaretLine(source: string, position: number): { content: string; line: number } {
+  let line = 0;
+  let lineStart = 0;
+  for (let index = 0; index < position; index += 1) {
+    if (source[index] === '\n') {
+      line += 1;
+      lineStart = index + 1;
+    }
+  }
+  return { content: source.slice(lineStart, position), line };
+}
+
+/**
+ * Replaces a range through `execCommand` so the edit lands on the browser's
+ * native undo stack. Falls back to a direct value write where `execCommand` is
+ * unavailable, which loses undo for that one edit rather than dropping it.
+ */
+function replaceRange(
+  textarea: HTMLTextAreaElement,
+  start: number,
+  end: number,
+  text: string,
+): void {
+  textarea.setSelectionRange(start, end);
+  if (document.execCommand?.('insertText', false, text)) {
+    return;
+  }
+  textarea.value = `${textarea.value.slice(0, start)}${text}${textarea.value.slice(end)}`;
+}
+
+export function CodeEditor({
+  source,
+  fileName,
+  language,
+  tabSize = 2,
+  setSource,
+  onActivate,
+  onExit,
+  onReady,
+}: CodeEditorProps) {
+  const { parseSourceAsync } = useCodeContext();
+  const textareaRef = React.useRef(null);
+  const lastEmittedRef = React.useRef(null);
+  const previousFileRef = React.useRef(fileName);
+
+  const bindTextarea = React.useCallback(
+    (textarea: HTMLTextAreaElement | null) => {
+      textareaRef.current = textarea;
+      onReady?.(textarea);
+    },
+    [onReady],
+  );
+
+  // Lay the textarea directly over the `` element and copy its resolved
+  // font metrics. Inheriting from the `
` is not enough: `` and its
+  // `.line` spans can carry their own font-size and line-height, and even a
+  // fraction of a pixel per line compounds into visible drift further down the
+  // block. Measured off `` so the textarea's own size cannot feed back in.
+  React.useLayoutEffect(() => {
+    const textarea = textareaRef.current;
+    const pre = textarea?.parentElement;
+    const code = pre?.querySelector('code');
+    if (!textarea || !pre || !code) {
+      return undefined;
+    }
+
+    const sync = () => {
+      const styles = window.getComputedStyle(code);
+      textarea.style.font = styles.font;
+      textarea.style.fontFamily = styles.fontFamily;
+      textarea.style.fontSize = styles.fontSize;
+      textarea.style.fontWeight = styles.fontWeight;
+      textarea.style.lineHeight = styles.lineHeight;
+      textarea.style.letterSpacing = styles.letterSpacing;
+      textarea.style.tabSize = styles.tabSize;
+      // The text is transparent, so `currentcolor` would make the caret
+      // invisible too. Paint it in the colour the code itself renders in.
+      textarea.style.caretColor = styles.color;
+
+      // Each line is wrapped in a frame span that carries the horizontal
+      // padding, so the glyphs start inboard of ``'s own box. Mirror that
+      // padding or every line sits a fixed offset left of the painted text.
+      const frame = code.querySelector('.frame, .line');
+      const frameStyles = frame ? window.getComputedStyle(frame) : null;
+      textarea.style.paddingLeft = frameStyles?.paddingLeft ?? '0px';
+      textarea.style.paddingRight = frameStyles?.paddingRight ?? '0px';
+
+      textarea.style.top = `${code.offsetTop - pre.clientTop}px`;
+      textarea.style.left = `${code.offsetLeft - pre.clientLeft}px`;
+      // A zero measurement means the block has not been laid out yet (or is
+      // hidden). Collapsing the textarea to 0×0 would make it unclickable, and
+      // nothing would resize it back if the observer never fires again, so fall
+      // back to filling the `
`.
+      textarea.style.width = code.scrollWidth > 0 ? `${code.scrollWidth}px` : '100%';
+      textarea.style.height = code.scrollHeight > 0 ? `${code.scrollHeight}px` : '100%';
+    };
+
+    sync();
+    const observer = new ResizeObserver(sync);
+    observer.observe(code);
+    observer.observe(pre);
+    return () => observer.disconnect();
+  }, [source]);
+
+  // Seeds the textarea and adopts source that did not originate here — a reset,
+  // a transform swap, or a file switch. An echo of our own last edit is ignored
+  // so the caret survives.
+  //
+  // The value is written imperatively rather than through `defaultValue`: the
+  // textarea lives inside the `
`, and a default value would become a child
+  // text node, so `pre.textContent` would return the source twice.
+  React.useLayoutEffect(() => {
+    const textarea = textareaRef.current;
+    if (!textarea) {
+      return;
+    }
+    const fileChanged = previousFileRef.current !== fileName;
+    previousFileRef.current = fileName;
+    if (!fileChanged && source === lastEmittedRef.current) {
+      return;
+    }
+    if (textarea.value !== source) {
+      textarea.value = source;
+    }
+  }, [source, fileName]);
+
+  const emit = React.useCallback(
+    (nextValue: string, selectionStart: number, selectionEnd: number) => {
+      lastEmittedRef.current = nextValue;
+      const position: Position = {
+        position: selectionStart,
+        extent: selectionEnd - selectionStart,
+        ...getCaretLine(nextValue, selectionStart),
+      };
+
+      // Hand the host a pre-parsed tree when a worker parser is available, so
+      // the re-highlight stays off the main thread during typing.
+      if (parseSourceAsync && fileName) {
+        const controller = new AbortController();
+        parseSourceAsync(nextValue, fileName, language, controller.signal).then(
+          (hast: HastRoot) => setSource(nextValue, fileName, position, hast),
+          () => setSource(nextValue, fileName, position),
+        );
+        return;
+      }
+      setSource(nextValue, fileName, position);
+    },
+    [setSource, fileName, language, parseSourceAsync],
+  );
+
+  // `execCommand` fires `input` synchronously, so a programmatic edit would
+  // otherwise emit twice — once here with the intermediate selection, once from
+  // the handler that applied it. Suppress this one and let the caller emit.
+  const applyingEditRef = React.useRef(false);
+
+  const handleInput = React.useCallback(
+    (event: React.InputEvent) => {
+      if (applyingEditRef.current) {
+        return;
+      }
+      const target = event.currentTarget;
+      emit(target.value, target.selectionStart, target.selectionEnd);
+    },
+    [emit],
+  );
+
+  const handleKeyDown = React.useCallback(
+    (event: React.KeyboardEvent) => {
+      if (event.key === 'Escape') {
+        event.preventDefault();
+        onExit?.();
+        return;
+      }
+      if (event.key !== 'Tab' || event.metaKey || event.ctrlKey || event.altKey) {
+        return;
+      }
+      const textarea = event.currentTarget;
+      const edit = event.shiftKey
+        ? outdentEdit(textarea.value, textarea.selectionStart, textarea.selectionEnd, tabSize)
+        : indentEdit(textarea.value, textarea.selectionStart, textarea.selectionEnd, tabSize);
+      if (!edit) {
+        return;
+      }
+      event.preventDefault();
+      applyingEditRef.current = true;
+      try {
+        replaceRange(textarea, edit.start, edit.end, edit.text);
+      } finally {
+        applyingEditRef.current = false;
+      }
+      textarea.setSelectionRange(edit.selectionStart, edit.selectionEnd);
+      emit(textarea.value, edit.selectionStart, edit.selectionEnd);
+    },
+    [emit, onExit, tabSize],
+  );
+
+  return (
+