From 34bf880a68dffeb32ebf3df685f432550b5d466f Mon Sep 17 00:00:00 2001 From: Brijesh Bittu <717550+brijeshb42@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:08:58 +0530 Subject: [PATCH 1/6] [docs-infra] Add a textarea code editor --- .../src/useCode/CodeEditor.test.tsx | 195 +++++++++++++ .../docs-infra/src/useCode/CodeEditor.tsx | 263 ++++++++++++++++++ .../docs-infra/src/useCode/CodeEditorLazy.tsx | 39 +++ .../docs-infra/src/useCode/codeEditorCache.ts | 57 ++++ .../src/useCode/codeEditorEdits.test.ts | 81 ++++++ .../docs-infra/src/useCode/codeEditorEdits.ts | 116 ++++++++ .../docs-infra/src/useCode/editingTypes.ts | 50 ++++ packages/docs-infra/src/useCode/index.ts | 2 + 8 files changed, 803 insertions(+) create mode 100644 packages/docs-infra/src/useCode/CodeEditor.test.tsx create mode 100644 packages/docs-infra/src/useCode/CodeEditor.tsx create mode 100644 packages/docs-infra/src/useCode/CodeEditorLazy.tsx create mode 100644 packages/docs-infra/src/useCode/codeEditorCache.ts create mode 100644 packages/docs-infra/src/useCode/codeEditorEdits.test.ts create mode 100644 packages/docs-infra/src/useCode/codeEditorEdits.ts create mode 100644 packages/docs-infra/src/useCode/editingTypes.ts 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..b4e7cd032 --- /dev/null +++ b/packages/docs-infra/src/useCode/CodeEditor.test.tsx @@ -0,0 +1,195 @@ +/** + * @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('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('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..56ccf2224 --- /dev/null +++ b/packages/docs-infra/src/useCode/CodeEditor.tsx @@ -0,0 +1,263 @@ +'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 (https://github.com/pierrecomputer/pierre).
+ */
+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`;
+      textarea.style.width = `${code.scrollWidth}px`;
+      textarea.style.height = `${code.scrollHeight}px`;
+    };
+
+    sync();
+    const observer = new ResizeObserver(sync);
+    observer.observe(code);
+    observer.observe(pre);
+    return () => observer.disconnect();
+  }, [source]);
+
+  // Adopt 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.
+  React.useEffect(() => {
+    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],
+  );
+
+  const handleInput = React.useCallback(
+    (event: React.FormEvent) => {
+      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();
+      replaceRange(textarea, edit.start, edit.end, edit.text);
+      textarea.setSelectionRange(edit.selectionStart, edit.selectionEnd);
+      emit(textarea.value, edit.selectionStart, edit.selectionEnd);
+    },
+    [emit, onExit, tabSize],
+  );
+
+  return (
+