diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63381ac9..9e76a977 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,42 @@ jobs: - name: Test run: npm run test:once + browser-compatibility: + name: Browser compatibility + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 'lts/*' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npm exec -- playwright install --with-deps chromium firefox webkit + + - name: Test browser compatibility + run: npm run test:browser + + - name: Upload browser failure artifacts + if: ${{ failure() && !cancelled() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: browser-compatibility-${{ github.run_attempt }} + path: | + playwright-report/ + test-results/ + if-no-files-found: ignore + retention-days: 14 + build-js: name: Build JS runs-on: ubuntu-latest @@ -58,9 +94,21 @@ jobs: - name: Install dependencies run: npm ci + - name: Install package-gate browser + run: npm exec -- playwright install --with-deps chromium + - name: Build run: npm run build + - name: Verify Lexical package graph + run: npm run test:lexical-versions + + - name: Verify packed React consumers + run: npm run test:package + + - name: Verify cross-version Markdown + run: npm run test:cross-version + build-docs: name: Build Docs runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index b477c5c0..0fec92c9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ tsdoc-metadata.json .vscode .env .vs +playwright-report/ +test-results/ +blob-report/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..bb15e555 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,173 @@ +# AGENTS.md + +This file provides guidance to coding agents working in this repository. + +## Project Overview + +MDXEditor is an open-source React component for rich-text markdown editing built on top of Lexical. It provides WYSIWYG markdown editing with support for MDX, tables, images, code blocks, JSX components, and more. + +**Key technologies:** + +- React 18/19 with TypeScript +- Lexical (Facebook's text editor framework) +- Gurx (reactive state management library) +- Vite (build tool) +- Vitest (testing) +- Ladle (component development) +- MDAST (markdown abstract syntax tree) + +## Development Commands + +**Build and development:** + +```bash +npm run build # Build the library with Vite +npm run dev # Start Ladle dev server (component explorer) +npm start # Alias for npm run dev +``` + +**Code quality:** + +```bash +npm run typecheck # Run TypeScript type checking (no emit) +npm run lint # Lint source files with ESLint +``` + +**Testing:** + +```bash +npm test # Run Vitest in watch mode +npm run test:once # Run Vitest once (CI mode) +npm run test:compat # Run focused jsdom and three-browser compatibility tests +npm run test:browser # Run the complete Playwright browser suite +npm run test:package # Verify packed React 18 and React 19 consumers +npm run test:cross-version # Verify Markdown replay through the legacy package +npm run test:lexical-versions # Assert the installed Lexical graph is lockstep +``` + +**Tests are located in:** `src/test/**/*.test.{ts,tsx}` + +### Browser Tests in Codex on macOS + +When running under Codex's managed sandbox on macOS, request elevated execution before the first invocation of any command that launches a native browser. Do not attempt it sandboxed first. + +This includes: + +- `npm run test:browser` +- `npm run test:package` +- `npm run test:cross-version` +- direct Playwright commands +- any other command launching Chromium, Firefox, or WebKit + +Sandboxed browser launches can fail during macOS Mach-port or GUI application registration, producing OS crash reports with signatures such as: + +- `MachPortRendezvousServer` +- `bootstrap_check_in ... Permission denied (1100)` +- `_RegisterApplication` +- `SIGTRAP` or startup-time `SIGABRT` + +These failures are environmental only when they occur before page creation. Assertion failures, page errors, or crashes after navigation are genuine test failures and must not be automatically retried as environmental failures. + +Keep lint, typecheck, Vitest, builds, and other non-browser commands sandboxed. After browser tests, verify that owned servers and port `61000` were cleaned up. + +**Other utilities:** + +```bash +npm run build:docs:api # Generate API docs with typedoc +npm run image-upload-backend # Start example file upload server on port 65432 +npm run export-icons # Export icons from Figma +``` + +## Architecture + +### Plugin System + +The editor is built around a **plugin architecture** using Gurx (reactive state management): + +- **RealmPlugin**: Core plugin interface with `init`, `postInit`, and `update` lifecycle methods +- **realmPlugin()**: Factory function to create plugins that accept parameters +- Plugins are initialized in `RealmWithPlugins` component which creates a Gurx Realm + +**Plugin structure:** + +```typescript +export const myPlugin = realmPlugin({ + init: (realm, params) => { + /* register nodes, visitors, cells */ + }, + postInit: (realm, params) => { + /* access other plugins' state */ + }, + update: (realm, params) => { + /* handle prop updates */ + } +}) +``` + +### State Management with Gurx + +Gurx primitives are exported with `$` suffix: + +- **Cells** (state): `markdown$`, `rootEditor$`, `activeEditor$`, `readOnly$`, etc. +- **Signals** (actions): `insertMarkdown$`, `setMarkdown$`, `applyBlockType$`, etc. + +Use React hooks to interact with Gurx: + +```typescript +const [markdown, rootEditor] = useCellValues(markdown$, rootEditor$) +const applyBlockType = usePublisher(applyBlockType$) +``` + +### Lexical Integration + +- **Root editor** (`rootEditor$`): Main Lexical editor instance +- **Active editor** (`activeEditor$`): Can be root or nested editor (e.g., code blocks) +- **Custom Lexical nodes**: Each feature typically registers custom LexicalNode subclasses +- **Dollar-prefixed functions** (e.g., `$isCodeBlockNode`): Follow Lexical conventions, used within editor read/update cycles + +### Markdown <-> Lexical Conversion + +The editor maintains bidirectional conversion between markdown and Lexical's internal state: + +**Import (Markdown → Lexical):** + +- `importMarkdownToLexical.ts`: Parses markdown to MDAST, then converts to Lexical nodes +- **MdastImportVisitor**: Interface for converting MDAST nodes to Lexical nodes +- Uses micromark + mdast-util libraries for parsing + +**Export (Lexical → Markdown):** + +- `exportMarkdownFromLexical.ts`: Converts Lexical nodes to MDAST, then serializes to markdown +- **LexicalExportVisitor**: Interface for converting Lexical nodes to MDAST +- Uses mdast-util-to-markdown for serialization + +Each plugin typically registers both import and export visitors for its node types. + +### Key Source Directories + +- **src/plugins/**: Plugin implementations (core, toolbar, headings, lists, table, image, codeblock, jsx, directives, etc.) +- **src/plugins/core/**: Core plugin with fundamental functionality, state management, and base visitors +- **src/plugins/toolbar/**: Toolbar UI components and primitives +- **src/examples/**: Ladle stories (component examples/demos) +- **src/jsx-editors/**: Editors for JSX components +- **src/directive-editors/**: Editors for directives (e.g., admonitions) +- **src/styles/**: CSS modules and theming +- **src/utils/**: Utility functions +- **src/test/**: Test files + +### Build Configuration + +- **Vite**: Library mode with preserveModules for tree-shaking +- **CSS Modules**: Scoped with camelCaseOnly convention +- **TypeScript**: Strict mode, path alias `@/*` → `src/*` +- **SVG**: Loaded as React components via vite-plugin-svgr (replaces black with currentColor) +- **External packages**: All dependencies and peerDependencies are externalized in the build + +## Conventions + +- Path alias: Use `@/` for imports from `src/` directory +- CSS Modules: Use camelCase for class names +- Gurx exports: Suffix with `$` (e.g., `markdown$`, `applyBlockType$`) +- Lexical functions: Prefix with `$` for functions used in editor read/update cycles (e.g., `$isCodeBlockNode`) +- Plugin nodes: Custom Lexical nodes typically stored in plugin directories +- Visitors: Separate files for MDAST and Lexical visitors per feature diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index d991d7dd..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,129 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -MDXEditor is an open-source React component for rich-text markdown editing built on top of Lexical. It provides WYSIWYG markdown editing with support for MDX, tables, images, code blocks, JSX components, and more. - -**Key technologies:** -- React 18/19 with TypeScript -- Lexical (Facebook's text editor framework) -- Gurx (reactive state management library) -- Vite (build tool) -- Vitest (testing) -- Ladle (component development) -- MDAST (markdown abstract syntax tree) - -## Development Commands - -**Build and development:** -```bash -npm run build # Build the library with Vite -npm run dev # Start Ladle dev server (component explorer) -npm start # Alias for npm run dev -``` - -**Code quality:** -```bash -npm run typecheck # Run TypeScript type checking (no emit) -npm run lint # Lint source files with ESLint -``` - -**Testing:** -```bash -npm test # Run Vitest in watch mode -npm run test:once # Run Vitest once (CI mode) -``` - -**Tests are located in:** `src/test/**/*.test.{ts,tsx}` - -**Other utilities:** -```bash -npm run build:docs:api # Generate API docs with typedoc -npm run image-upload-backend # Start example file upload server on port 65432 -npm run export-icons # Export icons from Figma -``` - -## Architecture - -### Plugin System - -The editor is built around a **plugin architecture** using Gurx (reactive state management): - -- **RealmPlugin**: Core plugin interface with `init`, `postInit`, and `update` lifecycle methods -- **realmPlugin()**: Factory function to create plugins that accept parameters -- Plugins are initialized in `RealmWithPlugins` component which creates a Gurx Realm - -**Plugin structure:** -```typescript -export const myPlugin = realmPlugin({ - init: (realm, params) => { /* register nodes, visitors, cells */ }, - postInit: (realm, params) => { /* access other plugins' state */ }, - update: (realm, params) => { /* handle prop updates */ } -}) -``` - -### State Management with Gurx - -Gurx primitives are exported with `$` suffix: -- **Cells** (state): `markdown$`, `rootEditor$`, `activeEditor$`, `readOnly$`, etc. -- **Signals** (actions): `insertMarkdown$`, `setMarkdown$`, `applyBlockType$`, etc. - -Use React hooks to interact with Gurx: -```typescript -const [markdown, rootEditor] = useCellValues(markdown$, rootEditor$) -const applyBlockType = usePublisher(applyBlockType$) -``` - -### Lexical Integration - -- **Root editor** (`rootEditor$`): Main Lexical editor instance -- **Active editor** (`activeEditor$`): Can be root or nested editor (e.g., code blocks) -- **Custom Lexical nodes**: Each feature typically registers custom LexicalNode subclasses -- **Dollar-prefixed functions** (e.g., `$isCodeBlockNode`): Follow Lexical conventions, used within editor read/update cycles - -### Markdown <-> Lexical Conversion - -The editor maintains bidirectional conversion between markdown and Lexical's internal state: - -**Import (Markdown → Lexical):** -- `importMarkdownToLexical.ts`: Parses markdown to MDAST, then converts to Lexical nodes -- **MdastImportVisitor**: Interface for converting MDAST nodes to Lexical nodes -- Uses micromark + mdast-util libraries for parsing - -**Export (Lexical → Markdown):** -- `exportMarkdownFromLexical.ts`: Converts Lexical nodes to MDAST, then serializes to markdown -- **LexicalExportVisitor**: Interface for converting Lexical nodes to MDAST -- Uses mdast-util-to-markdown for serialization - -Each plugin typically registers both import and export visitors for its node types. - -### Key Source Directories - -- **src/plugins/**: Plugin implementations (core, toolbar, headings, lists, table, image, codeblock, jsx, directives, etc.) -- **src/plugins/core/**: Core plugin with fundamental functionality, state management, and base visitors -- **src/plugins/toolbar/**: Toolbar UI components and primitives -- **src/examples/**: Ladle stories (component examples/demos) -- **src/jsx-editors/**: Editors for JSX components -- **src/directive-editors/**: Editors for directives (e.g., admonitions) -- **src/styles/**: CSS modules and theming -- **src/utils/**: Utility functions -- **src/test/**: Test files - -### Build Configuration - -- **Vite**: Library mode with preserveModules for tree-shaking -- **CSS Modules**: Scoped with camelCaseOnly convention -- **TypeScript**: Strict mode, path alias `@/*` → `src/*` -- **SVG**: Loaded as React components via vite-plugin-svgr (replaces black with currentColor) -- **External packages**: All dependencies and peerDependencies are externalized in the build - -## Conventions - -- Path alias: Use `@/` for imports from `src/` directory -- CSS Modules: Use camelCase for class names -- Gurx exports: Suffix with `$` (e.g., `markdown$`, `applyBlockType$`) -- Lexical functions: Prefix with `$` for functions used in editor read/update cycles (e.g., `$isCodeBlockNode`) -- Plugin nodes: Custom Lexical nodes typically stored in plugin directories -- Visitors: Separate files for MDAST and Lexical visitors per feature diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 36e77edf..c3a20fd9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,9 +33,61 @@ This starts Ladle on where you can browse and test the - `npm test` - Run Vitest in watch mode - `npm run test:once` - Run Vitest once (CI mode) +- `npm run test:compat` - Run the focused jsdom and three-browser Lexical compatibility gate +- `npm run test:lexical-versions` - Assert that the complete installed Lexical graph is on the supported lockstep version +- `npm run test:package` - Pack the built library and verify React 18 and React 19 consumers +- `npm run test:cross-version` - Replay the compatibility fixture through the packed package and the published legacy package Tests are located in `src/test/**/*.test.{ts,tsx}` +### Browser compatibility tests + +Install the Playwright-matched browsers once after installing dependencies: + +```bash +npm exec playwright install chromium firefox webkit +``` + +The browser tooling requires Node.js 18 or newer. This does not change the library's published Node.js engine support. + +Run the complete Lexical compatibility contract with `npm run test:compat`. The focused commands below localize a failure without replaying every passing phase: + +```bash +npm run test:compat:unit +npm run test:browser +npm run test:browser -- --project=chromium +npm run test:browser -- --project=firefox +npm run test:browser -- --project=webkit +npm run test:browser -- --project=chromium --grep CX-3 +``` + +Playwright owns the Ladle service and port `61000` for browser runs, including shutdown after success or failure. Do not start a separate Ladle process on that port while running the suite. Failure traces, screenshots, and videos are written under `test-results/`; the HTML report is written to `playwright-report/`. CI uploads both directories when the browser job fails. + +The paste scenario dispatches a real DOM `ClipboardEvent` with `text/plain` data as a deterministic browser-handling proxy. It does not certify OS clipboard permissions or integration. Mobile browsers and exhaustive IME behavior are outside this compatibility gate. + +### Packed-package compatibility tests + +Build the package and install the Playwright-matched Chromium browser before running the package gates: + +```bash +npm run build +npm exec playwright install chromium +npm run test:package +npm run test:cross-version +``` + +Both commands pack `dist`, create disposable consumers, and use an isolated temporary npm cache. `test:package` typechecks, Vite-bundles, serves, and renders the artifact with the pinned React 18 and React 19 toolchains. `test:cross-version` captures the packed package's public-ref Markdown, installs published `@mdxeditor/editor@4.0.4` with its complete Lexical graph pinned to 0.35.0, and directly replays the captured document. + +Use these failure-local commands while iterating: + +```bash +npm run test:package -- --react=18 +npm run test:package -- --react=19 +npm run test:cross-version +``` + +The gates require npm registry access for their disposable installations. They own their Chromium pages, preview servers, allocated loopback ports, tarballs, caches, and temporary applications, and clean them up on success or failure. + ## Project Architecture MDXEditor is built on: diff --git a/docs/extending-the-editor.md b/docs/extending-the-editor.md index 942ff557..caec2a04 100644 --- a/docs/extending-the-editor.md +++ b/docs/extending-the-editor.md @@ -161,4 +161,8 @@ The MDXEditor rich-text editing is built on top of the [Lexical framework](https Lexical is a powerful framework, so understanding its concepts is a challenge on its own. After [the docs themselves](https://lexical.dev/), A good place to start learning by example is the [Lexical playground source code](https://github.com/facebook/lexical/tree/main/packages/lexical-playground). -_Note: Lexical has its own react-based plugin system, which MDXEditor does not use._ +## Supported extension boundary + +MDXEditor constructs its root and nested Lexical editors with Lexical extensions internally. Those construction extensions are an implementation detail, not an additional consumer plugin API. Consumer integrations should continue to use `realmPlugin`, Gurx cells and signals, Markdown import/export visitors, registered Lexical nodes, and the composer-child signals exported by MDXEditor. + +Components registered through `addComposerChild$`, `addNestedEditorChild$`, and `addTableCellEditorChild$` remain ordinary React components. They run inside the standard Lexical composer context, so existing React plugins that use hooks such as `useLexicalComposerContext` do not need to become Lexical extensions. Do not import MDXEditor's private extension factory or depend on its root, nested, or history extension layout; those internals may change without changing the supported MDXEditor contracts. diff --git a/docs/search-replace.md b/docs/search-replace.md index 8fcfb7d2..e5702cd9 100644 --- a/docs/search-replace.md +++ b/docs/search-replace.md @@ -6,27 +6,27 @@ position: 0.85 # Search and Replace -This search is for the rich-text mode of the editor. The code-mirror source editor already has its own search functionality and API. +This search is for the rich-text mode of the editor. The CodeMirror source editor already has its own search functionality and API. The search plugin provides a comprehensive find-and-replace functionality for the editor. It's built for performance, using the native `CSS.highlights` API to mark search results without interfering with the editor's rendering logic. -The plugin itself works in the background to index text and find matches. To add a user interface for search, you can use the `useEditorSearch` hook. This hook provides all the necessary state and actions to build a search bar, like the one included in the official `MdxSearchToolbar` component. +The plugin derives matches from the current Lexical editor state. To add a user interface, use the `useEditorSearch` hook to build your own search bar. -To get started, add the `searchPlugin` to your editor's plugins array. Then, you can render a search UI component (like the provided `MdxSearchToolbar`) which uses the `useEditorSearch` hook to interact with the editor. +To get started, add `searchPlugin()` to the editor's plugins array, then render a component that calls `useEditorSearch` within the editor's plugin realm—for example, from `toolbarPlugin`. MDXEditor does not export a prebuilt search toolbar. A working example of a search UI is available in [the search-plugin-example repository](https://github.com/mdx-editor/search-plugin-example). **A couple of things to note**: -- Regex and wildcard support searches across newlines; `.*` -- The [`CSS.highlights`](https://caniuse.com/mdn-api_highlight_has) only recently (July 2024) became widely supported in browsers, so you may want to check compatibility if you're targeting older browsers. -- This plugin **_currently_** does not search embedded editors like CodeMirror very well, or at all. -- The search manages to search across styles and formatting, so it can find matches in bold, italic, and other styled text, as well as across newlines. +- Search terms are case-insensitive regular expressions. Patterns such as `.*` can match across formatting and block boundaries because the active editor's text nodes are indexed in document order. +- Invalid expressions and expressions that produce only zero-length matches return no results. Correcting the term resumes search without remounting the editor. +- Search is scoped to the currently active Lexical editor. The root editor, nested JSX/directive editors, and table-cell editors are independent scopes; a match never crosses between them. +- Text owned by atomic/custom editors, including CodeMirror code blocks, is not searched. Source and diff modes use their own search facilities. +- Visual highlighting uses the [`CSS.highlights`](https://caniuse.com/mdn-api_highlight_has) API. Search controls can still derive matches where that API is unavailable, but visual highlights are unavailable. ```tsx import { MDXEditor, searchPlugin, toolbarPlugin } from '@mdxeditor/editor' -// The MdxSearchToolbar is a component that provides the search UI. -// It uses the `useEditorSearch` hook internally. +// This is an application-owned component that calls useEditorSearch. import { MdxSearchToolbar } from './path-to-your-components/MdxSearchToolbar' function App() { @@ -63,10 +63,20 @@ The hook returns an object with the following properties and methods: - `prev()`: Navigates to the previous search result. - `replace(newText: string, onUpdate?: () => void)`: Replaces the currently highlighted search result with `newText`. - `replaceAll(newText: string, onUpdate?: () => void)`: Replaces all occurrences of the search term with `newText`. +- `openSearch()`, `closeSearch()`, and `toggleSearch()`: Control whether search is active. +- `isSearchOpen` and `setIsSearchOpen`: Read or directly set the open state. +- `scrollToRangeOrIndex(range: Range | number, options?)`: Scrolls to a supplied DOM range or a 1-based result index. Options accept `ignoreIfInView` and `behavior`. - `search: string`: The current search term. - `total: number`: The total number of matches found. - `cursor: number`: The 1-based index of the currently active match. - `currentRange: Range | null`: The DOM `Range` object for the current match. +- `ranges: Range[]`: Fresh DOM projections for the current state-backed matches. + +`replace` and `replaceAll` treat `newText` literally, so strings such as `$&` are inserted unchanged. Replace All resolves every match from Lexical state, applies the matches from last to first in one editor update, and creates one undo/redo history step. Each optional callback runs once after that update. + +The returned DOM ranges are current rendering projections for highlighting, scrolling, and compatibility. Do not retain them as durable document positions across later editor mutations; read the latest hook value instead. + +Closing search through any open-state method or setter clears its registered highlights. Highlights are also cleared when the active editor changes, the term becomes empty or invalid, or the editor unmounts. ### Example: Building a simple search UI diff --git a/package-lock.json b/package-lock.json index 43fbf390..0067ff2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,15 +15,17 @@ "@codemirror/merge": "^6.4.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.23.0", - "@lexical/clipboard": "^0.35.0", - "@lexical/link": "^0.35.0", - "@lexical/list": "^0.35.0", - "@lexical/markdown": "^0.35.0", - "@lexical/plain-text": "^0.35.0", - "@lexical/react": "^0.35.0", - "@lexical/rich-text": "^0.35.0", - "@lexical/selection": "^0.35.0", - "@lexical/utils": "^0.35.0", + "@lexical/clipboard": "^0.48.0", + "@lexical/extension": "^0.48.0", + "@lexical/history": "^0.48.0", + "@lexical/link": "^0.48.0", + "@lexical/list": "^0.48.0", + "@lexical/markdown": "^0.48.0", + "@lexical/plain-text": "^0.48.0", + "@lexical/react": "^0.48.0", + "@lexical/rich-text": "^0.48.0", + "@lexical/selection": "^0.48.0", + "@lexical/utils": "^0.48.0", "@mdxeditor/gurx": "^1.2.4", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-dialog": "^1.1.11", @@ -39,7 +41,7 @@ "codemirror": "^6.0.1", "downshift": "^7.6.0", "js-yaml": "4.2.0", - "lexical": "^0.35.0", + "lexical": "^0.48.0", "mdast-util-directive": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-frontmatter": "^2.0.1", @@ -73,6 +75,7 @@ "@babel/preset-typescript": "^7.23.3", "@ladle/react": "^5.1.1", "@ngneat/falso": "^6.4.0", + "@playwright/test": "1.61.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.1.6", "@testing-library/react": "^16.3.0", @@ -1735,32 +1738,29 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", - "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==", - "license": "MIT", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz", - "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==", - "license": "MIT", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "dependencies": { - "@floating-ui/core": "^1.7.2", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/react": { - "version": "0.27.14", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.14.tgz", - "integrity": "sha512-aSf9JXfyXpRQWMbtuW+CJQrnhzHu4Hg1Th9AkvR1o+wSW/vCUVMrtgXaRY5ToV5Fh5w3I7lXJdvlKVvYrQrppw==", - "license": "MIT", + "version": "0.27.20", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz", + "integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==", "dependencies": { - "@floating-ui/react-dom": "^2.1.4", - "@floating-ui/utils": "^0.2.10", + "@floating-ui/react-dom": "^2.1.9", + "@floating-ui/utils": "^0.2.12", "tabbable": "^6.0.0" }, "peerDependencies": { @@ -1769,12 +1769,11 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.4.tgz", - "integrity": "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==", - "license": "MIT", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", "dependencies": { - "@floating-ui/dom": "^1.7.2" + "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", @@ -1782,10 +1781,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "license": "MIT" + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==" }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", @@ -2818,259 +2816,466 @@ } } }, - "node_modules/@lexical/clipboard": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.35.0.tgz", - "integrity": "sha512-ko7xSIIiayvDiqjNDX6fgH9RlcM6r9vrrvJYTcfGVBor5httx16lhIi0QJZ4+RNPvGtTjyFv4bwRmsixRRwImg==", - "license": "MIT", + "node_modules/@lexical/a11y": { + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/a11y/-/a11y-0.48.0.tgz", + "integrity": "sha512-18W4ehyipkUim4YVoDZitoH63Om3j6iCN4c84zdqE9RgkWf/PE4rvI/8BHTm6Ni7NkVE14nimXgkpaP5ok15zA==", "dependencies": { - "@lexical/html": "0.35.0", - "@lexical/list": "0.35.0", - "@lexical/selection": "0.35.0", - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/extension": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@lexical/code": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.35.0.tgz", - "integrity": "sha512-ox4DZwETQ9IA7+DS6PN8RJNwSAF7RMjL7YTVODIqFZ5tUFIf+5xoCHbz7Fll0Bvixlp12hVH90xnLwTLRGpkKw==", - "license": "MIT", + "node_modules/@lexical/clipboard": { + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.48.0.tgz", + "integrity": "sha512-xO2trk6+yBl8XXa/VNe20kXczmPxFoWtUHjidbBLEtlGBj+mo63pJj6H5o/WlZsoMKmIOJxwxsn2ejrS8G0/7A==", + "dependencies": { + "@lexical/extension": "0.48.0", + "@lexical/html": "0.48.0", + "@lexical/internal": "0.48.0", + "@lexical/list": "0.48.0", + "@lexical/selection": "0.48.0", + "@lexical/utils": "0.48.0", + "@types/trusted-types": "^2.0.7", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/code-core": { + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/code-core/-/code-core-0.48.0.tgz", + "integrity": "sha512-+O1Ge06AuSo6+r8R2Xk6SkWG07H5/e4K/Scw9aqCM/BRjITxgvFTHTNvgQbaobCsP0uBJiwW1+ZGeWAWcBCY4w==", "dependencies": { - "@lexical/utils": "0.35.0", - "lexical": "0.35.0", - "prismjs": "^1.30.0" + "@lexical/extension": "0.48.0", + "@lexical/html": "0.48.0", + "@lexical/internal": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/devtools-core": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.35.0.tgz", - "integrity": "sha512-C2wwtsMCR6ZTfO0TqpSM17RLJWyfHmifAfCTjFtOJu15p3M6NO/nHYK5Mt7YMQteuS89mOjB4ng8iwoLEZ6QpQ==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.48.0.tgz", + "integrity": "sha512-4kvKWW6ebgQnJNLXPLmw7dqgSChvzYIBNYtfuR6c48Sw+V/QXQTWqfIUbCIe5X4uG8EEXd5O/udXaJx7GBuP+w==", "dependencies": { - "@lexical/html": "0.35.0", - "@lexical/link": "0.35.0", - "@lexical/mark": "0.35.0", - "@lexical/table": "0.35.0", - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/html": "0.48.0", + "@lexical/link": "0.48.0", + "@lexical/mark": "0.48.0", + "@lexical/table": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" }, "peerDependencies": { - "react": ">=17.x", - "react-dom": ">=17.x" + "react": ">=18.x", + "react-dom": ">=18.x", + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/dragon": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.35.0.tgz", - "integrity": "sha512-SL6mT5pcqrt6hEbJ16vWxip5+r3uvMd0bQV5UUxuk+cxIeuP86iTgRh0HFR7SM2dRTYovL6/tM/O+8QLAUGTIg==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.48.0.tgz", + "integrity": "sha512-uPuu7fVca9vmL/Oz30CRZ7FIPIodwMrTgNsRmV8jE6Qd6a7RNiTW7r3+EhbhIdkLb/sjcWsYyOMyUR1TJAB0wQ==", + "dependencies": { + "@lexical/extension": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/extension": { + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/extension/-/extension-0.48.0.tgz", + "integrity": "sha512-4uBObgz84mVbQWiumndmIhkuJL0ojHiMwFSvSUM/FCo1YMVIZvpI56blI0y+2Vix/oLui9EgVQSJjjWV4NAszw==", "dependencies": { - "lexical": "0.35.0" + "@lexical/internal": "0.48.0", + "@lexical/utils": "0.48.0", + "@preact/signals-core": "^1.14.1", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/hashtag": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.35.0.tgz", - "integrity": "sha512-LYJWzXuO2ZjKsvQwrLkNZiS2TsjwYkKjlDgtugzejquTBQ/o/nfSn/MmVx6EkYLOYizaJemmZbz3IBh+u732FA==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.48.0.tgz", + "integrity": "sha512-hPQtdnbVoNAFsmfnCGfgY7mDbvk6mIznlCRmIR7tLeQKXqz/0Tb6eH3W24EESk9hAF8wFUYNKWE1/Kb3Hl2vEQ==", "dependencies": { - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/text": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/history": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.35.0.tgz", - "integrity": "sha512-onjDRLLxGbCfHexSxxrQaDaieIHyV28zCDrbxR5dxTfW8F8PxjuNyuaG0z6o468AXYECmclxkP+P4aT6poHEpQ==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.48.0.tgz", + "integrity": "sha512-NllvUfO+u3mfi5uC8k2CodwdzeeopFFVtMZ/NMifzFbZFysdWi9m9mqfO46NrEA1rSFOydyefv6oMzC8ULXInA==", "dependencies": { - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/extension": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/html": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.35.0.tgz", - "integrity": "sha512-rXGFE5S5rKsg3tVnr1s4iEgOfCApNXGpIFI3T2jGEShaCZ5HLaBY9NVBXnE9Nb49e9bkDkpZ8FZd1qokCbQXbw==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.48.0.tgz", + "integrity": "sha512-uBxlgKl4YgSNEgHJSshdBqtGDzruWdx1ewop+u6faT67qHUdP3P0cUXIrG6NToDWvsL6fzCstAbN76PMER1Pnw==", "dependencies": { - "@lexical/selection": "0.35.0", - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/extension": "0.48.0", + "@lexical/internal": "0.48.0", + "@lexical/selection": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/internal": { + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/internal/-/internal-0.48.0.tgz", + "integrity": "sha512-sRwg53K7N0ZQ7KNAvcCY38LSwGizbXP1zlR1lIojZp0GoqHWNvR+vL49t1wYXu1nXx3Osf4ilHHm+aGcwq5hTw==", + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/link": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.35.0.tgz", - "integrity": "sha512-+0Wx6cBwO8TfdMzpkYFacsmgFh8X1rkiYbq3xoLvk3qV8upYxaMzK1s8Q1cpKmWyI0aZrU6z7fiK4vUqB7+69w==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.48.0.tgz", + "integrity": "sha512-E0UDmNLUXs/yMCnnE7hbFO0CvhWghmqa+qqPksFfzLkpMHdPpdS1yg59YEbYoNHLi/DXIu4cFRvpHIEuooxwNg==", "dependencies": { - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/extension": "0.48.0", + "@lexical/html": "0.48.0", + "@lexical/internal": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/list": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.35.0.tgz", - "integrity": "sha512-owsmc8iwgExBX8sFe8fKTiwJVhYULt9hD1RZ/HwfaiEtRZZkINijqReOBnW2mJfRxBzhFSWc4NG3ISB+fHYzqw==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.48.0.tgz", + "integrity": "sha512-9Qe/Vur44v9F9enj55SUzf79FVsijcGOQug7SpiIU8ekLr7JNzcilKYBYcZ6etGEo7bqQHsYMHXeJcSBbCI2zA==", "dependencies": { - "@lexical/selection": "0.35.0", - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/extension": "0.48.0", + "@lexical/html": "0.48.0", + "@lexical/internal": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/mark": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.35.0.tgz", - "integrity": "sha512-W0hwMTAVeexvpk9/+J6n1G/sNkpI/Meq1yeDazahFLLAwXLHtvhIAq2P/klgFknDy1hr8X7rcsQuN/bqKcKHYg==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.48.0.tgz", + "integrity": "sha512-DTtypWvnYSXyNUxEmsUnh4y0xXkmdk8Y72EZl8WDHcCwjqaLJlUakH7p/TuSJczs3uVSaoJzu0yh7oGkP1Vsvw==", "dependencies": { - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/markdown": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.35.0.tgz", - "integrity": "sha512-BlNyXZAt4gWidMw0SRWrhBETY1BpPglFBZI7yzfqukFqgXRh7HUQA28OYeI/nsx9pgNob8TiUduUwShqqvOdEA==", - "license": "MIT", - "dependencies": { - "@lexical/code": "0.35.0", - "@lexical/link": "0.35.0", - "@lexical/list": "0.35.0", - "@lexical/rich-text": "0.35.0", - "@lexical/text": "0.35.0", - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" - } - }, - "node_modules/@lexical/offset": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.35.0.tgz", - "integrity": "sha512-DRE4Df6qYf2XiV6foh6KpGNmGAv2ANqt3oVXpyS6W8hTx3+cUuAA1APhCZmLNuU107um4zmHym7taCu6uXW5Yg==", - "license": "MIT", - "dependencies": { - "lexical": "0.35.0" + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.48.0.tgz", + "integrity": "sha512-1WasBenW4bEsa5xtnycVo8G2hcxIqyYLn3/r98yD2Y+54ZyeFuIQfOeJYhIgCh4YkKpfqyrFwkMQwAOsQDqZjA==", + "dependencies": { + "@lexical/code-core": "0.48.0", + "@lexical/internal": "0.48.0", + "@lexical/link": "0.48.0", + "@lexical/list": "0.48.0", + "@lexical/rich-text": "0.48.0", + "@lexical/selection": "0.48.0", + "@lexical/text": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/overflow": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.35.0.tgz", - "integrity": "sha512-B25YvnJQTGlZcrNv7b0PJBLWq3tl8sql497OHfYYLem7EOMPKKDGJScJAKM/91D4H/mMAsx5gnA/XgKobriuTg==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.48.0.tgz", + "integrity": "sha512-1YEvMz2tW3EbwrON9mjrkjMVl/vdTcPYSn9P1j6mf5gj0LOoLDNI4TbvSD4SViy+TDghxNdG8YdCISyU2b4YKA==", "dependencies": { - "lexical": "0.35.0" + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/plain-text": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.35.0.tgz", - "integrity": "sha512-lwBCUNMJf7Gujp2syVWMpKRahfbTv5Wq+H3HK1Q1gKH1P2IytPRxssCHvexw9iGwprSyghkKBlbF3fGpEdIJvQ==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.48.0.tgz", + "integrity": "sha512-q4f/4VZKVgCrIW2FhDFR2RII1BU0ljedPgEmJ8XQn1zc+JOFPom8Lp0lV5nyEvZaAJYwM/TfoJ9g2V7ESFKznA==", "dependencies": { - "@lexical/clipboard": "0.35.0", - "@lexical/selection": "0.35.0", - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/clipboard": "0.48.0", + "@lexical/dragon": "0.48.0", + "@lexical/extension": "0.48.0", + "@lexical/selection": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/react": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.35.0.tgz", - "integrity": "sha512-uYAZSqumH8tRymMef+A0f2hQvMwplKK9DXamcefnk3vSNDHHqRWQXpiUo6kD+rKWuQmMbVa5RW4xRQebXEW+1A==", - "license": "MIT", - "dependencies": { - "@floating-ui/react": "^0.27.8", - "@lexical/devtools-core": "0.35.0", - "@lexical/dragon": "0.35.0", - "@lexical/hashtag": "0.35.0", - "@lexical/history": "0.35.0", - "@lexical/link": "0.35.0", - "@lexical/list": "0.35.0", - "@lexical/mark": "0.35.0", - "@lexical/markdown": "0.35.0", - "@lexical/overflow": "0.35.0", - "@lexical/plain-text": "0.35.0", - "@lexical/rich-text": "0.35.0", - "@lexical/table": "0.35.0", - "@lexical/text": "0.35.0", - "@lexical/utils": "0.35.0", - "@lexical/yjs": "0.35.0", - "lexical": "0.35.0", - "react-error-boundary": "^3.1.4" + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.48.0.tgz", + "integrity": "sha512-uVh9/QSrbtjLjVbxfJ+sfiMyhUq/rv7H6uBEVDDIw1rkZJSDY1fvf/CX+dyKgwcDFjKQZ8/9i5f9UCVPeQ01hA==", + "dependencies": { + "@floating-ui/react": "^0.27.19", + "@lexical/a11y": "0.48.0", + "@lexical/devtools-core": "0.48.0", + "@lexical/dragon": "0.48.0", + "@lexical/extension": "0.48.0", + "@lexical/hashtag": "0.48.0", + "@lexical/history": "0.48.0", + "@lexical/internal": "0.48.0", + "@lexical/link": "0.48.0", + "@lexical/list": "0.48.0", + "@lexical/mark": "0.48.0", + "@lexical/markdown": "0.48.0", + "@lexical/overflow": "0.48.0", + "@lexical/plain-text": "0.48.0", + "@lexical/rich-text": "0.48.0", + "@lexical/table": "0.48.0", + "@lexical/text": "0.48.0", + "@lexical/utils": "0.48.0", + "@lexical/yjs": "0.48.0", + "lexical": "0.48.0" }, "peerDependencies": { - "react": ">=17.x", - "react-dom": ">=17.x" + "react": ">=18.x", + "react-dom": ">=18.x", + "typescript": ">=5.2", + "yjs": ">=13.5.22" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "yjs": { + "optional": true + } } }, "node_modules/@lexical/rich-text": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.35.0.tgz", - "integrity": "sha512-qEHu8g7vOEzz9GUz1VIUxZBndZRJPh9iJUFI+qTDHj+tQqnd5LCs+G9yz6jgNfiuWWpezTp0i1Vz/udNEuDPKQ==", - "license": "MIT", - "dependencies": { - "@lexical/clipboard": "0.35.0", - "@lexical/selection": "0.35.0", - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.48.0.tgz", + "integrity": "sha512-QMXFnwCKAQ4yzxvx5FwmANcx3K+NaBkGTxAVD8s8pOKDD/U5rzDS1iIvhH6TLWaFp7VzvLmLB+Sl1Ie/RnkaDQ==", + "dependencies": { + "@lexical/clipboard": "0.48.0", + "@lexical/dragon": "0.48.0", + "@lexical/extension": "0.48.0", + "@lexical/html": "0.48.0", + "@lexical/selection": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/selection": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.35.0.tgz", - "integrity": "sha512-mMtDE7Q0nycXdFTTH/+ta6EBrBwxBB4Tg8QwsGntzQ1Cq//d838dpXpFjJOqHEeVHUqXpiuj+cBG8+bvz/rPRw==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.48.0.tgz", + "integrity": "sha512-Uc0wTrEtHcYK6z/aHHjkgH3vX/R4Bf8mO+qH3VbxfSAKYzNYktM0j+ZGdq5kIEL4frnw/9SulNCXlH7xpjuDgA==", "dependencies": { - "lexical": "0.35.0" + "@lexical/internal": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/table": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.35.0.tgz", - "integrity": "sha512-9jlTlkVideBKwsEnEkqkdg7A3mije1SvmfiqoYnkl1kKJCLA5iH90ywx327PU0p+bdnURAytWUeZPXaEuEl2OA==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.48.0.tgz", + "integrity": "sha512-t9Mz7q6ODLUz0lG5Xn9EY/5YiVpTHCqlPQP4EtFXlnBQT3DuKeDS3cC0Cn8sGSZc11YY5OLDfWpB64Frs9BL3g==", "dependencies": { - "@lexical/clipboard": "0.35.0", - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/clipboard": "0.48.0", + "@lexical/extension": "0.48.0", + "@lexical/html": "0.48.0", + "@lexical/internal": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/text": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.35.0.tgz", - "integrity": "sha512-uaMh46BkysV8hK8wQwp5g/ByZW+2hPDt8ahAErxtf8NuzQem1FHG/f5RTchmFqqUDVHO3qLNTv4AehEGmXv8MA==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.48.0.tgz", + "integrity": "sha512-ktTMRbsX4wKxdG2OpZCkrqtt8k9Vg/ZpWdukOQ0r1xPRtCuL1T+q91l7cy2ywIuCfMGYS0aoZGB4LdpUMe/H1g==", "dependencies": { - "lexical": "0.35.0" + "@lexical/internal": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/utils": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.35.0.tgz", - "integrity": "sha512-2H393EYDnFznYCDFOW3MHiRzwEO5M/UBhtUjvTT+9kc+qhX4U3zc8ixQalo5UmZ5B2nh7L/inXdTFzvSRXtsRA==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.48.0.tgz", + "integrity": "sha512-W4k4P+y6jmRfna8+ad4X+iMd5h8es5PC3bUw5tbi7MRApxaaFG/0w+uJiZVSwbT2Q6JnA2xhBaqzPgt/Gn6djg==", "dependencies": { - "@lexical/list": "0.35.0", - "@lexical/selection": "0.35.0", - "@lexical/table": "0.35.0", - "lexical": "0.35.0" + "@lexical/internal": "0.48.0", + "@lexical/selection": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/yjs": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.35.0.tgz", - "integrity": "sha512-3DSP7QpmTGYU9bN/yljP0PIao4tNIQtsR4ycauWNSawxs/GQCZtSmAPcLRnCm6qpqsDDjUtKjO/1Ej8FRp0m0w==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.48.0.tgz", + "integrity": "sha512-fFsE8EnPM/2KK9rMJ0z6T+Da5UW5V4P+XiAA03LoHTY5YQ/Oy8Q0i7Wcmocv/B/SpsY2o8g07euZEfudl9MVKA==", "dependencies": { - "@lexical/offset": "0.35.0", - "@lexical/selection": "0.35.0", - "lexical": "0.35.0" + "@lexical/internal": "0.48.0", + "@lexical/selection": "0.48.0", + "lexical": "0.48.0" }, "peerDependencies": { + "typescript": ">=5.2", "yjs": ">=13.5.22" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lezer/common": { @@ -3715,6 +3920,21 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -3760,6 +3980,15 @@ "node": ">=12" } }, + "node_modules/@preact/signals-core": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz", + "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@radix-ui/colors": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz", @@ -6256,6 +6485,11 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" + }, "node_modules/@types/unist": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", @@ -13581,10 +13815,20 @@ } }, "node_modules/lexical": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.35.0.tgz", - "integrity": "sha512-3VuV8xXhh5xJA6tzvfDvE0YBCMkIZUmxtRilJQDDdCgJCc+eut6qAv2qbN+pbqvarqcQqPN1UF+8YvsjmyOZpw==", - "license": "MIT" + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.48.0.tgz", + "integrity": "sha512-KK4Tyr/cPsleoZ7XvhGRiRmcrZidSmoFUdIXK9nPubIifoC+80Dc5THyc4xtGKtsW24S1TsHzk5gmfBU+TxmEg==", + "dependencies": { + "@lexical/internal": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } }, "node_modules/lilconfig": { "version": "2.1.0", @@ -18904,6 +19148,50 @@ "pathe": "^1.1.0" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", @@ -19202,15 +19490,6 @@ "react": ">=16.0.0" } }, - "node_modules/prismjs": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", - "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -19416,21 +19695,6 @@ "react": "^19.2.1" } }, - "node_modules/react-error-boundary": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", - "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=10", - "npm": ">=6" - }, - "peerDependencies": { - "react": ">=16.13.1" - } - }, "node_modules/react-hook-form": { "version": "7.56.1", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.1.tgz", @@ -21340,10 +21604,9 @@ } }, "node_modules/tabbable": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", - "license": "MIT" + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==" }, "node_modules/tagged-tag": { "version": "1.0.0", diff --git a/package.json b/package.json index d8315b18..97477208 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,14 @@ "lint": "eslint src --ext .ts,.tsx", "test": "vitest", "test:once": "vitest --run", + "test:compat:unit": "vitest --run src/test/compatibility.test.tsx", + "test:browser:serve": "ladle dev --host 127.0.0.1 --port 61000 --noWatch", + "test:browser": "playwright test", + "test:browser:chromium": "playwright test --project=chromium", + "test:compat": "npm run test:compat:unit && npm run test:browser", + "test:lexical-versions": "node scripts/assert-lexical-versions.mjs", + "test:package": "node scripts/verify-package-consumer.mjs", + "test:cross-version": "node scripts/verify-cross-version-markdown.mjs", "semantic-release": "semantic-release", "image-upload-backend": "node ./src/examples/file-backend.js", "export-icons": "node ./scripts/export-figma-icons.mjs" @@ -44,15 +52,17 @@ "@codemirror/merge": "^6.4.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.23.0", - "@lexical/clipboard": "^0.35.0", - "@lexical/link": "^0.35.0", - "@lexical/list": "^0.35.0", - "@lexical/markdown": "^0.35.0", - "@lexical/plain-text": "^0.35.0", - "@lexical/react": "^0.35.0", - "@lexical/rich-text": "^0.35.0", - "@lexical/selection": "^0.35.0", - "@lexical/utils": "^0.35.0", + "@lexical/clipboard": "^0.48.0", + "@lexical/extension": "^0.48.0", + "@lexical/history": "^0.48.0", + "@lexical/link": "^0.48.0", + "@lexical/list": "^0.48.0", + "@lexical/markdown": "^0.48.0", + "@lexical/plain-text": "^0.48.0", + "@lexical/react": "^0.48.0", + "@lexical/rich-text": "^0.48.0", + "@lexical/selection": "^0.48.0", + "@lexical/utils": "^0.48.0", "@mdxeditor/gurx": "^1.2.4", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-dialog": "^1.1.11", @@ -68,7 +78,7 @@ "codemirror": "^6.0.1", "downshift": "^7.6.0", "js-yaml": "4.2.0", - "lexical": "^0.35.0", + "lexical": "^0.48.0", "mdast-util-directive": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-frontmatter": "^2.0.1", @@ -106,6 +116,7 @@ "@babel/preset-typescript": "^7.23.3", "@ladle/react": "^5.1.1", "@ngneat/falso": "^6.4.0", + "@playwright/test": "1.61.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.1.6", "@testing-library/react": "^16.3.0", diff --git a/plans/001-lexical-compatibility-gate.md b/plans/001-lexical-compatibility-gate.md new file mode 100644 index 00000000..1cb4e4cd --- /dev/null +++ b/plans/001-lexical-compatibility-gate.md @@ -0,0 +1,340 @@ +--- +repo: /Users/petyo/w/mdx-editor/editor +--- + +# PRP: Lexical Compatibility Contract and Browser Gate + +## Goal + +Create the version-independent compatibility surface required before MDXEditor moves from Lexical 0.35.0 to 0.48.0: deterministic Markdown fixtures, focused jsdom characterization, and a real-browser suite running in Chromium, Firefox, and WebKit through the public `MDXEditor` boundary. Integrate the browser gate into CI and record the untouched 0.35 behavior in checked-in expectations. + +This PRP establishes evidence only. It must not update any Lexical package or fix behavior discovered during the later 0.48 migration. + +## Why + +- The existing 45 passing jsdom tests do not exercise the browser timing, DOM selection, nested-editor focus, clipboard, decorator, or history paths most exposed by the upgrade. +- R2 needs a repeatable 0.35 baseline so it can distinguish upstream behavior changes from MDXEditor regressions. +- Later selection, search, and composer/history PRPs need one shared browser runner rather than inventing separate harnesses. +- A checked-in canonical Markdown result makes the compatibility promise reviewable and reusable for cross-version comparisons. + +## Success Criteria + +- [x] `npm run test:compat` runs a focused public-boundary jsdom suite followed by Playwright in Chromium, Firefox, and WebKit, and passes on the unchanged Lexical 0.35.0 lockfile. +- [x] The compatibility fixture covers representative CommonMark, GFM, and MDX constructs with a checked-in canonical 0.35 export result. +- [x] Browser exercises directly cover root and nested editing, table/decorator boundaries, shared undo/redo, lists, links, Markdown shortcuts, DOM clipboard events, and maximum length. +- [x] CI installs the Playwright-matched browser binaries, runs all three browser projects with one worker, and retains trace/screenshot/video/report artifacts on failure. +- [x] Existing gates remain green, and contributor documentation covers browser installation, focused diagnosis, artifacts, and service ownership. + +## Assurance + +- **Profile**: Standard +- **Rationale**: The changes are test/tooling-only and reversible, but they define the regression contract for a public editor dependency migration and add three browser runtimes plus nested-editor interactions to CI. The observable scope is bounded and no persisted data, deployment migration, or security-policy change is involved. + +## Roadmap Context + +- **Parent roadmap**: `plans/roadmaps/001-lexical-048-adoption.md` +- **Roadmap step**: `R1` — establish the compatibility contract and browser gate on Lexical 0.35.0. +- **Satisfied dependencies**: None; repository baseline is commit `9373742` with Lexical 0.35.0 resolved and the existing gates passing. +- **Inherited decisions and invariants**: keep all direct Lexical packages lockstep at 0.35.0; use representative Chromium, Firefox, and WebKit; preserve public methods, plugins, visitors, and Markdown semantics; do not encode security-invalid behavior as compatibility; do not claim exhaustive device/IME coverage. +- **Contract produced for later steps**: child `CX-8` supplies one deterministic compatibility command and failure-local commands; inherited `CX-2`, `CX-3`, and `CX-4` gain executable 0.35 browser/jsdom evidence that R2-R5 can rerun. + +## Consumer Contract + +### Consumer and Public Boundary + +- **Consumer(s)**: the R2 dependency-upgrade implementer/verifier first; later R3-R5 implementers also consume the same command and fixtures. +- **Public or supported boundary**: npm scripts `test:compat`, `test:compat:unit`, `test:browser`, and `test:browser:chromium`; the `MDXEditor` React component, its documented props/ref methods, documented plugins, rendered contenteditables, and toolbar controls exercised by the fixture. +- **Entry point and prerequisites**: run `npm ci`, then `npm exec playwright install --with-deps chromium firefox webkit` on a supported CI/Linux host or `npm exec playwright install chromium firefox webkit` locally; execute commands from the repository root on the lockfile's 0.35 baseline. +- **Current observable behavior**: lint, typecheck, and 45 jsdom tests pass, with 1 skipped and 1 todo; there is no browser-test command or CI browser job. +- **Observable promise**: an upgrade implementer can run one gate and determine whether representative public Markdown and interaction behavior still matches the recorded 0.35 baseline, with failures attributed to unit or browser/project phases. +- **Must remain compatible with**: the current npm/Ladle/Vite/Vitest workflow; the package's React 18/19 public contract; all direct Lexical packages resolving to 0.35.0 for this child. +- **Not claimed**: that Lexical 0.48 already passes; byte identity for non-canonical Markdown; OS-native clipboard integration; mobile browsers; every IME; correct partial-selection Markdown (owned by R3); security-invalid URL preservation. + +### Acceptance Scenarios + +`CX-2` through `CX-4` inherit the same IDs and promises from the parent roadmap. `CX-8` is this foundational child's downstream-consumer contract. + +| ID | Given | When | Then | Exact exercise and prerequisites | Required evidence | +|---|---|---|---|---|---| +| `CX-8` | An R2 implementer has a fresh checkout at the recorded 0.35 lockfile with the matched Playwright browsers installed | They run `npm run test:compat` | The focused jsdom compatibility tests and all Chromium, Firefox, and WebKit projects finish successfully; a failure identifies its phase/project and preserves browser diagnostics | Run the composite command from the repository root; diagnose with `npm run test:compat:unit`, `npm run test:browser -- --project=`, or `npm run test:browser -- --grep ` | DIRECT REQUIRED — the command and per-project result are the produced contract | +| `CX-2` | The deterministic fixture contains headings, formatting, quote, safe link/autolink, lists/tasks, table, thematic break, frontmatter, fenced code, admonition, and nested JSX | A consumer renders it, calls `getMarkdown()`, calls `setMarkdown()` with an alternate value, resets the original, and reloads the canonical export | Each supported construct remains editable and the observed Markdown equals the checked-in 0.35 canonical expectation after editor updates settle | Vitest renders public `MDXEditor`; each Playwright project opens `/?story=lexical-compatibility--compatibility&mode=preview` and uses the fixture's accessible get/set/reset controls | DIRECT REQUIRED — public component/ref behavior in jsdom and real browsers | +| `CX-3` | The browser fixture contains root text, a nested JSX editor, a table cell, and an adjacent thematic-break decorator with Undo/Redo controls | A user types in root, nested JSX, and the table; moves across the decorator boundary; then undoes and redoes the edits | Focused content changes exactly once, parent Markdown reflects the active nested edit, boundary deletion does not remove unrelated content, and undo/redo restores the observed Markdown sequence without recursion or page errors | Repeat in Chromium, Firefox, and WebKit using contenteditable interaction and accessible toolbar controls; collect browser console/page errors as test failures | DIRECT REQUIRED — browser focus, DOM selection, nested synchronization, and history are not represented by jsdom alone | +| `CX-4a` | The fixture has unordered, ordered, and task-list items | A user presses Enter at an item end, Backspace on the resulting empty item, indents/outdents one item, and toggles a task | Exported Markdown retains the intended sibling/nesting/check state and unrelated list content; undo restores the prior Markdown | In each Playwright browser project, open the compatibility fixture and exercise keyboard plus rendered checkbox/list controls | DIRECT REQUIRED | +| `CX-4b` | The fixture has a safe link plus an editable plain-text paragraph and a collapsed caret | A user creates and removes a safe link, types `# ` at an empty paragraph start, undoes the shortcut, and requests selection Markdown with the collapsed caret | Link text and safe URL serialize as expected, removing the link preserves text, the shortcut creates a heading and undo restores a paragraph, and collapsed selection Markdown is empty | Repeat in every project through the public toolbar/dialog, keyboard, and fixture's selection-export button | DIRECT REQUIRED; partial-selection correctness is deliberately not asserted | +| `CX-4c` | The page has the main editor and a separate labeled editor capped at 10 text characters | A browser paste event inserts plain text in the main editor, a cut is undone, and the user types beyond the capped editor's limit | Pasted text appears once, undo restores cut content, and the capped editor never exports more than 10 text characters | Dispatch a real DOM `ClipboardEvent` with `text/plain` data for the cross-browser paste path; use keyboard cut/undo; repeat in every project | DIRECT REQUIRED for MDXEditor handling; the synthetic DOM clipboard event is an explicit proxy for OS clipboard integration | + +## Research Summary + +### Vetted Repository Findings + +- `package.json:16-27,47-55,71` — scripts have no browser runner and all direct Lexical packages resolve to 0.35.0 — **PRP impact**: add compatibility commands without touching Lexical declarations. +- `package.json:97-100,107,147-171` — React 18/19 are peers; Ladle/Vite/Vitest are dev tooling and the package engine is Node `>=16` — **PRP impact**: keep peer/engine fields and run browser tooling under CI Node LTS. +- `.ladle/config.mjs:1-4` — all `src/examples/*.tsx` files are auto-discovered — **PRP impact**: a thin `lexical-compatibility.tsx` export creates the deterministic browser route without another app/server. +- `vite.config.ts:70-74`, `src/test/setup.ts:1-5`, `src/test/core.test.tsx:31-36`, and `.github/workflows/ci.yml:13-40` — existing jsdom tests use the public-ref pattern and CI uses Node LTS — **PRP impact**: extend those patterns with focused characterization and an independent browser job. +- `src/MDXEditor.tsx:228-289` and `src/test/selection-markdown.test.tsx:9-36` — public ref methods exist, but selection tests cover only absent/empty selection — **PRP impact**: automate collapsed selection without reaching into the realm; partial correctness remains R3. +- `src/plugins/core/NestedLexicalEditor.tsx:215-280`, `src/examples/nested-jsx-elements.tsx:16-55,95-115`, and `src/examples/maxlength.tsx:1-6` — nested synchronization/history and maximum-length patterns exist — **PRP impact**: adapt them into direct browser characterization. +- `src/examples/_boilerplate.tsx:72-90` — `ALL_PLUGINS` includes network/backend content — **PRP impact**: create a local-only plugin list. + +### External Constraints + +- `@playwright/test 1.61.1` — every Playwright release requires its matching browser binaries; install through Playwright rather than system browsers — https://playwright.dev/docs/browsers +- Playwright Test projects — configure distinct Chromium, Firefox, and WebKit projects from one suite — https://playwright.dev/docs/test-projects +- Playwright `webServer` — it can own the Ladle command, wait on a URL, reuse a local server outside CI, and terminate it after the run — https://playwright.dev/docs/test-webserver +- Playwright CI — install browsers with `playwright install --with-deps`, prefer one worker in CI, and retain the HTML report/artifacts — https://playwright.dev/docs/ci + +### Settled Decisions and Rejected Alternatives + +- **Decision**: use exact `@playwright/test@1.61.1` and stock desktop projects — **Rationale**: first-party matched Chromium/Firefox/WebKit management meets the parent contract. +- **Decision**: reuse Ladle at `127.0.0.1:61000` via Playwright `webServer` — **Evidence/rationale**: the repository already maintains its rendering/plugin environment and discovers examples automatically. +- **Decision**: create one deterministic local-only harness — **Rationale**: examples include network/backend content and unstable demonstration selectors. +- **Decision**: check in fixture input and canonical 0.35 export strings in TypeScript — **Evidence/rationale**: Vitest and Ladle can share them, and exact output gives R2 a reviewable baseline. +- **Decision**: treat DOM `ClipboardEvent` paste as an explicit OS-clipboard proxy — **Rationale**: headless engines differ in OS permissions, while the event still exercises browser/Lexical handling. +- **Rejected**: a standalone Vite/React test application — **Reason**: duplicates configuration and adds a second long-lived host with no extra consumer evidence. +- **Rejected**: `ALL_PLUGINS`, site-demo, or wholesale `impex.test.tsx` reuse — **Reason**: network dependencies and stale skipped coverage are not a deterministic contract. +- **Rejected**: fixing partial selection, search, or composer behavior — **Reason**: those belong to R3-R5; R1 records valid 0.35 behavior only. + +### Spike Evidence + +- Local Ladle inspection on 2026-07-18 — **Question**: can the current host provide a deterministic direct story URL and be owned by a browser runner? — **Result/decision**: `npm run dev -- --host 127.0.0.1 --port 61000 --noWatch` became ready, Ladle generated IDs from filename/export names (for example `site-demo--basics`), and `mode=preview` removes navigation; use `/?story=lexical-compatibility--compatibility&mode=preview`. — **Limits**: no Playwright dependency/browsers are installed yet, so the future test suite itself remains to be implemented and verified. + +### Validation Baseline + +| Command | Status | Observed or expected result | +|---|---|---| +| `npm run lint` | Verified | Exit 0 on commit `9373742`. | +| `npm run typecheck` | Verified | Exit 0 on commit `9373742`. | +| `npm run test:once` | Verified | 45 passed, 1 skipped, 1 todo; known non-fatal React/CodeMirror/Radix warnings. | +| `npm run build` | Discovered but not run in R1 research | Existing CI command; final R1 validation must pass. | +| `npm run build:docs:api` | Discovered but not run in R1 research | Existing CI command; final R1 validation must pass. | +| `npm run dev -- --host 127.0.0.1 --port 61000 --noWatch` | Verified | Ladle reached ready state on the documented port; process was terminated after inspection. | +| `npm run test:browser` | Unavailable | No dependency, config, tests, or browser binaries exist; this PRP creates them. | + +### Research Coverage + +- **Depth**: Standard +- **Inspected**: package/lock versions, CI, Ladle/Vite/Vitest configuration, public methods, core Markdown tests, skipped import/export inventory, selection tests, nested editor and history patterns, maximum-length example, deterministic story routing, Playwright primary documentation. +- **Not inspected**: 0.48 implementation, mobile/IME/device farms, search, extension migration, or every example; these are later/out-of-scope surfaces. +- **Research confidence**: HIGH — the host, route convention, current public boundary, CI shape, and required browser tooling are directly evidenced; only reversible test implementation details remain. + +## Execution Contract + +- **Planned at commit**: `9373742` +- **Planning baseline**: source files are unchanged; `plans/` and `reports/` are pre-existing untracked user content and must be preserved. This PRP and its parent roadmap are the only planned edits within `plans/`. + +### Expected Changes + +- Runner/config: `package.json`, `package-lock.json`, `playwright.config.ts`, and `.gitignore` add exact Playwright tooling, scripts, projects, lifecycle, and local artifacts without changing Lexical or package engines. +- Fixture/unit: `src/test/fixtures/lexicalCompatibility.ts`, `LexicalCompatibilityHarness.tsx`, and `src/test/compatibility.test.tsx` add shared strings, local-only editors/plugins, and jsdom characterization. +- Browser: `src/examples/lexical-compatibility.tsx` and `tests/browser/lexical-compatibility.spec.ts` add the thin story and `CX-2`-`CX-4` exercises. +- Integration: `.github/workflows/ci.yml` and `CONTRIBUTING.md` add the CI job, prerequisites, diagnosis, lifecycle, and artifact documentation. + +### Explicitly Out of Scope + +- Any change to Lexical dependency versions, import paths, nodes, editor implementation, public methods, or production plugin behavior. +- Correct partial-selection serialization, Find/Replace, extension composer/history, or `@lexical/mdast`. +- React peer/Node engine changes or new malformed/unsafe URL promises. +- Visual/accessibility/performance certification, mobile/device farms, exhaustive IME testing, or making existing stories stable APIs. + +### Scope Expansion Rule + +Additional test/configuration files may be changed when necessary to satisfy the same compatibility scenarios without changing product code or the public contract. Record each added path and rationale in Execution Notes. Pause for user direction if evidence requires a production behavior change, a different browser platform, a public API/engine change, or splitting the compatibility contract into another roadmap step. + +### Pause and Reassess If + +- The 0.35 baseline is data-losing/security-invalid/contradicts docs, or a scenario requires private Lexical/Gurx/CSS-module details; do not bless that behavior. +- Playwright cannot reliably own Ladle/port 61000 or would require production configuration. +- Browser variance makes the observable Markdown/state promise inconsistent across Chromium, Firefox, and WebKit; capture the exact variance and request a classification decision rather than weakening the assertion. +- Playwright would require a package-engine/CI-platform change, or R1 would modify production behavior assigned to R2-R5. + +## Context + +### Key Files + +- `plans/roadmaps/001-lexical-048-adoption.md` — parent outcomes, inherited `CX-2`-`CX-4`, scope, and R2 handoff. +- `package.json`, `.ladle/config.mjs`, `vite.ladle.config.ts`, and `vite.config.ts` — dependency/command baseline plus existing host and test configuration. +- `src/test/core.test.tsx` and `src/test/selection-markdown.test.tsx` — public-ref analogue and selection gap. +- `src/examples/nested-jsx-elements.tsx`, `src/examples/_boilerplate.tsx`, and `src/plugins/core/NestedLexicalEditor.tsx` — nested/history pattern, plugin inventory, and interaction risk surface. +- `.github/workflows/ci.yml` — SHA-pinned action and Node/npm convention. + +### Gotchas + +- Pin Playwright and install matching binaries; keep `127.0.0.1` consistent across host/config/tests. +- Ladle serves its shell for arbitrary paths, so each test must assert the story's ready marker, not only server readiness. +- Reuse an existing server only outside CI; Playwright must own cleanup and CI must fail on port collision. +- Wait on exported Markdown/state, never sleeps; scope multiple contenteditables to fixture wrappers. +- Avoid network content and HTML snapshots. Assert Markdown, focus containment, controls, and page/console errors. +- Normalize only line endings; retain the documented OS-clipboard proxy limitation. + +## Implementation Blueprint + +### Data Models + +The fixture module exports named immutable strings: `compatibilityMarkdown`, `compatibilityMarkdown035`, and `alternateCompatibilityMarkdown`. `compatibilityMarkdown035` is the exact canonical output observed after the 0.35 editor settles, normalized only for line endings. The harness exposes labeled output regions for current Markdown and selection Markdown plus stable wrapper IDs for root, nested JSX, table, and capped editors. + +### Tasks + +```yaml +Task 1: Add the browser runner and deterministic lifecycle + MODIFY package.json: + - Add exact devDependency @playwright/test@1.61.1; update package-lock.json through npm. + - Add test:compat:unit, test:browser:serve, test:browser, test:browser:chromium, and composite test:compat scripts. + - Make test:compat run unit characterization first and browser tests second so the failing phase is visible. + - Do not alter Lexical declarations, React peers, or engines. + CREATE playwright.config.ts: + - Set testDir to tests/browser and baseURL to http://127.0.0.1:61000. + - Define chromium, firefox, and webkit projects with Playwright desktop device defaults. + - Use one worker in CI, zero retries, a line reporter plus non-opening HTML report, and retain trace/video/screenshot on failure. + - Own `npm run test:browser:serve` through webServer; wait on the direct compatibility URL; reuse only outside CI; allow 120 seconds for readiness; terminate with SIGTERM and a bounded grace period. + MODIFY .gitignore: + - Ignore playwright-report/, test-results/, and any configured local blob-report directory. + PATTERN: package.json:16-27; .ladle/config.mjs:1-4; Playwright webServer/projects references. + ENABLES: CX-8 + VERIFY: + - COMMAND: npm ls lexical @lexical/react @lexical/markdown @playwright/test + - EXPECTED: Lexical entries remain 0.35.0 and @playwright/test resolves exactly 1.61.1. + - COMMAND: npm run test:browser -- --list + - EXPECTED: Tests are collected once for each of chromium, firefox, and webkit without starting an orphan server. + - PROCESS-LIFECYCLE: Playwright prints host/test progress; exit 0 is success, any test/server failure is nonzero; Playwright owns the Ladle process group and port 61000, retains configured diagnostics, and terminates the host on success, failure, or interrupt. + +Task 2: Define the shared 0.35 fixture and jsdom characterization + CREATE src/test/fixtures/lexicalCompatibility.ts: + - Export deterministic representative source, alternate, and observed canonical 0.35 Markdown strings. + - Cover only locally renderable CommonMark/GFM/MDX constructs named by CX-2; include stable text anchors for browser locators. + CREATE src/test/fixtures/LexicalCompatibilityHarness.tsx: + - Build a local-only plugin list for headings, quote, lists/tasks, links/dialog, table, thematic break, frontmatter, fenced code, admonition, JSX Grid, Markdown shortcuts, and a minimal Undo/Redo toolbar. + - Adapt the Grid descriptor/NestedLexicalEditor pattern from nested-jsx-elements.tsx and label its wrapper. + - Render the primary editor plus a separate maxLengthPlugin(10) probe; expose accessible Get Markdown, Get Selection Markdown, Set Alternate, and Reset controls and labeled outputs using public refs. + - Add fixture-scoped data-testid wrappers only where roles/labels cannot distinguish contenteditables. + - Surface onChange/current output without exposing Lexical or realm internals. + CREATE src/test/compatibility.test.tsx: + - Verify initial canonical export, set/reset behavior after observable updates, supported construct anchors, and empty collapsed selection through public methods. + - Use Testing Library/act semantics and existing setup cleanup; do not use fixed timers or full DOM snapshots. + PATTERN: src/test/core.test.tsx:31-36; src/examples/nested-jsx-elements.tsx:16-55,95-115; src/examples/maxlength.tsx:1-6. + ENABLES: CX-2, CX-4b, CX-8 + VERIFY: + - COMMAND: npm run test:compat:unit + - EXPECTED: Focused compatibility tests pass on the exact 0.35 canonical expectation with no unhandled React errors. + +Task 3: Expose the compatibility story and implement direct browser evidence + CREATE src/examples/lexical-compatibility.tsx: + - Export `Compatibility` as a thin render of LexicalCompatibilityHarness. + - Add a visible heading/ready marker so tests prove the requested story loaded, not merely Ladle's SPA shell. + CREATE tests/browser/lexical-compatibility.spec.ts: + - Navigate every test to /?story=lexical-compatibility--compatibility&mode=preview and assert the ready marker. + - Register pageerror and unexpected console.error listeners that fail the test while allowing an explicit, minimal list of already-vetted benign warnings only if still unavoidable. + - Implement separate titled tests for CX-2, CX-3, CX-4a, CX-4b, and CX-4c so --grep isolates each behavior. + - Assert Markdown outputs and focus containment after observable state changes; never sleep or inspect Lexical/Gurx internals. + - For CX-3, capture the exported Markdown sequence after root, nested, and table edits, boundary action, undo, and redo; assert each intended transition exactly once. + - For CX-4c, construct a text/plain DataTransfer/ClipboardEvent in the page for paste, label the test as the DOM-event proxy, and use keyboard cut/undo separately. + PATTERN: .ladle/config.mjs:1-4; src/examples/nested-jsx-elements.tsx; tests use Playwright role/label/test-id locator guidance. + ENABLES: CX-2, CX-3, CX-4a, CX-4b, CX-4c, CX-8 + VERIFY: + - COMMAND: npm run test:browser:chromium + - EXPECTED: All compatibility test titles pass in Chromium and Ladle exits, leaving port 61000 free. + - COMMAND: npm run test:browser -- --project=firefox + - EXPECTED: The same tests pass in Firefox. + - COMMAND: npm run test:browser -- --project=webkit + - EXPECTED: The same tests pass in WebKit. + - PROCESS-LIFECYCLE: The line reporter and piped Ladle output show progress; exit 0 after all selected tests is success, any assertion/page/server error is failure; Playwright closes pages/contexts, stops Ladle, and owns artifacts and port cleanup. + +Task 4: Put the browser contract in CI + MODIFY .github/workflows/ci.yml: + - Add an independent Browser compatibility job following the repository's SHA-pinned checkout/setup-node conventions and Node lts/*. + - Run npm ci, npm exec playwright install --with-deps chromium firefox webkit, then npm run test:browser with CI workers fixed at one. + - Set a bounded job timeout that includes browser installation and tests. + - Upload playwright-report and test-results when the test step fails or the job is not cancelled; use the repository's pinned-action convention and do not grant write permissions. + PATTERN: .github/workflows/ci.yml:13-40; https://playwright.dev/docs/ci. + ENABLES: CX-8 + VERIFY: + - COMMAND: npm run test:browser + - EXPECTED: Local equivalent of the CI test phase passes all three projects; workflow syntax remains valid under repository lint/tooling. + - FAILURE-LOCAL: Browser installation: npm exec playwright install chromium firefox webkit; suite: npm run test:browser -- --project=<name>; scenario: npm run test:browser -- --project=<name> --grep <title>. + - PROCESS-LIFECYCLE: GitHub's step logs show install and line-reporter progress; nonzero install/test status fails the job; Playwright stops Ladle and the ephemeral runner cleans browser processes/files after success, failure, cancellation, or timeout. + +Task 5: Document and run the integrated handoff gate + MODIFY CONTRIBUTING.md: + - Document one-time local browser installation, test:compat, focused unit/browser/project/grep commands, port 61000 ownership, and failure artifact paths. + - State that DOM ClipboardEvent coverage is not OS clipboard certification and that mobile/every-IME coverage is outside this gate. + PATTERN: CONTRIBUTING.md:19-37. + ENABLES: CX-8 + VERIFY: + - COMMAND: npm run test:compat + - EXPECTED: Focused jsdom characterization and every browser project pass on Lexical 0.35.0; the command returns 0 and no Ladle process retains port 61000. + - FAILURE-LOCAL: Unit phase: npm run test:compat:unit; browser phase: npm run test:browser; project phase: npm run test:browser -- --project=<name>; scenario phase: add --grep <title>. + - PROCESS-LIFECYCLE: npm exposes each child command's progress and returns the first nonzero phase; Vitest owns jsdom cleanup, Playwright owns browsers/Ladle/port/artifacts, and no manual cleanup is required after either terminal state. +``` + +## Validation + +```bash + # Dependency/baseline identity +npm ls lexical @lexical/react @lexical/markdown @playwright/test + + # Static and existing test gates +npm run lint +npm run typecheck +npm run test:once + + # New focused and integrated compatibility gates +npm run test:compat:unit +npm run test:browser -- --project=chromium +npm run test:browser -- --project=firefox +npm run test:browser -- --project=webkit +npm run test:compat + + # Existing production/declaration gates +npm run build +npm run build:docs:api + + # Planning/patch hygiene during implementation handoff +git diff --check +``` + +`npm run test:compat` is the clean integrated `CX-8` gate. Use the focused commands above for failure localization without replaying passing browser projects. After each browser command, confirm the command has returned and a subsequent focused run can bind port 61000; Playwright owns explicit service cleanup. + +The `CX-N` table is authoritative. DOM/state helper assertions may support scenarios, but they do not replace the public `MDXEditor` ref, rendered contenteditable, toolbar/dialog, and exported-Markdown observations required there. + +## Unknowns & Risks + +- The exact canonical 0.35 output must be captured from the implemented full plugin set; the fixture source is designed to be canonical, but execution must check in the observed result rather than assume byte identity. +- Some browser keyboard behavior differs by platform. Tests must use Playwright's `ControlOrMeta` where appropriate and assert exported behavior, not platform-specific key labels. +- WebKit/Firefox may expose different contenteditable focus descendants. Assert containment within the labeled editor boundary rather than exact active-element tag structure. +- DOM clipboard construction support could vary. If one project cannot construct a standards-shaped `ClipboardEvent`/`DataTransfer`, this is a Pause and Reassess condition; do not silently skip the project or substitute an internal command. +- Strict failure on every console error can surface existing third-party development warnings. Any allowlist must be exact, justified in the test, and must not include Lexical invariant/update-recursion errors. +- Three browser projects increase CI time. Keep one deterministic fixture and scenario-focused tests; do not add sharding or retries until measured evidence justifies them. + +**Confidence: 9/10** for one-pass implementation success. The remaining uncertainty is browser-specific interaction detail inside the intentionally bounded suite, not architecture or public behavior. + +## Execution Notes + +### 2026-07-18 — implementation handoff + +- Implemented the planned test/tooling surface without changing production editor code, public APIs, package engines, or any Lexical declaration/resolution. `@playwright/test` is pinned to `1.61.1`; every inspected Lexical package still resolves to `0.35.0`. +- Added the shared fixture, public-ref harness, focused jsdom characterization, thin Ladle story, and five titled Playwright scenarios. The observed 0.35 canonical export uses `*` for unordered/task lists, `*text*` for emphasis, and `***` for the thematic break; those spellings are recorded explicitly rather than normalized away. +- Added two fixture-only plain paragraphs: `Link candidate text.` gives link creation/removal and cut/undo a deterministic unformatted selection target, while `Decorator boundary paragraph.` makes the thematic-break boundary action directly observable. These do not expand the product contract. +- Firefox constructs `ClipboardEvent` and `DataTransfer` but replaces initializer-provided `clipboardData` with an empty object. The browser test now detects that exact condition and defines the same real `DataTransfer` on the real DOM event before dispatch; it rechecks `text/plain`, never calls a Lexical command, and remains explicitly documented as an OS-clipboard proxy. +- Added an independent SHA-pinned CI job with Node LTS, matched browser installation, one CI worker, a bounded timeout, and failure artifact upload. Contributor docs record Node 18+ for browser tooling while leaving the library's published engine unchanged, plus focused commands, port ownership, artifacts, and coverage limits. +- Validation passed: dependency identity; `npm run lint`; `npm run typecheck`; `npm run test:once` (48 passed, 1 skipped, 1 todo); focused Chromium, Firefox, and WebKit projects (5 each); `npm run test:compat` (3 focused jsdom and 15 browser tests); `npm run build`; `npm run build:docs:api`; Prettier check; workflow YAML parse; and `git diff --check`. Port `61000` had no listener and no Ladle/Playwright test process remained after the integrated run. +- Existing non-failing warnings remain visible: stale Browserslist data, the legacy JSX transform warning, the known jsdom CodeMirror geometry exception log, and TypeDoc/API Extractor TypeScript-version warnings. No warning was added to a browser console-error allowlist; browser page errors and every `console.error` still fail the scenarios. +- Implementation is complete; independent PRP verification and an actual GitHub Actions run remain pending. R1 therefore stays `IN PROGRESS`, and R2 remains blocked. + +## Verification Record + +### 2026-07-18 — Standard assurance + +- **Verifier shape**: one fresh-context, read-only verifier independently reviewed the acceptance evidence, PRP compliance, and engineering quality. The main verifier reproduced and resolved every reported evidence gap, reran the affected scenarios, and requested a targeted fresh-context follow-up audit. +- **Acceptance grades**: + + | Scenario | Grade | Direct evidence | + |---|---|---| + | `CX-8` | DIRECTLY VERIFIED | `npm run test:compat` passed 3 focused jsdom tests and all 15 Playwright tests across Chromium, Firefox, and WebKit, with phase/project-local output and retained diagnostics on failure. | + | `CX-2` | DIRECTLY VERIFIED | The public fixture rendered and round-tripped the canonical Markdown, exercised get/set/reset and code editing, typed a plain URL, observed the rendered autolink, and verified its exported Markdown in all three engines. | + | `CX-3` | DIRECTLY VERIFIED | Public contenteditable interactions and the shared Undo/Redo toolbar restored root, nested JSX, table, and decorator-boundary states, with exact text/Markdown observations and no page or console errors in all three engines. | + | `CX-4a` | DIRECTLY VERIFIED | The suite observes the intermediate Tab indent, Shift+Tab restoration, Enter-created list item, Backspace merge, final list structure, task toggle, and task undo in all three engines. | + | `CX-4b` | DIRECTLY VERIFIED | Safe-link creation/removal, `# ` heading creation, undo back to a paragraph, the canonical escaped `\\# Shortcut paragraph` export, and empty collapsed-selection export passed in all three engines. | + | `CX-4c` | DIRECTLY VERIFIED | Cut/undo, single DOM `ClipboardEvent` paste handling, and the exact 10-character cap passed in all three engines. The synthetic event remains the explicitly accepted proxy for OS clipboard integration. | + +- **Fresh verification evidence**: `npm run test:browser -- --grep 'CX-2|CX-3|CX-4a|CX-4b'` passed 12/12; the final source-of-truth `npm run test:compat` passed 3/3 jsdom and 15/15 browser tests; `npm run lint`, `npm run typecheck`, and `git diff --check` returned zero. Execution evidence for dependency identity, the full existing test suite, builds, API docs, workflow parsing, formatting, and process/port cleanup remains valid because the verification fixes touched only the browser scenario file. +- **Resolved findings**: added a true typed-URL autolink journey; exercised shared toolbar history independently in root, nested, table, and decorator contexts; added positive intermediate list assertions; and proved that shortcut undo restores a paragraph. The follow-up verifier graded all four closures directly verified and found no remaining targeted issue. +- **Environment incident**: the verifier's first browser launch ran inside the restricted macOS Codex sandbox and generated four OS diagnostic reports. Chromium failed at `MachPortRendezvousServer` with permission denied, while Firefox/WebKit aborted during application startup. The same command outside that sandbox passed; subsequent outside-sandbox runs produced no new crash reports. This was a browser-process launch restriction, not an MDXEditor page crash. +- **Result**: VERIFIED. R1's 0.35 compatibility contract is ready for consumption, and parent roadmap R2 may move to `READY FOR PRP`. diff --git a/plans/002-lexical-048-upgrade.md b/plans/002-lexical-048-upgrade.md new file mode 100644 index 00000000..e49c37ba --- /dev/null +++ b/plans/002-lexical-048-upgrade.md @@ -0,0 +1,296 @@ +--- +repo: /Users/petyo/w/mdx-editor/editor +--- + +# PRP: Lexical 0.48 Mechanical Upgrade and Package Gate + +## Goal + +Upgrade every direct Lexical package from 0.35.0 to 0.48.0 in lockstep, make the minimum configuration and source changes required by the new type/node contracts, retain 0.48's fail-closed URL behavior, and prove that the built package and R1 compatibility surface work for React 18 and 19 consumers. + +This PRP is the reversible dependency-migration boundary. It does not integrate selection conversion, Find/Replace, extension composers/history, or `@lexical/mdast`. + +## Why + +- R1 now provides direct 0.35 evidence for the Markdown, browser, nested-editor, list, link, shortcut, clipboard/history, and maximum-length behavior most exposed by the upgrade. +- The clean 0.48 rehearsal found a bounded migration rather than a dependency-only update: conditional-export-aware module resolution, `CodeBlockNode.importJSON`, the horizontal-rule class/guard split, and declaration rollup must be addressed. +- Package consumers need stronger evidence than repository typechecking: the packed artifact must expose usable declarations, styles, and runtime code with both supported React majors. +- Lexical 0.48 hardens dangerous URLs. MDXEditor's custom link-preview anchor must not bypass that fail-closed behavior. + +## Success Criteria + +- [x] All installed `lexical` and `@lexical/*` packages resolve to 0.48.0 with no mixed Lexical version, while React peers and the published Node engine remain unchanged. +- [x] Typecheck, declaration rollup, package build, API docs, existing tests, and R1's 3-engine compatibility gate pass without broad compatibility casts. +- [x] The code-block JSON contract and one consistent legacy React horizontal-rule path compile and retain their public Markdown/browser behavior. +- [x] Safe links remain usable and dangerous or obfuscated schemes render and preview as `about:blank` without weakening the stored Markdown contract. +- [x] Disposable React 18 and React 19 consumers install the packed artifact, typecheck, bundle, and render it through documented exports and styles. + +## Assurance + +- **Profile**: Standard +- **Rationale**: This changes a public package dependency and browser behavior, but the migration is reversible before publication, persists only Markdown rather than an editor-state schema, and is bounded by R1's direct browser contract, a packed-consumer matrix, security-focused link evidence, and full build/declaration gates. No irreversible migration, deployment boundary, or unresolved cross-domain Deep trigger is present. + +## Roadmap Context + +- **Parent roadmap**: `plans/roadmaps/001-lexical-048-adoption.md` +- **Roadmap step**: `R2` — adopt Lexical 0.48 without feature rewrites. +- **Satisfied dependencies**: R1 is `VERIFIED`; its Verification Record proves `CX-8`, `CX-2`, `CX-3`, and `CX-4a`-`CX-4c` on the untouched 0.35 lockfile in jsdom, Chromium, Firefox, and WebKit. +- **Inherited decisions and invariants**: all Lexical packages stay lockstep; React 18/19, public MDXEditor methods/plugins/visitors, and Markdown remain compatible; unsafe URLs fail closed; no mixed 0.35/0.48 runtime; optional 0.48 features remain later children or separate initiatives. +- **Contract produced for later steps**: a compiling, buildable, packed, browser-verified 0.48 package baseline and classified intentional behavior record for R3-R5. + +## Consumer Contract + +### Consumer and Public Boundary + +- **Consumer(s)**: React 18/19 package integrators; Markdown/MDX authors; users of documented MDXEditor plugins, methods, and browser interactions; downstream R3-R5 implementers. +- **Public or supported boundary**: packed `@mdxeditor/editor` exports/declarations/style; `MDXEditor` props/ref methods; Markdown input/output; documented plugins and toolbar controls; root and nested contenteditables. +- **Entry point and prerequisites**: R1 changes are present; matched Playwright browsers are installed; package-consumer checks may access npm and use an isolated temporary npm cache. +- **Current observable behavior**: the verified 0.35 baseline passes 3 focused jsdom and 15 browser tests, but a temporary 0.48 rehearsal fails current type/declaration resolution and two node contracts. +- **Observable promise**: consumers can upgrade to the 0.48-based package without changing their supported MDXEditor code or Markdown, except for explicitly retained security hardening. +- **Must remain compatible with**: React/React DOM 18 and 19, published Node `>=16`, public plugin/visitor/Gurx APIs, current Markdown fixtures, CI/browser tooling, and rollback through Markdown. +- **Not claimed**: byte identity for deliberately canonicalized Markdown; React 17; mobile/every-IME certification; preservation of unsafe URL navigation; new selection/search/composer features; direct use of Lexical internals by consumers. + +### Acceptance Scenarios + +| ID | Given | When | Then | Exact exercise and prerequisites | Required evidence | +|---|---|---|---|---|---| +| `CX-1` | Separate minimal TypeScript apps use the pinned React 18 and React 19 consumer manifests with only documented `@mdxeditor/editor` imports and its stylesheet | Each installs the packed 0.48 artifact, typechecks, bundles, starts a disposable preview, and renders an editor | Package exports, declarations, CSS, peer resolution, and runtime initialization work in both supported React majors without repository-source imports | Run `npm run build && npm run test:package`. Exact manifests use React/DOM 18.3.1 with types 18.3.24/18.3.7, or React/DOM 19.2.1 with types 19.2.7/19.2.3; both pin TypeScript 5.9.3 and Vite 5.2.8. The script uses temp apps/caches, the tarball, loopback previews, and Playwright Chromium, then cleans up | DIRECT REQUIRED | +| `CX-2` | The checked-in 0.35 canonical fixture covers representative CommonMark, GFM, MDX, and custom constructs; published `@mdxeditor/editor@4.0.4` plus overrides for the complete exact 0.35.0 Lexical set is the pinned legacy reader | The packed 0.48 app imports the 0.35 fixture and exports a deterministic edited document, then the disposable legacy app imports that 0.48 output through the same documented plugins and exports it again | All constructs survive 0.35→0.48 and 0.48→0.35; the legacy replay equals the checked-in 0.35 canonical/semantic expectation with no lost construct | Run `npm run test:cross-version`; first assert the legacy installed tree contains only Lexical 0.35.0 (the 4.0.4 manifest's carets may otherwise float), capture 0.48 public-ref output, feed it to 4.0.4, and compare its export/construct anchors. Any syntax difference requires explicit 0.35/0.48 expected strings and legacy canonicalization evidence | DIRECT REQUIRED | +| `CX-3` | The R1 fixture contains root text, nested JSX, a table cell, and adjacent decorators with shared toolbar history | The same root/nested/table/boundary focus, typing, deletion, undo, and redo journeys run on 0.48 | Exact observed Markdown transitions remain coherent once, focus stays in the intended editor, and no page error, console error, stale parent state, or update recursion occurs | Run `CX-3` in Chromium, Firefox, and WebKit through the public contenteditables and controls; no private Lexical/Gurx inspection | DIRECT REQUIRED | +| `CX-4` | The R1 list/link/shortcut/clipboard/maximum-length fixtures are present and the link dialog accepts both safe and dangerous URLs | Existing interactions run, then a user creates exact safe and dangerous vectors and opens/copies their preview | Existing behavior remains stable or is explicitly classified; dangerous editor/preview hrefs are `about:blank` while visible text, copied value, `getMarkdown()`, and the prevented `onClickLinkCallback` payload retain the authored raw URL; safe/relative vectors remain unchanged | Run R1 `CX-4a`-`CX-4c` plus `CX-4d` in all engines. Exact vectors: `https://example.com/safe`, `mailto:user@example.com`, `/relative`, `#anchor`, `javascript:alert(1)`, `javascript://:99999999999/%0aalert(1)`, `java\tscript:alert(1)`, and `data:text/html,<script>alert(1)</script>`; assert `getAttribute('href')`, display, intercepted `clipboard.writeText`, Markdown, callback payload, and no execution/runtime error | DIRECT REQUIRED for editor/dialog/browser behavior; DOM paste and intercepted clipboard writing remain explicit OS-clipboard proxies | + +## Research Summary + +### Vetted Repository Findings + +- `package.json:47-71` and `package-lock.json` — ten direct Lexical packages declare/resolve 0.35.0; `@lexical/clipboard` and `@lexical/plain-text` have no direct `src` imports — **PRP impact**: update all declarations together but retain the two direct dependencies during this mechanical migration rather than alter the published dependency surface opportunistically. +- `tsconfig.json:3-8` — TypeScript 5.9 uses `module: "esnext"` with legacy `moduleResolution: "node"` — **PRP impact**: use `moduleResolution: "bundler"`, then verify rollup and consumers, not only `tsc`. +- `src/plugins/codeblock/CodeBlockNode.tsx:43-79` — `importJSON` requires `SerializedCodeBlockNode`, narrower than 0.48's base `SerializedLexicalNode & Record<string, unknown>` static contract — **PRP impact**: accept the base input, validate/default custom fields, and test valid plus missing/malformed data without an `unknown` double cast. +- `src/plugins/thematic-break/*.ts` and `src/plugins/markdown-shortcut/index.tsx:27-63` — MDXEditor uses the legacy React horizontal-rule node/plugin throughout — **PRP impact**: retain that architecture until R5 and replace the re-exported base-class guard with a subclass-correct predicate; do not partially adopt `HorizontalRuleExtension`. +- `vite.config.ts:14-30,46-68` and `package.json:200-209` — dependencies are externalized and declarations are rolled into the published `dist` export — **PRP impact**: a packed, installed consumer is required to catch conditional-export or declaration leakage. +- `src/plugins/link-dialog/LinkDialog.tsx:193-247` — the preview anchor uses the raw stored URL, while 0.48's `LinkNode.createDOM` sanitizes its own anchor — **PRP impact**: give preview navigation a sanitized href while preserving raw display/callback/Markdown data unless a separately approved validation policy changes it. +- `src/test/fixtures/lexicalCompatibility.ts`, `src/test/compatibility.test.tsx`, and `tests/browser/lexical-compatibility.spec.ts` — R1 records exact 0.35 Markdown and directly covers the highest-risk browser paths — **PRP impact**: treat failures as migration evidence; do not rewrite expected behavior before classification. +- `.github/workflows/ci.yml` — CI already separates tests, 3-engine compatibility, build, and API docs — **PRP impact**: add the packed-consumer matrix to the build job after `dist` exists, preserving failure-local jobs. + +### External Constraints + +- Lexical 0.48 packages expose types through conditional exports with a TypeScript `<5.2` fallback; `@lexical/react@0.48.0` peers on React/React DOM `>=18.x` and optional TypeScript `>=5.2` — [official 0.48 package manifest](https://github.com/facebook/lexical/blob/v0.48.0/packages/lexical-react/package.json). +- Lexical 0.48's base `importJSON` accepts `SerializedLexicalNode & Record<string, unknown>` and recommends delegating reusable restoration to `updateFromJSON` — [official node source](https://github.com/facebook/lexical/blob/v0.48.0/packages/lexical/src/LexicalNode.ts), [serialization guide](https://github.com/facebook/lexical/blob/v0.48.0/packages/lexical-website/docs/serialization/serialization.md#lexicalnodeimportjson). +- The React `HorizontalRuleNode` and plugin are deprecated subclasses/adapters over `@lexical/extension`, while the base extension owns different registration/selection behavior — [React node](https://github.com/facebook/lexical/blob/v0.48.0/packages/lexical-react/src/LexicalHorizontalRuleNode.tsx), [extension implementation](https://github.com/facebook/lexical/blob/v0.48.0/packages/lexical-extension/src/HorizontalRuleExtension.ts). +- `LinkNode.sanitizeUrl` allowlists `http`, `https`, `mailto`, `sms`, and `tel`, neutralizes unsupported/control-obfuscated schemes to `about:blank`, and preserves relative inputs — [official 0.48 link source](https://github.com/facebook/lexical/blob/v0.48.0/packages/lexical-link/src/LexicalLinkNode.ts). +- Published `@mdxeditor/editor@4.0.4` declares the full Lexical set at `^0.35.0` with the same React 18/19 peers and Node engine — [npm registry manifest](https://registry.npmjs.org/@mdxeditor%2feditor/4.0.4). + +### Settled Decisions and Rejected Alternatives + +- **Decision**: update every existing direct Lexical declaration to `^0.48.0` and require the installed tree to contain only 0.48.0 Lexical packages — **Rationale**: preserves the repository's declaration policy while making the lockfile/runtime invariant executable. +- **Decision**: use TypeScript `bundler` resolution — **Rationale**: it matches the ESM/Vite library build and reduced the rehearsal to real incompatibilities. +- **Decision**: keep the legacy React horizontal-rule node/plugin internally consistent for R2 — **Rationale**: the extension implementation requires registration/selection lifecycle work owned by R5; a local `instanceof HorizontalRuleNode` predicate fixes the 0.48 type split without broad casts. +- **Decision**: sanitize the custom preview anchor's navigable href through the active `LinkNode` behavior while retaining the raw URL for display, Markdown serialization, and callbacks — **Rationale**: closes the navigation bypass without silently rewriting authored content or callback contracts. +- **Rejected**: remove unused direct clipboard/plain-text dependencies during R2 — **Reason**: source search proves no direct import but not that no consumer relied on the published dependency surface; cleanup is not required for 0.48 compatibility. +- **Rejected**: adopt selection conversion, extension composer/history, stock table nodes, NodeState, autolink redesign, or `@lexical/mdast` — **Reason**: each changes architecture or behavior beyond this reversible migration boundary. + +### Spike Evidence + +- No new spike needed. The clean isolated 0.48 rehearsal in `reports/lexical-upgrade-0.35.0-to-0.48.0-assessment-2026-07-18.md` reduced module resolution failures to the two source incompatibilities above and showed the existing jsdom suite still passing; R1 subsequently supplied the missing browser evidence. + +### Validation Baseline + +| Command | Status | Observed or expected result | +|---|---|---| +| `npm run test:compat` on 0.35 | Verified | R1 Verification Record: 3 jsdom and 15 browser tests pass. | +| `npm run lint && npm run typecheck` on 0.35 | Verified | Fresh R1 verification passed. | +| `npm run test:once`, `npm run build`, `npm run build:docs:api` on 0.35 | Verified | R1 execution record: 48 passed, 1 skipped, 1 todo; build and API docs pass with known warnings. | +| Temporary 0.48 `npm run test:once` | Verified rehearsal | Pre-R1 suite passed 45, skipped 1, todo 1. | +| Temporary 0.48 typecheck/build | Baseline failing | Legacy resolution produces false missing exports; bundler resolution exposes code-block and horizontal-rule errors; declarations are not yet valid. | +| Packed React 18/19 consumer | Unavailable | No consumer fixture/command exists; this PRP creates it. | + +### Research Coverage + +- **Depth**: Standard +- **Inspected**: R1 produced contract, parent `CX-1`-`CX-4`, upgrade assessment/rehearsal, dependency/lock/build/type configuration, code-block and thematic-break nodes, link rendering/dialog path, tests/CI, package exports, official 0.48 node/link/horizontal-rule manifests and source. +- **Not inspected**: R3 selection implementation, R4 search redesign, R5 extension migration, mobile/device farms, `@lexical/mdast`, or unrelated upstream packages. +- **Research confidence**: HIGH — the only compile blockers were reproduced in the prior rehearsal, the horizontal/security choices are grounded in live and 0.48 source, and R1 directly covers the behavioral blast radius. + +## Execution Contract + +- **Planned at commit**: `9373742` +- **Planning baseline**: R1 changes and `plans/`/`reports/` are pre-existing uncommitted work and must be preserved. R2 starts from that verified worktree rather than resetting to HEAD. + +### Expected Changes + +- `package.json`, `package-lock.json`, `tsconfig.json` — lockstep 0.48 declarations/resolution plus focused version/package scripts. +- `src/plugins/codeblock/CodeBlockNode.tsx` and focused tests — compatible validated JSON import. +- `src/plugins/thematic-break/LexicalThematicBreakVisitor.ts` — subclass-correct legacy React node guard; other thematic files only if needed for consistent imports. +- `src/plugins/link-dialog/LinkDialog.tsx` and its state wiring/tests — separate displayed/raw URL from sanitized preview href without making an existing exported state type source-incompatible. +- `tests/browser/lexical-compatibility.spec.ts` and shared fixture/harness if needed — `CX-4d` security journey and classified 0.48 expectations. +- `scripts/assert-lexical-versions.mjs`, package/cross-version verification scripts, `tests/package-consumer/`, `.github/workflows/ci.yml`, and `CONTRIBUTING.md` — deterministic dependency invariant, packed React matrix, and published-0.35 replay gate. + +### Explicitly Out of Scope + +- R3-R5 feature work, `@lexical/mdast`, NodeState/custom-node modernization, Unicode/autolink policy redesign, stock Lexical table migration, Shadow DOM, performance refactors, or device/IME certification. +- Removing existing direct Lexical packages, changing React peers or Node engines, changing public plugin/visitor/method signatures, or adding Lexical extensions to consumer APIs. +- Restoring navigation for malformed/dangerous URLs or treating security hardening as a compatibility regression. + +### Scope Expansion Rule + +Additional compatibility/test files may change when 0.48 produces a directly evidenced compile or `CX-1`-`CX-4` failure. Record the path, upstream behavior, and evidence in Execution Notes. Pause before any new public API, package engine/peer change, editor architecture rewrite, data-loss acceptance, or optional feature adoption. + +### Pause and Reassess If + +- Any installed Lexical package cannot remain at exactly 0.48.0 or a fix requires mixed versions. +- A R1 construct loses content/meaning, nested updates recurse, or cross-version Markdown becomes unreadable rather than merely canonicalized differently. +- Horizontal-rule compatibility requires extension-composer adoption rather than the bounded legacy predicate/import fix. +- URL safety would require rewriting stored Markdown or callback payloads, rather than neutralizing automatic navigation. +- The packed artifact cannot support both React majors without changing published peer/engine/API contracts. + +## Context + +### Key Files + +- `plans/roadmaps/001-lexical-048-adoption.md` — inherited consumer and rollback contract. +- `plans/001-lexical-compatibility-gate.md` — verified R1 evidence and exact 0.35 fixture behavior. +- `reports/lexical-upgrade-0.35.0-to-0.48.0-assessment-2026-07-18.md` — changelog mapping and isolated rehearsal. +- `package.json`, `package-lock.json`, `tsconfig.json`, `vite.config.ts` — dependency, resolver, declaration, and package boundaries. +- `src/plugins/codeblock/CodeBlockNode.tsx` — custom static JSON incompatibility. +- `src/plugins/thematic-break/` and `src/plugins/markdown-shortcut/index.tsx` — legacy React horizontal-rule architecture. +- `src/plugins/link-dialog/LinkDialog.tsx`, `src/plugins/link-dialog/index.ts`, `src/plugins/link/` — raw, rendered, preview, callback, and Markdown URL paths. +- `src/test/fixtures/lexicalCompatibility.ts`, `src/test/compatibility.test.tsx`, `tests/browser/lexical-compatibility.spec.ts` — canonical/cross-version and real-browser gates. + +### Gotchas + +- Conditional type exports make a wrong resolver look like hundreds of removed APIs; fix the resolver before touching source imports. +- The React module re-exports `$isHorizontalRuleNode` from the base extension, so that guard does not narrow to the deprecated React subclass in 0.48. +- The preview dialog creates its own `<a>` and therefore does not inherit `LinkNode.createDOM` sanitization automatically. +- A browser's `anchor.href` property becomes absolute for relative URLs; test `getAttribute('href')` when the authored relative form is the contract. +- The local npm cache currently can contain ownership-conflicted files. The consumer script must use a temp cache and clean it with all packed apps/servers in `finally`. +- Run Playwright outside the restricted macOS Codex sandbox; sandbox launch aborts are environment failures and can generate OS crash reports. + +## Implementation Blueprint + +### Tasks + +```yaml +Task 1: Upgrade the package graph and resolver atomically + MODIFY package.json, package-lock.json, tsconfig.json: + - Change every existing direct lexical/@lexical declaration to ^0.48.0 in one install and set moduleResolution to bundler. + - Add test:lexical-versions backed by a script that checks direct declarations and recursively rejects any installed Lexical version other than 0.48.0. + - Preserve React peers, Node engine, and the direct clipboard/plain-text entries. + CREATE scripts/assert-lexical-versions.mjs + ENABLES: CX-1, CX-2, CX-3, CX-4 + VERIFY: + - COMMAND: npm run test:lexical-versions && npm run typecheck + - EXPECTED: one 0.48.0 Lexical graph and zero TypeScript errors after Tasks 2-3 land. + +Task 2: Resolve custom-node compatibility without architecture migration + MODIFY src/plugins/codeblock/CodeBlockNode.tsx: + - Accept the 0.48 base static JSON input, validate/default code/language/meta, and retain round-trip output without unknown double casts. + MODIFY src/plugins/thematic-break/LexicalThematicBreakVisitor.ts: + - Narrow with the registered React HorizontalRuleNode subclass instead of the extension-base re-exported guard; keep registration, creation, command, and shortcut dependencies on the same legacy React module. + MODIFY/CREATE focused src/test tests: + - Cover valid code-block JSON round trip plus missing/wrong custom fields and thematic-break Markdown round trip. + ENABLES: CX-2, CX-3, CX-4 + VERIFY: + - COMMAND: npm run test:once -- --run src/test/core.test.tsx && npm run typecheck + - EXPECTED: focused node/Markdown tests and source typing pass with no broad compatibility cast. + +Task 3: Retain fail-closed URL behavior through MDXEditor's custom UI + MODIFY src/plugins/link-dialog/LinkDialog.tsx and src/plugins/link-dialog/index.ts: + - Carry a sanitized navigation href derived from the active 0.48 LinkNode separately from raw display/callback/Markdown URL data. + - Keep the exported PreviewLinkDialog shape source-compatible; any added href field must be optional for external construction while every internal preview publication supplies it. + - Keep safe, mail, and relative URLs usable; never recreate or weaken the upstream allowlist. + MODIFY tests/browser/lexical-compatibility.spec.ts and focused link tests/fixture: + - Add CX-4d with the table's exact vectors; assert editor/preview getAttribute('href'), visible and copied raw URL, getMarkdown(), prevented callback payload, safe/relative preservation, and no script/page/console error. + ENABLES: CX-4 + VERIFY: + - COMMAND: npm run test:browser -- --grep 'CX-4b|CX-4d' + - EXPECTED: 6 focused tests pass; safe URLs retain their target, dangerous navigation is about:blank, and every raw data channel retains the authored value. + - PROCESS-LIFECYCLE: Playwright owns Ladle, browsers, artifacts, and port 61000; exit 0 is success and any assertion/runtime/server failure is nonzero. + +Task 4: Verify the packed package with both supported React majors + CREATE tests/package-consumer/* and scripts/verify-package-consumer.mjs: + - Check in complete exact manifests for the two CX-1 toolchains; build/pack once, copy each fixture to a disposable app with a temp npm cache, install, typecheck, Vite-bundle, serve, and assert public rendering/ref output in Chromium. + - Print phase/major progress, fail on install/type/bundle/runtime error, terminate each server, and remove tarball/apps/cache in finally. + CREATE scripts/verify-cross-version-markdown.mjs (or an isolated phase in the same runner): + - Capture deterministic public-ref Markdown from the packed 0.48 consumer; install exact @mdxeditor/editor@4.0.4 with overrides pinning every lexical/@lexical dependency to 0.35.0; assert that installed graph before importing the captured output with the same plugins and comparing its public-ref export to the explicit 0.35 expectation and construct anchors. + MODIFY package.json, .github/workflows/ci.yml, CONTRIBUTING.md: + - Add test:package and test:cross-version; install the Playwright-matched Chromium binary in build-js and run both after Build; document direct and failure-local commands plus network/browser prerequisites. + ENABLES: CX-1, CX-2 + VERIFY: + - COMMAND: npm run build && npm run test:package && npm run test:cross-version + - EXPECTED: both packed consumers pass and 4.0.4 directly imports/re-exports the captured 0.48 Markdown with no lost construct or retained process/temp state. + - FAILURE-LOCAL: npm run build; npm run test:package -- --react=18; npm run test:package -- --react=19; npm run test:cross-version. + - PROCESS-LIFECYCLE: the script owns temp directories/caches/tarball, Chromium contexts, child process groups, and allocated ports on success, failure, and interrupt. + +Task 5: Classify migration behavior and run the integrated handoff + MODIFY R1 fixture expectations only when directly evidenced: + - Preserve compatibilityMarkdown035; if 0.48 canonical syntax differs, add an explicit 0.48 expectation and semantic cross-version assertions rather than overwriting history. + - Record every intentional 0.48 difference and why it is security/semantic-preserving in Execution Notes. + ENABLES: CX-1, CX-2, CX-3, CX-4 + VERIFY: + - COMMAND: npm run lint && npm run typecheck && npm run test:lexical-versions && npm run test:once && npm run test:compat && npm run build && npm run test:package && npm run test:cross-version && npm run build:docs:api + - EXPECTED: all existing and new gates return 0 on the 0.48 lockfile; CX-1-CX-4 have direct evidence and no unclassified fixture change. + - FAILURE-LOCAL: npm run test:once; npm run test:compat:unit; npm run test:browser -- --project=<name>; npm run test:browser -- --grep <CX-title>; npm run build; npm run test:package -- --react=<18|19>; npm run test:cross-version; npm run build:docs:api. + - PROCESS-LIFECYCLE: npm exposes each phase; Vitest owns jsdom cleanup, Playwright owns Ladle/browser/61000, and the package script owns its scratch apps/servers/caches. Any phase nonzero fails; all owners clean up on every terminal path. +``` + +## Validation + +```bash +npm run test:lexical-versions +npm run lint +npm run typecheck +npm run test:once +npm run test:compat +npm run build +npm run test:package +npm run test:cross-version +npm run build:docs:api +git diff --check +``` + +The `CX-N` table is authoritative. Do not accept a changed snapshot merely because 0.48 emits it; first distinguish canonical syntax, intentional security hardening, and semantic/data loss. Browser commands must run outside the restricted macOS sandbox. + +## Unknowns & Risks + +- New 0.48 errors may appear only after the known resolver/node fixes; they are in scope only when they are necessary for the same public/build contract and are recorded with evidence. +- Continuous typing/history grouping may differ while exported state remains equivalent; assert restored public text/Markdown rather than a fixed number of internal history entries. +- Package-consumer installation requires npm availability. CI is the authoritative environment if local network/cache policy prevents direct evidence; do not substitute source imports for the packed artifact. +- The retained legacy horizontal-rule path is intentionally temporary and deprecated, but replacing it early would collapse R2 and R5's rollback boundaries. + +**Confidence: 9/10** for one-pass implementation success. The compile blockers and security bypass are concretely located; remaining uncertainty is limited to directly testable 0.48 behavior and packed-consumer integration. + +## Execution Notes + +### 2026-07-18 — implementation handoff + +- Upgraded all ten direct Lexical declarations to `^0.48.0`, regenerated the lockfile, changed TypeScript resolution to `bundler`, and added an executable graph assertion. The installed repository and both packed consumers contain 24 Lexical packages, all exactly `0.48.0`; React peers and the published Node `>=16` engine are unchanged. +- Broadened `CodeBlockNode.importJSON` to the 0.48 base contract and defaulted missing or non-string `code`, `language`, and `meta` fields. Kept the deprecated React horizontal-rule node/plugin/shortcut path internally consistent, used a subclass-correct predicate, and added explicit lint annotations documenting the deliberate R5 deferral. +- `bundler` resolution exposed an ESLint type-information failure for `micromark-util-symbol`'s declaration-only default export in `src/mdastUtilHtmlComment.ts`. The tokenizer now locally types only the four character codes and three token names it already used, with identical micromark literal values; tests, Markdown fixtures, declaration rollup, and the packed replay show no behavior change. +- Split link data from navigation in `PreviewLinkDialog`: internal preview publications derive `href` from the active 0.48 `LinkNode.sanitizeUrl`, while the new exported field remains optional and defaults fail-closed for external state construction. All exact `CX-4d` vectors pass in Chromium, Firefox, and WebKit. Safe, mail, path, and fragment hrefs remain usable; all four dangerous/obfuscated hrefs are `about:blank`; visible text, clipboard payload, callback payload, and the parsed Markdown destination retain the authored URL. Markdown may escape reserved destination syntax such as `mailto\:` while parsing back to the exact raw URL. +- Classified one intentional upstream list change already identified in the assessment: on 0.48, Backspace in a newly created empty list item converts it to a paragraph and leaves the surrounding list fragments intact, rather than merging subsequent typing into the preceding item as 0.35 did. All three engines produce the same Markdown (` after Backspace` between the two list fragments), every construct occurs exactly once, and task toggle/undo remains coherent. The browser contract records both the old expectation and the explicit 0.48 outcome instead of silently weakening the test. +- Added checked-in React 18.3.1 and React 19.2.1 consumers with their exact type packages, TypeScript 5.9.3, and Vite 5.2.8. The package gate packs once, installs through isolated temporary caches, typechecks, bundles, serves, renders documented exports/styles, reads Markdown through the public ref, asserts the exact 0.48 graph, and cleans browser/server/app/cache/tarball state on success, failure, or interrupt. +- Added direct cross-version replay: the packed 0.48 consumer imports the checked-in input and emits the unchanged 0.35 canonical result; published `@mdxeditor/editor@4.0.4` then imports that output with all 22 legacy Lexical packages overridden and asserted at exactly 0.35.0, and re-exports the same canonical document with every construct anchor present. +- Validation passed: `npm run test:lexical-versions`; `npm run lint`; `npm run typecheck`; `npm run test:once` (50 passed, 1 skipped, 1 todo); all 18 browser scenarios across Chromium, Firefox, and WebKit (15 in the full run plus the corrected three-engine `CX-4a` classification); `npm run build`; combined `npm run test:package`; `npm run test:cross-version`; `npm run build:docs:api`; Node syntax checks for all package scripts; Prettier checks; and `git diff --check`. +- Existing non-failing warnings remain visible: stale Browserslist data, the legacy JSX transform warning, the known jsdom CodeMirror geometry exception log, Radix/Vite consumer bundle warnings, chunk-size warnings, npm configuration deprecations, and TypeDoc/API Extractor TypeScript-version warnings. +- Implementation is complete with no public API, peer, engine, editor-architecture, or feature-scope expansion. Independent PRP verification remains pending, so R2 stays `IN PROGRESS`; R3 and R4 remain blocked. + +## Verification Record + +### 2026-07-18 — Standard assurance + +- **Verifier shape**: one fresh-context, read-only verifier independently performed consumer acceptance, PRP compliance, and engineering-quality passes. The main agent reproduced and fixed the verifier's one concrete finding; the same verifier then reran only the invalidated package/cross-version scenarios and cleanup checks. +- **Acceptance grades**: + + | Scenario | Grade | Direct evidence | + |---|---|---| + | `CX-1` | DIRECTLY VERIFIED | `npm run build` and the exact `npm run test:package` passed. Packed React 18.3.1 and 19.2.1 consumers with their pinned type packages, TypeScript 5.9.3, and Vite 5.2.8 installed, typechecked, bundled, served, rendered documented exports/styles, returned public-ref Markdown, and each asserted 24 Lexical packages at exactly 0.48.0. | + | `CX-2` | DIRECTLY VERIFIED | The exact `npm run test:cross-version` passed. The packed 0.48 writer preserved the checked-in canonical fixture, and published `@mdxeditor/editor@4.0.4` with all 22 legacy Lexical packages pinned and asserted at 0.35.0 imported and re-exported it without losing any construct anchor. | + | `CX-3` | DIRECTLY VERIFIED | `npm run test:browser` passed the root, nested JSX, table, decorator-boundary, focus, typing, deletion, undo, and redo journey in Chromium, Firefox, and WebKit with no page/console error or update recursion. | + | `CX-4` | DIRECTLY VERIFIED | `npm run test:browser` passed all list, shortcut, link, URL-security, paste, cut/undo, and maximum-length journeys in all three engines: 18/18 tests. All eight exact URL vectors retained raw display, parsed Markdown, clipboard, and callback values; dangerous/obfuscated navigable hrefs were `about:blank`, while safe and relative hrefs were unchanged. DOM paste and intercepted `clipboard.writeText` remain the contract's explicitly accepted OS-clipboard proxies. | + +- **Resolved finding**: the original exact package command failed before browser launch because `npm pack` inherited the ownership-conflicted default user cache even though disposable installs used a scratch cache. This contradicted the isolation/cleanup contract. `packCurrentPackage` now receives the already-created scratch cache and passes it through `npm pack --cache`; both package and cross-version callers use it. The main agent reproduced the original `EPERM`, and the independent verifier reran the exact documented `npm run test:package` and `npm run test:cross-version` without an environment override; both passed. +- **PRP compliance**: all ten direct Lexical dependencies remain present at `^0.48.0`; the installed graph is exclusively 0.48.0; React peers and Node `>=16` are unchanged. Resolver, code-block JSON, retained legacy thematic-break architecture, fail-closed preview URL channel, package consumers, rollback replay, CI, documentation, and cleanup behavior match Tasks 1-5. The recorded `src/mdastUtilHtmlComment.ts` compatibility adjustment is bounded by the scope-expansion rule. No R3-R5 feature, `@lexical/mdast`, NodeState, peer/engine, or public-method expansion was introduced. +- **Engineering review**: no unresolved correctness, security, error-handling, maintainability, or test-quality finding remains. The optional preview `href` field is source-compatible; raw link data is separated from navigation; process groups, browser pages, preview servers, allocated ports, temporary apps/caches, and tarballs have explicit terminal cleanup. +- **Fresh verification evidence**: structural validation passed with only the non-blocking executed-PRP size warning; `npm run test:lexical-versions`; `npm run lint`; `npm run typecheck`; `npm run test:once` (50 passed, 1 skipped, 1 todo); `npm run test:browser` (18 passed); `npm run build`; exact `npm run test:package`; exact `npm run test:cross-version`; `npm run build:docs:api`; Node syntax checks; Prettier checks; and `git diff --check`. Cleanup inspection found no R2 Chromium, Ladle, Vite-preview, or verification-script process, no listener on port 61000, and no package/cross-version scratch directory. +- **Limitations**: OS clipboard integration, mobile browsers, and exhaustive IME behavior remain outside the claimed surface. Existing non-failing toolchain/dependency warnings are unchanged and listed in the Execution Notes. +- **Result**: VERIFIED. R2 produces the stable Lexical 0.48 package/type/browser and rollback baseline; R3 and R4 are ready for just-in-time PRP generation. diff --git a/plans/003-selection-markdown.md b/plans/003-selection-markdown.md new file mode 100644 index 00000000..a8e34304 --- /dev/null +++ b/plans/003-selection-markdown.md @@ -0,0 +1,323 @@ +--- +repo: /Users/petyo/w/mdx-editor/editor +--- + +# PRP: Selection-Scoped Markdown Through the Active Editor Pipeline + +## Goal + +Make `MDXEditorMethods.getSelectionMarkdown()` and `getSelectionAsMarkdown(editor, exportParams)` return only the active Lexical editor's selected content while preserving MDXEditor's MDAST visitors, Markdown extensions/options, JSX descriptors, custom-node registrations, and root/nested routing. + +Replace the handwritten whole-node serializer with a selection-cloning boundary backed by Lexical 0.48's clipboard primitives and an isolated temporary editor. Do not introduce a second transformer configuration, mutate the source editor, or adopt `@lexical/mdast`. + +## Why + +- The current helper expands partial ranges to whole paragraphs/list items, so consumers can receive content they did not select. +- The handwritten serializer ignores its `exportParams`, loses built-in/custom constructs, flattens nested/task lists, and implements only a subset of MDXEditor's formatting semantics. +- PR #949 identified a useful upstream selection API but implemented a parallel `@lexical/markdown` transformer path with global/cache and plugin-order risks. R3 should retain one consumer-visible Markdown contract instead. +- Accurate selection export is an invariant needed before R5 changes root/nested composer internals. + +## Success Criteria + +- [x] Partial, backward, multi-block, formatted, linked, nested-list, and task-list selections return only the selected fragment with valid Markdown structure. +- [x] Meaningful node and custom-construct selections serialize through the same registered visitors, extensions, options, and descriptors as full-document export. +- [x] Root and active nested-editor selections route correctly; absent/collapsed selections and source/diff mode return `''`. +- [x] Two simultaneously rendered editors retain independent visitor/options configuration, and repeated calls do not change source state, selection, history, callbacks, or shared arrays. +- [x] The public method/helper signatures and full-document Markdown behavior remain source-compatible, and direct browser evidence passes in Chromium, Firefox, and WebKit. + +## Assurance + +- **Profile**: Standard +- **Rationale**: This corrects a public imperative method and touches shared Markdown export, custom-node reconstruction, and active nested-editor routing. The change is reversible, introduces no persisted schema or deployment boundary, and the architecture was resolved by a child-local scratch spike. Direct public-ref browser coverage plus focused state tests bound the remaining risk; no Deep trigger is evidenced. The near-exceptional document size is load-bearing because one inherited public scenario spans range direction, structural/atomic nodes, custom configuration, nested routing, non-mutation, three browser engines, and shared full-export/package regression gates. + +## Roadmap Context + +- **Parent roadmap**: `plans/roadmaps/001-lexical-048-adoption.md` +- **Roadmap step**: `R3` — make selection Markdown match the active editor contract. +- **Satisfied dependencies**: R2 is `VERIFIED`; its Verification Record proves a lockstep Lexical 0.48.0 graph, full package/build gates, React 18/19 consumers, cross-version Markdown, and 18 browser tests across Chromium, Firefox, and WebKit for parent `CX-1` through `CX-4`. +- **Inherited decisions and invariants**: public plugin/visitor/ref contracts remain source-compatible; full-document export stays on MDXEditor's MDAST visitor pipeline; `activeEditor$` chooses root versus nested content; fail-closed URL behavior remains; `@lexical/mdast`, search, and composer/history migration stay separate. +- **Contract produced for later steps**: directly verified parent `CX-5` behavior and a selection/full-export consistency invariant consumed by R5. + +## Consumer Contract + +### Consumer and Public Boundary + +- **Consumer(s)**: React integrators using `MDXEditorMethods`; authors selecting Markdown/MDX in root or nested editors; plugin authors registering Lexical nodes, export visitors, Markdown extensions/options, and JSX descriptors. +- **Public or supported boundary**: `MDXEditor` props/plugins, `MDXEditorMethods.getSelectionMarkdown()`, and the exported `getSelectionAsMarkdown(editor, exportParams)` helper. +- **Entry point and prerequisites**: a rendered Lexical 0.48 MDXEditor in rich-text mode with a non-collapsed browser selection or meaningful node selection; supported plugins/configuration are registered on that editor. +- **Current observable behavior**: absent/collapsed/source/diff cases return empty, but partial text expands to whole blocks, custom export configuration is ignored, nested/task lists are malformed, and many custom/decorator nodes degrade to text or disappear. +- **Observable promise**: the returned fragment contains only the active editor's selected content, expressed with that editor's normal Markdown semantics and supported custom constructs, without observable mutation. +- **Must remain compatible with**: `MDXEditorMethods`, direct helper callers, React 18/19, the verified R2 package/browser contract, public `LexicalExportVisitor`/plugin configuration, `additionalLexicalNodes`, root/nested active-editor semantics, and normal `getMarkdown()` output. +- **Not claimed**: clipboard integration, a live selection UI, byte-identical syntax beyond configured MDAST canonicalization, partial editing inside atomic decorator payloads, source/diff selection export, mobile/every-IME certification, or `@lexical/mdast` adoption. + +### Acceptance Scenarios + +<!-- prettier-ignore --> +| ID | Given | When | Then | Exact exercise and prerequisites | Required evidence | +|---|---|---|---|---|---| +| `CX-5` | A rendered editor has partial, backward, multi-block, nested/task-list, partial-link, node, built-in custom-node, consumer-visitor, or active nested-editor selection; a second editor may use different export options | The consumer invokes `getSelectionMarkdown()` through the public ref one or more times | Each result represents only that editor's selected fragment with its visitors, extensions, options, descriptors, and custom registrations; absent/collapsed/source/diff cases return `''`; repeated calls leave full Markdown, selection, history, callbacks, and the other editor unchanged | In Chromium, Firefox, and WebKit, use DOM `Range`/`Selection.setBaseAndExtent`, direct clicks for the selectable thematic break, and rendered nested content. Cover partial plain/bold/italic/code text; the upstream partial-link case excluding the first character; equivalent forward/backward multi-block ranges; nested ordered and task-list slices; a range spanning table/thematic break/code/image/directive/JSX/frontmatter representatives; a consumer text visitor plus distinct `toMarkdownOptions` in two simultaneous editors; a nested directive/JSX editor; two repeated reads; collapsed/no-selection and source/diff controls. Invoke only the public ref button and compare the returned fragment plus public `getMarkdown()`/observable change count | DIRECT REQUIRED | + +## Research Summary + +### Vetted Repository Findings + +- `src/MDXEditor.tsx:172-176,261-287` — the public method already routes through `activeEditor$` and gathers visitors, `toMarkdownExtensions`, `toMarkdownOptions`, JSX descriptors, and JSX availability — **PRP impact**: preserve this entry point and make the helper actually consume every supplied parameter. +- `src/utils/lexicalHelpers.ts:162-278` — the current public helper rejects every non-range selection, promotes selected leaves to whole blocks, hand-serializes a small format/node subset, ignores `_exportParams`, and trims the result — **PRP impact**: replace the implementation, retain its source-compatible signature, and explicitly cover node selection plus selected whitespace/structural boundary normalization. +- `src/exportMarkdownFromLexical.ts:100-221,390-439` — full export walks registered visitors into MDAST and applies editor-local extensions/options, but sorts the supplied visitor array in place — **PRP impact**: selected content must re-enter this pipeline, and visitor ordering must use a copy so repeated calls do not mutate shared realm configuration. +- `src/plugins/core/index.ts:108-118,251-288,326-360,940-1015` — root/active editor, registered nodes/visitors, extensions/options, and `additionalLexicalNodes` compose the live editor contract — **PRP impact**: the selected scratch editor must inherit the active editor's complete registration map, including node replacements, without adding a public configuration requirement. +- `src/plugins/core/NestedLexicalEditor.tsx:216-354` — nested editors use the same registered nodes/visitors and become active on focus/selection change — **PRP impact**: clone from the active nested state and return its fragment, not the outer directive/JSX wrapper. +- `src/plugins/table/TableNode.tsx:49-96`, `src/plugins/directives/DirectiveNode.tsx:27-70`, and `src/plugins/jsx/LexicalJsxNode.tsx:28-66` — built-in atomic/custom nodes preserve MDAST payloads through `exportJSON`/`importJSON` — **PRP impact**: Lexical selection JSON can retain these payloads for the existing export visitors. +- `src/test/selection-markdown.test.tsx:9-36` — current coverage checks only method existence and empty results — **PRP impact**: replace comments/manual reliance with selection, configuration, node, mutation, and failure-focused tests. +- `src/test/fixtures/LexicalCompatibilityHarness.tsx:83-179`, `tests/browser/lexical-compatibility.spec.ts:1-105`, and `playwright.config.ts` — R1 established a deterministic public-ref story and three-browser lifecycle on port 61000 — **PRP impact**: add a dedicated selection fixture/story/spec to the same harness rather than create another host. +- `reports/pr-949-review.md:15-48` — PR #949 correctly identifies partial selection loss but its transformer approach drops custom nodes, mutates shared transformers, caches table extensions globally/order-dependently, and diverges from configured MDAST output — **PRP impact**: do not copy its transformer/cache architecture; retain the upstream partial-link regression case. + +### External Constraints + +- Lexical 0.48's `$generateJSONFromSelectedNodes` slices selected text, retains qualifying ancestors through `extractWithChild`, supports `NodeSelection`, and serializes registered custom nodes; `$generateNodesFromSerializedNodes` reconstructs them through registered `importJSON` — [official 0.48 clipboard source](https://github.com/facebook/lexical/blob/v0.48.0/packages/lexical-clipboard/src/clipboard.ts). +- Creating a no-config child editor while an editor context is active inherits the active editor's registered node map; this lets a temporary editor reconstruct plugin nodes and replacements without private registry access — [official 0.48 editor source](https://github.com/facebook/lexical/blob/v0.48.0/packages/lexical/src/LexicalEditor.ts). +- `$convertSelectionToMarkdownString` is transformer-configured and its element transformers receive an optional selection; it does not consume MDXEditor's MDAST visitor/options contract — [official Markdown API](https://lexical.dev/docs/api/modules/lexical_markdown), [official 0.48 Markdown export source](https://github.com/facebook/lexical/blob/v0.48.0/packages/lexical-markdown/src/MarkdownExport.ts). + +### Settled Decisions and Rejected Alternatives + +- **Decision**: capture selected JSON in a read of the active source editor, create a no-config temporary editor inside that read so it inherits registrations, reconstruct/attach the selected nodes there, and run the existing MDAST exporter — **Evidence/rationale**: `plans/research/003-selection-markdown/spike-01-selection-export-pipeline.md` directly proved partial text/link, multi-block, custom node, repeatability, custom registration inheritance, and zero source updates. +- **Decision**: wrap consecutive top-level inline reconstructed nodes in temporary paragraphs and append block nodes directly to the temporary root — **Rationale**: Lexical selection JSON intentionally strips unneeded ancestors for single inline ranges, while MDXEditor's root exporter requires a valid block-capable tree. +- **Decision**: perform scratch reconstruction/export inside a guarded temporary update with transforms skipped, capture any thrown error, and rethrow after the update — **Rationale**: selected-node construction requires write context, but serialization errors must not become console-only partial/empty output. +- **Decision**: remove serializer-added terminal newline(s) without blanket `trim()` of user-selected inline content — **Rationale**: the result is a fragment, while selected leading/trailing content must not be silently broadened or discarded. +- **Rejected**: `$convertSelectionToMarkdownString` plus table/thematic/custom transformers — **Reason**: it creates a second public serialization configuration and repeats PR #949's custom-node and multi-editor risks. +- **Rejected**: temporarily setting or editing the source editor state — **Reason**: an imperative getter must not publish updates, affect history, move selection, or trigger parent/nested synchronization. +- **Rejected**: private `_nodes` access, module-level caches, or a new required node-registration parameter — **Reason**: the no-config temporary editor inherits the active registration map and preserves the existing public helper call shape. + +### Spike Evidence + +- `plans/research/003-selection-markdown/spike-01-selection-export-pipeline.md` — **Question**: can Lexical selection clipping feed the existing MDAST exporter without source mutation? — **Result/decision**: CONCLUSIVE; selected JSON plus a registration-inheriting temporary editor produced correct partial text/link, multi-block, and custom-node Markdown with stable repeated output and no source update — **Limits**: execution must still cover the full built-in/custom matrix, node replacements, options isolation, browser-created backward selection, and nested editors. + +### Validation Baseline + +| Command | Status | Observed or expected result | +| ----------------------------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `npm run test:once -- src/test/selection-markdown.test.tsx` | Verified | Existing baseline passes 3 tests; they cover only empty/method-exists behavior. | +| `npm run test:browser` | Verified inherited | R2 Verification Record: 18 tests pass across Chromium, Firefox, and WebKit; current selection assertion covers only collapsed empty output. | +| `npm run lint`, `npm run typecheck`, `npm run test:once` | Verified inherited | R2 verification passed; unit baseline was 50 passed, 1 skipped, 1 todo. | +| `npm run build`, `npm run build:docs:api`, `npm run test:package`, `npm run test:cross-version` | Verified inherited | R2 package/declaration, React 18/19, and 0.35 replay gates pass. | +| Scratch selection/MDAST prototype | Verified | Partial text/link, multi-block, custom decorator, repeated calls, and inherited custom-node registration all passed with source state unchanged. | + +### Research Coverage + +- **Depth**: Standard +- **Inspected**: parent R3 contract and R2 verification summary; public ref/helper; active/root editor and registration/config cells; normal MDAST exporter; representative built-in node JSON/visitors; nested editor lifecycle; existing unit/browser fixtures; PR #949 findings; installed and official Lexical 0.48 clipboard/editor/Markdown source. +- **Not inspected**: R4 search, R5 extension migration, clipboard behavior, mobile/device farms, exhaustive IME behavior, `@lexical/mdast`, or unrelated plugins. +- **Research confidence**: HIGH — the architecture and custom-registration inheritance were empirically proven; remaining cases are deterministic variations on the same public boundary and existing browser harness. + +## Execution Contract + +- **Planned at commit**: `9373742` +- **Planning baseline**: the worktree contains verified, uncommitted R1/R2 implementation plus existing `plans/` and `reports/`; preserve all of it. R3 planning adds only this PRP and its spike record before execution. + +### Expected Changes + +- `src/utils/lexicalHelpers.ts` — replace the handwritten selection serializer with selected-JSON capture, isolated reconstruction, fragment normalization, and existing-pipeline export. +- `src/exportMarkdownFromLexical.ts` — copy before visitor sorting and add only the internal factoring needed to serialize the temporary selected tree without changing normal export output. +- `src/MDXEditor.tsx` — documentation or wiring only if needed; retain the public method signature and current active-editor/config lookup. +- `src/test/selection-markdown.test.tsx` — focused helper/public-ref state, node, config, error, and non-mutation tests. +- `src/test/fixtures/SelectionMarkdownHarness.tsx` and `src/examples/get-selection-markdown.tsx` — deterministic public selection fixture on the existing Ladle host. +- `tests/browser/selection-markdown.spec.ts` — direct `CX-5` matrix in Chromium, Firefox, and WebKit. +- API documentation generated by `npm run build:docs:api` only if checked-in output changes under the repository's existing convention. + +### Explicitly Out of Scope + +- Clipboard/copy command integration, live selection preview UI, search/replace, composer/history extensions, `@lexical/mdast`, NodeState migration, Shadow DOM certification, or unrelated editor UX. +- Replacing the full-document MDAST visitor pipeline, adding transformer configuration to plugins, changing public visitor/helper/ref signatures, or requiring consumers to expose Lexical internals. +- Partial extraction from inside atomic decorator payloads; selecting an atomic node serializes that supported node as a whole, while selections inside its nested Lexical editor serialize only the nested active content. +- Changing source/diff behavior from the documented empty result or changing normal `getMarkdown()` canonical output. + +### Scope Expansion Rule + +Additional selection fixture or built-in-node files may change only when direct `CX-5` evidence exposes a selection serialization defect in that node's existing JSON/visitor contract. Record the path and evidence in Execution Notes. Pause before any public API requirement, visitor-contract break, full-export behavior change, source-state mutation, or adoption of a second serialization architecture. + +### Pause and Reassess If + +- A supported built-in or representative consumer node cannot round-trip selected JSON through its registered `exportJSON`/`importJSON` without losing content. +- The temporary editor cannot inherit `additionalLexicalNodes` replacements in the real rendered path without private registry access or a new public argument. +- Correct partial selection requires changing existing consumer visitors or narrowing parent `CX-5` to stock Markdown. +- A getter call emits source-editor updates, changes selection/history/full Markdown, or triggers nested-parent synchronization. +- Selection/full-export consistency requires replacing the established MDAST pipeline or adopting `@lexical/mdast`. + +## Context + +### Key Files + +- `plans/roadmaps/001-lexical-048-adoption.md` — parent `CX-5`, R3 boundaries, and R5 handoff. +- `plans/002-lexical-048-upgrade.md` — verified Lexical 0.48/package/browser dependency contract. +- `plans/research/003-selection-markdown/spike-01-selection-export-pipeline.md` — selected architecture and reproducible evidence. +- `src/MDXEditor.tsx` — public imperative method and editor-local configuration lookup. +- `src/utils/lexicalHelpers.ts` — broken current implementation and public helper signature. +- `src/exportMarkdownFromLexical.ts` — canonical visitor/MDAST/extension/options exporter. +- `src/plugins/core/index.ts` and `src/plugins/core/NestedLexicalEditor.tsx` — active-editor routing, node registration, and nested lifecycle. +- `src/plugins/core/LexicalTextVisitor.ts` and `src/plugins/lists/LexicalListItemVisitor.ts` — formatting and structural behavior that must receive reconstructed clipped nodes. +- `src/plugins/table/TableNode.tsx`, `src/plugins/directives/DirectiveNode.tsx`, `src/plugins/jsx/LexicalJsxNode.tsx` — representative atomic/custom JSON contracts. +- `src/test/fixtures/LexicalCompatibilityHarness.tsx`, `tests/browser/lexical-compatibility.spec.ts`, and `playwright.config.ts` — browser fixture/process conventions. + +### Gotchas + +- `$generateNodesFromSerializedNodes` creates nodes and cannot run in a read-only context; use a separate temporary editor update, never a source update. +- Call `createEditor()` with no explicit config while the source editor read is active; passing a new config builds a default registry and loses plugin/custom nodes. +- Single inline selections may serialize as top-level text/link nodes with no paragraph ancestor; group consecutive inline nodes before appending to the temporary root. +- The temporary editor's default error handler logs. Catch reconstruction/export errors inside its update and rethrow outside so failure cannot masquerade as empty Markdown. +- `visitors.sort(...)` mutates the realm-owned array. Sort a copy before both full and selected exports. +- A backward DOM selection must produce the same document-order fragment as its forward equivalent; do not order output by anchor/focus direction. +- Selected JSX may require its import statement. Preserve the existing descriptor/import-registration logic and exclude unrelated imports. +- Browser selection changes must be dispatched after `setBaseAndExtent`; wait for active nested-editor routing before invoking the ref. + +## Implementation Blueprint + +### Tasks + +```yaml +Task 1: Replace whole-node selection serialization with an isolated selected tree + MODIFY src/utils/lexicalHelpers.ts: + - Preserve getSelectionAsMarkdown(editor, exportParams) as the public call shape. + - In a source editor read, reject null, collapsed RangeSelection, and empty selections; capture selected JSON with $generateJSONFromSelectedNodes. + - Create a no-config temporary editor while the source editor context is active so all registered classes and replacements are inherited. + - In a guarded temporary update with transforms skipped, reconstruct selected nodes, group consecutive top-level inline nodes into paragraphs, attach block nodes directly, and export via exportMarkdownFromLexical with the supplied parameters. + - Return a fragment without serializer-added terminal newline while preserving selected inline content; capture and rethrow reconstruction/export failures. + - Do not publish, update, focus, or set state on the source editor. + MODIFY src/exportMarkdownFromLexical.ts: + - Sort a copy of visitors and factor only the reusable internal export entry needed by the selected tree. + - Preserve byte-for-byte normal full-document output for existing fixtures. + PATTERN: node_modules/@lexical/clipboard/src/clipboard.ts:500-696; src/exportMarkdownFromLexical.ts:112-221,426-439 + ENABLES: CX-5 + VERIFY: + - COMMAND: npm run test:once -- src/test/selection-markdown.test.tsx + - EXPECTED: Focused partial/structural/node/custom/config/non-mutation cases pass with no console-only failure. + +Task 2: Characterize the complete helper and editor-local contract + MODIFY src/test/selection-markdown.test.tsx: + - Cover partial offsets and selected leading/trailing content, mixed formatting, forward/backward multi-block ranges, partial links excluding the first character, nested/task lists, and NodeSelection. + - Cover table, thematic break, code block, image, directive, JSX, frontmatter, and a consumer visitor/additional-node replacement with registered JSON import/export. + - Assert editor-local toMarkdownOptions/extensions/descriptors, two-editor isolation, stable repeated output, unchanged source EditorState/selection/full export/history signals, and synchronous failure rather than silent content loss. + - Retain empty/null/collapsed behavior and source-compatible direct helper invocation. + MODIFY src/MDXEditor.tsx only if documentation/wiring is needed: + - Preserve activeEditor$ routing and every existing realm export parameter. + PATTERN: src/test/core.test.tsx; plans/research/003-selection-markdown/spike-01-selection-export-pipeline.md + ENABLES: CX-5 + VERIFY: + - COMMAND: npm run test:once -- src/test/selection-markdown.test.tsx + - EXPECTED: All focused cases pass and getStateAsMarkdown/full fixtures remain unchanged. + +Task 3: Add direct public-ref browser evidence + CREATE src/test/fixtures/SelectionMarkdownHarness.tsx: + - Render deterministic root/nested/custom content, a public ref read button/output, full-Markdown output, observable change count, source/diff controls, and two simultaneous independently configured editors. + - Register a representative consumer export visitor/node through public plugin APIs; do not expose a private serialization shortcut. + MODIFY src/examples/get-selection-markdown.tsx: + - Host the deterministic selection contract story on the existing Ladle application while retaining a usable example. + CREATE tests/browser/selection-markdown.spec.ts: + - Exercise every matrix row named in CX-5 through browser DOM selection/clicks and the public ref button. + - Assert exact fragments, forward/backward equality, custom/config isolation, nested routing, repeated-read non-mutation, full Markdown stability, empty mode behavior, and no page/console errors. + PATTERN: src/test/fixtures/LexicalCompatibilityHarness.tsx; tests/browser/lexical-compatibility.spec.ts + ENABLES: CX-5 + VERIFY: + - COMMAND: npm run test:browser -- --grep CX-5 + - EXPECTED: Every R3 scenario passes in Chromium, Firefox, and WebKit. + - FAILURE-LOCAL: npm run test:browser -- --project=chromium --grep CX-5; npm run test:browser -- --project=firefox --grep CX-5; npm run test:browser -- --project=webkit --grep CX-5 + - PROCESS-LIFECYCLE: Playwright starts Ladle on 127.0.0.1:61000, reports each project/test as progress, exits 0 only after all selected tests pass, retains traces/screenshots/video on failure, and owns graceful service/port cleanup on success or failure. + +Task 4: Prove integration and package stability + MODIFY documentation/API comments only where the corrected fragment semantics need clarification: + - State active-editor behavior, empty source/diff/collapsed result, and configured Markdown semantics without exposing the temporary-editor implementation. + RUN full repository gates: + - Re-run existing CX-2/CX-3 compatibility evidence because selected export shares the normal visitor pipeline and nested-editor routing. + - Rebuild declarations/API docs and the packed React consumers; replay the cross-version fixture to prove normal getMarkdown() output is unchanged. + ENABLES: CX-5 + VERIFY: + - COMMAND: npm run lint && npm run typecheck && npm run test:once && npm run test:browser && npm run build && npm run test:lexical-versions && npm run test:package && npm run test:cross-version && npm run build:docs:api + - EXPECTED: Every command exits 0; all CX-5 tests pass in three engines; normal compatibility, package, and cross-version outputs remain green. + - FAILURE-LOCAL: npm run lint; npm run typecheck; npm run test:once -- src/test/selection-markdown.test.tsx; npm run test:compat:unit; npm run test:browser -- --grep CX-5; npm run test:browser -- --project=chromium --grep CX-3; npm run build; npm run test:lexical-versions; npm run test:package -- --react=18; npm run test:package -- --react=19; npm run test:cross-version; npm run build:docs:api + - PROCESS-LIFECYCLE: Playwright owns Ladle/port 61000 and browser artifacts. Package/cross-version scripts own temporary apps, npm caches, tarballs, preview servers, Chromium pages, and loopback ports in finally blocks. Success is exit 0 from the integrated command; failure is the first nonzero phase with its named local command; no process, port, or scratch directory may remain afterward. +``` + +### Integration Points + +```yaml +PUBLIC_METHOD: + - src/MDXEditor.tsx — retain activeEditor$ and editor-local export parameter lookup. + +SELECTION_CLONE: + - src/utils/lexicalHelpers.ts — bridge Lexical selected JSON into an isolated valid tree. + +MARKDOWN_PIPELINE: + - src/exportMarkdownFromLexical.ts — one visitor/MDAST/options implementation for full and selected export. + +CUSTOMIZATION: + - src/plugins/core/index.ts — inherited registered nodes, visitors, extensions, options, and descriptors; no new consumer setup. + +BROWSER_EVIDENCE: + - src/examples/get-selection-markdown.tsx — Ladle route. + - tests/browser/selection-markdown.spec.ts — public-ref CX-5 verification in all configured engines. +``` + +## Validation + +```bash + # Focused implementation and public-contract checks +npm run test:once -- src/test/selection-markdown.test.tsx +npm run test:browser -- --grep CX-5 + + # Static and complete unit/browser regression gates +npm run lint +npm run typecheck +npm run test:once +npm run test:browser + + # Published artifact, full Markdown compatibility, and docs +npm run build +npm run test:lexical-versions +npm run test:package +npm run test:cross-version +npm run build:docs:api + + # Patch hygiene +npx prettier --check src/utils/lexicalHelpers.ts src/exportMarkdownFromLexical.ts src/MDXEditor.tsx src/test/selection-markdown.test.tsx src/test/fixtures/SelectionMarkdownHarness.tsx src/examples/get-selection-markdown.tsx tests/browser/selection-markdown.spec.ts plans/003-selection-markdown.md plans/research/003-selection-markdown/spike-01-selection-export-pipeline.md +git diff --check +``` + +The `CX-5` row is authoritative. Focused state tests support edge diagnosis, but verification must select real rendered content and invoke the public ref in Chromium, Firefox, and WebKit. Browser and package processes use the ownership/cleanup rules in Tasks 3-4; leftover Ladle/preview processes, port 61000 listeners, or disposable consumer directories fail cleanup. + +## Unknowns & Risks + +- A consumer node that does not implement Lexical's supported `exportJSON`/`importJSON` contract cannot be reconstructed faithfully. R3 directly verifies a representative compliant consumer node/replacement and fails loudly for malformed registration; it does not promise compatibility for unserializable nodes. +- Temporary editor creation adds per-call allocation. Selection export is an explicit consumer action, and the scratch editor has no DOM/listeners after the call; repeated-call tests guard correctness. Performance optimization may cache only per editor with explicit lifecycle cleanup and must not introduce global/shared configuration. +- Atomic nodes are all-or-nothing at the outer editor boundary. Nested Lexical editors remain independently selectable through `activeEditor$`; tests distinguish these cases. +- MDAST canonicalization can add structural markers/imports required to make the selected fragment valid. Tests assert configured semantic output rather than reusing the source bytes. + +**Confidence: 9/10** for one-pass implementation success. The load-bearing architecture, partial-link behavior, custom payload, registration inheritance, and source non-mutation were observed in scratch; the remaining risk is breadth across the built-in browser matrix rather than an unresolved design choice. + +## Execution Notes + +### 2026-07-18 — implementation handoff + +- Replaced the handwritten whole-node serializer with the planned selected-JSON boundary. The helper captures the active RangeSelection or NodeSelection without a source update, creates a no-config temporary editor inside that read to inherit registered nodes/replacements, reconstructs a valid selected tree with top-level inline normalization, and sends it through the existing MDAST visitor/extensions/options/descriptors pipeline. Reconstruction/export errors are caught inside the temporary update and rethrown synchronously. +- Changed `exportLexicalTreeToMdast` to sort a copy of the visitor array. Repeated selection and full-document exports therefore cannot reorder realm-owned or consumer-owned visitor configuration. Public helper/ref signatures and the normal full-export path are unchanged; the public method documentation now states active root/nested and configured-serialization behavior. +- Expanded the focused suite from 3 empty/method checks to 10 tests covering clipped whitespace, bold/link ranges including the PR #949 first-character exclusion, forward/backward multi-block equivalence, structural NodeSelection, editor-local list options, a registered consumer decorator/visitor, visitor-array/source-state/selection/update-listener non-mutation, repeatability, loud missing-visitor failure, and collapsed selection. +- Added a deterministic public-ref Ladle fixture and six `CX-5` browser scenarios. The final fixture covers plain/bold/italic/code/link ranges, backward multi-block selection, nested ordered/task lists, full ranges containing frontmatter/table/thematic break/code/image/directive/JSX, a high-priority consumer text visitor plus distinct list options in a simultaneous editor, repeated reads and callback/full-Markdown stability, active nested JSX routing, and collapsed/source/diff empty results. A local `/favicon.svg` image replaces the initial network placeholder so WebKit's console-error gate is deterministic. +- Validation passed: `npm run lint`; `npm run typecheck`; `npm run test:once` (57 passed, 1 skipped, 1 todo); `npm run test:browser` (36/36 across Chromium, Firefox, and WebKit, including all prior `CX-2`–`CX-4` scenarios and 18 `CX-5` runs); `npm run build`; `npm run test:lexical-versions` (24 packages at 0.48.0); `npm run test:package` (packed React 18 and 19 consumers); `npm run test:cross-version`; `npm run build:docs:api`; targeted Prettier checks; and `git diff --check`. +- The first sandboxed Playwright and package-consumer launches were denied by macOS `MachPortRendezvousServer` permissions before a page opened. The exact commands passed outside the restricted process sandbox. Existing non-failing stale Browserslist, legacy JSX transform, jsdom CodeMirror geometry, Radix bundle directive, chunk-size, and TypeDoc/API Extractor TypeScript-version warnings remain visible; no browser console-error allowlist was added. +- Implementation and main-agent integration verification are complete. Independent Standard-assurance verification remains pending, so R3 stays `IN PROGRESS`; R5 remains blocked on verified R3 and R4. + +## Verification Record + +### 2026-07-18 — Standard assurance + +- **Verifier shape**: one fresh-context, read-only verifier independently performed consumer acceptance, PRP compliance, and engineering-quality passes. The main agent reproduced and closed two concrete evidence gaps; the same verifier then inspected only those patches and reran the invalidated scenarios across all three engines. +- **Acceptance grade**: + + | Scenario | Grade | Direct evidence | + | -------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `CX-5` | DIRECTLY VERIFIED | The public-ref suite passed all six scenarios in Chromium, Firefox, and WebKit: 18/18 before the evidence correction and 18/18 after it. It covers partial/formatted/code/link selections, forward/backward multi-block ranges, nested/task lists, aggregate built-in/custom constructs, active nested-editor routing, empty modes, repeated reads, full-Markdown/callback stability, and two-editor option isolation. A direct click on the rendered thematic break creates a meaningful Lexical `NodeSelection` and returns `***`; a replacement-specific consumer visitor proves that a `TextNode` replacement supplied through `MDXEditorProps.additionalLexicalNodes` survives selected-JSON reconstruction and preserves editor-local list options. | + +- **Resolved findings**: the initial browser matrix included the thematic break only through Select All, so it did not directly exercise the PRP-required browser-created node selection. It also registered a custom class directly in the focused helper test and a text visitor in the fixture, but did not prove the public `additionalLexicalNodes` replacement path. The fixture now configures a `TextNode` replacement class plus replacement-specific visitor through public props/plugins; the browser spec directly clicks `<hr>` and asserts `***`, then asserts replacement-specific partial and list output. The main-agent rerun passed 18/18 `CX-5` tests, and the verifier's targeted rerun passed both affected scenarios in all three engines: 6/6. +- **PRP compliance**: Tasks 1-4 and all five success criteria are satisfied. Selection capture/reconstruction uses the registration-inheriting temporary editor and existing MDAST exporter; visitor sorting no longer mutates shared configuration; focused tests cover clipping, direction, node/custom serialization, options, repeatability, non-mutation, errors, and collapsed selection; and the public browser fixture covers root/nested routing, custom configuration, structural/atomic content, and empty modes. No clipboard, search, composer/history, `@lexical/mdast`, public API, or full-export architecture expansion was introduced. +- **Engineering review**: no unresolved correctness, regression, security, error-handling, maintainability, or test-quality finding remains. Source editor state, selection, callbacks, history signals, full Markdown, and visitor arrays remain unchanged by reads; unsupported selected nodes fail synchronously instead of disappearing; temporary reconstruction retains registered classes and replacements without private registry access or global caches. +- **Fresh verification evidence**: structural validation passed with the three documented non-blocking size warnings; focused selection unit tests passed 10/10; `npm run lint`; `npm run typecheck`; full unit suite (57 passed, 1 skipped, 1 todo); `npm run test:browser` (36/36); `npm run build`; `npm run test:lexical-versions` (24 packages at 0.48.0); packed React 18/19 consumers; cross-version replay; API docs; targeted Prettier; and `git diff --check`. After the evidence-only fixture/spec correction, typecheck, focused unit tests, targeted Prettier, patch hygiene, and the complete three-engine `CX-5` matrix passed again. Cleanup inspection found no listener on port 61000, owned browser/Ladle process, or package/cross-version scratch directory. +- **Environment note**: restricted macOS browser launches were denied at `MachPortRendezvousServer` before any page opened. The exact Playwright commands passed outside that process sandbox; this was not a product or browser-page crash. Existing non-failing toolchain warnings remain as recorded in Execution Notes. +- **Limitations**: clipboard integration, mobile/device-specific behavior, exhaustive IME behavior, partial extraction inside atomic decorator payloads, and source/diff selection export remain outside the claimed surface. +- **Result**: VERIFIED. R3 produces a directly tested selection/full-export consistency invariant for R5. R5 remains independently blocked until R4 verifies `CX-6`. diff --git a/plans/004-state-backed-search-replace.md b/plans/004-state-backed-search-replace.md new file mode 100644 index 00000000..c1faab3f --- /dev/null +++ b/plans/004-state-backed-search-replace.md @@ -0,0 +1,332 @@ +--- +repo: /Users/petyo/w/mdx-editor/editor +--- + +# PRP: State-Backed Find/Replace for the Active Editor + +## Goal + +Make `searchPlugin()` and `useEditorSearch()` derive matches and replacements from the current active Lexical editor state rather than retaining DOM `Range` objects as replacement authority. + +Preserve the documented public hook/cell surface and CSS Highlight rendering, while making mutation invalidation deterministic, applying Replace All in one editor update/history action, and defining root, nested JSX/directive, and table-cell editors as independent search scopes selected through `activeEditor$`. + +## Why + +- The current root DOM index retains live `Range` objects and later maps their DOM endpoints back to Lexical nodes. Editor reconciliation between indexing and replacement can make those endpoints stale or point at the wrong content. +- Replace All schedules one asynchronous editor update per captured range, so partial completion, callback timing, cursor state, and undo grouping depend on DOM/update timing. +- A consumer can keep search open while typing, replacing Markdown, or moving between root and nested editors; matches must follow the state the user can currently edit. +- R5 needs a direct `CX-6` invariant before it changes root/nested composer and shared-history internals. + +## Success Criteria + +- [x] Search results, cursor navigation, documented DOM `Range` projections, and CSS Highlights track the current active editor after text, formatting, block, and programmatic Markdown mutations. +- [x] Replace and Replace All resolve Lexical node-key/offset positions in the active editor, preserve content/formatting outside matches, invoke callbacks once, and never rely on previously captured DOM endpoints. +- [x] Replace All is one editor update and one undo/redo history step, including multiple matches and matches spanning formatted text nodes or blocks. +- [x] Root, nested JSX/directive, and table-cell editors are independent active search scopes; matches never cross editor or atomic/custom-editor boundaries. +- [x] Regex, empty/zero-length/invalid-term recovery, public exports, rich-text-only scope, and highlight cleanup remain source-compatible and pass direct browser evidence in Chromium, Firefox, and WebKit. + +## Assurance + +- **Profile**: Standard +- **Rationale**: This reversible change corrects a public plugin/hook across matching, invalidation, replacement/history, active nested-editor routing, and browser highlights, with no persisted or deployment boundary. The spike closed the load-bearing replacement question; reapplying the roadmap router still finds no Deep trigger. The exceptional size is load-bearing because one exported surface must preserve DOM-range/cell compatibility while specifying state authority, root/nested/table scope, regex recovery, history, three-browser evidence, and package regressions. + +## Roadmap Context + +- **Parent roadmap**: `plans/roadmaps/001-lexical-048-adoption.md` +- **Roadmap step**: `R4` — make Find/Replace state-backed and mutation-safe. +- **Satisfied dependencies**: R2 directly verified Lexical 0.48 update/history/browser behavior; R3 directly verified active root/nested routing and the configured Markdown pipeline. The R1 Playwright/Ladle harness is available in Chromium, Firefox, and WebKit. +- **Inherited decisions and invariants**: public React, Gurx plugin/cell, Markdown, and imperative contracts remain source-compatible; `activeEditor$` identifies the current root or nested Lexical editor; R4 does not migrate composer/history architecture or search source/diff/CodeMirror content. +- **Contract produced for later steps**: directly verified parent `CX-6` behavior and an active-editor state-position/history invariant consumed by R5. + +## Consumer Contract + +### Consumer and Public Boundary + +- **Consumer(s)**: authors finding/replacing rich-text content; React integrators building search controls with `useEditorSearch`; plugin authors consuming the exported search cells, `TextNodeIndex`, or `rangeSearchScan`. +- **Public or supported boundary**: `searchPlugin()`, `useEditorSearch()`, its documented methods/state, exported search Gurx cells/helpers, documented `MdxSearch`/`MdxFocusSearch` CSS Highlight names, and editor controls built from those APIs. +- **Entry point and prerequisites**: a rich-text `MDXEditor` with `searchPlugin()`, a consumer-provided search UI, CSS Custom Highlight support, and a root, nested JSX/directive, or table-cell Lexical editor that is active. +- **Current observable behavior**: case-insensitive regex spans styled nodes and exposes DOM ranges; a root MutationObserver indexes descendant DOM, replacement reverses captured endpoints, Replace All schedules separate updates, nested scope is implicit, and invalid patterns yield no matches. +- **Observable promise**: matching and replacement follow the current active editor's Lexical state; rendered ranges/highlights are current projections; mutations cannot cause stale replacement; Replace All is atomic and undoable once; invalid/zero-length terms are safe and recoverable. +- **Must remain compatible with**: hook values `total`, `cursor`, `search`, `currentRange: Range | null`, `isSearchOpen`, and `ranges: Range[]`; `next()`, `prev()`, `setSearch(string | null)`, the boolean setter plus `openSearch()`/`closeSearch()`/`toggleSearch()`, `scrollToRangeOrIndex(Range | number, { ignoreIfInView?, behavior? }?)`, and `replace(string, callback?)`/`replaceAll(string, callback?)`; exact exports `EmptyTextNodeIndex`, `editorSearchTerm$`, `editorSearchRanges$`, `editorSearchCursor$`, `editorSearchTextNodeIndex$`, `searchOpen$`, `editorSearchTermDebounced$`, `editorSearchScrollableContent$`, `MDX_SEARCH_NAME`, `MDX_FOCUS_SEARCH_NAME`, `TextNodeIndex`, `debouncedIndexer$`, `rangeSearchScan`, `useEditorSearch`, and `searchPlugin`; the existing `TextNodeIndex` fields and `rangeSearchScan(query, index)` generator; fixed highlight names; case-insensitive cross-format/block regex; React 18/19, Lexical 0.48, and R2/R3 contracts. +- **Not claimed**: source/diff/CodeMirror search, text inside atomic decorator payloads, matches spanning separate Lexical editors, simultaneous highlight registries for multiple independently searching MDXEditor instances, mobile/every-IME behavior, new search UI design, literal-search mode, case-sensitivity controls, or Unicode/grapheme policy redesign. + +### Acceptance Scenarios + +<!-- prettier-ignore --> +| ID | Given | When | Then | Exact exercise and prerequisites | Required evidence | +|---|---|---|---|---|---| +| `CX-6a` | Search is open on root rich text with repeated regex matches split across plain/bold/italic/link text and adjacent blocks | The consumer navigates next/previous, types before/inside matches, changes formatting, and calls public `setMarkdown()` while the term remains active | `total`, `cursor`, `currentRange`, `ranges`, documented CSS highlights, and scrolling reflect the latest editor state; stale text is not highlighted or replaceable | In all three engines, use a public-hook fixture to search, navigate, edit, and call `setMarkdown()`; assert hook/highlight values and call `scrollToRangeOrIndex` with both a supplied `Range` and number plus each option | DIRECT REQUIRED | +| `CX-6b` | The active editor has multiple matches, including one spanning differently formatted TextNodes and another in a later block | The consumer invokes Replace or Replace All through the hook UI and then uses the public Undo/Redo controls | Replace affects the current match; Replace All changes every current match in one update, calls its completion callback once, preserves unmatched Markdown/formatting, and one undo/redo step restores/reapplies the entire Replace All result | In all three engines, assert exact rendered text, public `getMarkdown()`, callback counts, remaining matches/cursor, and one-step Undo then Redo after replacing a literal containing regex replacement characters such as `$&` | DIRECT REQUIRED | +| `CX-6c` | The same term appears in root, nested JSX/directive, table-cell, and atomic/custom content; invalid and zero-length regexes are available | The author focuses each editor, searches, closes through `closeSearch`, `setIsSearchOpen(false)`, and direct `searchOpen$` publication, corrects invalid terms, and unmounts | Only the active Lexical editor contributes; atomic/inactive content is excluded; invalid/zero-length terms cannot mutate or hang; every close path and switch/unmount cleans highlights/listeners | In all three engines, render `SearchReplaceHarness`, operate its public focus/hook/cell controls, and assert scope, unchanged Markdown, recovery, highlight cleanup, no page/console error, and no retained listener/process | DIRECT REQUIRED | + +## Research Summary + +### Vetted Repository Findings + +- `src/plugins/search/index.tsx:7-38,72-126` — the public module stores DOM nodes/offsets and builds `Range` objects by walking text under `p`, headings, lists, and code containers — **PRP impact**: retain the exported compatibility shapes, but move authoritative matching to immutable editor-state positions. +- `src/plugins/search/index.tsx:193-233,286-338` — Replace maps DOM endpoints back to Lexical nodes, while Replace All captures current ranges and schedules one update per match through `requestIdleCallback`/`setTimeout` — **PRP impact**: replace both paths with state matches and one reverse-order active-editor update. +- `src/plugins/search/index.tsx:356-434` — search exits when CSS Highlights is unavailable, binds a root MutationObserver, and has a cleanup TODO — **PRP impact**: keep the highlight prerequisite, bind to active-editor state updates, and make close/editor-switch/unmount cleanup explicit. +- `docs/search-replace.md:7-109` — search is documented for rich-text mode, case-insensitive regex across formatting/newlines, custom UI via the public hook, DOM `currentRange`, fixed highlight names, and weak/no CodeMirror support — **PRP impact**: preserve those call shapes and clarify active-editor/unsupported boundaries instead of introducing a new UI or source search. +- `src/index.ts:33-43,78` — Gurx primitives and the entire search module are public package exports — **PRP impact**: `TextNodeIndex`, search cells/helpers, and inferred hook returns are compatibility surfaces, not implementation-private types. +- `src/plugins/core/index.ts:108-118,511-666` — `activeEditor$` and `createActiveEditorSubscription$` already route and rebind subscriptions across root/nested editors — **PRP impact**: use this supported lifecycle; no new core registry or public parameter is needed. +- `src/plugins/core/NestedLexicalEditor.tsx:216-354` and `src/plugins/table/TableEditor.tsx` — nested JSX/directive and table-cell editors are separate Lexical instances with shared history/focus integration — **PRP impact**: treat them as independent active scopes and prohibit cross-editor matches. +- `plans/003-selection-markdown.md` Verification Record — active root/nested routing and the public three-browser harness are directly verified — **PRP impact**: inherit that routing evidence and rerun the full browser suite after R4. + +### External Constraints + +- Lexical 0.48 `createDOMRange(editor, anchorNode, anchorOffset, focusNode, focusOffset)` projects Lexical positions through the editor's own document and returns `null` when DOM endpoints cannot be resolved — [official 0.48 source](https://github.com/facebook/lexical/blob/v0.48.0/packages/lexical-selection/src/utils.ts). +- Lexical 0.48 `registerUpdateListener` runs on editor updates, and `HISTORY_PUSH_TAG` creates an explicit history boundary — [official editor source](https://github.com/facebook/lexical/blob/v0.48.0/packages/lexical/src/LexicalEditor.ts), [official update tags](https://github.com/facebook/lexical/blob/v0.48.0/packages/lexical/src/LexicalUpdateTags.ts). +- CSS Custom Highlights are document-registered collections of `Range`/`StaticRange` objects; the specification recommends recreating stale ranges when reacting to content changes — [CSS Custom Highlight API](https://drafts.csswg.org/css-highlight-api-1/#range-invalidation). +- The installed Playwright 1.61.1 Chromium, Firefox, and WebKit binaries each directly reported both `CSS.highlights` and `Highlight` support during generation. + +### Settled Decisions and Rejected Alternatives + +- **Decision**: search only the current `activeEditor$`; root, nested JSX/directive, and table-cell editors are independent scopes — **Evidence/rationale**: user-approved roadmap decision; it gives Replace All one editor/update/history boundary and matches MDXEditor's active-editor toolbar semantics. +- **Decision**: retain DOM `Range` values as post-reconciliation public/highlight projections while keeping editor identity plus node keys/text offsets as the replacement authority — **Evidence/rationale**: preserves the documented hook/cell API without stale DOM-to-state reversal. +- **Decision**: concatenate active-editor TextNode content in document order without inserting synthetic block separators, preserving current cross-format/block regex behavior; retain the existing NFKD text normalization with an exact normalized-code-unit to original-offset map — **Rationale**: avoid an unrequested regex/Unicode behavior change while making offsets valid. +- **Decision**: Replace All applies non-overlapping matches from last to first inside one active-editor update tagged `HISTORY_PUSH_TAG`; callbacks run once after reconciliation — **Evidence/rationale**: the spike proved state positions remain usable across the reverse update and preserve unmatched formatting. +- **Rejected**: retain MutationObserver/DOM `Range` objects as match authority — **Reason**: reconciliation and asynchronous replacement recreate the stale-state bug R4 exists to remove. +- **Rejected**: aggregate all rendered nested editors — **Reason**: one Lexical update/history entry cannot atomically own multiple editor states; the user selected active-editor scope instead of expanding R4 into cross-editor transactions. +- **Rejected**: inject mark nodes or wrapper spans for highlighting — **Reason**: that mutates document state/Markdown and abandons the documented CSS Highlight contract. + +### Spike Evidence + +- `plans/research/004-state-backed-search/spike-01-state-position-replace-all.md` — **Question**: can state positions replace multiple split-node matches in one update? — **Result/decision**: CONCLUSIVE; two `alpha` matches, including one split across unformatted/bold nodes, became `X` in one reverse-order update with no stale key and unmatched bold `KEEP` preserved — **Limits**: browser DOM projection and shared-history undo remain direct execution evidence. + +### Validation Baseline + +| Command or surface | Status | Observed or expected result | +| ------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------- | +| `npm run typecheck` | Verified | Passes on the verified R1-R3 worktree after the R4 scope decision. | +| `npm run test:once`, `npm run test:browser` | Verified inherited | R3 Verification Record: 57 passed/1 skipped/1 todo and 36/36 browser tests; no search-specific scenario exists. | +| Build/package/cross-version/docs gates | Verified inherited | R3 Verification Record reports all green after selection changes. | +| Playwright CSS Highlight probe | Verified | Chromium, Firefox, and WebKit each returned `{highlights:true, Highlight:true}`. | +| State-position scratch spike | Verified | One reverse-order update replaced both split-node matches and preserved unmatched bold formatting. | + +### Research Coverage + +- **Depth**: Standard +- **Inspected**: R4/R5 and verified R2/R3; search implementation/docs/exports; active/nested/table lifecycle; history tags; DOM projection; browser support; current churn. +- **Not inspected**: source/diff/CodeMirror internals, mobile/device farms, exhaustive Unicode/grapheme policy, multi-MDXEditor highlight-name isolation, R5 composer implementation, or unrelated toolbar design. +- **Research confidence**: HIGH — public scope is user-settled, the replacement architecture is empirically proven, installed browsers support the evidence surface, and remaining work is bounded implementation/testing rather than an unresolved contract. + +## Execution Contract + +- **Planned at commit**: `9373742` +- **Planning baseline**: verified, uncommitted R1-R3 implementation plus existing `plans/` and `reports/` must be preserved. R4 generation adds this PRP and its spike record; search source/tests have no overlapping worktree edits. + +### Expected Changes + +- `src/plugins/search/index.tsx` — add active-editor state snapshots/matches, compatibility DOM projections, atomic replacement, cursor invalidation, and cleanup; remove DOM mutation/range authority. +- `src/test/search.test.tsx` — pure/index/replacement and rendered hook characterization for regex, offsets, mutation, callback, and non-mutation behavior. +- `src/test/fixtures/SearchReplaceHarness.tsx` and `src/examples/search-replace.tsx` — deterministic public-hook fixture for root, nested, table, atomic/custom, history, and ref observations. +- `tests/browser/search-replace.spec.ts` — direct `CX-6a`–`CX-6c` journeys in Chromium, Firefox, and WebKit. +- `docs/search-replace.md` — active-editor semantics, unsupported boundaries, invalid-term recovery, history/callback behavior, and accurate custom-toolbar wording. + +### Explicitly Out of Scope + +- Searching source/diff/CodeMirror content, atomic decorator payloads, or across multiple Lexical editors; cross-document/project search. +- New literal/case/whole-word modes, regex syntax changes, grapheme/locale redesign, search-result UI redesign, or keyboard-shortcut ownership. +- Composer/history extension migration, custom multi-editor transactions, NodeState, `@lexical/mdast`, or changes to public Gurx/plugin architecture. +- Renaming documented highlight keys, changing hook/cell/helper signatures, or removing public DOM `Range` projections. +- Solving document-global highlight-name collisions when multiple MDXEditor instances search simultaneously; record a separate issue/PRP if direct R4 evidence shows this blocks the single-editor contract. + +### Scope Expansion Rule + +Additional search fixture/style/core files may change only when direct `CX-6` evidence shows they are required for the same active-editor state-position contract. Record the path and reason in Execution Notes. Pause before any public API/type removal, whole-document nested search, source/diff/CodeMirror scope, cross-editor history transaction, regex policy change, or composer architecture work. + +### Pause and Reassess If + +- Public `Range`/`TextNodeIndex` compatibility cannot be retained without using DOM positions as replacement authority. +- A reverse-order state replacement invalidates an earlier match in the rendered editor or changes formatting/content outside the match. +- Replace All cannot form one undo/redo step through the existing shared-history setup and `HISTORY_PUSH_TAG`. +- Toolbar focus changes `activeEditor$` before the search action, making nested/table scope nondeterministic. +- Correct active-editor subscription or cleanup requires R5's composer/history migration or a new public registration API. +- CSS Highlight projection cannot be made deterministic in any configured Playwright engine that passed the generation probe. + +## Context + +### Key Files + +- `src/plugins/search/index.tsx` — current public API, DOM index, range replacement, highlight lifecycle, and primary modification point. +- `docs/search-replace.md` — documented consumer contract and styling names. +- `src/plugins/core/index.ts` — `activeEditor$`, `createActiveEditorSubscription$`, root/nested focus, and shared plugin lifecycle. +- `src/plugins/core/NestedLexicalEditor.tsx` — separate nested editor lifecycle and shared history behavior. +- `src/plugins/table/TableEditor.tsx` — table-cell active-editor boundary. +- `src/test/fixtures/SelectionMarkdownHarness.tsx` and `tests/browser/selection-markdown.spec.ts` — deterministic active nested-editor/public-control browser patterns. +- `src/test/fixtures/LexicalCompatibilityHarness.tsx` and `tests/browser/lexical-compatibility.spec.ts` — Undo/Redo, root/nested/table interaction, runtime-error, and cleanup patterns. +- `plans/research/004-state-backed-search/spike-01-state-position-replace-all.md` — selected state-position/one-update architecture evidence. + +### Gotchas + +- Never retain `LexicalNode` instances outside a read/update; store editor identity, node keys, and original text offsets, then resolve nodes inside replacement updates. +- NFKD can expand one source code unit into multiple indexed units; every normalized unit must map back to valid original start/end offsets. +- `createDOMRange` can return `null` before/after DOM attachment; omit unresolved projections and rebuild after reconciliation rather than falling back to stale ranges. +- `RangeSelection.insertText` determines replacement formatting from the selection; tests must assert content/formatting outside the match and characterize replacement formatting without inventing a new policy. +- Applying matches from first to last changes later offsets; Replace All must use the spike-proven reverse order inside one update. +- `CSS.highlights` is document-global and the public names are fixed. Always delete this plugin's registered highlights on close, active-editor switch, empty/invalid search, and teardown. +- A toolbar click is editor UI, so it should retain the previously active nested/table editor; browser tests must prove this instead of setting `activeEditor$` privately. + +## Implementation Blueprint + +### Data Models + +```ts +type SearchPoint = { key: NodeKey; offset: number } +type SearchStateMatch = { editor: LexicalEditor; start: SearchPoint; end: SearchPoint } +type SearchSnapshot = { editor: LexicalEditor; text: string; points: SearchPoint[] } +``` + +These are internal authoritative records. Existing exported DOM `TextNodeIndex`, `editorSearchRanges$`, `rangeSearchScan`, and hook `Range` values remain compatibility projections. + +### Tasks + +```yaml +Task 1: Build the active editor's authoritative state index and DOM projections + MODIFY src/plugins/search/index.tsx: + - Introduce pure snapshot/match helpers over active-editor TextNodes with NFKD-to-original offset mapping and zero-length-safe case-insensitive regex scanning. + - Bind through activeEditor$/createActiveEditorSubscription$; rebuild on active-editor changes and relevant editor updates, resetting/clamping cursor deterministically. + - Project current state matches with @lexical/selection createDOMRange after reconciliation for CSS Highlights and the existing public hook/cells/helpers. + - Remove MutationObserver and DOM TreeWalker/Range objects from authoritative matching while preserving exported TextNodeIndex/rangeSearchScan signatures. + - Clear ranges/highlights when searchOpen$ becomes false through any public setter/cell, and on empty/invalid term, active-editor switch, or teardown; allow correction without remounting. + ENABLES: CX-6a, CX-6c + CHARACTERIZE src/test/search.test.tsx: + - Import every search export through the public barrel; directly read/publish/subscribe to the term/range/cursor/index/open/debounced/scrollable cells and pin existing 250 ms propagation/linkage. + - Exercise consumer-built TextNodeIndex/rangeSearchScan output, both raw close paths, and scrollToRangeOrIndex with Range/number plus ignoreIfInView/behavior; presence-only assertions are insufficient. + VERIFY: + - COMMAND: npm run test:once -- src/test/search.test.tsx + - EXPECTED: Pure/state/rendered tests pass for split-node/block matching, normalization offsets, mutation invalidation, active-editor rebind, invalid/zero-length recovery, projections, and cleanup. + +Task 2: Make Replace and Replace All state-native and history-coherent + MODIFY src/plugins/search/index.tsx: + - Resolve the current SearchStateMatch keys/offsets inside the matching active editor update; never translate a stored DOM endpoint back to Lexical. + - Replace one match once and recompute cursor/ranges from the committed state; invoke the optional callback exactly once after reconciliation. + - Apply Replace All matches last-to-first in one update tagged HISTORY_PUSH_TAG; remove idle/timer per-match scheduling and invoke its callback once. + - Preserve nonmatched nodes/formatting and literal replacement strings, including '$&'; make stale/deleted keys fail closed with a recompute rather than partial mutation. + MODIFY src/test/search.test.tsx: + - Cover replacement across formatted TextNodes, multiple blocks, mutation between scan/action, literal replacement values, callback counts, one-update observation, and state recovery. + PATTERN: plans/research/004-state-backed-search/spike-01-state-position-replace-all.md; src/plugins/core/NestedLexicalEditor.tsx history-push usage + ENABLES: CX-6b + VERIFY: + - COMMAND: npm run test:once -- src/test/search.test.tsx + - EXPECTED: Replace paths are state-native, atomic, callback-stable, and preserve unaffected state; stale positions cause no partial write. + +Task 3: Add direct public-hook browser evidence for search, mutation, scope, and history + CREATE src/test/fixtures/SearchReplaceHarness.tsx: + - Render consumer-style controls using useEditorSearch/searchPlugin, public searchOpen$ where CX-6c requires raw-cell compatibility, MDXEditor refs, and Undo/Redo controls. + - Include repeated root text across formats/blocks, nested JSX/directive content, a table cell, an atomic/custom boundary, public setMarkdown/type controls, callback counters, Markdown output, and highlight/range observations. + CREATE src/examples/search-replace.tsx: + - Host the deterministic fixture on the existing Ladle server and keep the example usable as a custom search UI reference. + CREATE tests/browser/search-replace.spec.ts: + - Exercise CX-6a-CX-6c through visible contenteditable/ref/hook/public-cell controls in Chromium, Firefox, and WebKit; call no private helper. + - Assert current/focused highlight text, navigation, mutation/setMarkdown invalidation, exact replace/replace-all Markdown, callback counts, one-step Undo/Redo, active root/nested/table isolation, invalid/zero-length recovery, close/unmount cleanup, and no page/console errors. + PATTERN: src/test/fixtures/LexicalCompatibilityHarness.tsx; tests/browser/lexical-compatibility.spec.ts; tests/browser/selection-markdown.spec.ts + ENABLES: CX-6a, CX-6b, CX-6c + VERIFY: + - COMMAND: npm run test:browser -- --grep CX-6 + - EXPECTED: Every R4 public scenario passes in Chromium, Firefox, and WebKit. + - FAILURE-LOCAL: npm run test:browser -- --project=chromium --grep CX-6; npm run test:browser -- --project=firefox --grep CX-6; npm run test:browser -- --project=webkit --grep CX-6 + - PROCESS-LIFECYCLE: Playwright owns Ladle, port 61000, browser processes, traces/screenshots/video, and teardown. Each project/test is visible progress; exit 0 after all selected tests and no port/process is success; assertion/runtime/server/cleanup failure is nonzero. + +Task 4: Document the corrected contract and prove package/regression stability + MODIFY docs/search-replace.md: + - Document active-editor-only root/nested/table scope, unsupported atomic/CodeMirror/source/diff content, invalid/zero-length recovery, atomic Replace All/undo, callback timing, public DOM range projections, and CSS highlight cleanup. + - Correct the custom-toolbar wording so it does not imply that a non-exported MdxSearchToolbar ships in the package. + RUN full gates: + - Rebuild declarations/API docs and packed consumers because the search module is publicly exported. + - Rerun the full unit/browser suite so CX-2/CX-3/CX-5 prove Markdown, root/nested history, and selection behavior remain intact. + ENABLES: CX-6a, CX-6b, CX-6c + VERIFY: + - COMMAND: npm run lint && npm run typecheck && npm run test:once && npm run test:browser && npm run build && npm run test:lexical-versions && npm run test:package && npm run test:cross-version && npm run build:docs:api + - EXPECTED: Every phase exits 0; all CX-6 scenarios pass in three engines; existing CX-2-CX-5, package, Markdown, and public declaration contracts remain green. + - FAILURE-LOCAL: npm run lint; npm run typecheck; npm run test:once -- src/test/search.test.tsx; npm run test:browser -- --grep CX-6; npm run test:browser -- --project=chromium --grep 'CX-3|CX-5'; npm run build; npm run test:lexical-versions; npm run test:package -- --react=18; npm run test:package -- --react=19; npm run test:cross-version; npm run build:docs:api + - PROCESS-LIFECYCLE: Playwright owns Ladle/61000/browser artifacts; package/cross-version scripts own disposable apps, caches, tarballs, preview servers, Chromium pages, and ports in finally blocks. The first nonzero phase is failure; all owners must leave no listener/process/scratch state on either terminal path. +``` + +### Integration Points + +```yaml +PUBLIC_SEARCH: + - src/plugins/search/index.tsx — preserve plugin/hook/cells/helpers while changing internal authority. + +ACTIVE_EDITOR: + - src/plugins/core/index.ts — consume activeEditor$ and createActiveEditorSubscription$; no new public registration. + +DOM_HIGHLIGHT: + - @lexical/selection createDOMRange — project current state matches only after reconciliation. + - CSS.highlights — retain MdxSearch and MdxFocusSearch names with explicit cleanup. + +HISTORY: + - lexical HISTORY_PUSH_TAG and existing SharedHistoryPlugin — one Replace All boundary for the active editor. + +BROWSER_EVIDENCE: + - src/examples/search-replace.tsx — existing Ladle host. + - tests/browser/search-replace.spec.ts — direct public CX-6 matrix. +``` + +## Validation + +```bash +npm run lint && npm run typecheck && npm run test:once && npm run test:browser && npm run build && npm run test:lexical-versions && npm run test:package && npm run test:cross-version && npm run build:docs:api + +npm run test:once -- src/test/search.test.tsx +npm run test:browser -- --grep CX-6 + +npm run lint +npm run typecheck +npm run test:once +npm run test:browser + +npm run build +npm run test:lexical-versions +npm run test:package +npm run test:cross-version +npm run build:docs:api + +npx prettier --check src/plugins/search/index.tsx src/test/search.test.tsx src/test/fixtures/SearchReplaceHarness.tsx src/examples/search-replace.tsx tests/browser/search-replace.spec.ts docs/search-replace.md plans/004-state-backed-search-replace.md plans/research/004-state-backed-search/spike-01-state-position-replace-all.md +git diff --check +``` + +The `CX-6a`–`CX-6c` rows are authoritative. Unit tests diagnose state/index/history behavior but do not replace the rendered public-hook journey. Browser/package commands use the process ownership and cleanup rules in Tasks 3-4; sandbox launch denial before page creation is an environment failure and must be rerun in the permitted browser environment. + +## Unknowns & Risks + +- Public compatibility keeps DOM `Range`/`TextNodeIndex` projections even though state positions become authoritative. A consumer retaining those live ranges across its own mutations can still observe normal DOM live-range behavior; R4 promises fresh values from the hook/cells, not indefinite external range snapshots. +- NFKD expansion maps multiple indexed units to one source boundary. Focused tests must pin composed/decomposed input so search never constructs an invalid Lexical or DOM offset. +- Replacement formatting inside the replaced span follows Lexical `RangeSelection.insertText`; R4 guarantees unaffected formatting outside matches, not a new format-merging policy for inserted text. +- Fixed CSS highlight names remain document-global. R4 guarantees cleanup for one active search realm but does not claim simultaneous independent highlights across multiple MDXEditor instances. +- The headless spike could not establish shared-history undo. The rendered three-engine one-step Undo/Redo scenario is therefore required, not proxyable by the spike. + +**Confidence: 9/10** for one-pass implementation success. The public scope and architecture are settled, state replacement is proven, and the remaining risks have deterministic focused and browser evidence surfaces. + +## Execution Notes + +- 2026-07-18: Started execution at commit `9373742` on the verified, intentionally uncommitted R1-R3 worktree. Preflight found no overlapping changes in the search implementation or documentation; the current generation cold audit remains applicable. +- The integrated lint gate exposed an implicit-`any` callback in the untracked, verified R3 `SelectionMarkdownHarness`. Added a `TextNode` annotation only; this test-only integration fix changes no R3 behavior or public contract. +- The full parallel browser gate reproduced a Firefox mouse-drag truncation in R3's nested selection fixture. The helper still performs the mouse journey, then applies its already-computed endpoints through the same DOM Selection API previously used only for backward selections. This test-only stabilization changes no editor behavior and keeps the intended `CX-5` boundary exact across engines. +- Replaced DOM MutationObserver/range-to-node authority with active-editor Lexical snapshots containing normalized code-unit mappings to node keys/original offsets. DOM `Range` and `TextNodeIndex` values are rebuilt projections; public cells, debounce links, helpers, constants, and hook call shapes remain exported and characterized. +- Replace and Replace All resolve current state positions, apply matches last-to-first in one tagged update, and call the completion callback once. When shared history currently belongs to another initialized editor, its entry is preserved below an explicit active-editor baseline so the search action itself is the next single undo/redo step. +- Added five focused tests and a public Ladle fixture with three `CX-6` journeys. Direct evidence covers typing, formatting, `setMarkdown`, highlights/ranges/navigation/scrolling, literal replacement, callback and update counts, one-step Undo/Redo, root/JSX/directive/table scope, atomic exclusion, invalid/zero-length recovery, all close paths, active-editor switch, unmount cleanup, and browser runtime errors. +- Validation ledger: focused `src/test/search.test.tsx` 5/5; focused `CX-6` 9/9 across Chromium, Firefox, and WebKit; final `npm run lint && npm run typecheck && npm run test:once && npm run test:browser && npm run build && npm run test:lexical-versions && npm run test:package && npm run test:cross-version && npm run build:docs:api` passed with 62 unit tests (1 skipped, 1 todo), 45/45 browser scenarios, 24 Lexical packages at 0.48.0, packed React 18/19 consumers, and the published 4.0.4/0.35 replay. Formatting, PRP validation, and patch hygiene are checked at handoff. +- Implementation is complete with no public API, regex policy, multi-editor transaction, source/diff/CodeMirror, or composer-architecture expansion. Independent Standard-assurance verification remains pending, so R4 stays `IN PROGRESS` and R5 stays blocked. + +## Verification Record + +### 2026-07-18 — Standard assurance + +- **Verifier shape**: one fresh-context, read-only verifier independently performed consumer acceptance, PRP compliance, and engineering-quality passes. The main agent reproduced the verifier's evidence gaps, strengthened the tests, fixed the production issue exposed by that evidence, and reran the complete invalidated validation surface. The same verifier then reviewed only those changes and repeated the affected public scenarios and edge probes. +- **Acceptance grade**: + + | Scenario | Grade | Direct evidence | + | -------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | + | `CX-6a` | DIRECTLY VERIFIED | The public-hook journey passes in Chromium, Firefox, and WebKit with plain, split-bold, italic, and linked matches. It directly proves navigation, current/all DOM ranges, both highlight registries, both scrolling call shapes/options, typing before and inside an existing match, a confirmed formatting mutation, programmatic `setMarkdown()`, and current result recovery. | + | `CX-6b` | DIRECTLY VERIFIED | All three engines directly prove single Replace and Replace All, exact rendered/Markdown output, literal `$&`, cursor/range state, callback and update counts, unaffected nested/atomic content, and one-step Undo/Redo. Focused and browser probes additionally cover stale scan/action failure, cross-block replacement, complete linked-text replacement, multiple length-changing same-node replacements, and empty replacement. | + | `CX-6c` | DIRECTLY VERIFIED | All three engines directly prove independent root, JSX, directive, and table-cell search scopes; atomic exclusion; invalid/zero-length recovery without mutation or hang; every documented close path; active-editor switching; unmount cleanup; and absence of browser runtime errors. | + +- **Resolved findings**: the initial automated `CX-6a` evidence did not contain italic/link matches or directly type inside an existing match, and it formatted nonmatching text. The verifier also found missing focused stale-action/cross-block regressions and incomplete exact Replace assertions. Expanding the fixture exposed a real Lexical failure when Replace All targeted the complete sole `TextNode` inside a link after Undo: `RangeSelection.insertText` recursively reached a detached text node. Same-node matches now use reverse-order `TextNode.spliceText`, preserving format/style/link ancestry for nonempty replacements and removing an empty result; cross-node matches retain `RangeSelection.insertText`. The fixture and tests now cover every missing case. +- **PRP compliance**: Tasks 1-4 and all five success criteria are satisfied. Lexical editor identity plus node-key/original-offset positions remain authoritative; public DOM `Range`/`TextNodeIndex` values are projections; Replace All is one tagged update/history action; active-editor cleanup is explicit; and every public hook/cell/helper export and documented rich-text/regex boundary remains compatible. No source/diff/CodeMirror, cross-editor transaction, composer/history migration, new search syntax, highlight-name redesign, or public API expansion was introduced. The two recorded R3 test-only fixes remain bounded and behavior-neutral. +- **Engineering review**: no unresolved correctness, regression, security, error-handling, maintainability, or test-quality finding remains. The verifier directly confirmed reverse offsets with longer and empty replacements, clean empty-link removal, format/link preservation, stale-action fail-closed behavior, cross-block replacement, and process/listener cleanup. +- **Fresh verification evidence**: structural validation passed with the three documented non-blocking size warnings; the verifier independently passed focused search tests and the original 9/9 `CX-6` matrix before identifying evidence gaps. After correction, the main agent passed typecheck, focused search tests 8/8, and `CX-6` 9/9; the verifier independently repeated 8/8 and 9/9 plus direct all-engine edge probes. The final integrated `npm run lint && npm run typecheck && npm run test:once && npm run test:browser && npm run build && npm run test:lexical-versions && npm run test:package && npm run test:cross-version && npm run build:docs:api` passed with 65 unit tests (1 skipped, 1 todo), 45/45 browser scenarios, 24 Lexical packages at 0.48.0, packed React 18/19 consumers, and the published 4.0.4/Lexical 0.35 replay. Prettier and `git diff --check` pass; no port 61000 listener or owned browser/Ladle process remains. +- **Environment note**: restricted macOS browser launches were denied before page creation. The exact Playwright commands passed in the permitted browser environment; this was not a product or browser-page crash. Existing non-failing toolchain warnings remain as recorded in Execution Notes. +- **Limitations**: source/diff/CodeMirror content, atomic decorator payloads, cross-editor matches/transactions, simultaneous highlights across multiple MDXEditor instances, mobile/device-specific behavior, exhaustive IME behavior, and new regex/Unicode product policy remain outside the claimed surface. +- **Result**: VERIFIED. R4 produces the directly tested active-editor state-position/search-history invariant required by R5. diff --git a/plans/005-extension-composer-history.md b/plans/005-extension-composer-history.md new file mode 100644 index 00000000..7e50dcdf --- /dev/null +++ b/plans/005-extension-composer-history.md @@ -0,0 +1,349 @@ +--- +repo: /Users/petyo/w/mdx-editor/editor +--- + +# PRP: Extension-Based Composer and History Internals + +## Goal + +Build root, nested JSX/directive, and table-cell editors through Lexical 0.48 extension builder/composer APIs, replacing legacy React history plugins with `HistoryExtension`/`SharedHistoryExtension`. + +Keep public React/ref, Gurx plugin/cell/signal, Markdown visitor, composer-child, selection, search, and editor behavior source-compatible. Consumer plugins need not understand Lexical extensions. + +## Why + +- Root and nested editors duplicate node/theme/parent/history/lifecycle assembly across manual `createEditor`, custom context, `LexicalNestedComposer`, and React history paths. +- Lexical 0.48 supplies an extension composer plus parent-editor and shared-history extensions that replace legacy nested-composer mutation. +- R1-R4 provide direct package, browser, selection, and search invariants for this architectural change. +- An internal adapter keeps the documented Gurx extension model authoritative. + +## Success Criteria + +- [x] Root, nested JSX/directive, and table-cell editors are created by stable internal Lexical extensions and rendered through the extension React composer context, with deterministic disposal on mounted-editor teardown. +- [x] Root/nested history uses `HistoryExtension`/`SharedHistoryExtension`; table cells retain their current local-history behavior; Undo/Redo commands, availability, focus routing, and `suppressSharedHistory` behavior remain observable-compatible. +- [x] Consumer `realmPlugin` registrations for nodes, Markdown import/export visitors, root/nested/table React composer children, cells/signals, and active-editor subscriptions work without consumer adoption of Lexical extensions. +- [x] Public `MDXEditor`, `MDXEditorMethods`, props, Gurx/plugin/visitor exports, Markdown results, root/nested focus, selection export, and Find/Replace behavior remain source- and runtime-compatible. +- [x] Direct `CX-7` evidence passes in Chromium, Firefox, and WebKit, and the complete `CX-1`–`CX-6`, package, cross-version, declaration, build, and documentation gates remain green. + +## Assurance + +- **Profile**: Standard +- **Rationale**: This reversible internal migration changes lifecycle and history across one public boundary. Purpose-built APIs, a conclusive spike, and focused/browser/package gates isolate outcomes; no Deep trigger is evidenced. +- **Load-bearing detail**: near-ceiling detail pins three browser contracts, four history modes, lifecycle pauses, and the final `CX-1`–`CX-7` gate. + +## Roadmap Context + +- **Parent roadmap**: `plans/roadmaps/001-lexical-048-adoption.md` +- **Roadmap step**: `R5` — move composer and shared-history internals to Lexical extensions. +- **Satisfied dependencies**: R2 directly verified the Lexical 0.48 package/type/browser and 0.35 rollback baseline; R3 directly verified active-editor selection export (`CX-5`); R4 directly verified active-editor state-backed search/history (`CX-6`). +- **Inherited decisions and invariants**: all Lexical packages remain lockstep; public Gurx/plugin/visitor and imperative APIs remain extension-agnostic; the MDAST visitor pipeline remains authoritative; root/nested/table focus and history behavior, selection export, and search scopes must not change. +- **Contract produced for later steps**: direct `CX-7` evidence plus a complete `CX-1`–`CX-7` final checkpoint that can complete the parent roadmap. `@lexical/mdast` remains a separate future initiative. + +## Consumer Contract + +### Consumer and Public Boundary + +- **Consumer(s)**: React 18/19 package integrators; authors editing Markdown/MDX; consumers of `MDXEditorMethods`; authors of MDXEditor Gurx plugins, Lexical nodes, Markdown visitors, and React composer-child components. +- **Public or supported boundary**: the packed `@mdxeditor/editor` package; `MDXEditor` props/ref methods; Markdown input/output; `realmPlugin`; public core cells/signals/appenders; import/export visitor types; root/nested/table contenteditables; ordinary React components registered through composer-child appenders. +- **Entry point and prerequisites**: render `MDXEditor` with documented plugins or a consumer `realmPlugin`, optionally provide custom nodes/visitors/composer children and `suppressSharedHistory`, then edit/read/undo through public UI and methods. +- **Current observable behavior**: Gurx plugins register all nodes/visitors/React children before the core creates editors; the root uses a manual React context and external-history plugin, JSX/directive editors use `LexicalNestedComposer` plus that shared state, and table cells use their own `HistoryPlugin`. +- **Observable promise**: the same extension-agnostic consumer setup initializes, edits, serializes, focuses, searches, exports selections, and undoes/redoes identically while MDXEditor internally uses Lexical extensions and cleans up their registrations. +- **Must remain compatible with**: React 18/19; Lexical 0.48 lockstep; all `MDXEditorProps` including `readOnly`, `editorState`, `lexicalTheme`, `lexicalEditorNamespace`, `additionalLexicalNodes`, and `suppressSharedHistory`; all imperative methods; public Gurx/plugin/visitor exports; R2-R4 `CX-1`–`CX-6`; Markdown readable by published 4.0.4/Lexical 0.35. +- **Not claimed**: a public MDXEditor extension API, consumer migration to Lexical extensions, `@lexical/mdast`, public plugin rewrites, NodeState/DOM extension adoption, new history UX, changed collaboration policy, or byte-identical Markdown where existing canonicalization applies. + +### Acceptance Scenarios + +<!-- prettier-ignore --> +| ID | Given | When | Then | Exact exercise and prerequisites | Required evidence | +|---|---|---|---|---|---| +| `CX-7a` | A consumer `realmPlugin` registers a replacement/custom Lexical node, Markdown import/export visitors/extensions, a Gurx cell/subscription, and ordinary React children for root, nested, and table editors | A React 18 or 19 consumer renders Markdown containing root, JSX/directive, and table content, edits each surface, and reads through public methods/callbacks | The plugin initializes once per editor realm, each React child receives the correct composer editor, custom content imports/exports, edits persist, and the consumer imports no MDXEditor extension implementation | In the three-browser public fixture, use only exported MDXEditor/Gurx/plugin surfaces and normal `@lexical/react` plugin hooks; assert mount/editor identities and exact Markdown. Extend the packed consumers to typecheck, bundle, render, and read a minimal custom `realmPlugin` | DIRECT REQUIRED | +| `CX-7b` | The public toolbar controls an editor containing root, nested JSX/directive, and table-cell edits, with a second fixture configured with `suppressSharedHistory` | The author moves focus, types, invokes Undo/Redo, changes `readOnly`, and repeats actions in each editor | Normal root/nested shared history and table-local history preserve current ordering and availability; active-editor focus remains correct; the suppressed root has no history while nested external and table-local history retain the current behavior; read-only propagation remains coherent | In Chromium, Firefox, and WebKit, extend the existing `CX-3` journey with explicit command-availability and suppressed-history assertions through visible public controls; assert Markdown after every boundary and no update recursion warning | DIRECT REQUIRED | +| `CX-7c` | An editor with the consumer plugin and nested/table content is mounted under React Strict Mode | The consumer mounts, unmounts, remounts, calls public methods, and edits after remount | The new editor is usable, stale editors do not receive updates or remain active, extension registrations and React children are not duplicated, and no page/console error, listener warning, or retained editor-owned process occurs | In all three engines, toggle the public fixture through two mount cycles; assert fresh realm/editor/ref identities and exact React composer-child mount/cleanup counters (not a nonexistent `RealmPlugin` cleanup); verify Markdown, runtime errors, and server cleanup | DIRECT REQUIRED | + +### Authoritative History Sequences + +Use distinct atomic markers and compare public Markdown only when it changes; shared stacks may first reapply an already-visible state. Controls reflect `CAN_UNDO_COMMAND`/`CAN_REDO_COMMAND` on the active editor. + +- **Normal shared**: from initial `I`, edit root (`R`), nested sibling A (`A`), then sibling B (`B`). With B active, visible Undo states are `R+A`, `R`, `I`; Redo restores `R`, `R+A`, `R+A+B`. Availability is `U:on/R:off` after `B`, both on between endpoints, `U:off/R:on` at `I`, then `U:on/R:off` after full Redo. This pins one root/nested/sibling stack. +- **Suppressed root**: from `I`, edit active root (`R`) while both controls are off and dispatch is a no-op, then nested siblings (`A`, `B`). Nested Undo yields `R+A`, then `R`; Redo restores `R+A`, then `R+A+B`; root `R` never changes. Availability is `U:on/R:off` after `B`, both on after the first visible Undo/Redo, `U:off/R:on` at `R`, and `U:on/R:off` after full Redo. This pins sibling sharing without root history. +- **Table local then parent**: after cell edit `T`, availability is `U:on/R:off`; cell Undo restores its initial rendered value with `U:off/R:on`, and Redo restores `T` with `U:on/R:off`. Blur/save publishes `T` as one root change with `U:on/R:off`; focused-root Undo restores pre-save Markdown with `U:off/R:on`, and Redo restores `T` with `U:on/R:off`. Cell-local commands never traverse root/nested history. + +## Research Summary + +### Vetted Repository Findings + +- `src/RealmWithPlugins.tsx` runs plugin init/post-init during render; `corePlugin.postInit` then builds/publishes root from the complete registry. **Impact**: preserve all-init-then-all-post-init and root availability to later post-init hooks, but move the whole session to commit phase with private cleanup ownership. +- `src/MDXEditor.tsx` manually provides root context; nested/table editors separately use `createEditor`, `LexicalNestedComposer`, and shared/local React history plugins. **Impact**: replace construction/context/history while preserving React child placement, MDAST synchronization, focus/navigation, and table save behavior. +- Public docs/exports include Gurx cells/signals, `realmPlugin`, visitors, nodes, and composer-child appenders; existing browser suites pin Markdown, focus/history, selection, and search. **Impact**: keep the factory private, add direct `CX-7`, and rerun inherited scenarios. +- `@lexical/extension` and `@lexical/history` are transitive while the build externalizes declared dependencies. **Impact**: declare both at `^0.48.0` and retain lockstep/package gates. + +### External Constraints + +- Lexical React 0.48 `LexicalExtensionEditorComposer` renders an already extension-built editor through the normal `LexicalComposerContext`, leaves lifecycle ownership to the caller, and requires `ReactProviderExtension` plus `ReactExtension` — [official 0.48 source](https://github.com/facebook/lexical/blob/v0.48.0/packages/lexical-react/src/LexicalExtensionEditorComposer.tsx). +- Lexical Extension 0.48 `NestedEditorExtension` sets `parentEditor`, inherits theme, and can mirror parent editability; `buildEditorFromExtensions` returns a `LexicalEditorWithDispose` — [nested extension](https://github.com/facebook/lexical/blob/v0.48.0/packages/lexical-extension/src/NestedEditorExtension.ts), [builder](https://github.com/facebook/lexical/blob/v0.48.0/packages/lexical-extension/src/LexicalBuilder.ts). +- Lexical History 0.48 `HistoryExtension` exposes the history state and undo/redo signals; `SharedHistoryExtension` redirects nested history to the parent extension when available — [official 0.48 history source](https://github.com/facebook/lexical/blob/v0.48.0/packages/lexical-history/src/index.ts). + +### Settled Decisions and Rejected Alternatives + +- **Decision**: add one internal factory receiving the complete nodes, theme, namespace, editability, parent, and history mode and returning a disposable editor — **Rationale**: the spike proves the common bridge; one factory prevents divergent configurations. +- **Decision**: `RealmWithPlugins` creates a fresh session in a commit-phase effect, runs all init then all post-init, exposes children only after setup, and invokes private exactly-once disposers on cleanup. Nested/table owners likewise create and dispose one editor per effect setup. **Rationale**: the Strict Mode spike proves replay does not render or leak the discarded session and never reuses a disposed editor; no public `RealmPlugin` cleanup is added. +- **Decision**: render existing root/nested/table React plugin components under `LexicalExtensionEditorComposer`; keep `RichTextPlugin`, content-editable wrappers, toolbars/dialogs, and Gurx appenders as ordinary React children — **Rationale**: preserves the supported component contract while moving only editor construction/lifecycle to extensions. +- **Decision**: root history uses the existing `historyState$`; normal nested editors use `SharedHistoryExtension`; when root history is suppressed, nested editors continue consuming the external state as they do today; table cells retain local `HistoryExtension` — **Evidence/rationale**: source characterization preserves `suppressSharedHistory` and table behavior rather than silently redefining collaboration/history policy. +- **Decision**: add `@lexical/extension` and `@lexical/history` as direct lockstep dependencies — **Rationale**: production imports must not rely on `@lexical/react` transitive dependencies and the Vite build externalizes declared packages. +- **Rejected**: expose MDXEditor's internal extensions or require consumer plugins to become Lexical extensions — **Reason**: violates the roadmap's public extensibility invariant and would turn R5 into a breaking migration. +- **Rejected**: `ReactPluginHostExtension` — **Reason**: it is intended for editors not already hosted by React and adds a second React-root/portal lifecycle that MDXEditor does not need. +- **Rejected**: migrate Markdown conversion to `@lexical/mdast` or rewrite Gurx plugins — **Reason**: separate architecture/public-contract initiatives explicitly outside this roadmap. + +### Spike Evidence + +- `plans/research/005-extension-composer-history/spike-01-extension-compatibility-bridge.md` — **Question**: can extension-built root/nested editors preserve ordinary React children and shared history? — **Result/decision**: CONCLUSIVE; exact editor context, parent identity, editability, shared state, nested Undo, cross-editor Undo, and disposal worked — **Limits**: MDXEditor synchronization, table navigation, Strict Mode, and real browsers remain direct execution evidence. +- `plans/research/005-extension-composer-history/spike-02-strict-realm-session.md` — **Question**: can a commit-phase realm session own extension editors under Strict Mode? — **Result/decision**: CONCLUSIVE; replay created/disposed an abandoned session, rendered only the live session, and disposed both editors exactly once — **Limit**: actual Lexical unregister-after-dispose remains focused execution evidence. + +### Research and Validation Coverage + +- **Depth/confidence**: Standard/HIGH. Inspected the verified R2-R4 contracts; public exports/docs; realm and root/nested/table lifecycles; history commands/state; exact 0.48 builder, React, nested, and history sources; and package/test surfaces. Collaboration/Yjs, source/diff internals, `@lexical/mdast`, device farms, and unrelated rewrites remain outside scope. +- **Current baseline**: `npm run typecheck` and `git diff --check` pass. R4 records 65 passed/1 skipped/1 todo units, 45/45 browser tests, build, a 24-package Lexical lockstep graph, React 18/19 packed consumers, 0.35 replay, and API docs. The exact-0.48 spike passed its React context, parent/editability, shared-history, Undo, and disposal probes. + +## Execution Contract + +- **Planned at commit**: `9373742` +- **Planning baseline**: verified, intentionally uncommitted R1-R4 work plus `plans/`, `reports/`, browser/package harnesses, and the new R5 spike record must be preserved. R5 generation must not overwrite overlapping changes in `src/MDXEditor.tsx`, `package.json`, or `package-lock.json`; those are verified R2/R3 inputs to extend carefully. + +### Expected Changes + +- `package.json`, `package-lock.json` — add direct lockstep `@lexical/extension` and `@lexical/history` dependencies. +- `src/plugins/core/lexicalExtensions.ts` (or a comparably scoped internal file) — stable extension definitions/factory for root, shared nested, suppressed-root nested, and table-local history modes. +- `src/RealmWithPlugins.tsx`, `src/plugins/core/index.ts`, `src/MDXEditor.tsx` — commit-phase realm ownership, private cleanup registration, root creation/publication, extension composer rendering, and teardown. +- `src/plugins/core/NestedLexicalEditor.tsx`, `src/plugins/table/TableEditor.tsx` — extension-build nested/table editors while preserving MDAST synchronization, focus/navigation commands, React children, and teardown. +- `src/plugins/core/SharedHistoryPlugin.tsx` — remove after all callers use extension history. +- `src/test/extension-composer.test.tsx` — focused construction, node registry, history-mode, prop, lifecycle, and cleanup tests. +- `src/test/fixtures/ExtensionCompatibilityHarness.tsx`, `src/examples/extension-compatibility.tsx`, `tests/browser/extension-compatibility.spec.ts` — public extension-agnostic `CX-7a`–`CX-7c` evidence. +- `tests/package-consumer/shared/src/main.tsx`, `docs/extending-the-editor.md` — exercise/document that custom Gurx plugins remain the public model. + +### Explicitly Out of Scope + +- Exporting internal Lexical extensions, changing consumer plugin signatures, or requiring plugins/composer children to use extension APIs. +- `@lexical/mdast`, visitor removal/replacement, NodeState, DOM import/render extensions, Yjs/collaboration redesign, stock table-node migration, or unrelated plugin conversion. +- New Undo/Redo UI, history-depth policy, cross-document history, changed `suppressSharedHistory` semantics, or table behavior redesign. +- Public API removals, Markdown schema changes, React/Node support changes, or any Lexical version beyond 0.48.0. + +### Scope Expansion Rule + +Additional lifecycle/test/documentation files may change only when direct `CX-7` or inherited `CX-1`–`CX-6` evidence requires them for the same extension-internal compatibility contract. Record each path and reason in Execution Notes. Pause before any public extension export, Gurx/visitor contract change, collaboration/history policy change, MDAST migration, or behavior change not already pinned by the scenarios. + +### Pause and Reassess If + +- A consumer composer child cannot receive the normal Lexical React context without adopting an extension API or a second React root. +- Complete consumer node/replacement registrations are unavailable before extension construction, or root publication must move in a way that changes initial Markdown, ref readiness, or plugin initialization order. +- Nested/table extension construction cannot preserve parent synchronization, active-editor focus, read-only propagation, or the characterized history modes. +- Correct disposal requires changing the public `RealmPlugin` interface or causes duplicate registration under React Strict Mode. +- Any inherited `CX-1`–`CX-6` failure requires a public API/Markdown behavior change rather than a bounded internal correction. + +## Context + +### Key Files + +- `src/RealmWithPlugins.tsx`, `src/plugins/core/index.ts`, `src/MDXEditor.tsx` — plugin ordering, root construction/publication, React context, public props/ref methods, and teardown. +- `src/plugins/core/NestedLexicalEditor.tsx`, `src/plugins/table/TableEditor.tsx` — child construction, MDAST synchronization, focus/navigation, history, and consumer children. +- `src/plugins/toolbar/components/UndoRedo.tsx` — public command/availability behavior; it must not adopt internal extension signals. +- `src/test/fixtures/LexicalCompatibilityHarness.tsx`, `tests/browser/lexical-compatibility.spec.ts` — inherited root/nested/table/history journey. +- `plans/research/005-extension-composer-history/spike-01-extension-compatibility-bridge.md` — selected bridge evidence. + +### Gotchas + +- `LexicalExtensionEditorComposer` does not own the supplied editor; every mounted root/nested/table editor needs exactly-once `dispose()` cleanup after React/plugin children unmount. +- Extension arguments must be stable. Recreating them during render recreates editor state and registrations; derive them once from the post-init node/theme/namespace/history snapshot. +- `NestedEditorExtension` must receive the real parent editor during construction. Relying on later private `_parentEditor` mutation would retain the architecture R5 exists to remove. +- `ReactExtension` renders a default `ContentEditable`; configure its content editable to `null` because MDXEditor already owns wrappers, placeholders, refs, styling, and `RichTextPlugin`. +- Consumer node replacements must be present when the builder constructs each editor. Adding classes after construction is not a supported fallback. +- Do not replace command-driven Undo/Redo with public extension signals. `UndoRedo` and consumer command listeners are existing observable APIs; extension history must continue dispatching their availability commands. +- Preserve current history modes: normal root/nested editors share external state; suppressed root history does not imply silently removing nested/table history; table cells retain local history before parent synchronization. +- The existing `editorState` value path is intentionally not broadened during R5; only preserve its current initialization/null behavior unless a separate decision changes it. + +## Implementation Blueprint + +### Data Models + +```ts +type EditorHistoryMode = 'root-shared' | 'nested-shared' | 'nested-external' | 'table-local' + +interface ExtensionEditorConfig { + name: string + namespace: string + nodes: CreateEditorArgs['nodes'] + theme: EditorThemeClasses + editable: boolean + parentEditor?: LexicalEditor + historyMode: EditorHistoryMode + historyState?: HistoryState +} +``` + +The exact internal names are reversible; the four history/lifecycle modes and their observable behavior are not. + +### Tasks + +```yaml +Task 1: Add the internal extension editor factory and direct dependencies + MODIFY package.json, package-lock.json: + - Declare @lexical/extension and @lexical/history at ^0.48.0 and keep the entire installed graph lockstep. + CREATE src/plugins/core/lexicalExtensions.ts: + - Build stable disposable editors from complete nodes/theme/namespace/editability/error configuration. + - Include ReactProviderExtension and ReactExtension with no default ContentEditable. + - Configure root-shared, nested-shared, nested-external, and table-local history modes without exporting them publicly. + CREATE src/test/extension-composer.test.tsx: + - Pin editor config, node replacements, normal React context, parent/editability inheritance, each history mode, command availability, and exactly-once disposal. + PATTERN: plans/research/005-extension-composer-history/spike-01-extension-compatibility-bridge.md + ENABLES: CX-7a, CX-7b, CX-7c + VERIFY: + - COMMAND: npm run test:once -- src/test/extension-composer.test.tsx + - EXPECTED: Focused extension construction/history/lifecycle tests pass with no leaked listener or duplicate registration assertion. + +Task 2: Migrate the root editor and preserve Gurx/public initialization + MODIFY src/RealmWithPlugins.tsx: + - Create each realm session in a commit-phase effect, run all init then all post-init, and render children only for the live session. + - Add a private exactly-once cleanup registry; do not add a RealmPlugin cleanup callback or other public lifecycle API. + MODIFY src/plugins/core/index.ts: + - Replace createEditor and legacy SharedHistoryPlugin registration with the extension factory after all plugin init registrations. + - Register root disposal with its realm session; preserve initial Markdown, editorState null behavior, autoFocus, readOnly, root/active publication, namespace/theme/nodes, and subscriptions. + MODIFY src/MDXEditor.tsx: + - Replace the custom LexicalProvider with LexicalExtensionEditorComposer around the unchanged RichTextEditor/plugin child tree. + - Never render or publish a stale/disposed session during replay or remount. + DELETE src/plugins/core/SharedHistoryPlugin.tsx: + - Remove only after root/nested callers use extension history. + ENABLES: CX-7a, CX-7b, CX-7c + VERIFY: + - COMMAND: npm run typecheck && npm run test:once -- src/test/core.test.tsx src/test/extension-composer.test.tsx + - EXPECTED: Root methods, Markdown initialization, prop behavior, plugin registration order, history commands, and lifecycle tests pass. + +Task 3: Migrate nested JSX/directive and table-cell editors + MODIFY src/plugins/core/NestedLexicalEditor.tsx: + - Effect-create one disposable extension child per setup; replace LexicalNestedComposer/SharedHistoryPlugin with LexicalExtensionEditorComposer. + - Preserve block/inline import, parent export, focus/blur/selection/backspace commands, active-editor routing, consumer children, and current external-history behavior when root history is suppressed. + MODIFY src/plugins/table/TableEditor.tsx: + - Effect-create one disposable extension child per setup; replace LexicalNestedComposer/HistoryPlugin with LexicalExtensionEditorComposer. + - Preserve MDAST cell save, Tab/Enter/blur navigation, parent update commands, focus, consumer children, and local history. + ENABLES: CX-7a, CX-7b, CX-7c + VERIFY: + - COMMAND: npm run test:browser -- --project=chromium --grep 'CX-3|CX-7' + - EXPECTED: Root/nested/table focus, synchronization, history modes, custom consumer children, and remount behavior pass in Chromium without page/console errors. + - PROCESS-LIFECYCLE: Playwright owns Ladle, port 61000, Chromium, traces, and teardown; selected test progress is visible, exit 0 with no listener is success, and any assertion/runtime/server/cleanup error is failure. + +Task 4: Add direct extension-agnostic consumer evidence + CREATE src/test/fixtures/ExtensionCompatibilityHarness.tsx: + - Define a consumer realmPlugin using public node/visitor/Gurx/composer-child registration surfaces and root/JSX/directive/table content. + - Expose visible mount/cleanup/editor identity, history availability, Markdown, callback, active-editor, read-only, suppressed-history, and remount observations. + CREATE src/examples/extension-compatibility.tsx: + - Host the deterministic public fixture in Ladle. + CREATE tests/browser/extension-compatibility.spec.ts: + - Exercise CX-7a-CX-7c through visible controls and public refs in Chromium, Firefox, and WebKit; call no internal extension helper. + - Reuse existing focus/selection helpers but assert the new custom plugin, history-mode, Strict Mode, and cleanup outcomes directly. + MODIFY tests/package-consumer/shared/src/main.tsx: + - Add a minimal documented realmPlugin consumer and assert it typechecks, bundles, renders, and reads Markdown in both packed React consumers. + ENABLES: CX-7a, CX-7b, CX-7c + VERIFY: + - COMMAND: npm run test:browser -- --grep CX-7 + - EXPECTED: Nine direct CX-7 runs pass across Chromium, Firefox, and WebKit with no runtime error. + - FAILURE-LOCAL: npm run test:browser -- --project=chromium --grep CX-7; npm run test:browser -- --project=firefox --grep CX-7; npm run test:browser -- --project=webkit --grep CX-7 + - PROCESS-LIFECYCLE: Playwright owns Ladle/61000/browser artifacts and teardown on both terminal paths; nine completed tests plus exit 0 and no listener/process is success. + +Task 5: Document the compatibility boundary and run the final roadmap checkpoint + MODIFY docs/extending-the-editor.md: + - State that Lexical extensions are internal construction details; Gurx realmPlugin, visitors, nodes, and ordinary React composer children remain the supported consumer model. + RUN full gates: + - Rerun CX-1-CX-7, the full unit/browser matrix, declarations/build, lockstep graph, packed React 18/19 consumers, 0.35 replay, and API docs. + ENABLES: CX-7a, CX-7b, CX-7c + VERIFY: + - COMMAND: npm run lint && npm run typecheck && npm run test:once && npm run test:browser && npm run build && npm run test:lexical-versions && npm run test:package && npm run test:cross-version && npm run build:docs:api + - EXPECTED: Every phase exits 0; all CX-1-CX-7 evidence remains green; packed consumers use declared 0.48 dependencies and no public extension import. + - FAILURE-LOCAL: npm run lint; npm run typecheck; npm run test:once -- src/test/extension-composer.test.tsx; npm run test:browser -- --grep CX-7; npm run test:browser -- --project=chromium --grep 'CX-3|CX-5|CX-6'; npm run build; npm run test:lexical-versions; npm run test:package -- --react=18; npm run test:package -- --react=19; npm run test:cross-version; npm run build:docs:api + - PROCESS-LIFECYCLE: Playwright owns Ladle/61000/browsers; package scripts own disposable apps, isolated caches, tarballs, Chromium pages, preview servers, and allocated loopback ports in finally blocks. The first nonzero phase is failure; success requires final exit 0 and no owned process/listener/scratch state. +``` + +### Integration Points + +- `src/plugins/core/index.ts` and `src/index.ts` preserve every Gurx/plugin/node/visitor/composer-child export; the internal factory is not exported. +- `LexicalExtensionEditorComposer` supplies the existing React context boundary; nested/table wrappers keep parent synchronization around the shared factory. +- `historyState$` retains its external identity, and `UndoRedo.tsx` continues to observe public Lexical commands rather than extension signals. +- Existing compatibility/selection/search fixtures remain the `CX-1`–`CX-6` regression boundary; the new fixture and packed consumer directly own `CX-7`. + +## Validation + +```bash +npm run lint && npm run typecheck && npm run test:once && npm run test:browser && npm run build && npm run test:lexical-versions && npm run test:package && npm run test:cross-version && npm run build:docs:api + +npm run test:once -- src/test/extension-composer.test.tsx +npm run test:browser -- --grep CX-7 +npm run test:browser -- --project=chromium --grep 'CX-3|CX-5|CX-6|CX-7' + +npx prettier --check package.json src/plugins/core/lexicalExtensions.ts src/plugins/core/index.ts src/MDXEditor.tsx src/plugins/core/NestedLexicalEditor.tsx src/plugins/table/TableEditor.tsx src/test/extension-composer.test.tsx src/test/fixtures/ExtensionCompatibilityHarness.tsx src/examples/extension-compatibility.tsx tests/browser/extension-compatibility.spec.ts tests/package-consumer/shared/src/main.tsx docs/extending-the-editor.md +git diff --check +``` + +`CX-7a`–`CX-7c` own the new promise; `CX-1`–`CX-6` remain the regression authority and cannot be replaced by factory tests. A browser launch denial before page creation is environmental and must be rerun where permitted. + +## Unknowns & Risks + +- Extension definitions capture the complete node/replacement registry once. Any execution change that makes plugin registration dynamic after editor construction is a public architecture expansion and must pause. +- Root creation moves from render-time realm setup to a commit-phase session while preserving plugin order; Strict Mode tests must directly detect duplicate registrations and unsafe unregister-after-dispose. +- `suppressSharedHistory` has asymmetric current behavior: it removes root history, while nested editors still register against the external state and table cells retain local history. R5 preserves and characterizes this; changing it requires a separate collaboration/history decision. +- Extension history signals are implementation details. Command availability must remain the observable contract because toolbar and consumer listeners already depend on `CAN_UNDO_COMMAND`/`CAN_REDO_COMMAND`. +- Table-cell history is local until the cell saves to the parent table node. Converting it to shared history would change ordering and is prohibited even if technically simpler. +- The parent roadmap's final completion depends on rerunning every earlier public scenario after this broad internal migration; a green `CX-7` alone is insufficient. + +**Confidence: 8/10**. The bridges are proven and gates executable; remaining risk is lifecycle timing and table/suppressed-history semantics. + +## Execution Notes + +- **Started**: 2026-07-18 at commit `9373742` on the verified, intentionally uncommitted R1-R4 worktree. +- **Preserved baseline**: all existing source, package, browser, plan, report, and harness changes listed by preflight belong to R1-R4 or the user and remain in place. R5 extends the expected overlaps in `package.json`, `package-lock.json`, `src/MDXEditor.tsx`, and `src/test/core.test.tsx` without reverting them. +- **Implemented architecture**: root, JSX/directive, and table editors now use one private `buildEditorFromExtensions` factory plus `LexicalExtensionEditorComposer`; `HistoryExtension`/`SharedHistoryExtension` own the four pinned history modes; realm and child-editor setup/disposal are commit-phase and exactly once. +- **Compatibility adapters discovered during direct browser execution**: initial imports use `HISTORIC_TAG`; cross-editor selection focus records an explicit shared-history boundary; focus replays command-based availability after the active toolbar listener attaches; and `corePlugin.init` creates a fresh external `HistoryState` for every Realm/Strict replay session. These preserve the authoritative normal, suppressed-root, table-local/parent, remount, and command-availability sequences rather than adopting extension signals publicly. +- **Recorded scope additions**: `src/realmSession.ts` owns private realm cleanup; `scripts/assert-lexical-versions.mjs` recognizes the two new direct dependencies; and `scripts/package-consumer-utils.mjs` requires the packed public `realmPlugin` child to render. Verification restored the public `usedLexicalNodes$` value to its legacy class-only type and kept replacement descriptors on the supported `additionalLexicalNodes` paths. +- **Implementation evidence**: lint, typecheck, the 23 focused core/extension tests, the 24-package 0.48 lockstep assertion, production build, all nine direct `CX-7` runs across Chromium/Firefox/WebKit, patch hygiene, and rebuilt packed React 18/19 declarations/bundles/runtime/ref/plugin-child checks pass. The known CodeMirror jsdom geometry warning and stale Browserslist notice remain non-failing baseline warnings. +- **Status**: `VERIFIED`; fresh post-review verification directly confirms the three corrections and the complete integrated gate. The earlier Verification Record is retained below as historical evidence. +- **Post-verification review findings**: preserve initial-commit `MDXEditorMethods` readiness without returning Realm/plugin initialization to render; prevent table-local search replacement from writing into the Realm shared history; and give recursive nested editors their immediate containing editor as the extension parent. +- **Post-review implementation**: Realm creation, plugin ordering, and session disposal remain unchanged and commit-owned. `MDXEditor` now attaches a stable public methods facade on the initial commit, delegates to the live Realm after it mounts, and replays early mutating calls in order. Extension editors record their actual `HistoryExtension` state privately so search baselining skips table-local editors. Recursive `NestedLexicalEditor` construction now receives `parentEditor` from `NestedEditorsContext` while its history mode continues to follow the root sharing policy. +- **Post-review direct evidence**: a parent layout/passive-effect unit exercise proves initial ref readiness, queued `setMarkdown`, live-session delegation, unmount nulling, and unchanged Strict cleanup; focused history identity tests distinguish shared/nested/table states; the public browser fixture proves a recursive nested editor's parent is another nested editor; and table Replace All proves local Undo/Redo followed by exactly one parent save Undo/Redo. The affected 12-test matrix passes across Chromium, Firefox, and WebKit. +- **Post-review integrated evidence**: lint, typecheck, 70 passed/1 skipped/1 todo unit tests, 57/57 browser tests, production declarations/build, the 24-package Lexical 0.48 lockstep assertion, packed React 18/19 declaration/bundle/style/runtime/ref checks—including parent layout/mount-effect readiness and an early `setMarkdown` replay—0.48-to-published-4.0.4/0.35 Markdown replay, API docs, and patch hygiene pass. The first sandboxed cross-version Chromium launch was denied a macOS Mach port before page creation; the identical permitted rerun passed. Existing CodeMirror geometry, TypeDoc version/reference, Browserslist, bundle-size, and npm-config notices remain non-blocking baseline warnings. +- **Post-verification hardening**: closing the final evidence limitation exposed a real batching defect: synchronous replay of queued `setMarkdown`, `focus`, and `insertMarkdown` updated the DOM but could leave `markdown$` stale because the insertion landed inside `setMarkdown`'s mute window. The stable public facade now drains queued calls FIFO across microtask boundaries, keeps new calls on the tail during replay, prevents parallel Strict drains, and aborts stale methods without changing Realm ownership or normal ready-state calls. + +## Historical Verification Record (Superseded) + +- **Verified**: 2026-07-18 under the Standard assurance profile. +- **Fresh verifier**: `/root/r5_verification`, started without inherited conversation context and restricted to read-only inspection and test execution. +- **Consumer acceptance pass**: the full inherited public browser matrix passed 54/54 across Chromium, Firefox, and WebKit; direct `CX-7` passed 9/9; and packed React 18 and React 19 consumers passed declarations, bundle, CSS, runtime, public-ref, and plugin-child checks. +- **PRP compliance pass**: the extension architecture, history modes, public extension-agnostic plugin boundary, and inherited `CX-1`–`CX-6` behavior match the PRP. The initial pass identified missing direct proof for a custom import visitor/plugin initialization and for nested/table Strict-remount lifecycle identity; the fixture and `CX-7` tests were strengthened rather than accepting proxy evidence. +- **Engineering review pass**: the internal factory/composer/history architecture is bounded and no public extension API leaks. Review found one real source-compatibility regression: widening `usedLexicalNodes$` from `Klass<LexicalNode>[]` to `AdditionalLexicalNode[]` broke an exact legacy downstream assignment. The cell is class-only again, while replacement descriptors remain supported through `additionalLexicalNodes`. + +| Scenario | Grade | Direct evidence | +| -------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CX-7a` | DIRECTLY VERIFIED | A public consumer plugin registers a custom node class, priority import visitor, export visitor, cells/signals, and root/nested/table composer children. Initialization count is exactly one per Realm; the scenario passed in Chromium, Firefox, and WebKit. | +| `CX-7b` | DIRECTLY VERIFIED | Normal shared root/nested/sibling history, suppressed-root behavior, table-local history followed by parent save, focus routing, Markdown, and command availability passed in all three engines. | +| `CX-7c` | DIRECTLY VERIFIED | Strict replay/remount proves root/nested/table identity replacement, exact mount/cleanup counts, fresh public-ref and active-editor identity, stale-editor inactivity, remount editing, and final cleanup in all three engines. | + +- **Post-fix targeted rerun**: production build and declarations passed; the exact legacy `Klass<LexicalNode>[] = realm.getValue(usedLexicalNodes$)` consumer reproduction compiled; and strengthened `CX-7a`/`CX-7c` passed 6/6 across Chromium, Firefox, and WebKit. The unaffected `CX-7b` result from the full run remains direct evidence. +- **Inherited evidence**: `CX-1`–`CX-6` remained green in the 54/54 three-engine browser run. The verified R1–R4 records retain their focused unit, package, cross-version, build, and documentation evidence; R5's production build/declarations and packed React consumers passed again at this final checkpoint. +- **Hygiene and cleanup**: lint, typecheck, formatting/patch hygiene, the lockstep dependency assertion, and production/package gates are green. Disposable verifier state was removed, browser teardown completed, and port `61000` has no listener. Existing CodeMirror geometry and Browserslist notices remain non-blocking baseline warnings. +- **Structural validation**: the PRP validator passes with only its existing non-blocking compactness warnings. +- **Recommendation**: `VERIFIED`. No remaining R5 issue or roadmap blocker was found. + +## Verification Record + +- **Verified**: 2026-07-18 under the Standard assurance profile. +- **Fresh verifier**: `/root/r5_postreview_verification`, started without inherited conversation context and restricted to read-only inspection and test execution. +- **Consumer acceptance pass**: `CX-7a`, `CX-7b`, and `CX-7c` are `DIRECTLY VERIFIED`. The affected public matrix passed 12/12 across Chromium, Firefox, and WebKit; packed React 18/19 consumers directly confirmed initial parent layout/passive-effect ref readiness; and the focused Strict Mode exercise directly confirms pre-ready getters plus FIFO `setMarkdown` → `focus` → `insertMarkdown` replay. +- **PRP compliance pass**: all five success criteria and Tasks 1–5 are satisfied. The extension factory remains private, all four history modes match the contract, Realm setup/cleanup remains commit-owned, and no public plugin/visitor signature, Markdown schema, collaboration policy, or `@lexical/mdast` adoption entered scope. +- **Engineering review pass**: no significant R5-introduced issue remains. The verifier confirmed stale-session detach protection, FIFO imperative-call queuing, exact shared/local history identity, table-search isolation, recursive immediate-parent ancestry, inherited editability/theme, and idempotent listener/timer/editor cleanup. + +| Scenario | Grade | Direct evidence | +| -------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CX-7a` | DIRECTLY VERIFIED | Public custom node/import/export visitors, Gurx state, root/nested/table composer children, one initialization per Realm, Markdown editing, read-only propagation, recursive immediate-parent identity, and React 18/19 package consumers passed. | +| `CX-7b` | DIRECTLY VERIFIED | Normal shared, suppressed-root, table-local, and parent-save history journeys passed in all engines; table Replace All retained local Undo/Redo and exactly one root save Undo/Redo boundary. | +| `CX-7c` | DIRECTLY VERIFIED | Strict replay, unmount, and remount passed with fresh Realm/editor/ref identities, balanced cleanup, stale-editor inactivity, usable remounted editing, and no runtime errors. | + +- **Fresh reproduced evidence**: 14/14 focused units; 71 passed/1 skipped/1 todo full units; 12/12 affected, 57/57 full, and 9/9 post-hardening `CX-7` browser tests; React 18/19 packed consumers; lint, typecheck, declarations/build, the 24-package Lexical 0.48 lockstep assertion, patch hygiene, and clean port-61000 teardown. The first sandboxed browser launch was denied before page creation by macOS restrictions; the identical permitted rerun passed. +- **Reused unchanged integrated evidence**: cross-version Markdown replay and API documentation build from the post-review Execution Notes. +- **Targeted hardening follow-up**: `/root/r5_postreview_verification` independently passed the 6/6 extension-composer tests and confirmed FIFO microtask ordering, calls added during a drain, same/different-method reattachment, stale detach/unmount, exactly-once focus callbacks, and unchanged immediate ready-state behavior. The prior replay limitation is closed with no remaining finding (95% confidence). +- **Baseline notices**: existing CodeMirror geometry, Browserslist, JSX-transform, TypeDoc/reference, npm-config, and bundle-size notices remain non-failing. +- **Recommendation**: `VERIFIED`. No remaining R5 issue or roadmap blocker was found. diff --git a/plans/research/003-selection-markdown/spike-01-selection-export-pipeline.md b/plans/research/003-selection-markdown/spike-01-selection-export-pipeline.md new file mode 100644 index 00000000..8d2b1643 --- /dev/null +++ b/plans/research/003-selection-markdown/spike-01-selection-export-pipeline.md @@ -0,0 +1,68 @@ +# Spike 01: Preserve MDXEditor serialization for selected content + +## Status + +CONCLUSIVE + +## Question + +Can selection-scoped export use Lexical's selection cloning while still serializing through MDXEditor's existing MDAST visitors, JSX descriptors, `toMarkdownExtensions`, and `toMarkdownOptions`, without mutating the source editor? + +## Why It Blocks Planning + +R3 must choose between Lexical's `$convertSelectionToMarkdownString` transformer path and a selection-clipping path that reuses MDXEditor's established exporter. The choice determines implementation scope, custom-node support, multi-editor isolation, and the direct `CX-5` evidence strategy. + +## Hypotheses and Decision Rule + +- If Lexical's selection JSON can clip partial text/link content, preserve multi-block and custom-node payloads, and replay through MDXEditor's existing visitor exporter without a source-editor update, choose selection cloning plus the existing MDAST pipeline. +- If selected structure or custom nodes cannot survive that replay, use an adapted `$convertSelectionToMarkdownString` pipeline only if it can honor the same visitor/options contract without global caches or a second public serialization configuration. +- If neither path preserves the contract, mark the spike inconclusive and replan R3. + +## Minimal Experiment + +- **Environment and exact versions**: repository lockfile at commit `9373742`; `lexical`, `@lexical/clipboard`, `@lexical/link`, and `@lexical/markdown` 0.48.0; Node 24.18.0; Vite Node from the installed Vitest toolchain. +- **Setup**: disposable scripts in `/private/tmp` created a source Lexical editor, captured selected JSON with `$generateJSONFromSelectedNodes`, reconstructed the selected nodes in an isolated temporary editor, and invoked MDXEditor's `exportMarkdownFromLexical` with its existing root, paragraph, text, link, and representative custom-node visitors. +- **Action**: export a partial text range, a partial link excluding its first character, a forward multi-block range, and a custom decorator `NodeSelection`; repeat the partial-link export and observe source-editor state identity/update listeners. +- **Observation to capture**: clipped Markdown, preserved structure/custom payload, stable repeated output, and zero source-editor updates. +- **Safety and side-effect constraints**: no repository source or dependency mutation; temporary editor only; no browser, service, port, or external state. + +## Evidence + +- **Commands run**: + - `node /private/tmp/r3-selection-spike.mjs` + - `node_modules/.bin/vite-node /private/tmp/r3-selection-mdast-spike.ts` + - `node /private/tmp/r3-inherited-editor-spike.mjs` +- **Relevant output summary**: + - partial text `alpha bravo charlie` at offsets 6–11 serialized as `bravo`; + - partial link `linked text` at offsets 1–6 serialized as `[inked](https://example.com)`; + - a range from the first paragraph into the second preserved two Markdown blocks and the complete intervening link; + - a selected custom decorator retained its JSON payload and serialized through the representative custom visitor as a fenced block; + - two identical partial-link calls returned identical output; + - every source-editor check reported preserved editor-state identity and `sourceUpdateCount: 0`. + - a no-config `createEditor()` created inside the active editor read inherited its registered custom-node map; `$generateNodesFromSerializedNodes` reconstructed the selected node as the custom class with payload intact while preserving source-state identity. +- **Artifacts or source locations**: + - `node_modules/@lexical/clipboard/src/clipboard.ts:500-696` — official selection JSON clipping, ancestor extraction, custom-node JSON, and node regeneration. + - `src/exportMarkdownFromLexical.ts:112-221` and `src/exportMarkdownFromLexical.ts:426-439` — MDXEditor's visitor and MDAST-to-Markdown pipeline used by the prototype. + - Disposable scripts were removed after recording this result. + +## Result + +- **Outcome**: CONCLUSIVE +- **Observed behavior**: Lexical's selection JSON is a suitable clipping boundary. An isolated temporary editor can reconstruct selected nodes and feed them to MDXEditor's existing exporter, including partial links and custom serialized nodes, while the source editor remains read-only. +- **Decision**: R3 will use Lexical's selection JSON/cloning primitives to create an isolated selected tree, create the temporary editor without explicit configuration inside the active editor read so it inherits that editor's registered node map, normalize top-level inline fragments into a paragraph container, and serialize that tree through the existing MDXEditor visitors/options/descriptors. The active editor remains the source, so root and nested selections retain current routing without a new public parameter or private registry access. +- **Rejected alternatives**: + - `$convertSelectionToMarkdownString` plus custom transformers — it introduces a parallel configuration model and cannot automatically honor MDXEditor consumer visitors, JSX descriptors, or MDAST extensions. + - mutating or temporarily replacing the source editor state — repeated getter calls must not publish updates, alter history, or disturb selection. + - module-level transformer/extension caches — they violate multi-editor isolation and reproduce PR #949's ordering/global-state defects. +- **Representativeness limits**: the spike proved the architecture with text, links, multi-block structure, and a representative custom decorator. Execution must directly cover built-in lists/tasks, tables, thematic breaks, code blocks, images, directives/JSX, consumer node replacements, active nested editors, two simultaneous configurations, and browser-created backward selections. + +## Planning Impact + +- **Roadmap or PRP sections/tasks/tests changed by this result**: the R3 blueprint can fix one implementation path rather than carrying a conditional architecture choice; it must add selected-tree reconstruction, inline-root normalization, and visitor/options reuse before the public/browser matrix. +- **Consumer Contract or evidence changed**: parent `CX-5` remains unchanged and requires direct public-ref evidence. Repeated-call/source-state and two-editor isolation checks are mandatory rather than implementation-only assertions. +- **Remaining uncertainty**: exact private helper decomposition is a reversible execution detail. The selected architecture preserves plugin nodes and `additionalLexicalNodes` replacements through the inherited registration map and preserves the public `getSelectionAsMarkdown(editor, exportParams)` call shape. + +## Cleanup + +- **Disposable artifacts removed**: `/private/tmp/r3-selection-spike.mjs`, `/private/tmp/r3-selection-mdast-spike.ts`, `/private/tmp/r3-inherited-editor-spike.mjs`, and `/private/tmp/node_modules` after evidence capture. +- **Repository and external state checked**: only this spike record was added; no source, package, browser, port, or external state changed. diff --git a/plans/research/004-state-backed-search/spike-01-state-position-replace-all.md b/plans/research/004-state-backed-search/spike-01-state-position-replace-all.md new file mode 100644 index 00000000..79cb52e2 --- /dev/null +++ b/plans/research/004-state-backed-search/spike-01-state-position-replace-all.md @@ -0,0 +1,52 @@ +# Spike 01: Can state positions replace multiple split-node matches in one update? + +## Status + +CONCLUSIVE + +## Question + +Can Lexical node-key/text-offset positions replace multiple non-overlapping matches, including a match split across differently formatted text nodes, in one reverse-order editor update without changing formatting outside the matches? + +## Why It Blocks Planning + +R4 requires `replaceAll` to stop replaying stale DOM `Range` objects through separate asynchronous updates. If node-key positions become stale during an earlier replacement in the same update, the PRP needs a different position model or multiple update/history operations. + +## Hypotheses and Decision Rule + +- If reverse-order replacements retain the lower match positions and preserve unmatched formatting, plan one Lexical update using state positions. +- If an earlier reverse replacement invalidates a remaining key/offset or changes unmatched formatting, require a different state model before PRP finalization. +- This headless spike does not decide the user-visible root/nested search scope or prove browser history grouping. + +## Minimal Experiment + +- **Environment and exact versions**: repository Node runtime; `lexical` and `@lexical/history` 0.48.0. +- **Setup**: a headless editor containing `alpha beta alpha KEEP`; the first `alpha` spans unformatted `al` and bold `pha`, while unmatched `KEEP` is bold. +- **Action**: build an ordered character-to-node-key/offset index in an editor-state read, find both `/alpha/gi` matches, then replace them with `X` from last to first inside one discrete update tagged `HISTORY_PUSH_TAG`. +- **Observation to capture**: replacement result, surviving node formats, stale-key errors, and whether headless history can establish a meaningful undo baseline. +- **Safety and side-effect constraints**: disposable script only under `/private/tmp`; no repository source or dependency changes. + +## Evidence + +- **Command**: `node /private/tmp/mdx-r4-state-replace-spike.mjs`. +- **Observed result**: both matches were replaced in one update, producing `X beta X KEEP`; no key became stale; the unmatched `KEEP` node remained bold. The replacement normalized the matched formatted span to the anchor's unformatted text, which is consistent with `RangeSelection.insertText` behavior and does not affect content outside the replacement. +- **History limitation**: the headless editor did not produce a useful undo stack (`undoDepth: 0`), so the real shared-history behavior remains a required rendered-browser exercise rather than a claim from this spike. + +## Result + +- **Outcome**: CONCLUSIVE. +- **Observed behavior**: reverse-order state positions are stable for multiple matches in one editor update, including a match spanning adjacent text nodes with different formats; unmatched formatting remains intact. +- **Decision**: R4 may use immutable match records containing editor identity plus start/end Lexical node keys and text offsets, apply `replaceAll` from last to first in one update for the selected editor, and project DOM `Range` objects only after reconciliation for the existing public hook/highlight surface. +- **Rejected alternatives**: retaining DOM `Range` objects as replacement authority; scheduling one editor update per match. +- **Representativeness limits**: browser DOM projection, shared-history undo/redo, nested/editor-scope policy, table/nested registration, invalid regex recovery, and live mutation invalidation still require direct implementation tests. + +## Planning Impact + +- **PRP tasks**: separate state match indexing/replacement from DOM-range projection; require one-update replace-all plus one-step browser undo evidence. +- **Consumer Contract**: no public hook shape change is needed; documented `Range` results can remain projections while state positions are authoritative. +- **Remaining uncertainty**: root-versus-active/nested search scope is a product-visible choice because the existing root DOM walk includes descendant nested editors, while a coherent one-update replace-all can target only one Lexical editor. + +## Cleanup + +- Disposable script removed after recording this result. +- Repository source and dependency state were not changed; only this research record was added. diff --git a/plans/research/005-extension-composer-history/spike-01-extension-compatibility-bridge.md b/plans/research/005-extension-composer-history/spike-01-extension-compatibility-bridge.md new file mode 100644 index 00000000..87eb373d --- /dev/null +++ b/plans/research/005-extension-composer-history/spike-01-extension-compatibility-bridge.md @@ -0,0 +1,52 @@ +# Spike 01: Can Extension-Built Editors Preserve the Existing React and Shared-History Bridge? + +## Status + +CONCLUSIVE + +## Question + +Can Lexical 0.48 build root and nested editors through extensions while ordinary React composer children still receive the expected editor context and nested editors share the root history without consumer plugins adopting Lexical extensions? + +## Why It Blocks Planning + +R5 may replace internal editor construction only if existing Gurx registrations such as `addComposerChild$`, `addNestedEditorChild$`, and `addTableCellEditorChild$` can continue rendering ordinary React plugin components. The same architecture must provide a real parent-editor relationship and one shared history state for root and nested editors; otherwise R5 would require a public plugin-contract change or a different decomposition. + +## Hypotheses and Decision Rule + +- If `LexicalExtensionEditorComposer` provides the normal React composer context for extension-built root/nested editors and `NestedEditorExtension` plus `SharedHistoryExtension` shares history and undo behavior, use an internal compatibility bridge and preserve the public Gurx API. +- If React children cannot consume the normal context or history cannot cross editors, do not finalize the planned migration; replan R5 instead of exposing Lexical extensions publicly. +- If the result is mixed, mark the spike inconclusive and do not guess. + +## Minimal Experiment + +- **Environment and exact versions**: repository install at Lexical/@lexical 0.48.0, React 19.2.1, Node 24.18.0, jsdom. +- **Setup**: build a root editor from `ReactProviderExtension`, configured `ReactExtension`, and configured `HistoryExtension`; build a nested editor from the same React bridge plus configured `NestedEditorExtension` and `SharedHistoryExtension`. +- **Action**: render ordinary React children under `LexicalExtensionEditorComposer`; compare captured editor identities, parent linkage, history-state identity, editability, and nested/cross-editor Undo results after tagged updates. +- **Observation to capture**: React context identities, `_parentEditor`, shared `HistoryState`, text before/after Undo, and editor disposal. +- **Safety and side-effect constraints**: disposable script and `node_modules` symlink under `/private/tmp`; no repository source or dependency mutation. + +## Evidence + +- **Command run**: `node /private/tmp/mdx-r5-extension-spike.SsWECe/spike.mjs` +- **Relevant output summary**: React children captured the exact root and nested editors; the nested editor referenced the root parent and inherited editability; both `HistoryExtension` outputs referenced the supplied shared state; Undo changed nested text from `nested-two` to `nested-one`, and later crossed to root from `root-two` to `root-one`. +- **Exact installed sources**: `node_modules/@lexical/react/src/LexicalExtensionEditorComposer.tsx`, `node_modules/@lexical/extension/src/NestedEditorExtension.ts`, and `node_modules/@lexical/history/src/index.ts`. + +## Result + +- **Outcome**: CONCLUSIVE +- **Observed behavior**: extension-built editors retain the standard React composer context, explicit parent relationship, shared history identity, inherited editability, and nested/cross-editor Undo behavior. `dispose()` is available for deterministic cleanup. +- **Decision**: build root, nested JSX/directive, and table-cell editors through stable internal extension definitions; render current React children under `LexicalExtensionEditorComposer`; configure root/local history and nested shared history internally; preserve Gurx/plugin/visitor APIs unchanged. +- **Rejected alternatives**: requiring consumers to author Lexical extensions; retaining manual `createEditor` plus legacy composer/history plugins as the final R5 architecture; using `ReactPluginHostExtension`, which is intended for editors not already rendered through React and would add a second React root/portal lifecycle. +- **Representativeness limits**: the spike proves the bridge primitives and history lifecycle in jsdom, not MDXEditor's Markdown synchronization, focus/blur commands, table navigation, Strict Mode, or browser behavior. R5 requires focused lifecycle tests and the complete public three-browser matrix. + +## Planning Impact + +- **Roadmap or PRP sections/tasks/tests changed by this result**: R5 can remain one bounded PRP. Its implementation blueprint can use one internal extension factory/bridge for root and nested editor construction, with editor disposal and separate table-history behavior explicitly characterized. +- **Consumer Contract or evidence change**: `CX-7` remains DIRECT REQUIRED through a minimal extension-agnostic consumer plugin; the inherited `CX-1`–`CX-6` matrix remains the regression boundary. +- **Remaining uncertainty**: exact helper/file placement and whether table cells use local `HistoryExtension` or shared history are reversible implementation details, but current table Undo/navigation behavior must remain directly tested. + +## Cleanup + +- **Disposable artifacts removed**: `/private/tmp/mdx-r5-extension-spike.SsWECe` after recording. +- **Repository and external state checked**: only this spike record is retained; no dependency, service, port, or external state was changed. diff --git a/plans/research/005-extension-composer-history/spike-02-strict-realm-session.md b/plans/research/005-extension-composer-history/spike-02-strict-realm-session.md new file mode 100644 index 00000000..b90c2a0b --- /dev/null +++ b/plans/research/005-extension-composer-history/spike-02-strict-realm-session.md @@ -0,0 +1,57 @@ +# Spike 02: Can a Commit-Phase Realm Session Own Extension Editors in Strict Mode? + +## Status + +CONCLUSIVE + +## Question + +Can R5 preserve plugin `init`/`postInit` ordering while ensuring Strict Mode never leaks a render-discarded editor or reuses an editor disposed by effect replay? + +## Why It Blocks Planning + +`RealmWithPlugins` currently constructs the realm and runs plugins in `useMemo`; extension editors add an explicit `dispose()` obligation. Creating them in render or disposing an editor from a replayed child effect would make lifecycle correctness ambiguous. + +## Hypothesis and Decision Rule + +Create a fresh realm session in a commit-phase effect, run every `init` and then every `postInit`, register the root editor with a private session disposer, and expose children only after setup. Each effect setup must own a distinct editor and each cleanup must dispose that editor exactly once. If Strict Mode reuses a disposed session or leaks either setup, replan R5. + +## Minimal Experiment + +- Render a session owner under React 19 Strict Mode in jsdom. +- In its effect, create a numbered realm/editor, run simulated init/post-init, publish the session, and return an exactly-once disposer. +- Record session setup, disposal, descendant plugin mount/cleanup, the visible session, and final disposal count across replay and unmount. + +## Evidence + +The scratch script reported this order: + +```text +realm-init:1 +root-post-init:1 +realm-cleanup:1 +dispose:1 +realm-init:2 +root-post-init:2 +child-mount:2 +child-cleanup:2:false +child-mount:2 +visible:session-2 +realm-cleanup:2 +dispose:2 +child-cleanup:2:true +disposed-count:2 +``` + +The replay-only session never rendered children, the live session was not disposed during descendant effect replay, and both created editors were disposed once. On final unmount the session owner disposes before descendant passive cleanup, so focused tests must prove consumer unregister callbacks remain safe after editor disposal. + +## Result + +- **Outcome**: CONCLUSIVE. +- **Decision**: move realm/plugin initialization out of render into a commit-phase session owner; retain the all-init-then-all-post-init order; use a private cleanup registry rather than adding a public `RealmPlugin` cleanup callback; effect-create nested/table editors with the same one-setup/one-dispose ownership. +- **Consumer boundary**: a public plugin still initializes once per realm. Strict Mode may create an abandoned replay realm, as it already can, but only React composer-child mount/cleanup counters are asserted because `RealmPlugin` exposes no cleanup callback. +- **Required execution evidence**: actual extension-editor unregister-after-dispose safety, fresh public refs/editor identities, no duplicate listeners, and no runtime errors in focused tests and all three browsers. + +## Cleanup + +The scratch script in `/private/tmp/mdx-r5-strict-session-spike.mjs` was deleted. No dependency, port, browser, or service state changed. diff --git a/plans/roadmaps/001-lexical-048-adoption.md b/plans/roadmaps/001-lexical-048-adoption.md new file mode 100644 index 00000000..3f8a6951 --- /dev/null +++ b/plans/roadmaps/001-lexical-048-adoption.md @@ -0,0 +1,347 @@ +# PRP Roadmap: Lexical 0.48 Adoption and Integrations + +## Status + +- **Roadmap status**: COMPLETE +- **Assurance profile**: Standard — the dependency migration is reversible and has no persisted-schema or deployment migration, while public editor behavior, nested-editor timing, browser interaction, and package compatibility require staged evidence. No Deep trigger is currently evidenced; escalate if a child exposes an unresolved high-impact public-compatibility or editor-concurrency boundary that cannot be isolated with focused tests. +- **Created at commit**: `9373742` +- **Planning baseline**: source tree at `9373742`; pre-existing untracked `reports/` content must be preserved. No prior `plans/` artifacts existed. +- **Last updated**: 2026-07-18 + +## Outcome + +MDXEditor runs on a lockstep Lexical 0.48.0 package set, remains compatible at its documented React, Markdown, plugin, and editor-interaction boundaries, and adopts three high-value 0.48-era capabilities as independently verified changes: + +1. correct selection-scoped Markdown export; +2. state-backed, mutation-safe Find/Replace behavior; and +3. extension-based root/nested composer and shared-history plumbing without changing the public plugin contract. + +The initiative establishes compatibility evidence before changing dependencies, keeps optional rewrites out of the mechanical migration, and reuses the same consumer scenarios at each integration checkpoint. + +## Why + +- MDXEditor currently resolves Lexical 0.35.0, thirteen minor releases behind the assessed 0.48.0 target. +- A clean 0.48.0 rehearsal exposed TypeScript/package-resolution and custom-node incompatibilities that require source changes. +- The existing jsdom suite passes after a package swap but does not exercise the browser, nested-editor, selection, clipboard, IME, or decorator behavior most exposed to upstream changes. +- Lexical now provides APIs and reference implementations that can replace known weak or custom paths, but mixing them into the package upgrade would obscure regressions and rollback. + +## Roadmap Completion Criteria + +- [x] A version-independent compatibility suite characterizes the supported public behavior on Lexical 0.35.0 and runs in CI, including a representative real-browser surface. +- [x] All direct Lexical packages resolve to 0.48.0 together; lint, typecheck, jsdom tests, browser compatibility tests, declaration generation, API documentation, and the library build pass. +- [x] Representative Markdown created or edited on either side of the upgrade remains semantically readable on the other side, subject only to documented intentional 0.48 behavior/security changes. +- [x] Nested editors, custom decorators, lists, links, shortcuts, clipboard/history, and root/nested focus retain the Consumer Contract in representative browser exercises. +- [x] `getSelectionMarkdown()` serializes the selected content—not whole touched nodes—using the active editor's configured Markdown semantics, including supported custom constructs. +- [x] Find/Replace remains correct after document mutations and performs replacement through Lexical state rather than stale DOM-range assumptions. +- [x] Root and nested editors use the selected Lexical extension/composer/history architecture while existing MDXEditor plugin and imperative APIs remain compatible. +- [x] Every `CX-N` scenario is verified at its owning child and rerun after the final architectural migration. + +## Explicitly Out of Scope + +- Adopting `@lexical/mdast`. Its 0.48 API is experimental, extension-only, and not a self-contained replacement for MDXEditor's public visitor pipeline. Any adoption starts as a separate roadmap-level spike and, if viable, a separate roadmap. +- Replacing or removing the public `MdastImportVisitor`, `LexicalExportVisitor`, Gurx realm, or consumer plugin contracts. +- A general NodeState migration across custom nodes. +- Advertising Shadow DOM support; that requires a separate audit of MDXEditor's global DOM access and a product-level compatibility promise. +- General adoption of `DOMImportExtension` or `DOMRenderExtension` without a concrete consumer requirement. +- Yjs/collaboration, Prism/Shiki, and Lexical's stock table-node migration. +- Exhaustive real-device or every-IME certification. The roadmap requires representative Chromium, Firefox, and WebKit evidence; device-specific evidence must be graded separately when an appropriate environment is available. +- Unrelated formatting, visual design, or editor UX redesigns. + +## Consumer Contract + +- **Consumers**: React package integrators; people authoring Markdown/MDX; consumers of `MDXEditorMethods`; authors of MDXEditor plugins and custom Markdown visitors. +- **Public or supported boundaries**: the published `@mdxeditor/editor` package and declarations; `MDXEditor` props and `MDXEditorMethods`; Markdown input/output; documented plugins and toolbar flows; public import/export visitors and Gurx plugin registration; root and nested contenteditable interactions. +- **Current journey**: an integrator installs the package with React 18/19, renders `MDXEditor` with plugins and Markdown, edits through the root or nested editors, reads Markdown through callbacks or methods, optionally searches/replaces content, and may extend Markdown conversion with public visitor/plugin APIs. +- **Final observable promise**: the same journey works on Lexical 0.48.0 with semantically compatible Markdown, supported editor interactions, accurate selection Markdown, reliable Find/Replace, and no consumer-visible regression from the extension-framework migration. +- **Compatibility promise**: React 18/19 remains supported; all Lexical packages remain lockstep; public MDXEditor APIs and plugin/visitor contracts remain source-compatible unless a separately approved breaking change is planned; fail-closed URL sanitization and other security fixes are retained. +- **Not claimed**: byte-identical Markdown for syntax that MDXEditor intentionally canonicalizes; preservation of undocumented Lexical internals; complete mobile/IME certification; `@lexical/mdast` compatibility or adoption. + +### End-to-End Acceptance Scenarios + +| ID | Given | When | Then | Evidence surface | Required evidence | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `CX-1` | A minimal TypeScript/React 18 or 19 consumer using documented package exports | The consumer installs a packed build, typechecks, bundles, and renders the editor | Package exports, declarations, styles, and runtime initialization work without reaching into repository internals | Disposable minimal consumer using the packed artifact | DIRECT REQUIRED | +| `CX-2` | Representative CommonMark, GFM, and MDX documents using built-in plugins and custom constructs | A consumer loads, edits, reads, replaces via `setMarkdown`, and reloads the resulting Markdown | Supported constructs remain semantically equivalent and usable, including documents crossing the 0.35/0.48 boundary | Public `MDXEditor` props/methods in jsdom plus a real browser fixture; cross-version fixture comparison | DIRECT REQUIRED for public methods and browser rendering | +| `CX-3` | A document containing root text, directives/JSX children, table cells, and adjacent decorators | A user moves focus, types, deletes at boundaries, and performs undo/redo across root and nested editors | Focus, caret placement, parent synchronization, and shared history remain coherent without stale Markdown or update recursion | Real browser interaction suite | DIRECT REQUIRED | +| `CX-4` | Documents containing lists/checklists, links/autolinks, Markdown shortcuts, clipboard content, and configured maximum length | A user performs the corresponding edit, copy/paste, cut/undo, and boundary actions | Supported behavior remains stable or follows an explicitly documented intentional 0.48 change; unsafe URLs fail closed | Real browser suite plus focused state/serialization tests | DIRECT REQUIRED for interaction paths; PROXY ACCEPTABLE for platform clipboard limitations with the limitation recorded | +| `CX-5` | A consumer has a partial, backward, multi-block, nested-list, link, node, custom-node, or nested-editor selection | The consumer calls `getSelectionMarkdown()` | The result represents only the selected content using the active editor's plugin visitors/options, while absent or collapsed selections retain the documented empty result | Public ref method exercised through rendered editors | DIRECT REQUIRED | +| `CX-6` | Search is open on content that changes after indexing and includes matches across supported text containers | A user navigates matches and performs replace or replace-all | Highlights, cursor state, and replacements track current Lexical content; replace-all is one coherent editor update/history action | Search UI/hooks exercised through a real rendered editor | DIRECT REQUIRED | +| `CX-7` | A consumer plugin registers custom nodes, Markdown visitors/extensions, composer children, and nested-editor behavior through supported MDXEditor APIs | The root/nested composer and shared-history internals use Lexical extensions | The plugin continues to initialize, edit, serialize, and undo without adopting Lexical extension internals itself | Minimal custom-plugin consumer plus representative built-in plugins | DIRECT REQUIRED | + +## Evidence and Decisions + +### Vetted Findings + +- `package.json:47-71` and `package-lock.json:18-42` — all direct Lexical dependencies declare and resolve 0.35.0. **Roadmap impact**: R2 upgrades the full set atomically; mixed Lexical minor versions are forbidden. +- `package.json:97-100` — the public peer contract is React/React DOM 18 or 19. **Roadmap impact**: CX-1 exercises both supported major versions or records an explicit accepted proxy when dependency resolution prevents a dual matrix locally. +- `tsconfig.json:7` — the project uses legacy `moduleResolution: "node"`. The 0.48 rehearsal shows that conditional-export-aware resolution is required. **Roadmap impact**: R2 owns the compiler-resolution change and declaration revalidation. +- `vite.config.ts:16-29` — the package build rolls up declarations and treats dependencies and Lexical React subpaths as external. **Roadmap impact**: a passing `tsc --noEmit` is insufficient; R2 must verify the published declarations and package artifact. +- `vite.config.ts:59-63` and `package.json:23-24` — all current automated tests run under jsdom; no Playwright, Cypress, or equivalent browser harness is configured. **Roadmap impact**: R1 must produce a representative real-browser compatibility surface before R2 changes dependencies. +- `.github/workflows/ci.yml` — CI currently runs lint, typecheck, jsdom tests, and the Vite build in separate jobs. **Roadmap impact**: R1 integrates the new compatibility gate; R2 preserves every existing gate. +- `reports/lexical-upgrade-0.35.0-to-0.48.0-assessment-2026-07-18.md:67-128` — the clean rehearsal passed the existing tests but found module-resolution, `CodeBlockNode.importJSON`, horizontal-rule node, and declaration-generation blockers. **Roadmap impact**: these are the bounded mechanical scope of R2. +- `src/MDXEditor.tsx:173-176` and `src/MDXEditor.tsx:261-287` — `getSelectionMarkdown()` is a public imperative method and gathers the active editor's visitors, Markdown extensions/options, and JSX descriptors. **Roadmap impact**: R3 must preserve editor-local configuration and target selection-scoped parity with `getMarkdown()` semantics. +- `src/utils/lexicalHelpers.ts:162-207` — the current selection helper explicitly expands selected text to whole nodes/block parents. **Roadmap impact**: R3 owns a behavioral correction, not merely an internal refactor. +- `src/test/selection-markdown.test.tsx:9-36` — current tests cover only empty/no-selection and method existence. **Roadmap impact**: R1 establishes the baseline contract; R3 adds direct feature evidence. +- `src/plugins/core/NestedLexicalEditor.tsx:230-269` and `src/plugins/core/SharedHistoryPlugin.tsx:1-8` — nested editors import/export through the MDXEditor visitor pipeline and share external history through React plugins. **Roadmap impact**: R2 characterizes timing changes; R5 may change composer/history internals but must preserve the Markdown pipeline and CX-3. +- `src/MDXEditor.tsx:34-55`, `src/plugins/core/NestedLexicalEditor.tsx:216-354`, and `src/plugins/table/TableEditor.tsx:366-482` — root and nested editor construction is manually assembled around React composer context and `createEditor`. **Roadmap impact**: R5 is a separate architectural boundary after behavior is stable. +- `src/plugins/search/index.tsx:36-237` — search indexes DOM text, stores DOM `Range` objects, and maps them back to Lexical nodes for replacement. **Roadmap impact**: R4 owns a state-backed redesign and mutation-safety tests; it is not part of the package upgrade. +- `README.md:12-24`, `CONTRIBUTING.md:32-82`, and `src/index.ts:33-43` — MDXEditor is a published React Markdown editor with a documented extensible plugin/visitor architecture. **Roadmap impact**: compatibility is evaluated through package and plugin consumers, not only repository-internal tests. + +### Settled Decisions and Invariants + +- **Dependency target**: upgrade directly from 0.35.0 to 0.48.0 and keep every `lexical`/`@lexical/*` package on the exact same target version. **Evidence**: upgrade assessment and Lexical's cross-package release model. +- **Characterize before migration**: add stable compatibility evidence against 0.35.0 before changing dependency versions. Do not encode accidental internal node/caret structure when a public outcome can be asserted instead. **Evidence**: the 0.48 smoke tests pass despite acknowledged coverage gaps. +- **Mechanical isolation**: R2 contains only dependency/configuration compatibility, intentional upstream behavior alignment, and tests required to demonstrate that migration. Optional feature rewrites remain later children. +- **Security posture**: retain 0.48's fail-closed link sanitization and hardening. Compatibility pressure must not restore unsafe parsing. +- **Selection contract**: `getSelectionMarkdown()` is a selection-scoped equivalent of configured Markdown export for supported content, using the active editor's visitors/options rather than a standard-rich-text-only format. **Evidence**: the public method already gathers those editor-local export parameters before calling the current helper. +- **Selection isolation**: no global transformer mutation, module-global editor configuration, or last-editor-wins cache is permitted. Node selections remain eligible when they represent serializable content. +- **Public extensibility**: R5 may change internal composer/history construction but may not require consumer plugins to use Lexical extensions or replace MDXEditor's public Gurx/visitor contracts. +- **MDAST boundary**: `@lexical/mdast` adoption is not a child of this roadmap. The existing MDXEditor visitor/MDAST pipeline remains authoritative. +- **Verification ownership**: every child adds or updates tests for its outcome; no final “tests PRP” absorbs missing child evidence. Integration checkpoints rerun shared scenarios outside-in. +- **Single-writer sequencing**: R3 and R4 may be independently planned after R2 but are implemented sequentially. R5 starts only after both feature contracts are verified so its final checkpoint can prove it preserved them. + +### Roadmap-Level Spikes + +- None required before the first child. The R3 choice between adapting Lexical's selection converter and clipping/exporting through MDXEditor's existing MDAST pipeline is child-local: either result preserves the roadmap outcome, public contract, and decomposition. +- A possible `@lexical/mdast` spike is intentionally outside this roadmap because a positive result would create a separate multi-step migration initiative. + +### Compatibility and Rollback Contract + +- **Reversible boundary**: until a 0.48-based package is published, R2 can be reverted as one dependency/config/source change after preserving R1's tests. Later feature children remain separately revertible. +- **New data under rollback**: Markdown is the supported persisted interchange format; no new editor-state schema is introduced. Representative Markdown authored or serialized on 0.48 must remain importable by the 0.35 baseline when it uses the roadmap's supported constructs. Intentional canonical syntax differences are acceptable only when semantic equivalence is demonstrated. +- **Mixed-version behavior**: Lexical 0.35 and 0.48 are never mixed inside one editor instance. Compatibility concerns documents moving between released MDXEditor versions and downstream package consumers moving between package versions. Cross-version fixtures cover both directions. +- **Destructive boundary**: removal or signature changes to public visitor/plugin APIs would end easy rollback and are prohibited by this roadmap. Any such proposal requires a separately approved breaking-change plan. + +### Validation Baseline + +| Surface or command | Status | Observed result | +| ---------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `npm run lint` | Verified | Passes at `9373742` on 2026-07-18. | +| `npm run typecheck` | Verified | Passes with Lexical 0.35.0 at `9373742`. | +| `npm run test:once` | Verified | 45 passed, 1 skipped, 1 todo; known jsdom/CodeMirror and accessibility warnings are present. | +| `npm run build` on 0.35.0 | Discovered but not run | Required by CI and by R2; no fresh roadmap-generation result recorded. | +| `npm run build:docs:api` | Discovered but not run | Required by CI; R2 must preserve it. | +| Representative real-browser suite | Unavailable | No current harness or command exists; R1 owns creation of the bounded compatibility surface. | +| Temporary 0.48.0 `npm run test:once` rehearsal | Verified | Same 45 passed, 1 skipped, 1 todo in the isolated rehearsal recorded by the upgrade assessment. | +| Temporary 0.48.0 typecheck/build rehearsal | Baseline failing | Legacy module resolution creates false missing exports; bundler resolution exposes `CodeBlockNode` and horizontal-rule incompatibilities; declaration generation remains unverified until fixed. | + +## Decomposition Strategy + +R1 is a justified foundational child: it produces one stable compatibility contract and executable evidence set that every later child consumes. R2 is a narrow migration with a rollback boundary and no optional feature adoption. R3 and R4 are vertical public-capability slices with distinct entry points and validation loops. R5 is deliberately last because it changes root/nested lifecycle architecture and must demonstrate that the already-verified package, selection, search, and plugin contracts survive. + +Tests are not centralized in a trailing child. R1 creates the shared characterization surface; each producing child owns focused tests for its capability; checkpoints rerun the integrated Consumer Contract. + +## Dependency Map + +- `R1` -> `R2`: R1 produces the 0.35 compatibility contract, real-browser harness, fixtures, and known baseline needed to distinguish regressions from intentional 0.48 changes. +- `R2` -> `R3`: R2 produces a compiling, buildable 0.48 editor and the final node/API types against which selection export is implemented. +- `R2` -> `R4`: R2 produces stable 0.48 selection/update/history behavior and a browser harness for the state-backed search implementation. +- `R3` -> `R5`: R3 produces direct selection-export tests, including nested-editor behavior, which R5 must preserve while changing composer internals. +- `R4` -> `R5`: R4 produces mutation-safe search behavior and browser tests that R5 must preserve across editor lifecycle changes. + +## Outcome Coverage + +| Required outcome, `CX-N`, or contract | Producing step | Verification checkpoint | +| ----------------------------------------------------------------------------------- | -------------- | ----------------------------- | +| Executable compatibility contract and browser evidence surface; foundational `CX-8` | R1 | After R1 | +| Lexical 0.48 package/build compatibility; `CX-1` | R2 | After R2 and final checkpoint | +| Markdown and editor interaction compatibility; `CX-2`, `CX-3`, `CX-4` | R2 | After R2 and final checkpoint | +| Accurate selection Markdown; `CX-5` | R3 | After R3 and final checkpoint | +| Mutation-safe Find/Replace; `CX-6` | R4 | After R4 and final checkpoint | +| Extension-based internals with public plugin compatibility; `CX-7` | R5 | Final checkpoint | +| Cross-version Markdown rollback contract | R2 | After R2 | +| All roadmap completion criteria integrated | R5 | Final checkpoint | + +## Steps + +### R1: Establish the Compatibility Contract and Browser Gate + +- **Status**: VERIFIED +- **Outcome**: a bounded suite passes on Lexical 0.35.0 and directly characterizes the package's supported Markdown and editor-interaction behavior through jsdom and representative real browsers. +- **Why separate**: current tests are too weak to distinguish a safe upgrade from silent behavior drift; this evidence is independently useful and is the required input to every migration/integration child. +- **Depends on**: None. +- **Produces**: a stable compatibility scenario/fixture set, a real-browser test command integrated with CI, and a recorded 0.35 result consumed by R2-R5. +- **Consumer impact**: immediate consumer is the R2 implementer/verifier; enables `CX-1`-`CX-4` without claiming the upgrade itself. +- **External readiness inputs**: installable representative Chromium, Firefox, and WebKit engines in local/CI environments. Real mobile devices and language-specific IMEs are not required for this child. +- **Must preserve**: current public methods, plugin configuration, Markdown semantics, and existing tests; security-invalid behavior must not be enshrined as a compatibility promise. +- **In scope**: package/public-method fixtures; representative Markdown round trips; root/nested focus and history; decorator/list/link/shortcut/clipboard/selection boundaries; maximum-length boundary; the minimal real-browser harness and CI command; documented intentional-behavior classification rules. +- **Out of scope**: changing Lexical versions; fixing behavior only observed on 0.48; exhaustive browser/device certification; feature rewrites. +- **Validation boundary**: all existing gates and the new compatibility/browser commands pass against the untouched 0.35 package set, with captured fixture outputs and known environment limitations. +- **Remaining questions**: no blocking planning question. Execution must capture the canonical 0.35 output from the implemented full plugin set and classify any browser-specific interaction variance rather than weakening assertions. +- **Planning discoveries**: reuse Ladle's existing port-61000 host; expose one deterministic local-only story; use exact `@playwright/test@1.61.1` with Chromium, Firefox, and WebKit projects; let Playwright own the host lifecycle; treat a DOM `ClipboardEvent` as an explicit OS-clipboard proxy. The existing `ALL_PLUGINS` fixture is unsuitable because it includes network/backend content. +- **Child PRP**: `plans/001-lexical-compatibility-gate.md` — implementation-ready Standard PRP; research baseline and route spike are recorded in its Research Summary. +- **Completion evidence**: The Verification Record in `plans/001-lexical-compatibility-gate.md` directly verifies `CX-8`, `CX-2`, `CX-3`, `CX-4a`, `CX-4b`, and `CX-4c`. The untouched Lexical 0.35.0 baseline passes 3 focused jsdom tests and all 15 browser scenarios across Chromium, Firefox, and WebKit, plus the existing static, test, build, documentation, and patch-hygiene gates. R1 is `VERIFIED`. + +### R2: Adopt Lexical 0.48 Without Feature Rewrites + +- **Status**: VERIFIED +- **Outcome**: the published package uses a lockstep Lexical 0.48.0 set and passes the established compatibility, build, declaration, documentation, and security gates. +- **Why separate**: this is the migration and rollback boundary; keeping it mechanical makes failures attributable and permits reverting without removing later features. +- **Depends on**: R1's verified 0.35 compatibility fixtures, browser command, and baseline record. +- **Produces**: a stable 0.48 package/type/node baseline and documented intentional behavior changes consumed by R3-R5. +- **Consumer impact**: owns `CX-1`, `CX-2`, `CX-3`, and `CX-4`. +- **External readiness inputs**: R1 browser harness and the existing npm/CI toolchain; no deployment credentials. +- **Must preserve**: public React 18/19, Markdown, methods, visitors/plugins, nested-editor, and rollback contracts; fail-closed security behavior. +- **In scope**: lockstep dependency/lockfile update; conditional-export-aware TypeScript resolution; `CodeBlockNode.importJSON`; one consistent horizontal-rule node path; any other compile incompatibility discovered against 0.48; declaration/API-doc build; removal of `@lexical/clipboard` or `@lexical/plain-text` only if child research proves they are not part of the package/runtime contract; focused expectations for intentional upstream changes. +- **Out of scope**: selection serializer replacement, search redesign, extension composer/history migration, `@lexical/mdast`, NodeState refactors, general DOM-extension adoption. +- **Validation boundary**: R1's suite and all CI commands pass on 0.48; a packed minimal consumer passes; representative cross-version Markdown fixtures work in both directions; security URL cases fail closed. +- **Remaining questions**: no blocking planning question. Execution must classify any newly observed 0.48 canonical/security behavior against `CX-1`-`CX-4` rather than updating R1 expectations silently. +- **Planning discoveries**: keep the deprecated React horizontal-rule node/plugin internally consistent until R5 instead of partially adopting its extension replacement; broaden and validate `CodeBlockNode.importJSON`; harden the custom link-preview href without rewriting raw Markdown/callback data; verify the packed artifact with pinned React 18/19 apps; and replay 0.48-authored Markdown through published 4.0.4 with the complete Lexical graph overridden/asserted at 0.35.0. +- **Child PRP**: `plans/002-lexical-048-upgrade.md` — implementation-ready Standard PRP; cold contract audit closed the cross-version, URL-channel, and consumer-toolchain evidence gaps. +- **Completion evidence**: The Verification Record in `plans/002-lexical-048-upgrade.md` directly verifies `CX-1` through `CX-4`. The repository contains only Lexical 0.48.0; all 18 browser scenarios pass across Chromium, Firefox, and WebKit; packed React 18/19 consumers pass declarations, bundle, CSS, runtime, and public-ref checks; and published 4.0.4 with an exact Lexical 0.35 graph replays the 0.48 document without construct loss. R2 is `VERIFIED`. + +### R3: Make Selection Markdown Match the Active Editor Contract + +- **Status**: VERIFIED +- **Outcome**: `getSelectionMarkdown()` returns only the selected content with the same supported custom constructs and editor-local serialization configuration used by normal Markdown export. +- **Why separate**: this changes a public method's correctness and has its own consumer contract, custom-node coverage, and multi-editor isolation risks. +- **Depends on**: R2's verified 0.48 node/types baseline and the `CX-2`/`CX-3` compatibility evidence. +- **Produces**: accurate, editor-local selection serialization and a direct `CX-5` regression suite consumed by R5. +- **Consumer impact**: owns `CX-5`. +- **External readiness inputs**: R1's browser harness; no external service. +- **Must preserve**: empty result for absent/collapsed selections and source/diff mode; active nested-editor behavior; `toMarkdownOptions`, visitors, descriptors, and multi-editor isolation; normal full-document export. +- **In scope**: partial/backward/multi-block selections; formatting boundaries; nested/task lists; partial links; meaningful node selections; tables/thematic breaks; built-in custom nodes; consumer visitors; two simultaneous configurations; nested editor selections; removal of the whole-node handwritten serializer; upstream partial-link regression verification. +- **Out of scope**: clipboard serialization, live selection UX, `@lexical/mdast` adoption, normal full-document pipeline replacement. +- **Validation boundary**: `CX-5` passes through the public ref method in rendered editors, repeated calls do not mutate shared state, and selection/full-export consistency matches the settled contract. +- **Remaining questions**: no blocking planning question. Execution must fail loudly and replan if a supported custom node cannot survive Lexical's selected-JSON round trip or if source-editor mutation becomes necessary. +- **Planning discoveries**: use `$generateJSONFromSelectedNodes` to clip the active selection; create a no-config temporary editor inside the active editor read so it inherits the complete registered node/replacement map; reconstruct a valid selected tree; then reuse MDXEditor's existing visitors, extensions, options, and JSX descriptors. This path was proven with partial text/link, multi-block, custom decorator, repeated-read, and source-state evidence and avoids PR #949's parallel transformers/global caches. +- **Child PRP**: `plans/003-selection-markdown.md` — implementation-ready Standard PRP; the conclusive architecture spike is recorded at `plans/research/003-selection-markdown/spike-01-selection-export-pipeline.md`. +- **Completion evidence**: The Verification Record in `plans/003-selection-markdown.md` directly verifies `CX-5`. All six public-ref scenarios pass in Chromium, Firefox, and WebKit; focused state tests cover clipping, direction, meaningful node/custom serialization, options, non-mutation, repeatability, and loud failure. Direct thematic-break selection and a public `additionalLexicalNodes` replacement prove the node/registration boundary. Full unit/browser/build/package/cross-version/docs gates remain green. R3's selection/full-export invariant is now available to R5, which remains blocked on R4. + +### R4: Make Find/Replace State-Backed and Mutation-Safe + +- **Status**: VERIFIED +- **Outcome**: Find/Replace tracks current Lexical content and performs coherent replacement operations without relying on stale DOM-range-to-node mappings. +- **Why separate**: search has an independent UI/state boundary and can borrow upstream patterns without coupling its risk to dependency or composer migration. +- **Depends on**: R2's verified 0.48 update/selection behavior, R3's verified active-editor routing, and R1's browser harness. +- **Produces**: a Lexical-state position/index contract, mutation-safe highlights/navigation, coherent replace/replace-all history behavior, and direct `CX-6` tests consumed by R5. +- **Consumer impact**: owns `CX-6`. +- **External readiness inputs**: installed Chromium, Firefox, and WebKit directly report CSS Highlights support; no external service. +- **Must preserve**: existing public search controls and supported regex behavior unless a separately approved product decision changes them; editor content/formatting outside replacements; accessibility of the search UI. +- **In scope**: current-content indexing; mutation invalidation; navigation; replace and replace-all; one-update replace-all/history semantics; zero-length/invalid patterns; highlight cleanup; supported root content containers; explicit behavior at custom-decorator and nested-editor boundaries. +- **Out of scope**: unrelated toolbar redesign, new search syntax/modes without user direction, searching source/diff modes, cross-document search. +- **Validation boundary**: `CX-6` passes in a real rendered editor before and after document mutations, replacements preserve unaffected formatting/content, and undo behavior is deterministic. +- **Remaining questions**: no blocking planning question. Execution must pause if public DOM-range/cell compatibility requires DOM positions to remain mutation authority, or if active-editor focus cannot give Replace All one editor/history boundary. +- **Planning discoveries**: user selected active-editor-only search; root, nested JSX/directive, and table-cell editors are independent scopes. State node-key/original-offset positions become authoritative while DOM `Range`/`TextNodeIndex` values remain public projections. A conclusive spike replaced split formatted matches last-to-first in one update without stale keys or unrelated formatting loss; rendered one-step Undo/Redo remains required evidence. +- **Child PRP**: `plans/004-state-backed-search-replace.md` — implementation-ready Standard PRP; the conclusive state-position spike is recorded at `plans/research/004-state-backed-search/spike-01-state-position-replace-all.md`. +- **Completion evidence**: The Verification Record in `plans/004-state-backed-search-replace.md` directly verifies `CX-6a` through `CX-6c`. The public-hook suite passes all nine scenarios across Chromium, Firefox, and WebKit with active root/nested/table isolation, current ranges/highlights, mutation recovery, exact replacement output, and one-step history. Focused regressions cover stale actions, cross-block and linked-text replacement, normalized offsets, public cells/helpers, and same-node replacement safety. The full 65-unit/45-browser/build/package/cross-version/docs gate remains green. + +### R5: Move Composer and Shared History Internals to Lexical Extensions + +- **Status**: VERIFIED +- **Outcome**: root and nested editor lifecycle/history plumbing use the selected supported Lexical extension APIs while MDXEditor's public Gurx/plugin/visitor contract and all prior consumer behavior remain unchanged. +- **Why separate**: this is a core architectural migration with the broadest regression surface; it is safest after package, selection, and search behavior have direct tests. +- **Depends on**: verified R3 `CX-5` evidence and verified R4 `CX-6` evidence, in addition to R2's package/browser baseline. +- **Produces**: extension-based root/nested composer and shared-history internals plus a verified compatibility adapter for existing MDXEditor plugins, satisfying the final integration boundary. +- **Consumer impact**: owns `CX-7` and must preserve `CX-1`-`CX-6`. +- **External readiness inputs**: all earlier verification commands and fixtures; no external service. +- **Must preserve**: current Markdown visitor pipeline; public Gurx cells/signals and plugin registration; imperative methods; active-editor semantics; root/nested focus; shared undo/redo; consumer plugins are not required to know about Lexical extensions. +- **In scope**: root composer construction; nested directive/JSX/table editor construction; shared history; extension lifecycle cleanup; can-undo/can-redo integration only where it replaces existing internal signals without a public behavior change; development warnings/listener cleanup that naturally belong to the migrated lifecycle. +- **Out of scope**: `@lexical/mdast`; rewriting public plugins as Lexical extensions; public API removals; NodeState or DOM import/render migrations; unrelated editor feature changes. +- **Validation boundary**: `CX-7` passes through a minimal consumer plugin, all `CX-1`-`CX-6` evidence is rerun, and root/nested history/focus behavior has direct browser evidence with no update recursion warnings. +- **Remaining questions**: no blocking planning question. Execution must pause if complete consumer registrations are unavailable before editor construction, normal React children require a second React root or extension adoption, or correct disposal requires a public plugin lifecycle change. +- **Planning discoveries**: use one internal factory plus `LexicalExtensionEditorComposer`; keep React children extension-agnostic; and pin normal root/nested/sibling history, suppressed-root/nested-sibling history, and table-local then parent-save sequences. A conclusive exact-0.48 bridge spike proved context, parent/editability, shared state, Undo, and disposal; a second conclusive Strict Mode spike selected commit-phase realm setup with private exactly-once disposal and effect-owned child editors. Declare `@lexical/extension`/`@lexical/history` directly. `ReactPluginHostExtension`, a public extension API, and `@lexical/mdast` remain out of scope. +- **Child PRP**: `plans/005-extension-composer-history.md` — implementation-ready Standard PRP; the conclusive bridge spike is recorded at `plans/research/005-extension-composer-history/spike-01-extension-compatibility-bridge.md`. +- **Completion evidence**: The superseding Verification Record in `plans/005-extension-composer-history.md` directly verifies `CX-7a` through `CX-7c` after correcting initial imperative-ref timing, queued-method batching, table-local search/history isolation, and recursive nested-parent identity. Fresh evidence passed 14/14 focused units, 71 passed/1 skipped/1 todo full units, 12/12 affected, 57/57 full, and 9/9 post-hardening `CX-7` browser tests, React 18/19 packed consumers, lint, typecheck, declarations/build, and the 24-package Lexical lockstep assertion. No R5 issue remains; the roadmap is complete. + +## Integration Checkpoints + +### After R1 + +- **Integrated behavior to validate**: the compatibility suite observes stable public behavior on the untouched 0.35 baseline and is not coupled to implementation-only details. +- **Consumer scenarios**: foundational `CX-8` directly verifies the downstream gate and enables parent `CX-1`-`CX-4`; no upgraded outcome claimed yet. +- **Commands or observation**: existing lint/typecheck/jsdom gates plus the new browser command and fixture comparison command. +- **Evidence requirement**: DIRECT REQUIRED for rendered editor flows; environment-specific clipboard/IME behavior may use an explicit proxy with its limitation recorded. +- **Decision enabled**: R2 becomes READY FOR PRP. + +### After R2 + +- **Integrated behavior to validate**: package/type/build compatibility, cross-version Markdown, root/nested editor interactions, and intentional 0.48 behavior/security changes. +- **Consumer scenarios**: `CX-1`, `CX-2`, `CX-3`, `CX-4`. +- **Commands or observation**: all CI gates; packed scratch consumer; R1 browser suite; bidirectional 0.35/0.48 fixtures; focused unsafe/safe URL cases. +- **Evidence requirement**: DIRECT REQUIRED except named platform clipboard limitations. +- **Decision enabled**: R3 and R4 become eligible for just-in-time PRP generation; the 0.48 upgrade can be released independently if desired. + +### After R3 + +- **Integrated behavior to validate**: selection export is accurate across formatting, structural/custom nodes, multiple editor configurations, and nested editors without changing full-document export. +- **Consumer scenarios**: `CX-5`, with regression checks for `CX-2` and `CX-3`. +- **Commands or observation**: public-method browser/integration tests, selection matrix, repeated-call/multi-editor isolation checks. +- **Evidence requirement**: DIRECT REQUIRED. +- **Decision enabled**: R3's selection contract becomes an invariant for R5. + +### After R4 + +- **Integrated behavior to validate**: search navigation, highlighting, replacement, mutation invalidation, and history behavior operate on current editor content. +- **Consumer scenarios**: `CX-6`, with regression checks for `CX-2` and `CX-3` where search touches nested/custom content. +- **Commands or observation**: real-browser search flows plus focused Lexical-state tests. +- **Evidence requirement**: DIRECT REQUIRED for the consumer flow; a highlight-rendering proxy is acceptable only if the chosen CI browser lacks CSS Highlights and the limitation is recorded. +- **Decision enabled**: R4's state/index contract becomes an invariant for R5. + +### After R5 — Final Roadmap Checkpoint + +- **Integrated behavior to validate**: the packed package, Markdown pipeline, root/nested interactions, selection export, Find/Replace, and custom consumer plugins all work with extension-based composer/history internals. +- **Consumer scenarios**: `CX-1` through `CX-7`. +- **Commands or observation**: full CI; packed React consumer; full browser compatibility suite; selection/search feature suites; minimal custom-plugin consumer; review of warnings and cleanup behavior. +- **Evidence requirement**: DIRECT REQUIRED for every scenario except explicitly accepted platform proxies already recorded in the parent contract. +- **Decision enabled**: roadmap can be marked COMPLETE and the extension migration can ship independently of any future `@lexical/mdast` initiative. + +## Opportunity Register Outside Child PRPs + +After R2, reapply the PRP router rather than expanding an active child: + +- `SET_TEXT_FORMAT_COMMAND` for deterministic mixed-selection toolbar actions is likely a direct change with focused tests. +- `@lexical/a11y` development/test diagnostics are likely a direct tooling change after assessing signal and false positives. +- Custom autolink punctuation is likely a direct configuration change; a Unicode URL policy redesign is a separate PRP because it changes accepted input behavior. +- Performance improvements arrive automatically with 0.48; benchmark at R2/R5 checkpoints instead of creating an implementation PRP. +- Migrating the retained legacy React horizontal-rule node/plugin to `HorizontalRuleExtension` remains separate follow-up work after its Markdown-shortcut and selection behavior are characterized. +- Shadow DOM, NodeState, DOM import/render extensions, and `@lexical/mdast` remain separate initiatives when a concrete consumer outcome justifies them. + +## Risks and Replanning Triggers + +- R1 cannot build a representative browser surface without introducing an unbounded testing/platform project; split harness establishment from scenario coverage rather than weakening direct evidence silently. +- The 0.48 build exposes additional public type or declaration incompatibilities beyond the rehearsed blockers; reassess R2 scope and any public compatibility consequence before implementation expands. +- A security fix conflicts with previously accepted content. Preserve the secure behavior and request a product-level migration/validation decision. +- Cross-version fixtures show semantic Markdown loss rather than canonical syntax differences; block R2 release and assign ownership before proceeding. +- Accurate selection export cannot honor current consumer visitors/options without changing their public contract; pause R3 and revise the roadmap instead of shipping a standard-Markdown-only result under the existing method name. +- Active-editor-only search cannot retain stable nested/table focus through public toolbar actions or cannot provide one editor/history boundary; pause R4 and revisit the user-approved scope rather than silently aggregating editors. +- R5 requires consumer plugins to adopt Lexical extensions, changes public Gurx/visitor APIs, or couples to `@lexical/mdast`; stop and create a separate breaking-change roadmap. +- A later Lexical release supersedes 0.48.0 before R2 starts. Re-run the changelog/rehearsal and update this roadmap rather than silently changing the target. +- Representative browser verification reveals interacting failures across nested timing, clipboard, and composition that cannot be isolated under Standard assurance; escalate the affected child or roadmap to Deep with the evidenced trigger. + +## Progress Log + +### 2026-07-18 + +- Created roadmap `001` from the verified Lexical 0.35.0 → 0.48.0 assessment. +- Selected Standard assurance because the migration is reversible and the public/browser risks have bounded evidence surfaces. +- Initially marked R1 READY FOR PRP; R2-R5 remain blocked on concrete produced evidence. +- Kept `@lexical/mdast` adoption outside this roadmap based on its experimental extension-only API and MDXEditor's public custom visitor pipeline. +- Recorded current baseline: lint and typecheck pass; jsdom suite reports 45 passed, 1 skipped, 1 todo; no real-browser harness exists. +- Generated `plans/001-lexical-compatibility-gate.md`, selected Playwright over a second application host, and moved R1 to PRP READY. The child adds foundational `CX-8` for the executable gate while inheriting parent `CX-2`-`CX-4` for Markdown and interaction evidence. +- Started R1 execution against commit `9373742`; preflight reconfirmed the unchanged Lexical 0.35.0 baseline and preserved untracked `plans/` and `reports/` content. +- Completed the R1 implementation handoff: added the public-boundary fixture/harness, 3 focused jsdom tests, 5 scenarios across Chromium/Firefox/WebKit, deterministic Ladle lifecycle, CI browser job, and contributor documentation. Local integrated evidence is green on 0.35.0; R1 stays IN PROGRESS pending independent verification and no downstream step is unblocked. +- Verified R1 under the Standard assurance profile after closing four evidence gaps: typed-URL autolinking, shared history across root/nested/table/decorator contexts, positive list-transition assertions, and positive shortcut-undo paragraph recovery. The final compatibility gate passed 3 jsdom and 15 browser tests; R1 moved to VERIFIED and R2 became READY FOR PRP. +- Generated and contract-audited `plans/002-lexical-048-upgrade.md`. R2 is PRP READY with a lockstep 0.48 migration, conditional-export resolution, bounded custom-node fixes, fail-closed link-preview evidence, packed React 18/19 consumers, and direct 0.48→published-4.0.4/0.35 Markdown replay; optional feature integrations remain outside R2. +- Completed the R2 implementation handoff: the lockstep 0.48 graph, resolver/custom-node fixes, fail-closed preview link channel, React 18/19 packed consumers, direct published-0.35 replay, and all static/jsdom/browser/build/docs gates are green. The intentional 0.48 empty-list-item Backspace behavior is explicitly classified without data loss. R2 remains `IN PROGRESS` pending independent verification; R3 and R4 remain blocked. +- Verified R2 under the Standard assurance profile after fixing one package-harness isolation defect: `npm pack` now uses the same scratch cache as disposable installs. The fresh verifier reran the exact package and cross-version commands without an environment override, passed all 18 browser scenarios and every static/build/docs gate, and found no remaining engineering issue. R2 moved to `VERIFIED`; R3 and R4 moved to `READY FOR PRP`. +- Generated `plans/003-selection-markdown.md` and moved R3 to `PRP READY`. A conclusive scratch spike selected Lexical selected-JSON cloning plus a registration-inheriting temporary editor feeding the existing MDAST exporter; it preserved partial links, multi-block structure, custom payloads, repeated output, and source-state identity without adding a public parameter or adopting PR #949's transformer/cache design. +- Started R3 execution against commit `9373742`; preflight preserved the verified, uncommitted R1/R2 work and confirmed that R3's source targets have no overlapping edits. +- Completed the R3 implementation handoff: selection clipping now reuses the configured MDAST pipeline through an isolated registration-inheriting editor; focused state/custom/config tests pass; and the direct public-ref matrix passes 18/18 `CX-5` runs plus all 18 inherited browser scenarios across Chromium, Firefox, and WebKit. Static, package, cross-version, build, docs, formatting, and patch-hygiene gates are green. R3 remains `IN PROGRESS` pending independent Standard-assurance verification. +- Verified R3 under the Standard assurance profile after closing two evidence gaps: the browser now directly clicks the thematic break to create a meaningful node selection, and a replacement-specific consumer visitor proves that `additionalLexicalNodes` replacements survive selected-JSON reconstruction. The fresh verifier graded `CX-5` DIRECTLY VERIFIED after the complete 18-test matrix and a targeted 6/6 follow-up across Chromium, Firefox, and WebKit. R3 moved to `VERIFIED`; its selection/full-export invariant is ready for R5, which remains blocked on R4. +- Generated and cold-audited `plans/004-state-backed-search-replace.md`; R4 is `PRP READY` under Standard assurance. The user-selected active-editor-only contract, state-authoritative/DOM-projection design, one-update Replace All, raw public cell/helper compatibility, invalid/zero-length recovery, and direct three-engine `CX-6` evidence are pinned. A conclusive spike proved reverse state replacement across split formatting; browser Undo/Redo remains execution evidence. R5 stays blocked until R4 is implemented and verified. +- Started R4 execution against commit `9373742`; preflight preserved the verified, uncommitted R1-R3 work and confirmed that the search implementation/docs have no overlapping changes. R4 is `IN PROGRESS`; R5 remains blocked. +- Completed the R4 implementation handoff: active-editor Lexical positions now own matching/replacement; DOM ranges are current projections; Replace/Replace All are one-step undoable; and the public cell/helper/hook surface remains characterized. Five focused tests pass, direct `CX-6` passes 9/9 across Chromium, Firefox, and WebKit, and the full 62-unit/45-browser/build/package/cross-version/docs gate is green. R4 remains `IN PROGRESS` pending independent Standard-assurance verification; R5 remains blocked. +- Verified R4 under the Standard assurance profile after strengthening direct italic/link and mutation evidence exposed one complete-linked-text replacement defect. Same-node matches now splice authoritative text positions directly while cross-node matches retain Lexical range replacement. The fresh verifier passed the corrected 8-test focused suite and all 9 `CX-6` runs across Chromium, Firefox, and WebKit, including empty and length-changing same-node probes; the full 65-unit/45-browser/build/package/cross-version/docs gate is green. R4 moved to `VERIFIED`, and R5 moved to `READY FOR PRP` with the selection and search invariants available. +- Generated `plans/005-extension-composer-history.md` as a Standard R5 PRP and moved R5 to `PRP READY`. Two conclusive spikes selected the internal extension bridge and replay-safe commit-phase realm/disposal model. A cold contract audit closed lifecycle ownership and exact normal/suppressed/table Markdown plus Undo/Redo-availability sequences. Direct `CX-7` evidence runs in all three browsers; the final gate reruns `CX-1`–`CX-7`, package consumers, cross-version replay, build, and docs. Public extensions and `@lexical/mdast` remain separate; the roadmap stays `ACTIVE` until R5 is implemented and verified. +- Started R5 execution against commit `9373742`; preflight preserved the verified, intentionally uncommitted R1-R4 work and confirmed that the planned package/root overlap is the expected accumulated baseline. R5 is `IN PROGRESS`; the roadmap remains `ACTIVE`. +- Completed the R5 implementation handoff: extension-built root/nested/table editors, commit-phase realm ownership, private exactly-once disposal, shared/suppressed/local history adapters, and the extension-agnostic consumer boundary are implemented. Direct `CX-7` passes 9/9 across Chromium, Firefox, and WebKit; focused lint/type/build/lockstep checks and rebuilt packed React 18/19 consumers are green. R5 remains `IN PROGRESS` as `IMPLEMENTED — VERIFICATION PENDING`; the roadmap remains `ACTIVE` until a fresh verifier reruns the full inherited matrix. +- Verified R5 under the Standard assurance profile. The fresh verifier passed the 54/54 inherited public browser matrix, direct `CX-7` 9/9, and packed React 18/19 consumers, then found one public type regression and two direct-evidence gaps. `usedLexicalNodes$` is class-only again and the exact legacy downstream assignment compiles; public custom-import/init and root/nested/table Strict-remount probes now directly cover the missing behavior. The targeted follow-up passed production declarations/build and strengthened `CX-7a`/`CX-7c` 6/6 across Chromium, Firefox, and WebKit with clean teardown. R5 moved to `VERIFIED`; all roadmap completion criteria are satisfied and the roadmap moved to `COMPLETE`. Any future `@lexical/mdast` work remains a separate initiative. +- Reopened R5 after a cold whole-worktree review found three high-confidence compatibility gaps: Realm effect timing delays the public ref past a parent mount effect, table search baselining can mix local cell state into shared history, and recursive nested editors use the root rather than immediate containing editor as their extension parent. The roadmap returned to `ACTIVE`; the previous verification record remains historical evidence pending corrected implementation and fresh verification. +- Completed the reopened R5 implementation handoff without changing Realm construction or cleanup: a stable initial-commit methods facade delegates/queues to the committed Realm, table search baselines only an identical shared history state, and recursive nested editors use their immediate context parent. Focused unit evidence and the affected 12-test three-engine browser matrix pass; the complete gate is green with 70 unit tests, 57 browser tests, build/declarations, lockstep packages, packed React 18/19 consumers with direct parent layout/mount-effect ref checks, cross-version replay, API docs, and patch hygiene. R5 remains `IN PROGRESS` as `IMPLEMENTED — VERIFICATION PENDING`, and the roadmap remains `ACTIVE` until fresh verification supersedes the historical record. +- Fresh post-review verification superseded the historical R5 record. A cold read-only verifier directly passed `CX-7a`–`CX-7c`, 13/13 focused units, the 12/12 affected and 57/57 full three-engine browser matrices, React 18/19 packed consumers, and all static/build/lockstep gates; it found no remaining correctness issue. R5 is `VERIFIED`, every roadmap completion criterion is satisfied, and the roadmap is `COMPLETE`. The uncombined pre-ready `insertMarkdown`/`focus` test path is recorded as a non-blocking evidence limitation. +- Closed the final R5 evidence limitation before packaging the work. A new Strict Mode test exposed stale Markdown publication when queued `setMarkdown`, `focus`, and `insertMarkdown` replayed in one Lexical batch; the public facade now drains FIFO across microtasks without changing Realm construction or ready-state behavior. A targeted verifier independently passed 6/6 focused lifecycle tests and found no issue; full units are 71 passed/1 skipped/1 todo, packed React 18/19 consumers pass, and `CX-7` remains 9/9 across all engines. The roadmap remains `COMPLETE` with no accepted replay limitation. + +### 2026-07-19 + +- Addressed the pre-PR hardening review without reopening the completed roadmap: interrupt cleanup now settles every process stop before resource cleanup and reports combined failures; Realm disposal preserves reverse-order/first-error semantics for every thrown value and retains both plugin-initialization and cleanup failures; the NFKD offset map distinguishes unchanged code points from equal-length compatibility expansions; empty replacement and the affected error paths now have durable tests; and the retained legacy horizontal-rule path is documented as a separate follow-up. Lint, typecheck, 78 passed/1 skipped/1 todo unit tests, declarations/build, and all 12 `CX-6` scenarios across Chromium, Firefox, and WebKit pass with port `61000` cleaned up. diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..dfcac02b --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,47 @@ +import { defineConfig, devices } from '@playwright/test' + +const isCI = Boolean(process.env.CI) +const baseURL = 'http://127.0.0.1:61000' + +export default defineConfig({ + testDir: './tests/browser', + fullyParallel: false, + forbidOnly: isCI, + retries: 0, + workers: isCI ? 1 : undefined, + reporter: isCI + ? [['line'], ['html', { open: 'never', outputFolder: 'playwright-report' }]] + : [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]], + use: { + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure' + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] } + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] } + } + ], + webServer: { + command: 'npm run test:browser:serve', + url: `${baseURL}/?story=lexical-compatibility--compatibility&mode=preview`, + reuseExistingServer: !isCI, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + gracefulShutdown: { + signal: 'SIGTERM', + timeout: 5_000 + } + } +}) diff --git a/reports/lexical-upgrade-0.35.0-to-0.48.0-assessment-2026-07-18.md b/reports/lexical-upgrade-0.35.0-to-0.48.0-assessment-2026-07-18.md new file mode 100644 index 00000000..471f08fe --- /dev/null +++ b/reports/lexical-upgrade-0.35.0-to-0.48.0-assessment-2026-07-18.md @@ -0,0 +1,615 @@ +# Lexical 0.35.0 → 0.48.0 upgrade assessment + +Date: 2026-07-18 + +Status: research and upgrade rehearsal complete; implementation not started + +## Executive summary + +MDXEditor currently declares and resolves Lexical 0.35.0. The current stable Lexical release is 0.48.0, so the relevant upstream range is 0.36.0 through 0.48.0. + +The upgrade is worthwhile, but it is not dependency-only. A clean rehearsal against 0.48.0 found two categories of required source/configuration changes: + +1. TypeScript must resolve Lexical's conditional package exports. The current `moduleResolution: "node"` makes valid Lexical 0.48 exports appear missing. Use `bundler`, `node16`, or `nodenext` as appropriate for the project toolchain. +2. Two real source incompatibilities remain after fixing module resolution: + - `CodeBlockNode.importJSON` is narrower than the new base-node contract. + - The thematic-break visitor mixes the deprecated React `HorizontalRuleNode` subclass with the newer base node exported through the extension package. + +The existing test suite still passes after swapping the packages to 0.48.0, but that is not strong evidence of behavioral compatibility. The riskiest areas are nested editors and update ordering, custom decorator nodes, lists, links/autolinks, Markdown shortcuts, clipboard/history behavior, and IME/browser handling. These are all areas in which MDXEditor has substantial custom logic and Lexical changed behavior during this release range. + +The selection feature discussed in MDXEditor PR #949 is directly relevant. Lexical's new `$convertSelectionToMarkdownString` can replace the current handwritten selection serializer and fixes important correctness problems. The PR itself should not be reused unchanged: it mutates a global transformer array, unnecessarily rejects node selections, does not cover MDXEditor's custom nodes, has per-editor table configuration leakage, and adds no meaningful tests. + +## Scope and method + +This report maps the Lexical changelog from 0.35.0 to 0.48.0 against the packages and features used in this repository. It includes: + +- the direct Lexical dependency inventory; +- a clean 0.48.0 install/typecheck/build/test rehearsal; +- behavior changes that can plausibly cause regressions in MDXEditor; +- new APIs and features worth integrating; +- an assessment of the selection-export idea from MDXEditor PR #949; +- a proposed upgrade and regression-test sequence. + +The assessment intentionally excludes changes that are isolated to Lexical packages or features that MDXEditor does not use, except where they expose a useful replacement for existing custom functionality. + +## Current dependency and usage inventory + +`package.json` declares the following packages at `^0.35.0`, and the current lockfile resolves them to 0.35.0: + +- `lexical` +- `@lexical/clipboard` +- `@lexical/link` +- `@lexical/list` +- `@lexical/markdown` +- `@lexical/plain-text` +- `@lexical/react` +- `@lexical/rich-text` +- `@lexical/selection` +- `@lexical/utils` + +The repository uses these packages in functionality including: + +- the root editor and manually assembled composer context; +- nested editors for directives, JSX children, and table cells; +- shared history between root and nested editors; +- custom nodes and decorator nodes; +- lists and checklists; +- links and a custom autolink implementation; +- Markdown shortcuts; +- selection inspection and selection-to-Markdown export; +- clipboard and drag/drop behavior; +- custom maximum-length enforcement; +- custom search/find-and-replace; +- custom table and thematic-break handling. + +MDXEditor does not directly use `@lexical/table`, collaboration/Yjs, Prism/Shiki code highlighting, or Lexical's playground as runtime dependencies. Changes limited to those areas are generally out of scope. + +## Verified upgrade rehearsal + +### Baseline at 0.35.0 + +- `npm run typecheck`: passes. +- `npm run test:once`: 45 tests pass, 1 is skipped, and 1 is marked todo. + +### Package swap to 0.48.0 + +- The same test suite passes: 45 passed, 1 skipped, 1 todo. +- Typechecking with the existing TypeScript configuration produces hundreds of false missing-export errors. +- Typechecking with `--moduleResolution bundler` reduces the failures to four occurrences representing two real incompatibilities. +- The ordinary build cannot complete declaration generation until the TypeScript resolution and source incompatibilities are fixed. The JavaScript transform proceeds, but the declaration/API-extraction phase fails. + +The test result is encouraging only as a smoke test. The existing suite does not exercise enough browser selection, nested-editor, list, link, clipboard, IME, or custom-node behavior to rule out regressions. + +## Required upgrade changes + +### 1. Adopt conditional-export-aware TypeScript module resolution + +Location: `tsconfig.json` + +The project currently uses: + +```json +"moduleResolution": "node" +``` + +Starting in the 0.46 line, Lexical's package types are exposed through conditional exports, including a TypeScript-version condition and an intentional fallback error type for old or incompatible resolvers. TypeScript 5.9.3 is new enough; the resolver setting is the problem. + +Change the project to the module-resolution mode that matches the bundler/toolchain, most likely: + +```json +"moduleResolution": "bundler" +``` + +After changing it, verify all declaration generation and the public API rollup, not only `tsc --noEmit`. + +### 2. Broaden `CodeBlockNode.importJSON` + +Location: `src/plugins/codeblock/CodeBlockNode.tsx` + +Lexical 0.46's node configuration protocol broadens the base static `importJSON` contract. The custom method currently accepts only `SerializedCodeBlockNode`, making the custom class's static side incompatible with its base class. This also surfaces where the node class is passed as a Markdown transformer dependency and where it is registered in tests. + +The clean fix should accept the base serialized-node shape required by Lexical, validate or default the custom fields, and preferably put field restoration in `updateFromJSON` if that produces a clearer contract. Avoid retaining the broad `as unknown as` cast used in PR #949 merely to silence the error. + +### 3. Use one horizontal-rule node architecture consistently + +Location: `src/plugins/thematic-break/LexicalThematicBreakVisitor.ts` + +The Lexical extension work introduced a base `HorizontalRuleNode` in `@lexical/extension`, while `@lexical/react/LexicalHorizontalRuleNode` retains a deprecated React-oriented subclass/re-export path. The current visitor's node predicate and generic node type resolve to different sides of that split, so the type guard is not valid for the visitor's expected node type. + +Choose a single architecture for the thematic-break plugin: + +- migrate node registration and the visitor to the extension/base node; or +- deliberately stay on the deprecated React node for the initial upgrade, with imports and types consistently coming from that module. + +The first option better aligns with Lexical's direction, but it should be tested for serialization, selection, keyboard deletion, clipboard, and React rendering behavior. + +### 4. Reverify declaration bundling + +Once the preceding issues are fixed, run the full package build and inspect the generated declarations. The rehearsal reached an API Extractor/declaration failure after the JavaScript build, so a passing source typecheck alone is not the completion criterion. + +## Potential bug and behavior-change inventory + +The entries below are limited to changes that intersect MDXEditor's current implementation. “Risk” describes the likelihood or impact of an unnoticed behavior change, not a claim that the current editor is already broken. + +### Nested editors, parent delegation, and update ordering — high risk + +Relevant MDXEditor areas include `src/MDXEditor.tsx`, `src/plugins/core/NestedLexicalEditor.tsx`, `src/plugins/core/SharedHistoryPlugin.tsx`, directives, JSX children, and table-cell editors. + +Potentially observable changes: + +- Parent-editor command delegation became asynchronous in 0.43. Code that assumes a nested editor command reaches the parent synchronously can observe different state or focus ordering. +- Lexical 0.47 defers `onUpdate` callbacks encountered during nested commits. MDXEditor's nested editors propagate Markdown and node state through update listeners, so callback timing and batching may change. +- Input/composition state became per-editor in 0.47. This corrects cross-editor ownership but can change which nested editor consumes events during rapid focus changes. +- Selection is now retained more consistently when an unfocused editor is updated. External `setMarkdown` operations and parent-driven nested-editor updates can therefore preserve a selection that older Lexical versions discarded. +- A recursion detector and `onWarn` handling were added around update loops. Existing parent/child synchronization feedback loops may now warn or stop sooner instead of appearing to settle. +- Root replacement, queued update tags, decorator navigation, and block-cursor behavior received fixes throughout the range. These can alter focus transfer and keyboard navigation around nested decorators. + +Regression coverage should include: + +- editing directives and JSX children, then immediately reading root Markdown; +- table-cell edits and focus transfer between the cell and root editor; +- shared undo/redo across root and nested editors; +- Backspace/Delete at both sides of nested decorators; +- commands dispatched from a nested editor and handled by the parent; +- rapid focus changes while composing text; +- absence of update-listener recursion or stale parent Markdown. + +### Custom decorators, insertion, and selection — high risk + +MDXEditor uses custom decorator nodes for code blocks, images, directives/admonitions, JSX/MDX constructs, frontmatter, tables, and thematic breaks. + +Potentially observable changes: + +- DOM rendering was generalized around `DOMSlot`. Assumptions about a decorator's exact DOM parent, insertion point, or reconciliation boundary should be checked. +- Lexical now removes empty inline elements more aggressively. Empty inline JSX/MDX expressions, placeholder-like nodes, and empty Markdown links such as `[](https://example.com)` may disappear or move differently. +- Selection adjustment when nodes are moved was corrected. Commands that move or replace custom nodes may leave the caret in a different, now more accurate position. +- `$insertNodes` changed line-break preservation, including retaining the first line break and managing `<br>` elements more consistently. MDXEditor calls it in the core import/insertion paths, image insertion, and link-dialog insertion (`src/plugins/core/index.ts`, `src/plugins/image/index.ts`, and `src/plugins/link-dialog/index.ts`). +- Typing at the root's final offset now reuses an empty trailing block instead of creating another one. This intersects with the core importer's trailing-paragraph normalization and should be checked after importing documents that end in a decorator or other non-paragraph block. +- Named-slot behavior received fixes for typing, Backspace, copy, hydration, and DOM import. Even without explicitly using named slots, custom inline/decorator boundaries use related selection and DOM-reconciliation paths. +- Triple-click selection extension moved into a separate extension. Confirm whether the editor currently receives the expected triple-click paragraph selection through its installed React plugins. +- Backspace at a block boundary was changed to preserve the current block in cases where older behavior removed or merged it. +- Block insertion into inline-only parents is normalized differently. +- Several releases correct keyboard navigation around decorators: a caret stuck before a leading inline decorator, Enter on a block-decorator node selection, start/end movement in decorator-only elements, ArrowUp/ArrowDown skipping decorators, block-cursor placement between decorators and shadow roots, and `deleteLine` removing an adjacent block decorator. These are improvements, but they change user-visible caret placement around nearly every MDXEditor custom block. + +Test every custom node at its start/end boundary, when empty, during copy/paste, when selected as a node, and when adjacent to text, lists, links, and another decorator. + +### Lists and checklists — high risk + +Relevant code is concentrated in `src/plugins/lists` and in Markdown import/export visitors and transformers. + +Potentially observable changes: + +- Checklist attributes are now cleared more consistently when nesting or changing list type. +- Pasted checklist content from Joplin is recognized differently. +- A whitespace-only list item is treated as empty when Enter is pressed. +- Empty nested list parents are removed more consistently. +- Ordered-list numbering is preserved differently across block splits. +- Ctrl/Cmd+Backspace in an empty list can convert it to a paragraph. +- Tapping a checkbox on mobile toggles it. +- Nested-list HTML export was corrected. +- Backspace at the top of a list better preserves a preceding decorator node. +- Typing an ordered-list marker before an existing list may update the list's starting number. +- In 0.48, Backspace at the start of a list item outdents it or converts it to a paragraph, depending on nesting. +- Markdown marker and checklist round trips have changed. +- Markdown text transformers now use the first matching transformer, so transformer ordering is behaviorally significant. + +Test unordered, ordered, and task lists at the top level and nested; list splitting/merging; empty and whitespace-only items; decorators immediately before lists; list start numbers; mobile checkbox input; and Markdown/HTML round trips. + +### Links and autolinks — high risk, including security behavior + +Relevant code includes the link plugin and `src/plugins/link/AutoLinkPlugin.tsx`. + +Potentially observable changes: + +- `LinkNode` URL sanitization now fails closed when a URL cannot be parsed. This is an upstream XSS hardening fix and may reject or neutralize values the editor previously preserved. +- Selective unlinking, toggling links with nested children, and collapsed-selection behavior were corrected. In particular, toggling a link at a collapsed selection can now remove it; retest the link dialog's remove and URL-update paths, which dispatch `TOGGLE_LINK_COMMAND` from `src/plugins/link-dialog/index.ts`. +- Markdown import prevents nested links more consistently. +- Adjacent equivalent links can merge while preserving the caret differently. +- Autolinking no longer occurs in code and avoids creating extra paragraphs in several cases. +- A manually unlinked value can become an autolink again after editing. +- Paste-to-wrap behavior skips content that is not simple text. +- Disabled autolinks no longer open. +- Lexical can now configure additional punctuation as an autolink boundary. + +Lexical's playground also improved Unicode URL handling, but that fix does not automatically apply here: MDXEditor uses its own ASCII-oriented URL regular expression. Unicode domains, non-ASCII paths, trailing punctuation, and adjacent formatted text need explicit tests and possibly a local regex redesign. + +Security expectation: retain the new fail-closed sanitization. If compatibility tests reveal rejected legacy URLs, handle them through a documented validation/migration policy rather than weakening the sanitizer. + +### Markdown shortcuts and inline formatting — medium/high risk + +Relevant code is in `src/plugins/markdown-shortcut` and the Markdown transformers used by plugins. + +Potentially observable changes: + +- Element shortcuts can trigger on Enter in additional cases. +- Shortcuts now respond after committed IME composition. +- Code spans have stronger precedence over formatting and text-match transformers. +- CommonMark backslash and delimiter-flanking rules were corrected. +- Unicode whitespace is recognized more consistently. +- Hard breaks, nested fenced blocks, backticks, and fence-like lines received parsing fixes. +- Existing inline formatting is retained differently when a matching marker is typed. +- Overlapping inline formats round-trip more accurately. +- Applying a Markdown shortcut now creates a more consistent undo-history entry. +- `formatText` toggle direction and `SET_TEXT_FORMAT_COMMAND` behavior changed in 0.47. + +The main `getMarkdown()` path is driven by MDXEditor's custom visitors and is not automatically replaced by these transformer changes. Most immediate effects are in live Markdown shortcuts and any APIs that explicitly use `@lexical/markdown` conversion. + +Test delimiter edge cases, Unicode whitespace, inline code mixed with emphasis, escaped markers, multiline/fenced code, IME-completed shortcuts, existing formatting, and a single-step undo after each shortcut. + +### Clipboard, drag/drop, history, and maximum length — medium/high risk + +Potentially observable changes: + +- Selection extraction caching, copying between documents/windows, empty selections, and copying while unfocused were corrected. +- Same-block drag/drop and dragover behavior changed several times. Native text drop was restored in 0.48 after earlier changes, so only the final behavior should be tested. +- Lexical now records a history snapshot before cut and paste, changing undo grouping. +- Root cached-text updates were fixed, which may change when MDXEditor's custom maximum-length plugin observes a new length. + +Lexical's own overflow/character-limit fixes do not automatically repair MDXEditor's custom trimming implementation in `src/plugins/maxlength`. Test typing, paste, IME, deletion, and undo exactly at and beyond the configured limit, including nested editors. + +Clipboard tests should cover rich text, plain text, MDX custom nodes, partial formatted selections, node selections, cross-window copy where practical, and undo after cut/paste. + +### HTML import/export and paste fidelity — medium risk/opportunity + +Lexical's DOM conversion paths gained several fidelity improvements that can change imported or pasted editor state: + +- `data-lexical-indent` is preserved through HTML import/export; +- list-item alignment is recovered through `setFormatFromDOM`; +- inline CSS declared in pasted `<style>` elements can be applied during conversion; +- imported `dir` attributes are retained more consistently. + +These should improve content pasted from Word, Google Docs, and other rich-text sources, but the resulting Lexical/MDAST tree may differ from 0.35. Test indentation, alignment, directionality, and inline styles through paste, HTML conversion, and Markdown export. Decide explicitly which styling is preserved in Markdown and which is intentionally discarded. + +### IME, mobile, and browser behavior — medium/high risk + +The release range includes fixes for: + +- Safari/macOS composition deletion; +- iOS duplicated text; +- Android zero-width spaces and keyboard suggestions; +- Korean IME input; +- Firefox composition-end handling; +- formatting during composition; +- Markdown shortcuts after composition. + +These are generally fixes, but they change event timing and selection state. MDXEditor's nested contenteditables and embedded CodeMirror editors make cross-editor focus/composition behavior especially important. Browser-level verification should cover at least Safari, Firefox, Chrome/Android, and iOS Safari for supported environments. + +### Package/API removals and hardening — medium risk + +- Deprecated exports dating from Lexical 0.32.1 were removed in 0.46. The compile rehearsal did not reveal additional removed-export blockers beyond the horizontal-rule split and custom node typing issue. +- React 17 support was dropped in 0.47. MDXEditor already requires React 18 or 19, so this is not a blocker. +- `LexicalErrorBoundary` no longer relies on the `react-error-boundary` package, removing one upstream transitive dependency without requiring a source change in MDXEditor. +- `@lexical/clipboard` and `@lexical/plain-text` are declared direct dependencies but are not imported anywhere under `src`. Confirm whether they are intentionally exposed/required transitively; otherwise they are candidates for removal while aligning package versions. +- Regular-expression and prototype-pollution hardening landed across the range. Treat these as security fixes; unexpected rejection of malformed input is preferable to restoring unsafe legacy behavior. +- The URL sanitization change is a concrete behavior change and security fix, as described above. + +## New features worth integrating + +### 1. Replace handwritten selection serialization with `$convertSelectionToMarkdownString` + +Priority: high + +Lexical 0.45 added `$convertSelectionToMarkdownString`. It directly matches MDXEditor's public `getSelectionMarkdown()` feature and is the most immediately valuable integration in this upgrade. + +The current implementation in `src/utils/lexicalHelpers.ts` starts with `selection.getNodes()`, promotes nodes to block parents, and manually serializes formats. This loses selection boundaries and cannot reliably represent: + +- a selection that starts or ends in the middle of a text node; +- backward selections; +- formatting that begins or ends inside the selection; +- multi-paragraph selections; +- partial, nested, or task-list selections. + +The upstream converter serializes the actual selection and has dedicated tests for these cases. It should become the basis of the clean implementation, subject to the MDX-specific contract and extension work described below. + +### 2. Use Lexical's Find/Replace implementation as a reference + +Priority: high-value spike + +Lexical 0.47 added a substantially tested Find/Replace implementation to its playground. It is not a packaged drop-in plugin, but it is relevant to `src/plugins/search`. + +MDXEditor's current search observes DOM mutations and maps DOM ranges back to Lexical positions. The upstream implementation offers useful patterns for: + +- building an offset map from Lexical state; +- case-sensitive and regular-expression search; +- performing replace-all in one editor update; +- using CSS Highlights; +- testing navigation and replacement edge cases. + +Port the design selectively rather than depending on playground internals. Compare its behavior with custom decorators and nested editors before replacing the current implementation. + +### 3. Evaluate the extension framework for composer and history setup + +Priority: medium/high architectural opportunity + +The extension framework now includes `LexicalExtensionEditorComposer`, nested-editor extension support, and a corrected `SharedHistoryExtension`. `HistoryExtension` also supports `maxDepth` and undo/redo availability signals. + +This maps directly to MDXEditor's manually assembled composer context and custom shared-history wiring. A migration could reduce custom integration code and standardize cleanup, but it touches the editor's core lifecycle and should be a separate step after the dependency upgrade is stable. + +### 4. Spike `@lexical/mdast` + +Priority: medium, exploratory + +Lexical 0.47 introduced `@lexical/mdast`, followed by examples for HTML and custom MDAST constructs in 0.48. It is relevant because MDXEditor already maintains a rich Lexical ↔ MDAST visitor system. + +It is not a drop-in replacement. MDXEditor's MDX JSX, expressions, directives, frontmatter, plugin visitors, and serialization options are broader than standard Markdown. A spike should compare extensibility, fidelity, error handling, and migration cost before any adoption decision. + +### 5. Add `@lexical/a11y` checks to tests or development tooling + +Priority: medium + +Lexical 0.47 introduced accessibility validation helpers. These may catch invalid editor DOM structures introduced by custom decorators and nested editors. Integrate them first in tests or development diagnostics to assess signal and false-positive rate. + +### 6. Reuse the new Markdown node-generation and selection utilities + +Priority: medium + +The release range adds or improves APIs including `$generateNodesFromMarkdownString` and the selection converter. These can simplify insertion/import paths that currently have to create a temporary editor state or manually coordinate transformers. + +### 7. Improve custom autolink boundaries and Unicode handling + +Priority: medium + +Lexical's configurable punctuation boundaries can replace local edge-case handling. Separately, MDXEditor's custom URL matcher should be assessed for Unicode domains and paths; playground-only regex fixes do not flow into the local plugin automatically. + +### 8. Adopt lifecycle and command utilities opportunistically + +Priority: low/medium + +Potentially useful additions include: + +- `COMMAND_PRIORITY_BEFORE` for handlers that must run before ordinary priorities; +- `onWarn` for surfacing update recursion and editor warnings in development; +- DOM event helpers and listener cleanup functions returned by root/editable listeners; +- can-undo/can-redo signals from the history extension; +- `getDocument()` for code that must use the editor's owning document rather than the global document. + +### 9. Support Shadow DOM embeddings deliberately + +Priority: medium if web components are supported + +Lexical 0.46 added Shadow DOM support for editor selection and DOM access, and 0.47 added `getDocument()`. This can unlock reliable MDXEditor use inside web components. Audit custom plugins for direct use of global `document`, `window.getSelection()`, or document-level listeners before advertising support; Lexical's core support does not automatically correct global DOM access in MDXEditor's plugins. + +### 10. Evaluate `DOMImportExtension` and `DOMRenderExtension` + +Priority: low/medium architectural opportunity + +The new DOM import/render extension points, ordering controls, and `decorateDOM` behavior allow per-editor DOM overrides without requiring every variation to subclass a node. They may simplify consumer-specific paste/import rules or rendering customization. Keep this separate from the dependency upgrade unless an existing node incompatibility requires it. + +### 11. Benchmark the automatic rendering and state-performance improvements + +Priority: low implementation cost, potentially high benefit for large documents + +The release range includes a copy-on-write NodeMap and key-to-DOM map, a faster `reconcileChildren` path with incremental suffix caching, DOM reuse when nodes move between parents, and deferred DOM selection reads. These improvements arrive with the upgrade rather than through a new MDXEditor API. Benchmark large MDX documents, decorator-heavy documents, and nested tables to confirm the expected gains and catch any custom code that depended on reconciliation timing or DOM replacement. + +### 12. Strengthen custom-node cloning and evaluate NodeState + +Priority: low/medium architectural opportunity + +Lexical has hardened the `afterCloneFrom` pattern across built-in nodes, and NodeState now supports copy/reset behavior such as `resetOnCopy`. `ImageNode` and `CodeBlockNode` already implement `afterCloneFrom`; review the other state-carrying custom nodes to ensure that static `clone` plus `afterCloneFrom` preserves every mutable field. + +NodeState may reduce serialization and cloning boilerplate, but converting nodes during the dependency upgrade would unnecessarily enlarge the change. Evaluate it afterward on one contained custom node first. + +### 13. Use `SET_TEXT_FORMAT_COMMAND` for deterministic toolbar formatting + +Priority: medium + +Lexical 0.47 adds a set-style text-format command alongside corrected `formatText` toggle direction. A set operation can make toolbar behavior deterministic for mixed-format selections, where toggling otherwise depends on the selection's current aggregate state. Compare the current toolbar contract before adopting it, especially for partially bold/italic selections. + +## Selection export and lessons from MDXEditor PR #949 + +Reference: [mdx-editor/editor#949](https://github.com/mdx-editor/editor/pull/949) + +### Relevance + +The feature is highly relevant to the upgrade. It addresses a real limitation in the public `getSelectionMarkdown()` method by using Lexical's selection-aware Markdown converter rather than serializing every node touched by the selection. + +Its scope is narrow: + +- It affects programmatic selection-to-Markdown export. +- It does not change the user's live selection mechanics. +- It does not replace clipboard serialization. +- It does not affect search. +- It does not change the normal full-document `getMarkdown()` path. + +### Problems not to carry into the clean implementation + +#### Global transformer mutation + +The PR assigns Lexical's exported `TRANSFORMERS` array directly and then pushes plugin transformers into it. Every call therefore changes global module state. Repeated calls accumulate duplicate transformers, and one editor's plugins can leak into another editor. + +Always create a new array: + +```ts +const transformers = [...TRANSFORMERS] +``` + +Then append only the transformers for the editor instance involved in that call. + +#### Unnecessary rejection of node selections + +The PR accepts only a `RangeSelection`. Lexical's converter accepts a `BaseSelection`, which allows meaningful node selections such as a selected table or thematic break. + +The empty/collapsed guard should preserve non-range selections: + +```ts +if (!selection || ($isRangeSelection(selection) && selection.isCollapsed())) { + return +} +``` + +#### No feature-level tests + +The PR does not add tests for the new behavior. The existing selection-Markdown tests only cover no selection, empty selection, and method presence. Passing CI therefore does not demonstrate correct selection serialization. + +#### Table configuration leaks between editor instances + +The table transformer reads module-global cached extension data, so the most recently initialized editor can determine how another editor serializes a table. Build transformers from the active editor's realm/configuration and do not cache editor-specific values globally. + +#### Incomplete coverage of MDXEditor's node model + +The PR combines Lexical's standard Markdown transformers with table and thematic-break support. It still omits MDXEditor-specific constructs such as: + +- custom code blocks; +- images; +- JSX elements and attributes; +- MDX expressions; +- directives and admonitions; +- frontmatter; +- generic HTML or consumer-provided import/export visitors. + +Selections crossing those nodes can be incomplete, empty, or inconsistent with full-document serialization. + +#### Different serialization contract from `getMarkdown()` + +The upstream converter emits canonical Lexical-transformer Markdown. MDXEditor's `getMarkdown()` uses MDAST visitors and honors project/consumer serialization behavior such as `toMarkdownOptions` and custom visitors. The two methods can therefore disagree about equivalent content. + +Before implementation, explicitly choose the contract: + +1. **Standard rich-text selection Markdown:** document that the method supports only constructs represented by the configured Lexical Markdown transformers; or +2. **Selection-scoped equivalent of `getMarkdown()`:** extend the pipeline to all built-in custom nodes and consumer visitors/options, so selected content is serialized with the same semantics as a full document. + +The second contract is more useful and intuitive, but it is materially more work. + +#### Partial links need a regression test + +There is a known edge case in which a selection entirely inside a link, excluding its first character, can serialize incorrectly or return an empty result. Add a project-level regression test and verify current upstream behavior before relying on the converter for links. + +#### Upgrade casts and horizontal-rule mixing + +The PR uses a broad cast around the custom code-block node and mixes old/new horizontal-rule paths. The clean implementation should resolve the underlying `importJSON` contract and choose one horizontal-rule node architecture rather than hiding the incompatibilities. + +### Required selection-export test matrix + +At minimum, add tests for: + +- a partial selection within one text node; +- a backward selection; +- selection boundaries inside bold, italic, underline, strikethrough, and inline code; +- multiple paragraphs with partial first and last paragraphs; +- partial unordered, ordered, nested, and task lists; +- a partial link, including a selection wholly inside the link that excludes the first character; +- repeated calls, proving that transformer arrays do not grow or leak; +- a node-selected table and thematic break; +- selections crossing custom decorator nodes; +- code blocks, images, JSX, MDX expressions, directives/admonitions, frontmatter, and generic HTML; +- two simultaneous editor instances with different plugin configurations; +- a selection inside a nested editor/table cell; +- consistency with `toMarkdownOptions` and consumer visitors if that is part of the chosen contract. + +Upstream selection-conversion tests are a useful starting reference: [LexicalMarkdown selection tests](https://github.com/facebook/lexical/blob/v0.48.0/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts#L2573). + +## Recommended upgrade sequence + +Keep the dependency upgrade and architectural adoption separable so regressions can be attributed clearly. + +1. Add browser-level regression coverage for the high-risk current behaviors before changing dependencies. +2. Upgrade all direct Lexical packages together to 0.48.0; do not mix minor versions. +3. Change TypeScript module resolution and verify source typechecking. +4. fix `CodeBlockNode.importJSON` and the horizontal-rule node mismatch without broad casts. +5. Run the full build, declaration generation, API extraction, unit tests, and examples. +6. Exercise nested editors, lists, links, clipboard, selection, IME, and custom decorators in supported browsers. +7. Add the clean `$convertSelectionToMarkdownString` integration with immutable, editor-local transformers and the agreed serialization contract. +8. Treat extension-framework adoption, Find/Replace redesign, `@lexical/mdast`, NodeState migration, and other architectural opportunities as follow-up changes. + +This order avoids mixing necessary compatibility work with optional rewrites. + +## Suggested regression suite for the upgrade PR + +### Automated unit/integration tests + +- all existing tests; +- full and partial selection-to-Markdown conversion; +- root/nested editor update ordering and shared history; +- custom-node JSON import/export and cloning; +- list editing and Markdown/HTML round trips; +- link sanitization and autolink boundaries; +- Markdown shortcut parsing, formatting, and undo; +- clipboard copy/cut/paste and history grouping; +- HTML import/export fidelity for indentation, alignment, direction, and inline styles; +- maximum-length behavior at the boundary; +- search highlighting and replace operations after editor updates; +- declaration build and public API extraction. + +### Browser interaction tests + +- keyboard navigation and deletion around every decorator node; +- root ↔ nested-editor focus transfer; +- table-cell editing and shared undo; +- drag/drop within one block and between blocks; +- composition input in root and nested editors; +- mobile checklist toggling; +- copy/paste while focused and unfocused; +- selection direction, triple-click selection, and node selection; +- Shadow DOM embedding if it is part of the supported integration surface. + +### Performance checks + +- benchmark editing and reconciliation for a large plain-text document; +- benchmark decorator-heavy MDX and deeply nested lists; +- benchmark tables with multiple active nested editors; +- compare update latency, DOM churn, and selection stability with the 0.35 baseline. + +### Security tests + +- malformed and dangerous link URLs fail closed; +- safe relative, absolute, mail, and anchor URLs preserve expected behavior; +- imported objects cannot alter prototypes; +- Markdown regex cases complete without pathological runtime. + +## Changelog areas currently considered out of scope + +These upstream areas do not currently map to MDXEditor's direct runtime use: + +- Yjs/collaboration changes; +- Lexical table-plugin behavior that does not affect MDXEditor's custom table implementation; +- Shiki/Prism code-highlighting integrations; +- playground-only UI features other than useful implementation references; +- framework adapters not used by the repository. + +Reassess them if the dependency inventory changes during implementation. + +## Primary upstream references + +- [Lexical changelog](https://github.com/facebook/lexical/blob/main/CHANGELOG.md) +- [Lexical 0.48.0 package](https://www.npmjs.com/package/lexical/v/0.48.0) +- [Lexical Extension framework and horizontal-rule split, #7706](https://github.com/facebook/lexical/pull/7706) +- [`$convertSelectionToMarkdownString`, #8395](https://github.com/facebook/lexical/pull/8395) +- [Conditional package type exports, #8628](https://github.com/facebook/lexical/pull/8628) +- [Node `$config()` protocol and serialization typing, #8645](https://github.com/facebook/lexical/pull/8645) +- [Asynchronous parent-editor command delegation, #8308](https://github.com/facebook/lexical/pull/8308) +- [Selection retention in an unfocused editor, #7941](https://github.com/facebook/lexical/pull/7941) +- [Deferred `onUpdate` during nested commits, #8672](https://github.com/facebook/lexical/pull/8672) +- [Per-editor input state, #8809](https://github.com/facebook/lexical/pull/8809) +- [Update recursion warnings, #8644](https://github.com/facebook/lexical/pull/8644) +- [DOMSlot rendering work, #8519](https://github.com/facebook/lexical/pull/8519) +- [Empty inline element removal, #8497](https://github.com/facebook/lexical/pull/8497) +- [Selection adjustment when moving nodes, #8501](https://github.com/facebook/lexical/pull/8501) +- [`$insertNodes` line-break behavior, #8615](https://github.com/facebook/lexical/pull/8615) +- [Reuse of an empty trailing root block, #8686](https://github.com/facebook/lexical/pull/8686) +- [Decorator caret/navigation fixes: #8558](https://github.com/facebook/lexical/pull/8558), [#8526](https://github.com/facebook/lexical/pull/8526), [#8577](https://github.com/facebook/lexical/pull/8577), [#8775](https://github.com/facebook/lexical/pull/8775), [#8758](https://github.com/facebook/lexical/pull/8758), [#8744](https://github.com/facebook/lexical/pull/8744), and [#8772](https://github.com/facebook/lexical/pull/8772) +- [Named slots, #8603](https://github.com/facebook/lexical/pull/8603) +- [Triple-click selection extension, #8520](https://github.com/facebook/lexical/pull/8520) +- [Backspace at block boundaries, #8493](https://github.com/facebook/lexical/pull/8493) +- [List-item Backspace behavior, #8829](https://github.com/facebook/lexical/pull/8829) +- [Fail-closed LinkNode URL sanitization, #8846](https://github.com/facebook/lexical/pull/8846) +- [Configurable autolink punctuation, #8378](https://github.com/facebook/lexical/pull/8378) +- [Text-format toggle direction, #8807](https://github.com/facebook/lexical/pull/8807) +- [History snapshot before cut/paste, #8649](https://github.com/facebook/lexical/pull/8649) +- [HTML fidelity: indent #8536](https://github.com/facebook/lexical/pull/8536), [list alignment #8460](https://github.com/facebook/lexical/pull/8460), [pasted style elements #8326](https://github.com/facebook/lexical/pull/8326), and [`dir` import #8412](https://github.com/facebook/lexical/pull/8412) +- [Playground Find/Replace implementation, #8779](https://github.com/facebook/lexical/pull/8779) +- [Extension composer/nested editor/shared history, #8202](https://github.com/facebook/lexical/pull/8202) +- [History maximum depth, #8537](https://github.com/facebook/lexical/pull/8537) +- [`@lexical/mdast`, #8794](https://github.com/facebook/lexical/pull/8794) +- [MDAST custom constructs examples, #8826](https://github.com/facebook/lexical/pull/8826) +- [`@lexical/a11y`, #8591](https://github.com/facebook/lexical/pull/8591) +- [Markdown node generation, #8789](https://github.com/facebook/lexical/pull/8789) +- [`COMMAND_PRIORITY_BEFORE`, #8375](https://github.com/facebook/lexical/pull/8375) +- [Shadow DOM support, #8694](https://github.com/facebook/lexical/pull/8694) +- [Editor `getDocument()`, #8788](https://github.com/facebook/lexical/pull/8788) +- [DOM event registration helpers, #8767](https://github.com/facebook/lexical/pull/8767) +- [Listener cleanup return values, #8219](https://github.com/facebook/lexical/pull/8219) +- [DOM import extensions, #8528](https://github.com/facebook/lexical/pull/8528) +- [Performance work: copy-on-write maps #8481](https://github.com/facebook/lexical/pull/8481), [reconciliation fast path #8482](https://github.com/facebook/lexical/pull/8482), [cross-parent DOM reuse #8441](https://github.com/facebook/lexical/pull/8441), and [deferred selection reads #8422](https://github.com/facebook/lexical/pull/8422) +- [Custom-node `afterCloneFrom`, #8229](https://github.com/facebook/lexical/pull/8229) +- [NodeState `resetOnCopy`, #8221](https://github.com/facebook/lexical/pull/8221) +- [Self-contained `LexicalErrorBoundary`, #8720](https://github.com/facebook/lexical/pull/8720) diff --git a/reports/pr-949-review.md b/reports/pr-949-review.md new file mode 100644 index 00000000..20a12e4b --- /dev/null +++ b/reports/pr-949-review.md @@ -0,0 +1,48 @@ +# PR #949 review findings + +PR: https://github.com/mdx-editor/editor/pull/949 — "Raise lexical version and use it to improve text selection" (doxman, 2026-07-18, open) + +Status: not planned for merge. Findings recorded for reference. + +## What the PR does + +- Upgrades all `lexical` / `@lexical/*` packages from 0.35.0 to 0.48.0, adds `@lexical/extension`. +- Replaces the hand-rolled `getSelectionAsMarkdown` in `src/utils/lexicalHelpers.ts` with Lexical's `$convertSelectionToMarkdownString` (added in lexical 0.45). +- Adds custom `ElementTransformer`s for tables (`src/plugins/table/transformer.ts`) and thematic breaks (`src/plugins/thematic-break/transformer.ts`), fed to the converter when the respective plugins are active. +- Registers `'table'` as a named active plugin; table plugin subscribes to `toMarkdownExtensions$` and caches them in a module-level variable for the transformer. +- Collateral changes: `tsconfig.json` `moduleResolution: "node"` → `"bundler"`, typecast workarounds in `mdastUtilHtmlComment.ts` and `CodeBlockNode.tsx`, partial migration of horizontal-rule imports from deprecated `@lexical/react` modules to `@lexical/extension`. + +The selection improvement is legitimate: the old implementation treated partially selected nodes as fully selected, garbled nested lists, dropped checklist markers, and silently ignored its `exportParams` argument (`_exportParams` was unused). + +## Bugs in the new code + +1. **Shared-array mutation** (`src/utils/lexicalHelpers.ts`): `const transformers: Transformer[] = TRANSFORMERS` then `transformers.push(...)` mutates the `TRANSFORMERS` array exported by `@lexical/markdown`. Every copy operation appends the table/thematic-break transformers again — unbounded growth of a shared library export, leaking MDXEditor transformers into any other consumer. Must be `[...TRANSFORMERS]`. +2. **Module-level global cache** (`src/plugins/table/transformer.ts`): `setTableTransformerExtensions` stores extensions in a module singleton. Multiple MDXEditor instances on one page clobber each other's table export config; no cleanup on unmount. Benign only while all instances use identical config. +3. **Custom decorator nodes silently dropped from selection export**: the stock lexical transformers don't know about MDXEditor's `CodeBlockNode`, `ImageNode`, directives, JSX, or frontmatter. The PR only covers tables and thematic breaks, so copying a selection containing e.g. a code block or image omits it entirely. The old implementation at least emitted text content as a fallback. +4. **The table transformer's extension cache is order-dependent and can stay empty** (`src/plugins/table/index.ts` + `transformer.ts`): Gurx `realm.sub` does not replay a cell's current value — it only fires on subsequent publishes. `tablePlugin.init` publishes its own `gfmTableToMarkdown` extension into `toMarkdownExtensions$` (an `Appender` cell) *before* registering the subscription, so the sub only ever fires if a plugin initialized *after* `tablePlugin` appends another toMarkdown extension. If `tablePlugin` is last in the user's plugin array (or no later plugin registers one), `cachedExtensions` stays `[]`, and `toMarkdown(mdastTable, { extensions: [] })` throws ("cannot handle unknown node `table`" — gfm table serialization *requires* the extension), the `catch` logs and returns `null`, and the table is silently dropped from the copied selection — the exact node type the transformer was written to support. Working configurations work by accident of plugin ordering. Fix would be reading the cell value at export time (or `realm.getValue` at subscription setup), not a fire-on-change cache. +5. **Errors swallowed to silent data loss**: `TABLE_TRANSFORMER.export`'s `try/catch` turns any serialization failure into `console.error` + `null`, i.e. content silently missing from the clipboard with no user-visible signal. This is also what masks finding 4. + +## API and build concerns + +6. **Public API break**: `getSelectionAsMarkdown(editor, exportParams)` → `(editor, activePlugins: string[])` changes the signature of a public `@group Utils` export. The old param was dead weight, but the break should be release-noted (or the new param made optional/derived). +7. **`moduleResolution: "bundler"`** affects the whole build and declaration output. Commit b34e0f7 recently repaired the declaration rollup — the `.d.ts` output must be explicitly re-verified on this branch. +8. **Mixed horizontal-rule module identities**: `$isHorizontalRuleNode` is imported from `@lexical/extension` in `LexicalThematicBreakVisitor.ts` but from the deprecated `@lexical/react/LexicalHorizontalRuleNode` in `transformer.ts`, while node registration uses the react class. If the re-export ever stops aliasing the same class, instanceof-style checks split across module identities. Unify on one source. + +## Process concerns + +9. **No test changes** for a 13-minor-version upgrade bundled with a feature. Per `reports/lexical-0.35-to-0.48.md`, the upgrade's real cost is behavioral and none of it is exercised here: + - Backspace-at-block-start semantics (v0.45 breaking), list-item backspace outdent (v0.48) + - Decorator caret navigation changes (v0.45–0.47) — affects code blocks, tables, images, directives, JSX + - Markdown shortcuts on Enter / IME composition commit (v0.45) + - `LinkNode.sanitizeUrl()` failing closed on unparseable URLs (v0.48) + - Async nested-editor delegation (v0.43) and deferred `onUpdate` during nested commits (v0.47) — directly relevant because table cells and `NestedLexicalEditor` run on `LexicalNestedComposer` and markdown export runs in an update listener + Recommendation: split the upgrade and the selection feature into separate PRs so regressions are attributable, and run the regression pass from the upgrade report. +10. **Known edge case flagged by the author, unfixed**: a partially selected link that excludes its first character (with nothing else selected) returns an empty string. Deferred to upstream lexical. + +## Additional notes + +- Copy-selection output now comes from lexical's transformer pipeline, a parallel serialization path to MDXEditor's mdast visitor pipeline — the two can disagree (e.g. `***` vs configured thematic-break marker, table pipe alignment options). +- **`@lexical/mdast` (lexical 0.47 #8794, 0.48 #8826) points at a better long-term approach.** The same lexical release this PR upgrades to introduced an official micromark/mdast bridge — the same architecture MDXEditor's import/export layer is built on. The PR instead invests further in the `@lexical/markdown` transformer pipeline (custom `ElementTransformer`s for tables and thematic breaks, extension caching), i.e. new code on the pipeline `@lexical/mdast` is positioned to supersede. A selection export built on the mdast path could reuse MDXEditor's existing export visitors and `toMarkdown` extensions directly — eliminating the dual-pipeline divergence and the transformer/cache machinery (findings 2–5 and the divergence note above) rather than patching around them. Worth evaluating before accepting any variant of this feature. See `reports/lexical-0.35-to-0.48.md`, section 2, item 1. +- `editor.getEditorState().read(fn, { editor })` is required because `$convertSelectionToMarkdownString` needs active-editor context inside a plain read (0.46+ API). +- `CodeBlockNode.importJSON` now takes `SerializedLexicalNode & Record<string, unknown>` with an `as unknown as` cast — works, but loses type safety; revisit when doing the upgrade properly. +- The PR bumps `@lexical/clipboard` and `@lexical/plain-text` but keeps them as direct dependencies even though nothing in `src/` imports them (see the upgrade report) — an upgrade PR is the natural place to drop them. diff --git a/scripts/assert-lexical-versions.mjs b/scripts/assert-lexical-versions.mjs new file mode 100644 index 00000000..688ec49e --- /dev/null +++ b/scripts/assert-lexical-versions.mjs @@ -0,0 +1,71 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +const expectedVersion = '0.48.0' +const expectedRange = '^0.48.0' +const expectedDirectPackages = [ + '@lexical/clipboard', + '@lexical/extension', + '@lexical/history', + '@lexical/link', + '@lexical/list', + '@lexical/markdown', + '@lexical/plain-text', + '@lexical/react', + '@lexical/rich-text', + '@lexical/selection', + '@lexical/utils', + 'lexical' +] + +const packageJson = JSON.parse(await readFile('package.json', 'utf8')) +const packageLock = JSON.parse(await readFile('package-lock.json', 'utf8')) +const directLexicalPackages = Object.entries(packageJson.dependencies ?? {}) + .filter(([name]) => name === 'lexical' || name.startsWith('@lexical/')) + .sort(([left], [right]) => left.localeCompare(right)) + +const failures = [] + +if (JSON.stringify(directLexicalPackages.map(([name]) => name)) !== JSON.stringify(expectedDirectPackages)) { + failures.push(`direct Lexical declarations differ: ${directLexicalPackages.map(([name]) => name).join(', ')}`) +} + +for (const [name, range] of directLexicalPackages) { + if (range !== expectedRange) { + failures.push(`${name} declares ${range}; expected ${expectedRange}`) + } +} + +const lexicalPackagePath = /(?:^|\/)node_modules\/(lexical|@lexical\/[^/]+)$/ +const installedPackages = [] + +for (const [packagePath, lockEntry] of Object.entries(packageLock.packages ?? {})) { + const match = packagePath.match(lexicalPackagePath) + if (!match) continue + + const name = match[1] + installedPackages.push(`${name}@${lockEntry.version}`) + if (lockEntry.version !== expectedVersion) { + failures.push(`${packagePath} locks ${lockEntry.version}; expected ${expectedVersion}`) + } + + try { + const installedManifest = JSON.parse(await readFile(path.join(packagePath, 'package.json'), 'utf8')) + if (installedManifest.version !== expectedVersion) { + failures.push(`${packagePath} installs ${installedManifest.version}; expected ${expectedVersion}`) + } + } catch (error) { + failures.push(`${packagePath} is missing from node_modules (${error.message})`) + } +} + +if (installedPackages.length === 0) { + failures.push('no installed Lexical packages were found in package-lock.json') +} + +if (failures.length > 0) { + console.error(failures.join('\n')) + process.exitCode = 1 +} else { + console.log(`Verified ${installedPackages.length} installed Lexical packages at ${expectedVersion}.`) +} diff --git a/scripts/package-consumer-utils.mjs b/scripts/package-consumer-utils.mjs new file mode 100644 index 00000000..6bcf68be --- /dev/null +++ b/scripts/package-consumer-utils.mjs @@ -0,0 +1,238 @@ +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { cp, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' + +export const repoRoot = process.cwd() +export const compatibilityInputPath = path.join(repoRoot, 'src/test/fixtures/lexicalCompatibility.input.md') +export const compatibilityExpected035Path = path.join(repoRoot, 'src/test/fixtures/lexicalCompatibility.035.md') +const activeProcesses = new Set() + +export function phase(message) { + console.log(`[package-gate] ${message}`) +} + +export async function run(command, args, options = {}) { + const child = spawn(command, args, { + cwd: options.cwd ?? repoRoot, + env: { ...process.env, ...options.env }, + detached: process.platform !== 'win32', + stdio: 'inherit' + }) + activeProcesses.add(child) + try { + const [code, signal] = await once(child, 'exit') + if (code !== 0) { + throw new Error(`${command} ${args.join(' ')} failed with ${signal ?? `exit ${code}`}`) + } + } finally { + activeProcesses.delete(child) + } +} + +export async function createScratchRoot(prefix) { + return mkdtemp(path.join(os.tmpdir(), prefix)) +} + +export async function packCurrentPackage(scratchRoot, cacheRoot) { + phase('packing the built repository artifact') + await run('npm', ['pack', '--ignore-scripts', '--cache', cacheRoot, '--pack-destination', scratchRoot]) + const tarballs = (await readdir(scratchRoot)).filter((entry) => entry.endsWith('.tgz')) + if (tarballs.length !== 1) throw new Error(`Expected one package tarball, found ${tarballs.length}`) + return path.join(scratchRoot, tarballs[0]) +} + +export async function createConsumerApp({ scratchRoot, name, manifest, editorDependency, markdown, overrides }) { + const appRoot = path.join(scratchRoot, name) + await cp(path.join(repoRoot, 'tests/package-consumer/shared'), appRoot, { recursive: true }) + const packageJson = JSON.parse(await readFile(manifest, 'utf8')) + packageJson.dependencies['@mdxeditor/editor'] = editorDependency + if (overrides) packageJson.overrides = overrides + await writeFile(path.join(appRoot, 'package.json'), `${JSON.stringify(packageJson, null, 2)}\n`) + await mkdir(path.join(appRoot, 'src'), { recursive: true }) + await writeFile(path.join(appRoot, 'src/compatibility.md'), markdown) + return appRoot +} + +export async function installAndBuildConsumer(appRoot, cacheRoot, label) { + phase(`${label}: installing with isolated npm cache`) + await run('npm', ['install', '--cache', cacheRoot, '--no-audit', '--no-fund'], { cwd: appRoot }) + phase(`${label}: typechecking`) + await run('npm', ['run', 'typecheck'], { cwd: appRoot }) + phase(`${label}: bundling with Vite`) + await run('npm', ['run', 'build'], { cwd: appRoot }) +} + +async function allocatePort() { + const server = net.createServer() + server.unref() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (typeof address !== 'object' || address === null) throw new Error('Failed to allocate a preview port') + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + return address.port +} + +async function waitForPreview(url, child) { + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`Preview exited before becoming ready (${child.exitCode})`) + try { + const response = await fetch(url) + if (response.ok) return + } catch { + // The preview is still starting. + } + await new Promise((resolve) => setTimeout(resolve, 200)) + } + throw new Error(`Timed out waiting for ${url}`) +} + +async function stopProcessGroup(child) { + if (child.exitCode !== null || child.signalCode !== null) return + try { + if (process.platform === 'win32') child.kill('SIGTERM') + else process.kill(-child.pid, 'SIGTERM') + } catch (error) { + if (error.code !== 'ESRCH') throw error + } + await Promise.race([once(child, 'exit'), new Promise((resolve) => setTimeout(resolve, 5_000))]) + if (child.exitCode === null && child.signalCode === null) { + try { + if (process.platform === 'win32') child.kill('SIGKILL') + else process.kill(-child.pid, 'SIGKILL') + } catch (error) { + if (error.code !== 'ESRCH') throw error + } + } +} + +export async function runInterruptCleanup(processes, cleanup, stop = stopProcessGroup) { + const stopResults = await Promise.allSettled([...processes].map(stop)) + const errors = stopResults.filter((result) => result.status === 'rejected').map((result) => result.reason) + try { + await cleanup() + } catch (error) { + errors.push(error) + } + if (errors.length > 0) { + throw new AggregateError(errors, 'Interrupt cleanup failed') + } +} + +export function installInterruptCleanup(cleanup) { + let handlingSignal = false + const handlers = new Map() + for (const [signal, exitCode] of [ + ['SIGINT', 130], + ['SIGTERM', 143] + ]) { + const handler = () => { + if (handlingSignal) return + handlingSignal = true + void runInterruptCleanup(activeProcesses, cleanup) + .catch((error) => { + console.error(error) + }) + .finally(() => process.exit(exitCode)) + } + handlers.set(signal, handler) + process.once(signal, handler) + } + return () => { + for (const [signal, handler] of handlers) process.removeListener(signal, handler) + } +} + +export async function captureConsumerMarkdown(appRoot, browser, label) { + const port = await allocatePort() + const url = `http://127.0.0.1:${port}` + phase(`${label}: serving packed consumer on ${url}`) + const preview = spawn('npm', ['run', 'preview', '--', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], { + cwd: appRoot, + env: process.env, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'] + }) + activeProcesses.add(preview) + preview.stdout.pipe(process.stdout) + preview.stderr.pipe(process.stderr) + + let page + try { + await waitForPreview(url, preview) + page = await browser.newPage() + const runtimeErrors = [] + page.on('pageerror', (error) => runtimeErrors.push(`pageerror: ${error.message}`)) + page.on('console', (message) => { + if (message.type() === 'error') runtimeErrors.push(`console.error: ${message.text()}`) + }) + await page.goto(url) + await page.getByRole('heading', { name: 'Package consumer ready' }).waitFor() + await page.getByLabel('Package realm plugin').waitFor() + const realmPluginMarker = (await page.getByLabel('Package realm plugin').textContent()) ?? '' + if (realmPluginMarker !== 'ready') throw new Error(`${label} custom realm plugin did not initialize`) + const layoutRefReady = (await page.getByLabel('Package layout ref ready').textContent()) ?? '' + if (layoutRefReady !== 'true') throw new Error(`${label} public ref was unavailable in the parent layout effect`) + const effectRefReady = (await page.getByLabel('Package effect ref ready').textContent()) ?? '' + if (effectRefReady !== 'true') throw new Error(`${label} public ref was unavailable in the parent mount effect`) + await page.getByRole('button', { name: 'Read package Markdown' }).click() + await page.waitForFunction(() => document.querySelector('[aria-label="Package Markdown"]')?.textContent !== '') + const error = (await page.getByLabel('Package Error').textContent()) ?? '' + if (error !== '') throw new Error(`${label} reported editor error: ${error}`) + if (runtimeErrors.length > 0) throw new Error(`${label} runtime errors:\n${runtimeErrors.join('\n')}`) + return ((await page.getByLabel('Package Markdown').textContent()) ?? '').replaceAll('\r\n', '\n') + } finally { + await page?.close() + await stopProcessGroup(preview) + activeProcesses.delete(preview) + } +} + +export async function assertLexicalGraph(appRoot, expectedVersion, expectedNames) { + const lock = JSON.parse(await readFile(path.join(appRoot, 'package-lock.json'), 'utf8')) + const lexicalPath = /(?:^|\/)node_modules\/(lexical|@lexical\/[^/]+)$/ + const installedNames = [] + const failures = [] + for (const [packagePath, entry] of Object.entries(lock.packages ?? {})) { + const match = packagePath.match(lexicalPath) + if (!match) continue + installedNames.push(match[1]) + if (entry.version !== expectedVersion) failures.push(`${match[1]}@${entry.version}`) + const manifest = JSON.parse(await readFile(path.join(appRoot, packagePath, 'package.json'), 'utf8')) + if (manifest.version !== expectedVersion) failures.push(`${match[1]} installed as ${manifest.version}`) + } + const uniqueNames = [...new Set(installedNames)].sort() + if (expectedNames && JSON.stringify(uniqueNames) !== JSON.stringify([...expectedNames].sort())) { + failures.push(`package set differs: ${uniqueNames.join(', ')}`) + } + if (uniqueNames.length === 0 || failures.length > 0) { + throw new Error(`Lexical ${expectedVersion} graph assertion failed: ${failures.join('; ') || 'empty graph'}`) + } + phase(`verified ${uniqueNames.length} Lexical packages at ${expectedVersion}`) +} + +export function assertConstructAnchors(markdown, label) { + for (const anchor of [ + '# Compatibility heading', + '[safe link](https://example.com/safe)', + '* Bullet alpha', + '| Table | Stable |', + '***', + '```ts compatibility-meta', + ':::note', + '<Grid>', + 'Nested compatibility content.' + ]) { + if (!markdown.includes(anchor)) throw new Error(`${label} lost construct anchor: ${anchor}`) + } +} + +export async function cleanupScratchRoot(scratchRoot) { + await rm(scratchRoot, { recursive: true, force: true }) +} diff --git a/scripts/verify-cross-version-markdown.mjs b/scripts/verify-cross-version-markdown.mjs new file mode 100644 index 00000000..ac34389a --- /dev/null +++ b/scripts/verify-cross-version-markdown.mjs @@ -0,0 +1,96 @@ +import path from 'node:path' +import { mkdir, readFile } from 'node:fs/promises' +import { chromium } from '@playwright/test' +import { + assertConstructAnchors, + assertLexicalGraph, + captureConsumerMarkdown, + cleanupScratchRoot, + compatibilityExpected035Path, + compatibilityInputPath, + createConsumerApp, + createScratchRoot, + installAndBuildConsumer, + installInterruptCleanup, + packCurrentPackage, + phase, + repoRoot +} from './package-consumer-utils.mjs' + +const lexical035Packages = [ + '@lexical/clipboard', + '@lexical/code', + '@lexical/devtools-core', + '@lexical/dragon', + '@lexical/hashtag', + '@lexical/history', + '@lexical/html', + '@lexical/link', + '@lexical/list', + '@lexical/mark', + '@lexical/markdown', + '@lexical/offset', + '@lexical/overflow', + '@lexical/plain-text', + '@lexical/react', + '@lexical/rich-text', + '@lexical/selection', + '@lexical/table', + '@lexical/text', + '@lexical/utils', + '@lexical/yjs', + 'lexical' +] +const lexical035Overrides = Object.fromEntries(lexical035Packages.map((name) => [name, '0.35.0'])) + +const scratchRoot = await createScratchRoot('mdxeditor-cross-version-') +let browser +const removeInterruptCleanup = installInterruptCleanup(async () => { + await browser?.close() + await cleanupScratchRoot(scratchRoot) +}) +try { + const cacheRoot = path.join(scratchRoot, 'npm-cache') + await mkdir(cacheRoot, { recursive: true }) + const tarball = await packCurrentPackage(scratchRoot, cacheRoot) + const input = await readFile(compatibilityInputPath, 'utf8') + const expected035 = (await readFile(compatibilityExpected035Path, 'utf8')).trimEnd() + const react18Manifest = path.join(repoRoot, 'tests/package-consumer/react-18/package.json') + browser = await chromium.launch() + + const currentApp = await createConsumerApp({ + scratchRoot, + name: 'current-048-writer', + manifest: react18Manifest, + editorDependency: `file:${tarball}`, + markdown: input + }) + await installAndBuildConsumer(currentApp, cacheRoot, '0.48 writer') + await assertLexicalGraph(currentApp, '0.48.0') + const currentOutput = (await captureConsumerMarkdown(currentApp, browser, '0.48 writer')).trimEnd() + assertConstructAnchors(currentOutput, '0.48 writer') + if (currentOutput !== expected035) { + throw new Error( + '0.48 canonical output differs from the checked-in 0.35 expectation; classify and check in an explicit 0.48 expectation' + ) + } + + const legacyApp = await createConsumerApp({ + scratchRoot, + name: 'legacy-035-reader', + manifest: react18Manifest, + editorDependency: '4.0.4', + markdown: currentOutput, + overrides: lexical035Overrides + }) + await installAndBuildConsumer(legacyApp, cacheRoot, '4.0.4 / Lexical 0.35 reader') + await assertLexicalGraph(legacyApp, '0.35.0', lexical035Packages) + const legacyOutput = (await captureConsumerMarkdown(legacyApp, browser, '4.0.4 / Lexical 0.35 reader')).trimEnd() + assertConstructAnchors(legacyOutput, 'legacy replay') + if (legacyOutput !== expected035) throw new Error('Legacy replay differs from the checked-in 0.35 canonical expectation') + phase('0.35 input -> packed 0.48 output -> published 4.0.4/0.35 replay passed') +} finally { + removeInterruptCleanup() + await browser?.close() + await cleanupScratchRoot(scratchRoot) +} diff --git a/scripts/verify-package-consumer.mjs b/scripts/verify-package-consumer.mjs new file mode 100644 index 00000000..e04f15b9 --- /dev/null +++ b/scripts/verify-package-consumer.mjs @@ -0,0 +1,60 @@ +import path from 'node:path' +import { chromium } from '@playwright/test' +import { + assertConstructAnchors, + assertLexicalGraph, + captureConsumerMarkdown, + cleanupScratchRoot, + compatibilityExpected035Path, + compatibilityInputPath, + createConsumerApp, + createScratchRoot, + installAndBuildConsumer, + installInterruptCleanup, + packCurrentPackage, + phase, + repoRoot +} from './package-consumer-utils.mjs' +import { mkdir, readFile } from 'node:fs/promises' + +const requestedReact = process.argv.find((argument) => argument.startsWith('--react='))?.split('=')[1] +const majors = requestedReact ? [requestedReact] : ['18', '19'] +if (majors.some((major) => major !== '18' && major !== '19')) { + throw new Error('--react must be 18 or 19') +} + +const scratchRoot = await createScratchRoot('mdxeditor-package-consumer-') +let browser +const removeInterruptCleanup = installInterruptCleanup(async () => { + await browser?.close() + await cleanupScratchRoot(scratchRoot) +}) +try { + const cacheRoot = path.join(scratchRoot, 'npm-cache') + await mkdir(cacheRoot, { recursive: true }) + const tarball = await packCurrentPackage(scratchRoot, cacheRoot) + const input = await readFile(compatibilityInputPath, 'utf8') + const expected = (await readFile(compatibilityExpected035Path, 'utf8')).trimEnd() + browser = await chromium.launch() + + for (const major of majors) { + const label = `React ${major}` + const appRoot = await createConsumerApp({ + scratchRoot, + name: `react-${major}`, + manifest: path.join(repoRoot, `tests/package-consumer/react-${major}/package.json`), + editorDependency: `file:${tarball}`, + markdown: input + }) + await installAndBuildConsumer(appRoot, cacheRoot, label) + await assertLexicalGraph(appRoot, '0.48.0') + const output = (await captureConsumerMarkdown(appRoot, browser, label)).trimEnd() + if (output !== expected) throw new Error(`${label} public ref output differs from the checked-in 0.35 expectation`) + assertConstructAnchors(output, label) + phase(`${label}: packed declarations, bundle, styles, runtime, and ref output passed`) + } +} finally { + removeInterruptCleanup() + await browser?.close() + await cleanupScratchRoot(scratchRoot) +} diff --git a/src/MDXEditor.tsx b/src/MDXEditor.tsx index 1f07e2a2..fc0a3c4d 100644 --- a/src/MDXEditor.tsx +++ b/src/MDXEditor.tsx @@ -1,4 +1,4 @@ -import { useCellValue, useCellValues, usePublisher, useRealm } from '@mdxeditor/gurx' +import { type Realm, useCellValue, useCellValues, usePublisher, useRealm } from '@mdxeditor/gurx' import React, { JSX } from 'react' import { activeEditor$, @@ -31,15 +31,14 @@ import { } from './plugins/core' import { RealmPlugin, RealmWithPlugins } from './RealmWithPlugins' -import { createLexicalComposerContext, LexicalComposerContext, LexicalComposerContextType } from '@lexical/react/LexicalComposerContext' +import { LexicalExtensionEditorComposer } from '@lexical/react/LexicalExtensionEditorComposer' import { ContentEditable } from '@lexical/react/LexicalContentEditable' import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' import classNames from 'classnames' -import { EditorState, EditorThemeClasses, LexicalEditor } from 'lexical' +import { EditorState, EditorThemeClasses, LexicalEditorWithDispose } from 'lexical' import { defaultSvgIcons, IconKey } from './defaultSvgIcons' import { ToMarkdownOptions } from './exportMarkdownFromLexical' -import { lexicalTheme } from './styles/lexicalTheme' import styles from './styles/ui.module.css' import { noop } from './utils/fp' import { getSelectionAsMarkdown } from './utils/lexicalHelpers' @@ -47,12 +46,8 @@ import { getSelectionAsMarkdown } from './utils/lexicalHelpers' const LexicalProvider: React.FC<{ children: JSX.Element | string | (JSX.Element | string)[] }> = ({ children }) => { - const rootEditor = useCellValue(rootEditor$)! - const composerContextValue = React.useMemo(() => { - return [rootEditor, createLexicalComposerContext(null, lexicalTheme)] as [LexicalEditor, LexicalComposerContextType] - }, [rootEditor]) - - return <LexicalComposerContext.Provider value={composerContextValue}>{children}</LexicalComposerContext.Provider> + const rootEditor = useCellValue(rootEditor$) as LexicalEditorWithDispose + return <LexicalExtensionEditorComposer initialEditor={rootEditor}>{children}</LexicalExtensionEditorComposer> } const RichTextEditor: React.FC = () => { @@ -170,7 +165,8 @@ export interface MDXEditorMethods { getContentEditableHTML: () => string /** - * Gets the markdown representation of the current selection. + * Gets the markdown representation of the active root or nested editor selection + * using the editor's configured visitors and serialization options. * Returns an empty string if there is no selection, if selection is collapsed, or if editor is in source/diff mode. */ getSelectionMarkdown: () => string @@ -225,71 +221,75 @@ const EditorRootElement: React.FC<{ ) } -const Methods: React.FC<{ mdxRef: React.ForwardedRef<MDXEditorMethods> }> = ({ mdxRef }) => { - const realm = useRealm() +function createMethods(realm: Realm): MDXEditorMethods { + return { + getMarkdown: () => { + const viewMode = realm.getValue(viewMode$) + if (viewMode === 'source' || viewMode === 'diff') { + return realm.getValue(markdownSourceEditorValue$) + } - React.useImperativeHandle( - mdxRef, - () => { - return { - getMarkdown: () => { - const viewMode = realm.getValue(viewMode$) - if (viewMode === 'source' || viewMode === 'diff') { - return realm.getValue(markdownSourceEditorValue$) - } - - return realm.getValue(markdown$) - }, - setMarkdown: (markdown) => { - realm.pub(setMarkdown$, markdown) - }, - insertMarkdown: (markdown) => { - realm.pub(insertMarkdown$, markdown) - }, - focus: ( - callbackFn?: () => void, - opts?: { - defaultSelection?: 'rootStart' | 'rootEnd' - preventScroll?: boolean - } - ) => { - realm.getValue(rootEditor$)?.focus(callbackFn, opts) - }, - getContentEditableHTML: () => { - return realm.getValue(contentEditableRef$)?.current.innerHTML ?? '' - }, - getSelectionMarkdown: () => { - // Return empty string in source/diff mode - const viewMode = realm.getValue(viewMode$) - if (viewMode === 'source' || viewMode === 'diff') { - return '' - } - - // Use activeEditor$ for nested editor support - const activeEditor = realm.getValue(activeEditor$) - if (!activeEditor) { - return '' - } - - // Get all export parameters from realm - const visitors = realm.getValue(exportVisitors$) - const toMarkdownExtensions = realm.getValue(toMarkdownExtensions$) - const toMarkdownOptions = realm.getValue(toMarkdownOptions$) - const jsxComponentDescriptors = realm.getValue(jsxComponentDescriptors$) - const jsxIsAvailable = realm.getValue(jsxIsAvailable$) - - return getSelectionAsMarkdown(activeEditor, { - visitors, - toMarkdownExtensions, - toMarkdownOptions, - jsxComponentDescriptors, - jsxIsAvailable - }) - } + return realm.getValue(markdown$) + }, + setMarkdown: (markdown) => { + realm.pub(setMarkdown$, markdown) + }, + insertMarkdown: (markdown) => { + realm.pub(insertMarkdown$, markdown) + }, + focus: ( + callbackFn?: () => void, + opts?: { + defaultSelection?: 'rootStart' | 'rootEnd' + preventScroll?: boolean } + ) => { + realm.getValue(rootEditor$)?.focus(callbackFn, opts) }, - [realm] - ) + getContentEditableHTML: () => { + return realm.getValue(contentEditableRef$)?.current.innerHTML ?? '' + }, + getSelectionMarkdown: () => { + const viewMode = realm.getValue(viewMode$) + if (viewMode === 'source' || viewMode === 'diff') { + return '' + } + + const activeEditor = realm.getValue(activeEditor$) + if (!activeEditor) { + return '' + } + + const visitors = realm.getValue(exportVisitors$) + const toMarkdownExtensions = realm.getValue(toMarkdownExtensions$) + const toMarkdownOptions = realm.getValue(toMarkdownOptions$) + const jsxComponentDescriptors = realm.getValue(jsxComponentDescriptors$) + const jsxIsAvailable = realm.getValue(jsxIsAvailable$) + + return getSelectionAsMarkdown(activeEditor, { + visitors, + toMarkdownExtensions, + toMarkdownOptions, + jsxComponentDescriptors, + jsxIsAvailable + }) + } + } +} + +const Methods: React.FC<{ + onReady: (methods: MDXEditorMethods) => void + onDispose: (methods: MDXEditorMethods) => void +}> = ({ onReady, onDispose }) => { + const realm = useRealm() + const methods = React.useMemo(() => createMethods(realm), [realm]) + + React.useLayoutEffect(() => { + onReady(methods) + return () => { + onDispose(methods) + } + }, [methods, onDispose, onReady]) return null } @@ -405,6 +405,82 @@ export interface MDXEditorProps { * @group MDXEditor */ export const MDXEditor = React.forwardRef<MDXEditorMethods, MDXEditorProps>((props, ref) => { + const realmMethods = React.useRef<MDXEditorMethods | null>(null) + const replayingRealmMethods = React.useRef<MDXEditorMethods | null>(null) + const pendingMethodCalls = React.useRef<((methods: MDXEditorMethods) => void)[]>([]) + const preReadyMarkdown = React.useRef(props.trim === false ? props.markdown : props.markdown.trim()) + const callWhenReady = React.useCallback((call: (methods: MDXEditorMethods) => void) => { + const methods = realmMethods.current + if (methods && replayingRealmMethods.current === null) { + call(methods) + } else { + pendingMethodCalls.current.push(call) + } + }, []) + const publicMethods = React.useMemo<MDXEditorMethods>( + () => ({ + getMarkdown: () => realmMethods.current?.getMarkdown() ?? preReadyMarkdown.current, + setMarkdown: (markdown) => { + preReadyMarkdown.current = markdown + callWhenReady((methods) => { + methods.setMarkdown(markdown) + }) + }, + insertMarkdown: (markdown) => { + callWhenReady((methods) => { + methods.insertMarkdown(markdown) + }) + }, + focus: (callbackFn, opts) => { + callWhenReady((methods) => { + methods.focus(callbackFn, opts) + }) + }, + getContentEditableHTML: () => realmMethods.current?.getContentEditableHTML() ?? '', + getSelectionMarkdown: () => realmMethods.current?.getSelectionMarkdown() ?? '' + }), + [callWhenReady] + ) + const attachRealmMethods = React.useCallback((methods: MDXEditorMethods) => { + realmMethods.current = methods + if (replayingRealmMethods.current === methods) { + return + } + if (pendingMethodCalls.current.length === 0) { + preReadyMarkdown.current = methods.getMarkdown() + return + } + + replayingRealmMethods.current = methods + const replayNext = () => { + if (realmMethods.current !== methods) { + if (replayingRealmMethods.current === methods) { + replayingRealmMethods.current = null + } + return + } + + const call = pendingMethodCalls.current.shift() + if (!call) { + replayingRealmMethods.current = null + preReadyMarkdown.current = methods.getMarkdown() + return + } + + call(methods) + queueMicrotask(replayNext) + } + replayNext() + }, []) + const detachRealmMethods = React.useCallback((methods: MDXEditorMethods) => { + if (realmMethods.current === methods) { + preReadyMarkdown.current = methods.getMarkdown() + realmMethods.current = null + } + }, []) + + React.useImperativeHandle(ref, () => publicMethods, [publicMethods]) + return ( <RealmWithPlugins plugins={[ @@ -437,7 +513,7 @@ export const MDXEditor = React.forwardRef<MDXEditorMethods, MDXEditorProps>((pro <RichTextEditor /> </LexicalProvider> </EditorRootElement> - <Methods mdxRef={ref} /> + <Methods onReady={attachRealmMethods} onDispose={detachRealmMethods} /> </RealmWithPlugins> ) }) diff --git a/src/RealmWithPlugins.tsx b/src/RealmWithPlugins.tsx index 099a8026..4b1c6a9e 100644 --- a/src/RealmWithPlugins.tsx +++ b/src/RealmWithPlugins.tsx @@ -1,6 +1,6 @@ import React from 'react' import { Realm, RealmContext } from '@mdxeditor/gurx' -import { tap } from './utils/fp' +import { disposeRealmSession } from './realmSession' /** * A plugin for the editor. @@ -43,23 +43,43 @@ export function realmPlugin<Params>(plugin: { /** @internal */ export function RealmWithPlugins({ children, plugins }: { children: React.ReactNode; plugins: RealmPlugin[] }) { - const theRealm = React.useMemo(() => { - return tap(new Realm(), (r) => { + const [theRealm, setTheRealm] = React.useState<Realm | null>(null) + + React.useEffect(() => { + const realm = new Realm() + try { for (const plugin of plugins) { - plugin.init?.(r) + plugin.init?.(realm) } for (const plugin of plugins) { - plugin.postInit?.(r) + plugin.postInit?.(realm) + } + } catch (error) { + try { + disposeRealmSession(realm) + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'Realm plugin initialization and cleanup both failed') } - }) + throw error + } + setTheRealm(realm) + return () => { + disposeRealmSession(realm) + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) React.useEffect(() => { - for (const plugin of plugins) { - plugin.update?.(theRealm) + if (theRealm) { + for (const plugin of plugins) { + plugin.update?.(theRealm) + } } }) + if (!theRealm) { + return null + } + return <RealmContext.Provider value={theRealm}>{children}</RealmContext.Provider> } diff --git a/src/examples/extension-compatibility.tsx b/src/examples/extension-compatibility.tsx new file mode 100644 index 00000000..dffe329e --- /dev/null +++ b/src/examples/extension-compatibility.tsx @@ -0,0 +1,4 @@ +import React from 'react' +import { ExtensionCompatibilityHarness } from '../test/fixtures/ExtensionCompatibilityHarness' + +export const ExtensionCompatibility = () => <ExtensionCompatibilityHarness /> diff --git a/src/examples/get-selection-markdown.tsx b/src/examples/get-selection-markdown.tsx index f1172370..d5dee0b3 100644 --- a/src/examples/get-selection-markdown.tsx +++ b/src/examples/get-selection-markdown.tsx @@ -1,63 +1,6 @@ import React from 'react' -import { MDXEditor, MDXEditorMethods } from '../' -import { ALL_PLUGINS } from './_boilerplate' - -const sampleMarkdown = ` -# Get Selection Markdown Demo - -Try selecting some text below and click the button! - -## Features - -- **Bold text** and *italic text* -- [Links to websites](https://example.com) -- Inline \`code\` samples - -### Lists - -* First item -* Second item with **bold** -* Third item - -### Table - -| Column 1 | Column 2 | -|----------|----------| -| Cell 1 | Cell 2 | -| Cell 3 | Cell 4 | -`.trim() +import { SelectionMarkdownHarness } from '../test/fixtures/SelectionMarkdownHarness' export function GetSelectionMarkdown() { - const ref = React.useRef<MDXEditorMethods>(null) - const [result, setResult] = React.useState<string>('') - - return ( - <div> - <button - onClick={() => { - const markdown = ref.current?.getSelectionMarkdown() ?? '' - setResult(markdown) - console.log('Selected markdown:', markdown) - }} - > - Get Selection Markdown - </button> - - {result && ( - <div style={{ marginTop: '10px', padding: '10px', background: '#f5f5f5' }}> - <strong>Result:</strong> - <pre style={{ whiteSpace: 'pre-wrap' }}>{result || '(empty)'}</pre> - </div> - )} - - <MDXEditor - ref={ref} - markdown={sampleMarkdown} - plugins={ALL_PLUGINS} - onChange={(md) => { - console.log('change', { md }) - }} - /> - </div> - ) + return <SelectionMarkdownHarness /> } diff --git a/src/examples/lexical-compatibility.tsx b/src/examples/lexical-compatibility.tsx new file mode 100644 index 00000000..941261eb --- /dev/null +++ b/src/examples/lexical-compatibility.tsx @@ -0,0 +1,4 @@ +import React from 'react' +import { LexicalCompatibilityHarness } from '../test/fixtures/LexicalCompatibilityHarness' + +export const Compatibility = () => <LexicalCompatibilityHarness /> diff --git a/src/examples/search-replace.tsx b/src/examples/search-replace.tsx new file mode 100644 index 00000000..458447d6 --- /dev/null +++ b/src/examples/search-replace.tsx @@ -0,0 +1,4 @@ +import React from 'react' +import { SearchReplaceHarness } from '../test/fixtures/SearchReplaceHarness' + +export const SearchReplace = () => <SearchReplaceHarness /> diff --git a/src/exportMarkdownFromLexical.ts b/src/exportMarkdownFromLexical.ts index b192402d..cff6ddd4 100644 --- a/src/exportMarkdownFromLexical.ts +++ b/src/exportMarkdownFromLexical.ts @@ -120,7 +120,7 @@ export function exportLexicalTreeToMdast({ const referredComponents = new Set<string>() const knownImportSources = new Map<string, ImportStatement>() - visitors = visitors.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) + visitors = [...visitors].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) visit(root, null) diff --git a/src/mdastUtilHtmlComment.ts b/src/mdastUtilHtmlComment.ts index 555c59ba..87684d05 100644 --- a/src/mdastUtilHtmlComment.ts +++ b/src/mdastUtilHtmlComment.ts @@ -3,10 +3,15 @@ import type { Handle, Transform } from 'mdast-util-from-markdown' import { factorySpace } from 'micromark-factory-space' import { markdownLineEnding } from 'micromark-util-character' -import { codes, types } from 'micromark-util-symbol' import type { Code, Extension, Tokenizer } from 'micromark-util-types' +// These are the only micromark symbols used by the tokenizer. Keeping their +// literal values typed locally avoids losing their declarations when tools +// resolve micromark-util-symbol's declaration-only default export. +const codes = { dash: 45, eof: null, exclamationMark: 33, greaterThan: 62 } as const +const types = { data: 'data', lineEnding: 'lineEnding', linePrefix: 'linePrefix' } as const + declare module 'micromark-util-types' { interface TokenTypeMap { comment: 'comment' diff --git a/src/plugins/codeblock/CodeBlockNode.tsx b/src/plugins/codeblock/CodeBlockNode.tsx index e4b73201..fa0b8f81 100644 --- a/src/plugins/codeblock/CodeBlockNode.tsx +++ b/src/plugins/codeblock/CodeBlockNode.tsx @@ -68,8 +68,10 @@ export class CodeBlockNode extends DecoratorNode<JSX.Element> { this.__focusEmitter = voidEmitter() } - static importJSON(serializedNode: SerializedCodeBlockNode): CodeBlockNode { - const { code, meta, language } = serializedNode + static importJSON(serializedNode: SerializedLexicalNode & Record<string, unknown>): CodeBlockNode { + const code = typeof serializedNode.code === 'string' ? serializedNode.code : '' + const language = typeof serializedNode.language === 'string' ? serializedNode.language : '' + const meta = typeof serializedNode.meta === 'string' ? serializedNode.meta : '' return $createCodeBlockNode({ code, language, diff --git a/src/plugins/core/NestedLexicalEditor.tsx b/src/plugins/core/NestedLexicalEditor.tsx index 503d3fb5..56dca302 100644 --- a/src/plugins/core/NestedLexicalEditor.tsx +++ b/src/plugins/core/NestedLexicalEditor.tsx @@ -12,8 +12,8 @@ import { EditorConfig, KEY_BACKSPACE_COMMAND, LexicalEditor, + LexicalEditorWithDispose, SELECTION_CHANGE_COMMAND, - createEditor, COMMAND_PRIORITY_LOW } from 'lexical' import * as Mdast from 'mdast' @@ -32,23 +32,24 @@ import { lexicalTheme$, nestedEditorChildren$, rootEditor$, + historyState$, usedLexicalNodes$ } from '.' import { ContentEditable } from '@lexical/react/LexicalContentEditable' import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' -import { LexicalNestedComposer } from '@lexical/react/LexicalNestedComposer' +import { LexicalExtensionEditorComposer } from '@lexical/react/LexicalExtensionEditorComposer' import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' import classNames from 'classnames' import { exportLexicalTreeToMdast } from '../../exportMarkdownFromLexical' import { importMdastTreeToLexical } from '../../importMarkdownToLexical' import styles from '../../styles/ui.module.css' -import { SharedHistoryPlugin } from './SharedHistoryPlugin' import { mergeRegister } from '@lexical/utils' import { VoidEmitter } from '../../utils/voidEmitter' import { isPartOftheEditorUI } from '../../utils/isPartOftheEditorUI' import { useCellValues, usePublisher, useRealm } from '@mdxeditor/gurx' import { DirectiveNode } from '../directives' import { LexicalJsxNode } from '../jsx/LexicalJsxNode' +import { createExtensionEditor, editorHasSharedHistory } from './lexicalExtensions' /** * The value of the {@link NestedEditorsContext} React context. @@ -178,7 +179,7 @@ export const NestedLexicalEditor = function <T extends Mdast.RootContent>(props: block?: boolean }) { const { getContent, getUpdatedMdastNode, contentEditableProps, block = false } = props - const { mdastNode, lexicalNode, focusEmitter } = useNestedEditorContext<T>() + const { parentEditor, mdastNode, lexicalNode, focusEmitter } = useNestedEditorContext<T>() const updateMdastNode = useMdastNodeUpdater<T>() const removeNode = useLexicalNodeRemove() const content = getContent(mdastNode) @@ -212,52 +213,70 @@ export const NestedLexicalEditor = function <T extends Mdast.RootContent>(props: const setEditorInFocus = usePublisher(editorInFocus$) - const [editor] = React.useState(() => { - const editor = createEditor({ + const [editor, setEditor] = React.useState<LexicalEditorWithDispose | null>(null) + + React.useEffect(() => { + if (!rootEditor) { + return + } + const editor = createExtensionEditor({ + name: '@mdxeditor/nested', nodes: usedLexicalNodes, - theme: realm.getValue(lexicalTheme$), - namespace: 'NestedEditor' + theme: lexicalTheme, + namespace: 'NestedEditor', + parentEditor, + historyMode: editorHasSharedHistory(rootEditor) ? 'nested-shared' : 'nested-external', + historyState: realm.getValue(historyState$), + initialEditorState: () => { + let theContent: Mdast.PhrasingContent[] | Mdast.RootContent[] = content + if (block) { + if (theContent.length === 0) { + theContent = [{ type: 'paragraph', children: [] }] + } + } else { + theContent = [{ type: 'paragraph', children: content as Mdast.PhrasingContent[] }] + } + + importMdastTreeToLexical({ + root: $getRoot(), + mdastRoot: { + type: 'root', + children: theContent + }, + visitors: importVisitors, + directiveDescriptors, + codeBlockEditorDescriptors, + defaultCodeBlockLanguage, + jsxComponentDescriptors + }) + } }) - return editor - }) + setEditor(editor) + return () => { + editor.dispose() + } + // The editor owns the complete mount-time plugin/configuration snapshot. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) React.useEffect(() => { + if (!editor) { + return + } focusEmitter.subscribe(() => { editor.focus() }) + return () => { + focusEmitter.subscribe(() => undefined) + } }, [editor, focusEmitter]) React.useEffect(() => { - editor.update(() => { - $getRoot().clear() - let theContent: Mdast.PhrasingContent[] | Mdast.RootContent[] = content - if (block) { - if (theContent.length === 0) { - theContent = [{ type: 'paragraph', children: [] }] - } - } else { - theContent = [{ type: 'paragraph', children: content as Mdast.PhrasingContent[] }] - } - - importMdastTreeToLexical({ - root: $getRoot(), - mdastRoot: { - type: 'root', - children: theContent - }, - visitors: importVisitors, - directiveDescriptors, - codeBlockEditorDescriptors, - defaultCodeBlockLanguage, - jsxComponentDescriptors - }) - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editor, block, importVisitors]) - - React.useEffect(() => { + if (!editor) { + return + } function updateParentNode() { - editor.getEditorState().read(() => { + editor!.getEditorState().read(() => { const mdast = exportLexicalTreeToMdast({ root: $getRoot(), visitors: exportVisitors, @@ -338,8 +357,12 @@ export const NestedLexicalEditor = function <T extends Mdast.RootContent>(props: rootEditor ]) + if (!editor) { + return null + } + return ( - <LexicalNestedComposer initialEditor={editor} initialTheme={lexicalTheme}> + <LexicalExtensionEditorComposer initialEditor={editor}> <RichTextPlugin contentEditable={ <ContentEditable {...contentEditableProps} className={classNames(styles.nestedEditor, contentEditableProps?.className)} /> @@ -347,10 +370,9 @@ export const NestedLexicalEditor = function <T extends Mdast.RootContent>(props: placeholder={null} ErrorBoundary={LexicalErrorBoundary} /> - <SharedHistoryPlugin /> {nestedEditorChildren.map((Child, index) => ( <Child key={index} /> ))} - </LexicalNestedComposer> + </LexicalExtensionEditorComposer> ) } diff --git a/src/plugins/core/SharedHistoryPlugin.tsx b/src/plugins/core/SharedHistoryPlugin.tsx deleted file mode 100644 index 6a891b23..00000000 --- a/src/plugins/core/SharedHistoryPlugin.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin' -import React from 'react' -import { historyState$ } from '.' -import { useCellValue } from '@mdxeditor/gurx' - -export const SharedHistoryPlugin = () => { - return <HistoryPlugin externalHistoryState={useCellValue(historyState$)} /> -} diff --git a/src/plugins/core/index.ts b/src/plugins/core/index.ts index dc3e882b..477635d7 100644 --- a/src/plugins/core/index.ts +++ b/src/plugins/core/index.ts @@ -1,5 +1,5 @@ import { realmPlugin } from '../../RealmWithPlugins' -import { createEmptyHistoryState } from '@lexical/react/LexicalHistoryPlugin.js' +import { createEmptyHistoryState } from '@lexical/history' import { $isHeadingNode, HeadingTagType } from '@lexical/rich-text' import { $setBlocksType } from '@lexical/selection' import { $findMatchingParent, $insertNodeToNearestRoot, $wrapNodeInElement } from '@lexical/utils' @@ -30,8 +30,7 @@ import { SELECTION_CHANGE_COMMAND, TextFormatType, TextNode, - createCommand, - createEditor + createCommand } from 'lexical' import * as Mdast from 'mdast' @@ -68,13 +67,14 @@ import { MdastHTMLVisitor } from './MdastHTMLVisitor' import { MdastParagraphVisitor } from './MdastParagraphVisitor' import { MdastRootVisitor } from './MdastRootVisitor' import { MdastTextVisitor } from './MdastTextVisitor' -import { SharedHistoryPlugin } from './SharedHistoryPlugin' import { DirectiveDescriptor } from '../directives' import { CodeBlockEditorDescriptor } from '../codeblock' import { comment, commentFromMarkdown } from '../../mdastUtilHtmlComment' import { lexicalTheme } from '../../styles/lexicalTheme' import { FORMAT } from '../../FormatConstants' import { IconKey } from '../../defaultSvgIcons' +import { registerRealmCleanup } from '../../realmSession' +import { createExtensionEditor } from './lexicalExtensions' export * from './MdastHTMLNode' export * from './GenericHTMLNode' @@ -742,7 +742,7 @@ export const editorWrappers$ = Cell<React.ComponentType<{ children: React.ReactN export const addEditorWrapper$ = Appender(editorWrappers$) /** - * React Components registered to be rendered inside `LexicalNestedComposer`. Useful if you're using lexical editor plugins that are wrapped as react components. + * React components registered inside each nested editor's standard Lexical composer context. Useful for Lexical plugins wrapped as React components. * @group Core */ export const nestedEditorChildren$ = Cell<React.ComponentType[]>([]) @@ -976,6 +976,7 @@ export const corePlugin = realmPlugin<{ [placeholder$]: params?.placeholder, [readOnly$]: params?.readOnly, [translation$]: params?.translation, + [historyState$]: createEmptyHistoryState(), [addMdastExtension$]: [gfmStrikethroughFromMarkdown(), highlightMarkFromMarkdown], [addSyntaxExtension$]: [gfmStrikethrough(), highlightMark()], [addToMarkdownExtension$]: [mdxJsxToMarkdown(), gfmStrikethroughToMarkdown(), highlightMarkToMarkdown], @@ -996,44 +997,39 @@ export const corePlugin = realmPlugin<{ [addImportVisitor$]: MdastHTMLVisitor }) } - - if (!params?.suppressSharedHistory) { - r.pub(addComposerChild$, SharedHistoryPlugin) - } }, postInit(r, params) { - const newEditor = createEditor({ - // ...(params?.editorState ? { editorState: params.editorState } : {}), + const newEditor = createExtensionEditor({ + name: '@mdxeditor/root', editable: params?.readOnly !== true, namespace: params?.lexicalEditorNamespace ?? 'MDXEditor', nodes: [...r.getValue(usedLexicalNodes$), ...(params?.additionalLexicalNodes ?? [])], - onError: (error) => { - throw error - }, - theme: r.getValue(lexicalTheme$) + theme: r.getValue(lexicalTheme$), + historyMode: params?.suppressSharedHistory ? 'none' : 'root-shared', + historyState: r.getValue(historyState$), + initialEditorState: + params?.editorState === null + ? null + : () => { + const markdown = params?.initialMarkdown.trim() ?? '' + tryImportingMarkdown(r, $getRoot(), markdown) + } }) - if (params?.editorState !== null) { - newEditor.update(() => { - const markdown = params?.initialMarkdown.trim() ?? '' - tryImportingMarkdown(r, $getRoot(), markdown) + registerRealmCleanup(r, () => { + newEditor.dispose() + }) - const autoFocusValue = params?.autoFocus - if (autoFocusValue) { - if (autoFocusValue === true) { - // Default 'on' state - setTimeout(() => { - newEditor.focus(noop, { defaultSelection: 'rootStart' }) - }) - return - } - setTimeout(() => { - newEditor.focus(noop, { - defaultSelection: autoFocusValue.defaultSelection ?? 'rootStart' - }) - }) - } + const autoFocusValue = params?.autoFocus + if (params?.editorState !== null && autoFocusValue) { + const autoFocusTimer = setTimeout(() => { + newEditor.focus(noop, { + defaultSelection: autoFocusValue === true ? 'rootStart' : autoFocusValue.defaultSelection ?? 'rootStart' + }) + }) + registerRealmCleanup(r, () => { + clearTimeout(autoFocusTimer) }) } diff --git a/src/plugins/core/lexicalExtensions.ts b/src/plugins/core/lexicalExtensions.ts new file mode 100644 index 00000000..d810d356 --- /dev/null +++ b/src/plugins/core/lexicalExtensions.ts @@ -0,0 +1,204 @@ +import { buildEditorFromExtensions, getPeerDependencyFromEditor, InitialStateExtension, NestedEditorExtension } from '@lexical/extension' +import { HistoryExtension, HistoryState, HistoryStateEntry, SharedHistoryExtension } from '@lexical/history' +import { ReactExtension } from '@lexical/react/ReactExtension' +import { ReactProviderExtension } from '@lexical/react/ReactProviderExtension' +import { + AnyLexicalExtensionArgument, + CAN_REDO_COMMAND, + CAN_UNDO_COMMAND, + COMMAND_PRIORITY_LOW, + configExtension, + CreateEditorArgs, + defineExtension, + EditorThemeClasses, + FOCUS_COMMAND, + HISTORIC_TAG, + InitialEditorStateType, + LineBreakNode, + LexicalEditor, + LexicalEditorWithDispose, + ParagraphNode, + RootNode, + TabNode, + TextNode +} from 'lexical' + +export type EditorHistoryMode = 'none' | 'root-shared' | 'nested-shared' | 'nested-external' | 'table-local' + +export interface ExtensionEditorConfig { + name: string + namespace: string + nodes: CreateEditorArgs['nodes'] + theme?: EditorThemeClasses + editable?: boolean + parentEditor?: LexicalEditor + historyMode: EditorHistoryMode + historyState?: HistoryState + initialEditorState?: InitialEditorStateType + onError?: (error: Error) => void +} + +const editorsWithSharedHistory = new WeakSet<LexicalEditor>() +const editorHistoryStates = new WeakMap<LexicalEditor, HistoryState>() +const extensionBuiltinNodes = new Set<unknown>([RootNode, TextNode, LineBreakNode, TabNode, ParagraphNode]) +const meaningfulHistoryEntries = new WeakMap<HistoryState, HistoryStateEntry | null>() + +function externalHistoryExtension(historyState: HistoryState) { + return configExtension(HistoryExtension, { + createInitialHistoryState: () => historyState, + disabled: false + }) +} + +/** @internal */ +export function createExtensionEditor({ + name, + namespace, + nodes, + theme, + editable = true, + parentEditor, + historyMode, + historyState, + initialEditorState, + onError = (error) => { + throw error + } +}: ExtensionEditorConfig): LexicalEditorWithDispose { + const dependencies: AnyLexicalExtensionArgument[] = [ + ReactProviderExtension, + configExtension(ReactExtension, { contentEditable: null }), + configExtension(InitialStateExtension, { + setOptions: { tag: HISTORIC_TAG }, + updateOptions: { tag: HISTORIC_TAG } + }) + ] + + if (parentEditor) { + dependencies.push( + configExtension(NestedEditorExtension, { + $getParentEditor: () => parentEditor, + inheritEditableFromParent: true + }) + ) + } + + if (historyMode === 'nested-shared') { + dependencies.push(SharedHistoryExtension) + } else if (historyMode === 'root-shared' || historyMode === 'nested-external') { + if (!historyState) { + throw new Error(`${historyMode} requires a historyState`) + } + dependencies.push(externalHistoryExtension(historyState)) + } else if (historyMode === 'table-local') { + dependencies.push(configExtension(HistoryExtension, { disabled: false })) + } + + const editorExtension = defineExtension({ + name, + namespace, + // InitialStateExtension already declares Lexical's built-ins. Filtering + // them here also prevents Vite's optimized extension bundle from seeing + // the same built-in constructor through two module facades. + nodes: nodes?.filter((node) => !extensionBuiltinNodes.has(node)), + theme, + editable, + $initialEditorState: initialEditorState, + onError, + dependencies + }) + + const editor = buildEditorFromExtensions(editorExtension) + const historyDependency = getPeerDependencyFromEditor<typeof HistoryExtension>(editor, HistoryExtension.name) + const editorHistoryState = historyDependency?.output.historyState.value + if (editorHistoryState) { + editorHistoryStates.set(editor, editorHistoryState) + } + + // Legacy React history plugins registered after the caller's initial + // import. The configured InitialStateExtension uses HISTORIC_TAG to keep + // initial root/nested/table content out of Undo and Redo. + if (editorHistoryState) { + if (!meaningfulHistoryEntries.has(editorHistoryState)) { + meaningfulHistoryEntries.set(editorHistoryState, editorHistoryState.current) + } + } + + const unregisterHistoryFocusGuard = editorHistoryState + ? editor.registerUpdateListener(({ dirtyElements, dirtyLeaves, tags }) => { + const current = editorHistoryState.current + if (tags.has(HISTORIC_TAG)) { + meaningfulHistoryEntries.set(editorHistoryState, current) + return + } + + if (dirtyElements.size > 0 || dirtyLeaves.size > 0) { + meaningfulHistoryEntries.set(editorHistoryState, current) + return + } + + const meaningfulEntry = meaningfulHistoryEntries.get(editorHistoryState) + if (current?.editor === editor && meaningfulEntry && meaningfulEntry.editor !== editor) { + // registerHistory treats a selection-only focus update as a merge, + // even when it switches editors. Preserve the previous editor's + // current state as an explicit boundary before accepting the newly + // focused editor's baseline; otherwise sibling Undo skips edits. + editorHistoryState.undoStack.push({ ...meaningfulEntry }) + editorHistoryState.redoStack = [] + editor.dispatchCommand(CAN_UNDO_COMMAND, true) + editor.dispatchCommand(CAN_REDO_COMMAND, false) + meaningfulHistoryEntries.set(editorHistoryState, current) + } else { + meaningfulHistoryEntries.set(editorHistoryState, current) + } + }) + : () => undefined + const availabilityTimers = new Set<ReturnType<typeof setTimeout>>() + const unregisterHistoryAvailability = editorHistoryState + ? editor.registerCommand( + FOCUS_COMMAND, + () => { + // Availability commands are emitted by the editor that mutates the + // shared state. Replay them after active-editor React effects attach + // so a focus switch observes changes made by a sibling/table editor. + const timer = setTimeout(() => { + availabilityTimers.delete(timer) + editor.dispatchCommand(CAN_UNDO_COMMAND, editorHistoryState.undoStack.length > 0) + editor.dispatchCommand(CAN_REDO_COMMAND, editorHistoryState.redoStack.length > 0) + }) + availabilityTimers.add(timer) + return false + }, + COMMAND_PRIORITY_LOW + ) + : () => undefined + + const dispose = editor.dispose.bind(editor) + let disposed = false + editor.dispose = () => { + if (disposed) { + return + } + disposed = true + availabilityTimers.forEach(clearTimeout) + availabilityTimers.clear() + unregisterHistoryAvailability() + unregisterHistoryFocusGuard() + dispose() + } + + if (historyMode === 'root-shared') { + editorsWithSharedHistory.add(editor) + } + return editor +} + +/** @internal */ +export function editorHasSharedHistory(editor: LexicalEditor): boolean { + return editorsWithSharedHistory.has(editor) +} + +/** @internal */ +export function editorUsesHistoryState(editor: LexicalEditor, historyState: HistoryState): boolean { + return editorHistoryStates.get(editor) === historyState +} diff --git a/src/plugins/link-dialog/LinkDialog.tsx b/src/plugins/link-dialog/LinkDialog.tsx index 9959c7c3..c1203007 100644 --- a/src/plugins/link-dialog/LinkDialog.tsx +++ b/src/plugins/link-dialog/LinkDialog.tsx @@ -231,7 +231,8 @@ export const LinkDialog: React.FC = () => { <> <a className={styles.linkDialogPreviewAnchor} - href={linkDialogState.url} + data-testid="link-dialog-preview" + href={linkDialogState.href ?? 'about:blank'} {...(urlIsExternal ? { target: '_blank', rel: 'noreferrer' } : {})} onClick={(e) => { if (onClickLinkCallback !== null) { diff --git a/src/plugins/link-dialog/index.ts b/src/plugins/link-dialog/index.ts index 9ce38256..6a2dea8a 100644 --- a/src/plugins/link-dialog/index.ts +++ b/src/plugins/link-dialog/index.ts @@ -13,6 +13,7 @@ import { COMMAND_PRIORITY_LOW, KEY_DOWN_COMMAND, KEY_ESCAPE_COMMAND, + type LexicalEditor, type LexicalNode, type RangeSelection } from 'lexical' @@ -47,6 +48,8 @@ export interface PreviewLinkDialog { type: 'preview' title: string url: string + /** The fail-closed URL used only for automatic preview navigation. */ + href?: string linkNodeKey: string rectangle: RectData } @@ -83,6 +86,18 @@ function getLinkNodeInSelection(selection: RangeSelection | null) { return null } +function getPreviewHref(editor: LexicalEditor | null, linkNodeKey: string, url: string): string { + if (!editor) return 'about:blank' + + return editor.getEditorState().read(() => { + const keyedNode = linkNodeKey === '' ? null : $getNodeByKey(linkNodeKey) + const selection = $getSelection() + const selectedNode = $isRangeSelection(selection) ? getLinkNodeInSelection(selection) : null + const linkNode = $isLinkNode(keyedNode) ? keyedNode : selectedNode + return linkNode?.sanitizeUrl(url) ?? 'about:blank' + }) +} + /** * Emits when the window is resized. * @group Utils @@ -209,7 +224,8 @@ export const linkDialogState$ = Cell<InactiveLinkDialog | PreviewLinkDialog | Ed linkNodeKey: state.linkNodeKey, rectangle: state.rectangle, title, - url + url, + href: getPreviewHref(editor, state.linkNodeKey ?? '', url) } as PreviewLinkDialog) } else { if (state.type === 'edit' && state.initialUrl !== '') { @@ -236,6 +252,7 @@ export const linkDialogState$ = Cell<InactiveLinkDialog | PreviewLinkDialog | Ed return { type: 'preview' as const, url: state.initialUrl, + href: getPreviewHref(editor, state.linkNodeKey, state.initialUrl), linkNodeKey: state.linkNodeKey, rectangle: state.rectangle } as PreviewLinkDialog @@ -266,6 +283,7 @@ export const linkDialogState$ = Cell<InactiveLinkDialog | PreviewLinkDialog | Ed return { type: 'preview', url: node.getURL(), + href: node.sanitizeUrl(node.getURL()), linkNodeKey: node.getKey(), title: node.getTitle(), rectangle: rect diff --git a/src/plugins/markdown-shortcut/index.tsx b/src/plugins/markdown-shortcut/index.tsx index 5d8b709a..3741b2ba 100644 --- a/src/plugins/markdown-shortcut/index.tsx +++ b/src/plugins/markdown-shortcut/index.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-deprecated -- The legacy React horizontal-rule path remains the compatibility implementation; extension migration is tracked separately. */ import React from 'react' import { BOLD_ITALIC_STAR, diff --git a/src/plugins/search/index.tsx b/src/plugins/search/index.tsx index 39d93735..9c0c5201 100644 --- a/src/plugins/search/index.tsx +++ b/src/plugins/search/index.tsx @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/no-unnecessary-condition */ import { Cell, debounceTime, useCell, useCellValue, useRealm } from '@mdxeditor/gurx' -import { $createRangeSelection, $getNearestNodeFromDOMNode, $isTextNode, getNearestEditorFromDOMNode } from 'lexical' +import { createDOMRange } from '@lexical/selection' +import { $createRangeSelection, $getNodeByKey, $getRoot, $isTextNode, HISTORY_PUSH_TAG, type LexicalEditor, type NodeKey } from 'lexical' import { realmPlugin } from '../../RealmWithPlugins' -import { contentEditableRef$, createRootEditorSubscription$ } from '../core' +import { activeEditor$, contentEditableRef$, createActiveEditorSubscription$, historyState$ } from '../core' +import { editorUsesHistoryState } from '../core/lexicalExtensions' export const EmptyTextNodeIndex: TextNodeIndex = { allText: '', @@ -20,7 +22,7 @@ export const editorSearchTermDebounced$ = Cell<string>('', (realm) => { }) export const editorSearchScrollableContent$ = Cell<HTMLElement | null>(null, (r) => r.sub(contentEditableRef$, (cref) => { - r.pub(editorSearchScrollableContent$, cref?.current?.parentNode ?? null) + r.pub(editorSearchScrollableContent$, cref?.current?.parentElement ?? null) }) ) @@ -37,6 +39,32 @@ export const debouncedIndexer$ = Cell<TextNodeIndex>(EmptyTextNodeIndex, (realm) realm.link(debouncedIndexer$, realm.pipe(editorSearchTextNodeIndex$, realm.transformer(debounceTime(250)))) }) +interface SearchPoint { + key: NodeKey + offset: number +} + +interface SearchUnit { + key: NodeKey + startOffset: number + endOffset: number +} + +interface SearchSnapshot { + editor: LexicalEditor + text: string + units: SearchUnit[] +} + +interface SearchStateMatch { + editor: LexicalEditor + start: SearchPoint + end: SearchPoint +} + +const editorSearchActiveEditor$ = Cell<LexicalEditor | null>(null) +const editorSearchStateMatches$ = Cell<SearchStateMatch[]>([]) + function* searchText(allText: string, searchQuery: string): Generator<[start: number, end: number]> { if (!searchQuery) { return @@ -45,12 +73,11 @@ function* searchText(allText: string, searchQuery: string): Generator<[start: nu let regex: RegExp try { regex = new RegExp(searchQuery, 'gi') - } catch (e) { - console.error('Invalid search pattern:', e) + } catch { return } - let match + let match: RegExpExecArray | null while ((match = regex.exec(allText)) !== null) { if (match[0].length === 0) { if (regex.lastIndex === match.index) { @@ -59,63 +86,19 @@ function* searchText(allText: string, searchQuery: string): Generator<[start: nu continue } - const start = match.index - const end = start + match[0].length - 1 - yield [start, end] + yield [match.index, match.index + match[0].length - 1] } } -/** - * Creates a single, unified index of all text nodes within valid content containers. - * This allows matches to span across different block-level elements. - */ -function indexAllTextNodes(root: HTMLElement | null): TextNodeIndex { - let allText = '' - const nodeIndex: Node[] = [] - const offsetIndex: number[] = [] - - if (!root) { - return { allText: '', nodeIndex, offsetIndex } - } - - // A CSS selector for all valid content-hosting elements. - const contentSelector = 'p, h1, h2, h3, h4, h5, h6, li, code, pre' - - const treeWalker = document.createTreeWalker( - root, - NodeFilter.SHOW_TEXT, - // The corrected heuristic: accept any text node that is a descendant of a valid content container. - (node) => { - // Use `closest()` on the parent to see if it's inside a valid container. - if (node.parentElement?.closest(contentSelector)) { - return NodeFilter.FILTER_ACCEPT - } - return NodeFilter.FILTER_REJECT - } - ) - - let currentNode: Node | null - while ((currentNode = treeWalker.nextNode())) { - const nodeContent = currentNode.textContent?.normalize('NFKD') ?? currentNode.textContent ?? '' - for (let i = 0; i < nodeContent.length; i++) { - nodeIndex.push(currentNode) - offsetIndex.push(i) - allText += nodeContent[i] ?? '' - } - } - - return { allText, nodeIndex, offsetIndex } -} - export function* rangeSearchScan(searchQuery: string, { allText, offsetIndex, nodeIndex }: TextNodeIndex) { for (const [start, end] of searchText(allText, searchQuery)) { const startOffset = offsetIndex[start] const endOffset = offsetIndex[end] const startNode = nodeIndex[start] const endNode = nodeIndex[end] - const range = new Range() + const ownerDocument = startNode?.ownerDocument ?? document + const range = ownerDocument.createRange() - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (startNode === undefined || endNode === undefined || startOffset === undefined || endOffset === undefined) { throw new Error('Invalid range: startNode, endNode, startOffset, or endOffset is undefined.') } @@ -125,19 +108,120 @@ export function* rangeSearchScan(searchQuery: string, { allText, offsetIndex, no yield range } } + +function createSearchSnapshot(editor: LexicalEditor): SearchSnapshot { + let text = '' + const units: SearchUnit[] = [] + + for (const node of $getRoot().getAllTextNodes()) { + const nodeText = node.getTextContent() + let sourceOffset = 0 + + while (sourceOffset < nodeText.length) { + const codePoint = nodeText.codePointAt(sourceOffset) + if (codePoint === undefined) break + + const sourceUnit = String.fromCodePoint(codePoint) + const normalizedUnit = sourceUnit.normalize('NFKD') + const mapsOneToOne = normalizedUnit === sourceUnit + + for (let normalizedOffset = 0; normalizedOffset < normalizedUnit.length; normalizedOffset++) { + const startOffset = mapsOneToOne ? sourceOffset + normalizedOffset : sourceOffset + const endOffset = mapsOneToOne ? startOffset + 1 : sourceOffset + sourceUnit.length + units.push({ key: node.getKey(), startOffset, endOffset }) + text += normalizedUnit[normalizedOffset] ?? '' + } + + sourceOffset += sourceUnit.length + } + } + + return { editor, text, units } +} + +function findStateMatches(snapshot: SearchSnapshot, searchQuery: string): SearchStateMatch[] { + const matches: SearchStateMatch[] = [] + + for (const [startIndex, endIndex] of searchText(snapshot.text, searchQuery)) { + const startUnit = snapshot.units[startIndex] + const endUnit = snapshot.units[endIndex] + if (!startUnit || !endUnit) continue + + matches.push({ + editor: snapshot.editor, + start: { key: startUnit.key, offset: startUnit.startOffset }, + end: { key: endUnit.key, offset: endUnit.endOffset } + }) + } + + return matches +} + +function projectSnapshot(snapshot: SearchSnapshot, matches: SearchStateMatch[]) { + const nodeIndex: Node[] = [] + const offsetIndex: number[] = [] + const domTextNodes = new Map<NodeKey, Node>() + const projectedMatches: { match: SearchStateMatch; range: Range }[] = [] + let projectionComplete = true + + snapshot.editor.getEditorState().read(() => { + for (const unit of snapshot.units) { + let domTextNode = domTextNodes.get(unit.key) + if (!domTextNode) { + const lexicalNode = $getNodeByKey(unit.key) + if (!$isTextNode(lexicalNode)) { + projectionComplete = false + break + } + const nodeRange = createDOMRange(snapshot.editor, lexicalNode, 0, lexicalNode, lexicalNode.getTextContentSize()) + if (!nodeRange || nodeRange.startContainer !== nodeRange.endContainer) { + projectionComplete = false + break + } + domTextNode = nodeRange.startContainer + domTextNodes.set(unit.key, domTextNode) + } + nodeIndex.push(domTextNode) + offsetIndex.push(unit.startOffset) + } + + for (const match of matches) { + const startNode = $getNodeByKey(match.start.key) + const endNode = $getNodeByKey(match.end.key) + if (!$isTextNode(startNode) || !$isTextNode(endNode)) continue + + const range = createDOMRange(snapshot.editor, startNode, match.start.offset, endNode, match.end.offset) + if (range) projectedMatches.push({ match, range }) + } + }) + + return { + index: projectionComplete ? { allText: snapshot.text, nodeIndex, offsetIndex } : EmptyTextNodeIndex, + projectedMatches + } +} + +function supportsHighlights() { + return typeof CSS !== 'undefined' && typeof CSS.highlights !== 'undefined' && typeof Highlight !== 'undefined' +} + const focusHighlightRange = (range?: Range | null) => { + if (!supportsHighlights()) return CSS.highlights.delete(MDX_FOCUS_SEARCH_NAME) if (range) CSS.highlights.set(MDX_FOCUS_SEARCH_NAME, new Highlight(range)) } const highlightRanges = (ranges: Range[] | Iterable<Range>) => { + if (!supportsHighlights()) return CSS.highlights.set(MDX_SEARCH_NAME, new Highlight(...ranges)) } const resetHighlights = () => { + if (!supportsHighlights()) return CSS.highlights.delete(MDX_SEARCH_NAME) CSS.highlights.delete(MDX_FOCUS_SEARCH_NAME) } + const scrollToRange = ( range: Range, contentEditable: HTMLElement | undefined, @@ -147,10 +231,8 @@ const scrollToRange = ( behavior?: ScrollBehavior } ) => { - // Set defaults if options or any property is undefined const ignoreIfInView = options?.ignoreIfInView ?? true const behavior = options?.behavior ?? 'smooth' - const [first] = range.getClientRects() if (!contentEditable) { @@ -162,86 +244,145 @@ const scrollToRange = ( return } - // Get bounding rects relative to the scroll container const containerRect = contentEditable.getBoundingClientRect() const topRelativeToContainer = first.top - containerRect.top const bottomRelativeToContainer = first.bottom - containerRect.top - // Optionally ignore if already in view if (ignoreIfInView) { - // The visible area is [scrollTop, scrollTop + clientHeight] - // The range is in view if its top and bottom are within this area const rangeTop = topRelativeToContainer + contentEditable.scrollTop const rangeBottom = bottomRelativeToContainer + contentEditable.scrollTop const visibleTop = contentEditable.scrollTop const visibleBottom = visibleTop + contentEditable.clientHeight - const inView = rangeTop >= visibleTop && rangeBottom <= visibleBottom - - if (inView) return + if (rangeTop >= visibleTop && rangeBottom <= visibleBottom) return } - // Scroll so the range is near the top, with some offset if desired - const top = topRelativeToContainer + contentEditable.scrollTop - first.height // adjust this offset as needed - + const top = topRelativeToContainer + contentEditable.scrollTop - first.height contentEditable.scrollTo({ top, behavior }) } -function isSimilarRange(range1: Pick<Range, 'startContainer' | 'startOffset'>, range2: Pick<Range, 'startContainer' | 'startOffset'>) { - return range1.startContainer === range2.startContainer && range1.startOffset === range2.startOffset +function isSameStart(first: SearchStateMatch | undefined, second: SearchStateMatch) { + return first?.editor === second.editor && first.start.key === second.start.key && first.start.offset === second.start.offset +} + +function clearSearchResults(realm: ReturnType<typeof useRealm>) { + realm.pubIn({ + [editorSearchStateMatches$]: [], + [editorSearchRanges$]: [], + [editorSearchCursor$]: 0 + }) + resetHighlights() } -function replaceTextInRange(range: Range, str: string, onUpdate?: () => void) { - const startDomNode = range.startContainer - const endDomNode = range.endContainer - const startOffset = range.startOffset - const endOffset = range.endOffset +function refreshSearch( + realm: ReturnType<typeof useRealm>, + editor: LexicalEditor, + advanceFrom?: { cursor: number; match: SearchStateMatch } +) { + if (realm.getValue(editorSearchActiveEditor$) !== editor) return + + let snapshot!: SearchSnapshot + editor.getEditorState().read(() => { + snapshot = createSearchSnapshot(editor) + }) + + const searchOpen = realm.getValue(searchOpen$) + const searchQuery = realm.getValue(editorSearchTerm$) + const stateMatches = searchOpen ? findStateMatches(snapshot, searchQuery) : [] + const { index, projectedMatches } = projectSnapshot(snapshot, stateMatches) + + if (searchOpen) { + realm.pub(editorSearchTextNodeIndex$, index) + } else { + realm.pub(debouncedIndexer$, index) + } - const editor = getNearestEditorFromDOMNode(startDomNode) - if (!editor) { - console.warn('No editor found for the provided DOM node.') + if (!searchOpen || !searchQuery || projectedMatches.length === 0) { + clearSearchResults(realm) return } + + const matches = projectedMatches.map(({ match }) => match) + const ranges = projectedMatches.map(({ range }) => range) + const previousCursor = realm.getValue(editorSearchCursor$) + let cursor = Math.min(Math.max(previousCursor, 1), ranges.length) + + if (advanceFrom && isSameStart(matches[advanceFrom.cursor - 1], advanceFrom.match)) { + cursor = (advanceFrom.cursor % ranges.length) + 1 + } + + realm.pubIn({ + [editorSearchStateMatches$]: matches, + [editorSearchRanges$]: ranges, + [editorSearchCursor$]: cursor + }) + highlightRanges(ranges) + focusHighlightRange(ranges[cursor - 1]) +} + +function resolveMatch(match: SearchStateMatch) { + const startNode = $getNodeByKey(match.start.key) + const endNode = $getNodeByKey(match.end.key) + if (!$isTextNode(startNode) || !$isTextNode(endNode)) return null + if (match.start.offset < 0 || match.start.offset > startNode.getTextContentSize()) return null + if (match.end.offset < 0 || match.end.offset > endNode.getTextContentSize()) return null + return { startNode, endNode } +} + +function replaceStateMatches(editor: LexicalEditor, matches: SearchStateMatch[], replacement: string, onUpdate?: () => void) { + if (matches.length === 0 || matches.some((match) => match.editor !== editor)) return + editor.update( () => { - // 1. Find the Lexical nodes corresponding to the DOM nodes in your range. - const startLexicalNode = $getNearestNodeFromDOMNode(startDomNode) - const endLexicalNode = $getNearestNodeFromDOMNode(endDomNode) + if (matches.some((match) => resolveMatch(match) === null)) return + + for (let index = matches.length - 1; index >= 0; index--) { + const match = matches[index] + if (!match) continue + const resolved = resolveMatch(match) + if (!resolved) { + throw new Error('Search match became stale during replacement.') + } - // 2. Safety check: Ensure they are valid TextNodes. - if (!$isTextNode(startLexicalNode) || !$isTextNode(endLexicalNode)) { - return - } + if (resolved.startNode.is(resolved.endNode)) { + const replacedNode = resolved.startNode.spliceText(match.start.offset, match.end.offset - match.start.offset, replacement, true) + if (replacedNode.getTextContentSize() === 0) { + replacedNode.remove() + } + continue + } - // 3. Create a Lexical RangeSelection that mirrors your DOM Range. - try { const selection = $createRangeSelection() - selection.anchor.set(startLexicalNode.getKey(), startOffset, 'text') - selection.focus.set(endLexicalNode.getKey(), endOffset, 'text') - - // 4. Perform the replacement. This deletes the selected content - // and inserts the new string. - selection.insertText(str) - } catch (e) { - console.warn('Error replacing text in the editor:', e) - if (onUpdate) { - onUpdate() - } - // Optionally, you can throw an error or handle it gracefully. - // throw new Error("Failed to replace text in the editor"); + selection.anchor.set(resolved.startNode.getKey(), match.start.offset, 'text') + selection.focus.set(resolved.endNode.getKey(), match.end.offset, 'text') + selection.insertText(replacement) } }, { + tag: HISTORY_PUSH_TAG, onUpdate } ) } +function ensureHistoryBaseline(realm: ReturnType<typeof useRealm>, editor: LexicalEditor) { + const historyState = realm.getValue(historyState$) + if (!editorUsesHistoryState(editor, historyState)) { + return + } + if (historyState.current?.editor !== editor) { + if (historyState.current) { + historyState.undoStack.push(historyState.current) + } + historyState.current = { editor, editorState: editor.getEditorState() } + } +} + export function useEditorSearch() { const realm = useRealm() const ranges = useCellValue(editorSearchRanges$) const cursor = useCellValue(editorSearchCursor$) const search = useCellValue(editorSearchTerm$) - const currentRange = ranges[cursor - 1] ?? null + const currentRange: Range | null = cursor > 0 ? ranges.at(cursor - 1) ?? null : null const contentEditable = useCellValue(editorSearchScrollableContent$) const [isSearchOpen, setIsSearchOpen] = useCell(searchOpen$) @@ -255,13 +396,12 @@ export function useEditorSearch() { setIsSearchOpen(!isSearchOpen) } - const rangeCount = ranges.length const scrollToRangeOrIndex = (range: Range | number, options?: { ignoreIfInView?: boolean; behavior?: ScrollBehavior }) => { const scrollRange = typeof range === 'number' ? ranges[range - 1] : range if (!scrollRange) { throw new Error('Error scrolling to range, range does not exist') } - scrollToRange(scrollRange, contentEditable!, options) + scrollToRange(scrollRange, contentEditable ?? undefined, options) } const setSearch = (term: string | null) => { @@ -269,74 +409,51 @@ export function useEditorSearch() { realm.pub(editorSearchCursor$, 0) } realm.pub(editorSearchTermDebounced$, term ?? '') - //reset cursor } const next = () => { if (!ranges.length) return - const newVal = (cursor % ranges.length) + 1 - scrollToRangeOrIndex(newVal) - realm.pub(editorSearchCursor$, newVal) + const newCursor = (cursor % ranges.length) + 1 + scrollToRangeOrIndex(newCursor) + realm.pub(editorSearchCursor$, newCursor) } const prev = () => { if (!ranges.length) return - const newVal = cursor <= 1 ? ranges.length : cursor - 1 - scrollToRangeOrIndex(newVal) - realm.pub(editorSearchCursor$, newVal) + const newCursor = cursor <= 1 ? ranges.length : cursor - 1 + scrollToRangeOrIndex(newCursor) + realm.pub(editorSearchCursor$, newCursor) } - const replace = (str: string, onUpdate?: () => void) => { - const currentRange = ranges[cursor - 1] - if (!currentRange) { - return - } - const { startContainer, startOffset } = currentRange ?? {} - replaceTextInRange(currentRange, str, () => { - //when the replaced text continues to match the search term - //cursor must be incremented to the next match - const unsub = realm.sub(editorSearchRanges$, (newRanges) => { - unsub() - if ( - isSimilarRange(newRanges[cursor - 1] ?? {}, { - startOffset, - startContainer - }) - ) { - realm.pub(editorSearchCursor$, (cursor + 1) % (newRanges.length + 1) || 1) - } - }) + const replace = (replacement: string, onUpdate?: () => void) => { + const editor = realm.getValue(editorSearchActiveEditor$) + const matches = realm.getValue(editorSearchStateMatches$) + const match = matches[cursor - 1] + if (!editor || !match) return + + ensureHistoryBaseline(realm, editor) + replaceStateMatches(editor, [match], replacement, () => { + refreshSearch(realm, editor, { cursor, match }) onUpdate?.() }) } - const replaceAll = (str: string, onUpdate?: () => void) => { - const runReplaceAll = () => { - let ticks = 0 - for (let i = ranges.length - 1; i >= 0; i--) { - const textReplaceRange = ranges[i] - if (!textReplaceRange) { - throw new Error('error replacing all text range does not exist') - } - replaceTextInRange(textReplaceRange, str, () => { - ticks++ - if (ticks >= ranges.length) { - onUpdate?.() - } - }) - } - } - if (typeof requestIdleCallback === 'function') { - requestIdleCallback(runReplaceAll) - } else { - setTimeout(runReplaceAll, 0) - } + const replaceAll = (replacement: string, onUpdate?: () => void) => { + const editor = realm.getValue(editorSearchActiveEditor$) + const matches = realm.getValue(editorSearchStateMatches$) + if (!editor || matches.length === 0) return + + ensureHistoryBaseline(realm, editor) + replaceStateMatches(editor, matches, replacement, () => { + refreshSearch(realm, editor) + onUpdate?.() + }) } return { next, prev, - total: rangeCount, + total: ranges.length, cursor, setSearch, search, @@ -354,81 +471,74 @@ export function useEditorSearch() { } export const searchPlugin = realmPlugin({ - //TODO: ensure proper event cleanup init(realm) { - if (typeof CSS.highlights === 'undefined') { - console.warn('CSS.highlights is not supported in this browser. Search functionality will be limited.') - return + if (!supportsHighlights()) { + console.warn('CSS.highlights is not supported in this browser. Search highlighting will be unavailable.') } + realm.sub(editorSearchCursor$, (cursor) => { - const ranges = realm.getValue(editorSearchRanges$) - focusHighlightRange(ranges[cursor - 1]) + if (realm.getValue(searchOpen$)) { + focusHighlightRange(realm.getValue(editorSearchRanges$)[cursor - 1]) + } + }) + + realm.sub(editorSearchTerm$, () => { + const editor = realm.getValue(editorSearchActiveEditor$) + if (editor) refreshSearch(realm, editor) }) - const updateHighlights = (searchQuery: string, textNodeIndex: TextNodeIndex) => { - if (!searchQuery) { - realm.pub(editorSearchCursor$, 0) - realm.pub(editorSearchRanges$, []) - resetHighlights() + realm.sub(searchOpen$, (searchOpen) => { + if (!searchOpen) { + clearSearchResults(realm) return } - const ranges = Array.from(rangeSearchScan(searchQuery, textNodeIndex)) - realm.pub(editorSearchRanges$, ranges) - highlightRanges(ranges) - if (ranges.length) { - const currentCursor = realm.getValue(editorSearchCursor$) || 1 - focusHighlightRange(ranges[currentCursor - 1]) - realm.pub(editorSearchCursor$, currentCursor) - const scrollRange = ranges[currentCursor - 1] - if (!scrollRange) throw new Error('error updating highlights, scroll range does not exist') - const contentEditable = realm.getValue(editorSearchScrollableContent$) - scrollToRange(scrollRange, contentEditable!, { - ignoreIfInView: true - }) - } else { - resetHighlights() - } - } - - realm.sub(editorSearchTextNodeIndex$, (textNodeIndex) => { - updateHighlights(realm.getValue(editorSearchTerm$), textNodeIndex) + const editor = realm.getValue(editorSearchActiveEditor$) + if (editor) refreshSearch(realm, editor) }) - realm.sub(editorSearchTerm$, (searchQuery) => { - updateHighlights(searchQuery, realm.getValue(editorSearchTextNodeIndex$)) - }) + realm.pub(createActiveEditorSubscription$, (editor) => { + realm.pubIn({ + [editorSearchActiveEditor$]: editor, + [editorSearchTextNodeIndex$]: EmptyTextNodeIndex, + [editorSearchScrollableContent$]: editor.getRootElement()?.parentElement ?? null + }) + clearSearchResults(realm) + refreshSearch(realm, editor) - realm.pub(createRootEditorSubscription$, (editor) => { - let observer: MutationObserver | null = null - return editor.registerRootListener((rootElement) => { - if (observer) { - observer.disconnect() - observer = null - } - if (rootElement) { - //why is this in an array? - const initialIndex = indexAllTextNodes(rootElement) - realm.pub(editorSearchTextNodeIndex$, initialIndex) - - observer = new MutationObserver(() => { - const newIndex = indexAllTextNodes(rootElement) - if (realm.getValue(searchOpen$)) { - realm.pub(editorSearchTextNodeIndex$, newIndex) - } else { - //TODO: indexing on every update may be too heavy handed, - // an index should only happen when search is made active IF the - // the search closeOn on blur is set to true - realm.pub(debouncedIndexer$, newIndex) - } - }) - observer.observe(rootElement, { - childList: true, - subtree: true, - characterData: true - }) - return () => observer?.disconnect() + const unregisterUpdate = editor.registerUpdateListener(({ dirtyElements, dirtyLeaves }) => { + if (dirtyElements.size > 0 || dirtyLeaves.size > 0) refreshSearch(realm, editor) + }) + const unregisterRoot = editor.registerRootListener((rootElement) => { + if (!rootElement) { + if (realm.getValue(editorSearchActiveEditor$) === editor) { + realm.pubIn({ + [editorSearchTextNodeIndex$]: EmptyTextNodeIndex, + [editorSearchScrollableContent$]: null + }) + clearSearchResults(realm) + } + return } + + realm.pub(editorSearchScrollableContent$, rootElement.parentElement) + refreshSearch(realm, editor) }) + + return () => { + unregisterUpdate() + unregisterRoot() + if (realm.getValue(editorSearchActiveEditor$) === editor) { + realm.pub(editorSearchActiveEditor$, null) + clearSearchResults(realm) + } + } + }) + + realm.sub(activeEditor$, (editor) => { + if (!editor) { + realm.pub(editorSearchScrollableContent$, null) + clearSearchResults(realm) + } }) } }) diff --git a/src/plugins/table/TableEditor.tsx b/src/plugins/table/TableEditor.tsx index a81ff2e9..62deba76 100644 --- a/src/plugins/table/TableEditor.tsx +++ b/src/plugins/table/TableEditor.tsx @@ -1,6 +1,6 @@ import { ContentEditable } from '@lexical/react/LexicalContentEditable' import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' -import { LexicalNestedComposer } from '@lexical/react/LexicalNestedComposer' +import { LexicalExtensionEditorComposer } from '@lexical/react/LexicalExtensionEditorComposer' import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' import * as RadixPopover from '@radix-ui/react-popover' import { @@ -10,21 +10,18 @@ import { COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_LOW, - EditorThemeClasses, FOCUS_COMMAND, KEY_ENTER_COMMAND, KEY_TAB_COMMAND, LexicalEditor, - createEditor + LexicalEditorWithDispose } from 'lexical' import * as Mdast from 'mdast' import React, { ElementType } from 'react' import { exportLexicalTreeToMdast } from '../../exportMarkdownFromLexical' import { importMdastTreeToLexical } from '../../importMarkdownToLexical' -import { lexicalTheme } from '../../styles/lexicalTheme' import { TableNode } from './TableNode' -import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin' import { mergeRegister } from '@lexical/utils' import * as RadixToolbar from '@radix-ui/react-toolbar' import classNames from 'classnames' @@ -49,6 +46,7 @@ import { usedLexicalNodes$ } from '../core' import { useCellValues } from '@mdxeditor/gurx' +import { createExtensionEditor } from '../core/lexicalExtensions' /** * Returns the element type for the cell based on the rowIndex @@ -362,30 +360,40 @@ const CellEditor: React.FC<CellProps> = ({ focus, setActiveCell, parentEditor, l tableCellEditorChildren$ ) - const [editor] = React.useState(() => { - const editor = createEditor({ - nodes: usedLexicalNodes, - theme: lexicalTheme as EditorThemeClasses, - namespace: 'TableCellEditor' - }) + const [editor, setEditor] = React.useState<LexicalEditorWithDispose | null>(null) - editor.update(() => { - importMdastTreeToLexical({ - root: $getRoot(), - mdastRoot: { type: 'root', children: [{ type: 'paragraph', children: contents }] }, - visitors: importVisitors, - jsxComponentDescriptors, - directiveDescriptors, - codeBlockEditorDescriptors, - defaultCodeBlockLanguage - }) + React.useEffect(() => { + const editor = createExtensionEditor({ + name: '@mdxeditor/table-cell', + nodes: usedLexicalNodes, + namespace: 'TableCellEditor', + parentEditor, + historyMode: 'table-local', + initialEditorState: () => { + importMdastTreeToLexical({ + root: $getRoot(), + mdastRoot: { type: 'root', children: [{ type: 'paragraph', children: contents }] }, + visitors: importVisitors, + jsxComponentDescriptors, + directiveDescriptors, + codeBlockEditorDescriptors, + defaultCodeBlockLanguage + }) + } }) - - return editor - }) + setEditor(editor) + return () => { + editor.dispose() + } + // The editor owns the complete mount-time plugin/configuration snapshot. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) const saveAndFocus = React.useCallback( (nextCell: [number, number] | null) => { + if (!editor) { + return + } editor.getEditorState().read(() => { const mdast = exportLexicalTreeToMdast({ root: $getRoot(), @@ -408,6 +416,9 @@ const CellEditor: React.FC<CellProps> = ({ focus, setActiveCell, parentEditor, l ) React.useEffect(() => { + if (!editor) { + return + } return mergeRegister( editor.registerCommand( KEY_TAB_COMMAND, @@ -466,20 +477,23 @@ const CellEditor: React.FC<CellProps> = ({ focus, setActiveCell, parentEditor, l }, [colIndex, editor, rootEditor, rowIndex, saveAndFocus, setActiveCell]) React.useEffect(() => { - if (focus) { + if (focus && editor) { editor.focus() } }, [focus, editor]) + if (!editor) { + return null + } + return ( - <LexicalNestedComposer initialEditor={editor}> + <LexicalExtensionEditorComposer initialEditor={editor}> <RichTextPlugin contentEditable={<ContentEditable />} placeholder={<div></div>} ErrorBoundary={LexicalErrorBoundary} /> {tableCellEditorChildren.map((Child, index) => ( <Child key={index} /> ))} - <HistoryPlugin /> - </LexicalNestedComposer> + </LexicalExtensionEditorComposer> ) } diff --git a/src/plugins/thematic-break/LexicalThematicBreakVisitor.ts b/src/plugins/thematic-break/LexicalThematicBreakVisitor.ts index 5e2c1e7a..f9dc0dda 100644 --- a/src/plugins/thematic-break/LexicalThematicBreakVisitor.ts +++ b/src/plugins/thematic-break/LexicalThematicBreakVisitor.ts @@ -1,9 +1,10 @@ -import { $isHorizontalRuleNode, HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode.js' +/* eslint-disable @typescript-eslint/no-deprecated -- The legacy React horizontal-rule path remains the compatibility implementation; extension migration is tracked separately. */ +import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode.js' import * as Mdast from 'mdast' import { LexicalExportVisitor } from '../../exportMarkdownFromLexical' export const LexicalThematicBreakVisitor: LexicalExportVisitor<HorizontalRuleNode, Mdast.ThematicBreak> = { - testLexicalNode: $isHorizontalRuleNode, + testLexicalNode: (node): node is HorizontalRuleNode => node instanceof HorizontalRuleNode, visitLexicalNode({ actions }) { actions.addAndStepInto('thematicBreak') } diff --git a/src/plugins/thematic-break/MdastThematicBreakVisitor.ts b/src/plugins/thematic-break/MdastThematicBreakVisitor.ts index b1a36d51..3d6c0431 100644 --- a/src/plugins/thematic-break/MdastThematicBreakVisitor.ts +++ b/src/plugins/thematic-break/MdastThematicBreakVisitor.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-deprecated -- The legacy React horizontal-rule path remains the compatibility implementation; extension migration is tracked separately. */ import { $createHorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode.js' import * as Mdast from 'mdast' import { MdastImportVisitor } from '../../importMarkdownToLexical' diff --git a/src/plugins/thematic-break/index.ts b/src/plugins/thematic-break/index.ts index 038c1872..a2c735a7 100644 --- a/src/plugins/thematic-break/index.ts +++ b/src/plugins/thematic-break/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-deprecated -- The legacy React horizontal-rule path remains the compatibility implementation; extension migration is tracked separately. */ import { realmPlugin } from '../../RealmWithPlugins' import { HorizontalRuleNode, INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode.js' import { HorizontalRulePlugin } from '@lexical/react/LexicalHorizontalRulePlugin.js' diff --git a/src/realmSession.ts b/src/realmSession.ts new file mode 100644 index 00000000..82bbbda1 --- /dev/null +++ b/src/realmSession.ts @@ -0,0 +1,39 @@ +import { Realm } from '@mdxeditor/gurx' + +const realmCleanups = new WeakMap<Realm, Set<() => void>>() + +export function registerRealmCleanup(realm: Realm, cleanup: () => void): () => void { + let cleanups = realmCleanups.get(realm) + if (!cleanups) { + cleanups = new Set() + realmCleanups.set(realm, cleanups) + } + cleanups.add(cleanup) + return () => { + cleanups.delete(cleanup) + } +} + +export function disposeRealmSession(realm: Realm): void { + const cleanups = realmCleanups.get(realm) + realmCleanups.delete(realm) + if (!cleanups) { + return + } + + let hasError = false + let firstError: unknown + for (const cleanup of [...cleanups].reverse()) { + try { + cleanup() + } catch (error) { + if (!hasError) { + hasError = true + firstError = error + } + } + } + if (hasError) { + throw firstError instanceof Error ? firstError : new Error('Realm cleanup failed with a non-Error value') + } +} diff --git a/src/test/compatibility.test.tsx b/src/test/compatibility.test.tsx new file mode 100644 index 00000000..7074763b --- /dev/null +++ b/src/test/compatibility.test.tsx @@ -0,0 +1,58 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import React from 'react' +import { describe, expect, it } from 'vitest' +import { LexicalCompatibilityHarness } from './fixtures/LexicalCompatibilityHarness' +import { alternateCompatibilityMarkdown, compatibilityMarkdown035, maxLengthInitialMarkdown } from './fixtures/lexicalCompatibility' + +describe('Lexical 0.35 compatibility contract', () => { + it('round-trips the representative Markdown fixture through public methods', async () => { + render(<LexicalCompatibilityHarness />) + + fireEvent.click(screen.getByRole('button', { name: 'Get Markdown' })) + expect(screen.getByLabelText('Current Markdown').textContent).toBe(compatibilityMarkdown035) + + fireEvent.click(screen.getByRole('button', { name: 'Set Alternate' })) + expect( + await screen.findByRole('heading', { + name: 'Alternate compatibility document' + }) + ).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Get Markdown' })) + await waitFor(() => { + expect(screen.getByLabelText('Current Markdown').textContent).toBe(alternateCompatibilityMarkdown.trimEnd()) + }) + + fireEvent.click(screen.getByRole('button', { name: 'Reset Compatibility Markdown' })) + expect(await screen.findByRole('heading', { name: 'Compatibility heading' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Get Markdown' })) + await waitFor(() => { + expect(screen.getByLabelText('Current Markdown').textContent).toBe(compatibilityMarkdown035) + }) + }) + + it('renders the supported custom constructs without private editor access', async () => { + render(<LexicalCompatibilityHarness />) + + expect(screen.getByLabelText('Compatibility Error')).toBeEmptyDOMElement() + expect(await screen.findByRole('heading', { name: 'Compatibility heading' })).toBeInTheDocument() + expect(await screen.findByText('Admonition compatibility content.')).toBeInTheDocument() + expect(screen.getByTestId('compatibility-nested-editor')).toHaveTextContent('Nested compatibility content.') + expect(screen.getByTestId('compatibility-code-block')).toHaveValue('const compatible = true') + expect(screen.getByRole('table')).toHaveTextContent('TableStable') + }) + + it('keeps collapsed selection empty and exposes the capped editor baseline', () => { + render(<LexicalCompatibilityHarness />) + + const rootEditor = screen.getByTestId('compatibility-root-editor') + const contentEditable = rootEditor.querySelector<HTMLElement>('[contenteditable="true"]') + expect(contentEditable).not.toBeNull() + contentEditable?.focus() + + fireEvent.click(screen.getByRole('button', { name: 'Get Selection Markdown' })) + expect(screen.getByLabelText('Selection Markdown')).toBeEmptyDOMElement() + + const cappedEditor = within(screen.getByTestId('compatibility-max-length-editor')) + expect(cappedEditor.getByText(maxLengthInitialMarkdown)).toBeInTheDocument() + }) +}) diff --git a/src/test/core.test.tsx b/src/test/core.test.tsx index 528e892e..a81120b6 100644 --- a/src/test/core.test.tsx +++ b/src/test/core.test.tsx @@ -1,6 +1,6 @@ import React from 'react' import { describe, expect, it, test, vi } from 'vitest' -import { codeBlockPlugin, codeMirrorPlugin, MDXEditor, MDXEditorMethods } from '../' +import { codeBlockPlugin, codeMirrorPlugin, MDXEditor, MDXEditorMethods, thematicBreakPlugin } from '../' import { render } from '@testing-library/react' import { $getRoot, createEditor, ParagraphNode, TextNode } from 'lexical' import { QuoteNode } from '@lexical/rich-text' @@ -186,6 +186,45 @@ After fence. expect(html).toContain('After fence.') expect(ref.current?.getMarkdown().trim()).toEqual(markdown) }) + + it('imports valid and malformed code block JSON without leaking invalid custom fields', () => { + const editor = createEditor({ + namespace: 'code-block-json-test', + nodes: [CodeBlockNode], + onError(error) { + throw error + } + }) + + editor.update( + () => { + const serializedNodes = [ + CodeBlockNode.importJSON({ + type: 'codeblock', + version: 1, + code: 'const valid = true', + language: 'ts', + meta: 'live' + }), + CodeBlockNode.importJSON({ type: 'codeblock', version: 1 }), + CodeBlockNode.importJSON({ type: 'codeblock', version: 1, code: 42, language: null, meta: false }) + ] + expect(serializedNodes.map((node) => node.exportJSON())).toEqual([ + { type: 'codeblock', version: 1, code: 'const valid = true', language: 'ts', meta: 'live' }, + { type: 'codeblock', version: 1, code: '', language: '', meta: '' }, + { type: 'codeblock', version: 1, code: '', language: '', meta: '' } + ]) + }, + { discrete: true } + ) + }) + + it('round-trips thematic breaks through the retained React node path', () => { + const ref = React.createRef<MDXEditorMethods>() + render(<MDXEditor ref={ref} markdown={'Before\n\n***\n\nAfter'} plugins={[thematicBreakPlugin()]} />) + + expect(ref.current?.getMarkdown().trim()).toEqual('Before\n\n***\n\nAfter') + }) }) describe('List parsing and serialization', () => { diff --git a/src/test/extension-composer.test.tsx b/src/test/extension-composer.test.tsx new file mode 100644 index 00000000..b2a2426f --- /dev/null +++ b/src/test/extension-composer.test.tsx @@ -0,0 +1,400 @@ +import { getExtensionDependencyFromEditor } from '@lexical/extension' +import { createEmptyHistoryState, HistoryExtension } from '@lexical/history' +import { LexicalExtensionEditorComposer } from '@lexical/react/LexicalExtensionEditorComposer' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { Realm, useRealm } from '@mdxeditor/gurx' +import { render, waitFor } from '@testing-library/react' +import { + $createParagraphNode, + $createTextNode, + $getRoot, + HISTORY_PUSH_TAG, + TextNode, + UNDO_COMMAND, + type LexicalEditorWithDispose +} from 'lexical' +import React from 'react' +import { describe, expect, it } from 'vitest' +import { RealmWithPlugins, type RealmPlugin } from '../RealmWithPlugins' +import { disposeRealmSession, registerRealmCleanup } from '../realmSession' +import { createExtensionEditor, editorUsesHistoryState } from '../plugins/core/lexicalExtensions' +import { MDXEditor, type MDXEditorMethods } from '../MDXEditor' + +class ConsumerTextNode extends TextNode { + static getType() { + return 'consumer-text' + } + + static clone(node: ConsumerTextNode) { + return new ConsumerTextNode(node.__text, node.__key) + } +} + +function replaceText(text: string) { + $getRoot() + .clear() + .append($createParagraphNode().append($createTextNode(text))) +} + +function readText(editor: LexicalEditorWithDispose) { + return editor.getEditorState().read(() => $getRoot().getTextContent()) +} + +describe('extension editor construction', () => { + it('provides normal React context and preserves consumer node registrations', async () => { + const editor = createExtensionEditor({ + name: 'test-root-context', + namespace: 'test-root-context', + nodes: [ConsumerTextNode], + historyMode: 'none', + initialEditorState: () => { + replaceText('root') + } + }) + let contextEditor: LexicalEditorWithDispose | undefined + const CaptureEditor = () => { + const [capturedEditor] = useLexicalComposerContext() + contextEditor = capturedEditor as LexicalEditorWithDispose + return null + } + + const view = render( + <LexicalExtensionEditorComposer initialEditor={editor}> + <CaptureEditor /> + </LexicalExtensionEditorComposer> + ) + + expect(contextEditor).toBe(editor) + expect(editor.hasNodes([ConsumerTextNode])).toBe(true) + await waitFor(() => { + expect(readText(editor)).toBe('root') + }) + view.unmount() + editor.dispose() + }) + + it('shares normal nested history and inherits parent editability', async () => { + const historyState = createEmptyHistoryState() + const root = createExtensionEditor({ + name: 'test-shared-root', + namespace: 'test-shared-root', + nodes: [], + historyMode: 'root-shared', + historyState, + initialEditorState: () => { + replaceText('root-one') + } + }) + const nested = createExtensionEditor({ + name: 'test-shared-nested', + namespace: 'test-shared-nested', + nodes: [], + parentEditor: root, + historyMode: 'nested-shared', + initialEditorState: () => { + replaceText('nested-one') + } + }) + + expect(nested._parentEditor).toBe(root) + expect(getExtensionDependencyFromEditor(root, HistoryExtension).output.historyState.value).toBe(historyState) + expect(getExtensionDependencyFromEditor(nested, HistoryExtension).output.historyState.value).toBe(historyState) + expect(editorUsesHistoryState(root, historyState)).toBe(true) + expect(editorUsesHistoryState(nested, historyState)).toBe(true) + + await waitFor(() => { + expect(readText(nested)).toBe('nested-one') + }) + expect(historyState.current).toBeNull() + expect(historyState.undoStack).toEqual([]) + expect(historyState.redoStack).toEqual([]) + nested.update( + () => { + replaceText('nested-one') + }, + { discrete: true, tag: HISTORY_PUSH_TAG } + ) + expect(historyState.current?.editor).toBe(nested) + expect(historyState.undoStack).toEqual([]) + nested.update( + () => { + replaceText('nested-two') + }, + { discrete: true, tag: HISTORY_PUSH_TAG } + ) + expect(historyState.undoStack).toHaveLength(1) + nested.dispatchCommand(UNDO_COMMAND, undefined) + await waitFor(() => { + expect(readText(nested)).toBe('nested-one') + }) + + root.setEditable(false) + expect(nested.isEditable()).toBe(false) + nested.dispose() + expect(() => { + nested.dispose() + }).not.toThrow() + root.dispose() + }) + + it('keeps suppressed nested history external and table history local', () => { + const externalHistory = createEmptyHistoryState() + const parent = createExtensionEditor({ + name: 'test-no-history-root', + namespace: 'test-no-history-root', + nodes: [], + historyMode: 'none' + }) + const nested = createExtensionEditor({ + name: 'test-external-nested', + namespace: 'test-external-nested', + nodes: [], + parentEditor: parent, + historyMode: 'nested-external', + historyState: externalHistory + }) + const table = createExtensionEditor({ + name: 'test-local-table', + namespace: 'test-local-table', + nodes: [], + parentEditor: parent, + historyMode: 'table-local' + }) + + expect(getExtensionDependencyFromEditor(nested, HistoryExtension).output.historyState.value).toBe(externalHistory) + const tableHistory = getExtensionDependencyFromEditor(table, HistoryExtension).output.historyState.value + expect(tableHistory).not.toBe(externalHistory) + expect(editorUsesHistoryState(table, externalHistory)).toBe(false) + expect(editorUsesHistoryState(table, tableHistory)).toBe(true) + expect(tableHistory.current).toBeNull() + expect(tableHistory.undoStack).toEqual([]) + + table.dispose() + nested.dispose() + parent.dispose() + }) +}) + +describe('realm session lifecycle', () => { + it('runs every cleanup in reverse order and preserves the first cleanup failure', () => { + const realm = new Realm() + const order: string[] = [] + const firstFailure = new Error('later registration failed first') + + registerRealmCleanup(realm, () => { + order.push('first registration') + }) + registerRealmCleanup(realm, () => { + order.push('throwing registration') + throw firstFailure + }) + registerRealmCleanup(realm, () => { + order.push('last registration') + }) + + expect(() => { + disposeRealmSession(realm) + }).toThrow(firstFailure) + expect(order).toEqual(['last registration', 'throwing registration', 'first registration']) + expect(() => { + disposeRealmSession(realm) + }).not.toThrow() + }) + + it('reports falsy cleanup failures instead of silently dropping them', () => { + const realm = new Realm() + registerRealmCleanup(realm, () => { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- verifies cleanup values from untyped consumer JavaScript. + throw false + }) + + expect(() => { + disposeRealmSession(realm) + }).toThrow('Realm cleanup failed with a non-Error value') + }) + + it('cleans up a failed plugin session without replacing its initialization error', () => { + const initializationFailure = new Error('postInit failed') + let cleanups = 0 + const plugin: RealmPlugin = { + init(realm) { + registerRealmCleanup(realm, () => { + cleanups++ + }) + }, + postInit() { + throw initializationFailure + } + } + + expect(() => { + render(<RealmWithPlugins plugins={[plugin]}>unreachable</RealmWithPlugins>) + }).toThrow(initializationFailure) + expect(cleanups).toBe(1) + }) + + it('cleans up failed plugin sessions and keeps both failures when cleanup also fails', () => { + const initializationFailure = new Error('postInit failed') + const cleanupFailure = new Error('cleanup failed') + let cleanups = 0 + const plugin: RealmPlugin = { + init(realm) { + registerRealmCleanup(realm, () => { + cleanups++ + throw cleanupFailure + }) + }, + postInit() { + throw initializationFailure + } + } + + let thrown: unknown + try { + render(<RealmWithPlugins plugins={[plugin]}>unreachable</RealmWithPlugins>) + } catch (error) { + thrown = error + } + expect(thrown).toBeInstanceOf(AggregateError) + expect((thrown as AggregateError).errors).toEqual([initializationFailure, cleanupFailure]) + expect(cleanups).toBe(1) + }) + + it('keeps public methods available to parent layout and mount effects without render-time Realm setup', async () => { + const editorRef = React.createRef<MDXEditorMethods>() + let layoutRef: MDXEditorMethods | null = null + let effectRef: MDXEditorMethods | null = null + + const Parent = () => { + React.useLayoutEffect(() => { + layoutRef = editorRef.current + editorRef.current?.setMarkdown('updated from parent layout effect') + }, []) + React.useEffect(() => { + effectRef = editorRef.current + }, []) + return <MDXEditor ref={editorRef} markdown="initial markdown" /> + } + + const view = render(<Parent />) + expect(layoutRef).not.toBeNull() + expect(effectRef).not.toBeNull() + await waitFor(() => { + expect(editorRef.current?.getMarkdown()).toBe('updated from parent layout effect') + }) + view.unmount() + expect(editorRef.current).toBeNull() + }) + + it('replays pre-ready focus and insertion in order onto the live Strict Mode session', async () => { + const editorRef = React.createRef<MDXEditorMethods>() + const queued = { current: false } + let markdownBeforeReady = '' + let markdownAfterQueuedSet = '' + let htmlBeforeReady = 'not-read' + let selectionBeforeReady = 'not-read' + let focusCallbacks = 0 + + const Parent = () => { + React.useLayoutEffect(() => { + if (queued.current) { + return + } + queued.current = true + + const methods = editorRef.current + expect(methods).not.toBeNull() + expect(document.querySelector('[contenteditable="true"]')).toBeNull() + markdownBeforeReady = methods!.getMarkdown() + htmlBeforeReady = methods!.getContentEditableHTML() + selectionBeforeReady = methods!.getSelectionMarkdown() + methods!.setMarkdown('queued base') + markdownAfterQueuedSet = methods!.getMarkdown() + methods!.focus( + () => { + focusCallbacks++ + }, + { defaultSelection: 'rootEnd' } + ) + methods!.insertMarkdown('TAIL') + }, []) + + return <MDXEditor ref={editorRef} markdown=" initial markdown " /> + } + + const view = render( + <React.StrictMode> + <Parent /> + </React.StrictMode> + ) + + expect(markdownBeforeReady).toBe('initial markdown') + expect(markdownAfterQueuedSet).toBe('queued base') + expect(htmlBeforeReady).toBe('') + expect(selectionBeforeReady).toBe('') + await waitFor(() => { + expect(editorRef.current?.getMarkdown()).toBe('queued baseTAIL') + expect(focusCallbacks).toBe(1) + }) + + view.unmount() + expect(editorRef.current).toBeNull() + }) + + it('disposes every Strict Mode session once and permits descendant unregister after disposal', async () => { + const editors = new WeakMap<Realm, LexicalEditorWithDispose>() + let setups = 0 + let disposals = 0 + let childMounts = 0 + let childCleanups = 0 + const plugin: RealmPlugin = { + postInit(realm) { + setups++ + const editor = createExtensionEditor({ + name: `strict-session-${setups}`, + namespace: `strict-session-${setups}`, + nodes: [], + historyMode: 'none' + }) + editors.set(realm, editor) + registerRealmCleanup(realm, () => { + disposals++ + editor.dispose() + }) + } + } + const ConsumerChild = () => { + const realm = useRealm() + const editor = editors.get(realm)! + React.useEffect(() => { + childMounts++ + const unregister = editor.registerUpdateListener(() => undefined) + return () => { + childCleanups++ + unregister() + } + }, [editor]) + return <div>ready</div> + } + + const view = render( + <React.StrictMode> + <RealmWithPlugins plugins={[plugin]}> + <ConsumerChild /> + </RealmWithPlugins> + </React.StrictMode> + ) + + await waitFor(() => { + expect(view.getByText('ready')).toBeInTheDocument() + }) + expect(setups).toBeGreaterThanOrEqual(2) + expect(disposals).toBe(setups - 1) + expect(childMounts).toBe(2) + expect(childCleanups).toBe(1) + + view.unmount() + expect(disposals).toBe(setups) + expect(childCleanups).toBe(childMounts) + }) +}) diff --git a/src/test/fixtures/ExtensionCompatibilityHarness.tsx b/src/test/fixtures/ExtensionCompatibilityHarness.tsx new file mode 100644 index 00000000..fe10f7b7 --- /dev/null +++ b/src/test/fixtures/ExtensionCompatibilityHarness.tsx @@ -0,0 +1,377 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { Cell, useCellValue } from '@mdxeditor/gurx' +import type * as Mdast from 'mdast' +import type { MdxJsxFlowElement } from 'mdast-util-mdx' +import React from 'react' +import { $getRoot, SerializedTextNode, TextNode, type LexicalEditor } from 'lexical' +import { + activeEditor$, + addComposerChild$, + addExportVisitor$, + addImportVisitor$, + addLexicalNode$, + addNestedEditorChild$, + addTableCellEditorChild$, + JsxComponentDescriptor, + LexicalExportVisitor, + MdastImportVisitor, + MDXEditor, + MDXEditorMethods, + NestedLexicalEditor, + realmPlugin, + tablePlugin, + jsxPlugin, + toolbarPlugin, + UndoRedo +} from '../..' + +const extensionCompatibilityMarkdown = `Custom consumer text. + +Root history text. + +<Grid> +Nested A text. +<Grid> +Deep nested text. +</Grid> +</Grid> + +<Grid> +Nested B text. +</Grid> + +| Name | +| ---- | +| Table text | +` + +class ConsumerTextNode extends TextNode { + static getType() { + return 'extension-consumer-text' + } + + static clone(node: ConsumerTextNode) { + return new ConsumerTextNode(node.__text, node.__key) + } + + static importJSON(serializedNode: SerializedTextNode) { + return new ConsumerTextNode(serializedNode.text).updateFromJSON(serializedNode) + } +} + +const ConsumerTextVisitor: LexicalExportVisitor<ConsumerTextNode, Mdast.Text> = { + priority: 1000, + testLexicalNode: (node): node is ConsumerTextNode => node instanceof ConsumerTextNode, + visitLexicalNode: ({ lexicalNode, mdastParent, actions }) => { + const value = lexicalNode.getTextContent() + actions.appendToParent(mdastParent, { + type: 'text', + value: value === 'Custom consumer text.' ? `consumer:${value}` : value + }) + } +} + +const ConsumerTextImportVisitor: MdastImportVisitor<Mdast.Text> = { + priority: 1000, + testNode: (node) => node.type === 'text' && node.value === 'Custom consumer text.', + visitNode: ({ mdastNode, actions }) => { + const node = new ConsumerTextNode(mdastNode.value) + node.setFormat(actions.getParentFormatting()) + const style = actions.getParentStyle() + if (style !== '') { + node.setStyle(style) + } + actions.addAndStepInto(node) + } +} + +const consumerRealmId$ = Cell('uninitialized') +const consumerRealmInitCount$ = Cell(0) +const consumerRealmInitCounts = new WeakMap<object, number>() +let nextRealmId = 0 + +type Surface = 'root' | 'nested' | 'table' +interface ProbeContextValue { + mount: (surface: Surface, editor: LexicalEditor) => void + cleanup: (surface: Surface, editor: LexicalEditor) => void + editorId: (editor: LexicalEditor) => number +} + +const ProbeContext = React.createContext<ProbeContextValue | null>(null) + +function ComposerProbe({ surface }: { surface: Surface }) { + const [editor] = useLexicalComposerContext() + const realmId = useCellValue(consumerRealmId$) + const realmInitCount = useCellValue(consumerRealmInitCount$) + const activeEditor = useCellValue(activeEditor$) + const context = React.useContext(ProbeContext)! + + React.useEffect(() => { + context.mount(surface, editor) + return () => { + context.cleanup(surface, editor) + } + }, [context, editor, surface]) + + return ( + <output + data-testid={`extension-${surface}-probe`} + data-editor-id={context.editorId(editor)} + data-parent-editor-id={editor._parentEditor ? context.editorId(editor._parentEditor) : undefined} + data-active-editor-id={activeEditor ? context.editorId(activeEditor) : undefined} + data-plugin-init-count={realmInitCount} + > + {surface}:{realmId} + </output> + ) +} + +const RootProbe = () => <ComposerProbe surface="root" /> +const NestedProbe = () => <ComposerProbe surface="nested" /> +const TableProbe = () => <ComposerProbe surface="table" /> + +const consumerPlugin = realmPlugin({ + init(realm) { + const initCount = (consumerRealmInitCounts.get(realm) ?? 0) + 1 + consumerRealmInitCounts.set(realm, initCount) + realm.pub(consumerRealmId$, `realm-${++nextRealmId}`) + realm.pub(consumerRealmInitCount$, initCount) + realm.pub(addLexicalNode$, ConsumerTextNode) + realm.pub(addImportVisitor$, ConsumerTextImportVisitor) + realm.pub(addExportVisitor$, ConsumerTextVisitor) + realm.pub(addComposerChild$, RootProbe) + realm.pub(addNestedEditorChild$, NestedProbe) + realm.pub(addTableCellEditorChild$, TableProbe) + } +}) + +const NestedGridEditor = () => ( + <div data-testid="extension-nested-editor"> + <NestedLexicalEditor<MdxJsxFlowElement> + block + getContent={(node) => node.children} + getUpdatedMdastNode={(node, children) => ({ ...node, children: children as MdxJsxFlowElement['children'] })} + /> + </div> +) + +const jsxComponentDescriptors: JsxComponentDescriptor[] = [ + { + name: 'Grid', + kind: 'flow', + props: [], + hasChildren: true, + Editor: NestedGridEditor + } +] + +type ProbeStats = Record<Surface, { mounts: number; cleanups: number }> +const emptyProbeStats = (): ProbeStats => ({ + root: { mounts: 0, cleanups: 0 }, + nested: { mounts: 0, cleanups: 0 }, + table: { mounts: 0, cleanups: 0 } +}) + +export const ExtensionCompatibilityHarness = () => { + const editorRef = React.useRef<MDXEditorMethods>(null) + const editorIds = React.useRef(new WeakMap<LexicalEditor, number>()) + const nextEditorId = React.useRef(0) + const staleEditors = React.useRef(new Map<number, LexicalEditor>()) + const publicRefIds = React.useRef(new WeakMap<object, number>()) + const nextPublicRefId = React.useRef(0) + const [mounted, setMounted] = React.useState(true) + const [generation, setGeneration] = React.useState(1) + const [strictMode, setStrictMode] = React.useState(false) + const [suppressed, setSuppressed] = React.useState(false) + const [readOnly, setReadOnly] = React.useState(false) + const [currentMarkdown, setCurrentMarkdown] = React.useState(extensionCompatibilityMarkdown.trim()) + const [readMarkdown, setReadMarkdown] = React.useState('') + const [error, setError] = React.useState('') + const [probeStats, setProbeStats] = React.useState<ProbeStats>(emptyProbeStats) + const [publicRefId, setPublicRefId] = React.useState<number | null>(null) + const [staleEditorSnapshot, setStaleEditorSnapshot] = React.useState<{ id: number; text: string }[]>([]) + const [layoutRefReady, setLayoutRefReady] = React.useState(false) + const [effectRefReady, setEffectRefReady] = React.useState(false) + + React.useLayoutEffect(() => { + setLayoutRefReady(mounted && editorRef.current !== null) + }, [generation, mounted, strictMode, suppressed]) + React.useEffect(() => { + setEffectRefReady(mounted && editorRef.current !== null) + }, [generation, mounted, strictMode, suppressed]) + + const editorId = React.useCallback((editor: LexicalEditor) => { + let id = editorIds.current.get(editor) + if (!id) { + id = ++nextEditorId.current + editorIds.current.set(editor, id) + } + return id + }, []) + const mount = React.useCallback( + (surface: Surface, editor: LexicalEditor) => { + editorId(editor) + setProbeStats((stats) => ({ + ...stats, + [surface]: { ...stats[surface], mounts: stats[surface].mounts + 1 } + })) + }, + [editorId] + ) + const cleanup = React.useCallback( + (surface: Surface, editor: LexicalEditor) => { + staleEditors.current.set(editorId(editor), editor) + setProbeStats((stats) => ({ + ...stats, + [surface]: { ...stats[surface], cleanups: stats[surface].cleanups + 1 } + })) + }, + [editorId] + ) + const probeContext = React.useMemo(() => ({ mount, cleanup, editorId }), [cleanup, editorId, mount]) + const plugins = React.useMemo( + () => [consumerPlugin(), jsxPlugin({ jsxComponentDescriptors }), tablePlugin(), toolbarPlugin({ toolbarContents: () => <UndoRedo /> })], + [] + ) + + const remountInMode = (nextSuppressed: boolean) => { + setSuppressed(nextSuppressed) + setGeneration((value) => value + 1) + setMounted(true) + setCurrentMarkdown(extensionCompatibilityMarkdown.trim()) + setReadMarkdown('') + } + + const editor = mounted ? ( + <section data-testid="extension-root-editor"> + <MDXEditor + key={`${generation}-${suppressed}-${strictMode}`} + ref={editorRef} + markdown={extensionCompatibilityMarkdown} + plugins={plugins} + readOnly={readOnly} + suppressSharedHistory={suppressed} + onChange={(markdown) => { + setCurrentMarkdown(markdown) + }} + onError={({ error: nextError }) => { + setError(nextError) + }} + /> + </section> + ) : null + + return ( + <ProbeContext.Provider value={probeContext}> + <main> + <h1>Extension compatibility fixture ready</h1> + <div role="group" aria-label="Extension controls"> + <button + type="button" + onClick={() => { + setReadMarkdown(editorRef.current?.getMarkdown() ?? '') + }} + > + Read extension Markdown + </button> + <button + type="button" + onClick={() => { + setReadOnly((value) => !value) + }} + > + Toggle extension read only + </button> + <button + type="button" + onClick={() => { + setMounted(false) + }} + > + Unmount extension editor + </button> + <button + type="button" + onClick={() => { + remountInMode(suppressed) + }} + disabled={mounted} + > + Remount extension editor + </button> + <button + type="button" + onClick={() => { + remountInMode(false) + }} + > + Use normal history + </button> + <button + type="button" + onClick={() => { + remountInMode(true) + }} + > + Use suppressed root history + </button> + <button + type="button" + onClick={() => { + setStrictMode(true) + setGeneration((value) => value + 1) + }} + disabled={strictMode} + > + Enable extension Strict Mode + </button> + <button + type="button" + onClick={() => { + const methods = editorRef.current + if (!methods) { + setPublicRefId(null) + return + } + let id = publicRefIds.current.get(methods) + if (!id) { + id = ++nextPublicRefId.current + publicRefIds.current.set(methods, id) + } + setPublicRefId(id) + }} + > + Capture extension public ref + </button> + <button + type="button" + onClick={() => { + setStaleEditorSnapshot( + [...staleEditors.current].map(([id, staleEditor]) => ({ + id, + text: staleEditor.getEditorState().read(() => $getRoot().getTextContent()) + })) + ) + }} + > + Inspect stale extension editors + </button> + </div> + + <output aria-label="Extension mode">{suppressed ? 'suppressed' : 'normal'}</output> + <output aria-label="Extension React mode">{strictMode ? 'strict' : 'standard'}</output> + <output aria-label="Extension generation">{generation}</output> + <output aria-label="Extension public ref id">{publicRefId ?? ''}</output> + <output aria-label="Extension layout ref ready">{String(layoutRefReady)}</output> + <output aria-label="Extension effect ref ready">{String(effectRefReady)}</output> + <pre aria-label="Extension probe stats">{JSON.stringify(probeStats)}</pre> + <pre aria-label="Extension stale editor snapshot">{JSON.stringify(staleEditorSnapshot)}</pre> + <pre aria-label="Extension current Markdown">{currentMarkdown}</pre> + <pre aria-label="Extension read Markdown">{readMarkdown}</pre> + <pre aria-label="Extension error">{error}</pre> + + {strictMode ? <React.StrictMode>{editor}</React.StrictMode> : editor} + </main> + </ProbeContext.Provider> + ) +} diff --git a/src/test/fixtures/LexicalCompatibilityHarness.tsx b/src/test/fixtures/LexicalCompatibilityHarness.tsx new file mode 100644 index 00000000..c166f55e --- /dev/null +++ b/src/test/fixtures/LexicalCompatibilityHarness.tsx @@ -0,0 +1,203 @@ +import type { MdxJsxFlowElement } from 'mdast-util-mdx' +import React from 'react' +import { + AdmonitionDirectiveDescriptor, + type CodeBlockEditorDescriptor, + type CodeBlockEditorProps, + CreateLink, + JsxComponentDescriptor, + ListsToggle, + MDXEditor, + MDXEditorMethods, + NestedLexicalEditor, + UndoRedo, + codeBlockPlugin, + directivesPlugin, + frontmatterPlugin, + headingsPlugin, + jsxPlugin, + linkDialogPlugin, + linkPlugin, + listsPlugin, + markdownShortcutPlugin, + maxLengthPlugin, + quotePlugin, + tablePlugin, + thematicBreakPlugin, + toolbarPlugin, + useCodeBlockEditorContext +} from '../..' +import { + alternateCompatibilityMarkdown, + compatibilityMarkdown, + compatibilityMarkdown035, + maxLengthInitialMarkdown +} from './lexicalCompatibility' + +const GridEditor = () => ( + <div data-testid="compatibility-nested-editor" style={{ border: '1px solid #999', padding: 8 }}> + <NestedLexicalEditor<MdxJsxFlowElement> + block + getContent={(node) => node.children} + getUpdatedMdastNode={(node, children) => ({ ...node, children: children as MdxJsxFlowElement['children'] })} + /> + </div> +) + +const jsxComponentDescriptors: JsxComponentDescriptor[] = [ + { + name: 'Grid', + kind: 'flow', + props: [], + hasChildren: true, + Editor: GridEditor + } +] + +const CompatibilityCodeBlockEditor: React.FC<CodeBlockEditorProps> = ({ code }) => { + const codeBlockEditor = useCodeBlockEditorContext() + + return ( + <textarea + aria-label="Compatibility code block" + data-testid="compatibility-code-block" + defaultValue={code} + onChange={(event) => { + codeBlockEditor.setCode(event.target.value) + }} + onKeyDown={(event) => { + event.nativeEvent.stopImmediatePropagation() + }} + /> + ) +} + +const codeBlockEditorDescriptor: CodeBlockEditorDescriptor = { + priority: 0, + match: () => true, + Editor: CompatibilityCodeBlockEditor +} + +const normalizeLineEndings = (markdown: string) => markdown.replaceAll('\r\n', '\n') + +export const LexicalCompatibilityHarness = () => { + const editorRef = React.useRef<MDXEditorMethods>(null) + const cappedEditorRef = React.useRef<MDXEditorMethods>(null) + const [currentMarkdown, setCurrentMarkdown] = React.useState(compatibilityMarkdown035) + const [selectionMarkdown, setSelectionMarkdown] = React.useState('') + const [cappedMarkdown, setCappedMarkdown] = React.useState(maxLengthInitialMarkdown) + const [errorMessage, setErrorMessage] = React.useState('') + const [lastClickedUrl, setLastClickedUrl] = React.useState('') + + const plugins = React.useMemo( + () => [ + headingsPlugin({ allowedHeadingLevels: [1, 2, 3] }), + quotePlugin(), + listsPlugin(), + linkPlugin(), + linkDialogPlugin({ + linkAutocompleteSuggestions: ['https://example.com/safe'], + onClickLinkCallback: (url) => { + setLastClickedUrl(url) + } + }), + tablePlugin(), + thematicBreakPlugin(), + frontmatterPlugin(), + codeBlockPlugin({ codeBlockEditorDescriptors: [codeBlockEditorDescriptor] }), + directivesPlugin({ directiveDescriptors: [AdmonitionDirectiveDescriptor] }), + jsxPlugin({ jsxComponentDescriptors }), + markdownShortcutPlugin(), + toolbarPlugin({ + toolbarContents: () => ( + <> + <UndoRedo /> + <ListsToggle /> + <CreateLink /> + </> + ) + }) + ], + [] + ) + + return ( + <main> + <h1>Lexical compatibility fixture ready</h1> + + <div role="group" aria-label="Compatibility controls"> + <button + type="button" + onClick={() => { + setCurrentMarkdown(normalizeLineEndings(editorRef.current?.getMarkdown() ?? '')) + }} + > + Get Markdown + </button> + <button + type="button" + onClick={() => { + setSelectionMarkdown(editorRef.current?.getSelectionMarkdown() ?? '') + }} + > + Get Selection Markdown + </button> + <button + type="button" + onClick={() => { + editorRef.current?.setMarkdown(alternateCompatibilityMarkdown) + }} + > + Set Alternate + </button> + <button + type="button" + onClick={() => { + editorRef.current?.setMarkdown(compatibilityMarkdown) + }} + > + Reset Compatibility Markdown + </button> + </div> + + <section aria-label="Compatibility editor" data-testid="compatibility-root-editor"> + <MDXEditor + ref={editorRef} + markdown={compatibilityMarkdown} + plugins={plugins} + onChange={(markdown) => { + setCurrentMarkdown(normalizeLineEndings(markdown)) + }} + onError={({ error }) => { + setErrorMessage(error) + }} + /> + </section> + + <pre aria-label="Current Markdown">{currentMarkdown}</pre> + <pre aria-label="Selection Markdown">{selectionMarkdown}</pre> + <pre aria-label="Compatibility Error">{errorMessage}</pre> + <pre aria-label="Last Clicked URL">{lastClickedUrl}</pre> + + <section aria-label="Maximum length editor" data-testid="compatibility-max-length-editor"> + <MDXEditor + ref={cappedEditorRef} + markdown={maxLengthInitialMarkdown} + plugins={[maxLengthPlugin(10)]} + onChange={(markdown) => { + setCappedMarkdown(normalizeLineEndings(markdown)) + }} + /> + </section> + <button + type="button" + onClick={() => { + setCappedMarkdown(normalizeLineEndings(cappedEditorRef.current?.getMarkdown() ?? '')) + }} + > + Get Maximum Length Markdown + </button> + <pre aria-label="Maximum Length Markdown">{cappedMarkdown}</pre> + </main> + ) +} diff --git a/src/test/fixtures/SearchReplaceHarness.tsx b/src/test/fixtures/SearchReplaceHarness.tsx new file mode 100644 index 00000000..1e929ab7 --- /dev/null +++ b/src/test/fixtures/SearchReplaceHarness.tsx @@ -0,0 +1,336 @@ +import { useRealm } from '@mdxeditor/gurx' +import type { ContainerDirective } from 'mdast-util-directive' +import type { MdxJsxFlowElement } from 'mdast-util-mdx' +import React from 'react' +import { + BoldItalicUnderlineToggles, + type CodeBlockEditorDescriptor, + type CodeBlockEditorProps, + type DirectiveDescriptor, + type JsxComponentDescriptor, + MDXEditor, + type MDXEditorMethods, + NestedLexicalEditor, + UndoRedo, + codeBlockPlugin, + directivesPlugin, + headingsPlugin, + jsxPlugin, + linkPlugin, + searchOpen$, + searchPlugin, + tablePlugin, + thematicBreakPlugin, + toolbarPlugin, + useCodeBlockEditorContext, + useEditorSearch +} from '../..' + +export const searchReplaceFixture = `# Search Replace Fixture + +Root al**pha** first. + +Root alpha second and KEEP. + +Root *alpha* italic and [alpha](https://example.com) link. + +| Scope | Value | +| ----- | ----- | +| Table | Cell alpha alpha | + +:::note +Directive alpha. +::: + +<Grid> +Nested alpha and alpha. +</Grid> + +\`\`\`txt +Atomic alpha alpha +\`\`\` + +*** +` + +const updatedSearchFixture = `# Updated Search Replace Fixture + +Updated alpha alpha alpha. +` + +const preserveActiveEditor = (event: React.MouseEvent<HTMLButtonElement>) => { + event.preventDefault() +} + +const SearchGridEditor = () => ( + <div data-testid="search-jsx-editor" style={{ border: '1px solid #999', padding: 8 }}> + <NestedLexicalEditor<MdxJsxFlowElement> + block + getContent={(node) => node.children} + getUpdatedMdastNode={(node, children) => ({ ...node, children: children as MdxJsxFlowElement['children'] })} + /> + </div> +) + +const jsxComponentDescriptors: JsxComponentDescriptor[] = [ + { + name: 'Grid', + kind: 'flow', + props: [], + hasChildren: true, + Editor: SearchGridEditor + } +] + +const SearchDirectiveEditor = () => ( + <div data-testid="search-directive-editor" style={{ border: '1px solid #999', padding: 8 }}> + <NestedLexicalEditor<ContainerDirective> + block + getContent={(node) => node.children} + getUpdatedMdastNode={(node, children) => ({ ...node, children: children as ContainerDirective['children'] })} + /> + </div> +) + +const searchDirectiveDescriptor: DirectiveDescriptor<ContainerDirective> = { + name: 'search-note', + attributes: [], + hasChildren: true, + type: 'containerDirective', + testNode: (node): node is ContainerDirective => node.type === 'containerDirective' && node.name === 'note', + Editor: SearchDirectiveEditor +} + +const SearchCodeBlockEditor: React.FC<CodeBlockEditorProps> = ({ code }) => { + const codeBlockEditor = useCodeBlockEditorContext() + return ( + <textarea + aria-label="Atomic code editor" + defaultValue={code} + onChange={(event) => { + codeBlockEditor.setCode(event.target.value) + }} + onKeyDown={(event) => { + event.nativeEvent.stopImmediatePropagation() + }} + /> + ) +} + +const codeBlockEditorDescriptor: CodeBlockEditorDescriptor = { + priority: 0, + match: () => true, + Editor: SearchCodeBlockEditor +} + +const SearchControls = () => { + const realm = useRealm() + const search = useEditorSearch() + const [replacement, setReplacement] = React.useState('$&') + const [replaceCallbacks, setReplaceCallbacks] = React.useState(0) + const [scrollCalls, setScrollCalls] = React.useState(0) + + const recordReplacement = () => { + setReplaceCallbacks((count) => count + 1) + } + + return ( + <div aria-label="Search controls"> + <input + aria-label="Search term" + value={search.search} + onChange={(event) => { + search.setSearch(event.target.value) + }} + /> + <input + aria-label="Replacement text" + value={replacement} + onChange={(event) => { + setReplacement(event.target.value) + }} + /> + <output aria-label="Search total">{search.total}</output> + <output aria-label="Search cursor">{search.cursor}</output> + <output aria-label="Current match">{search.currentRange?.toString() ?? ''}</output> + <output aria-label="Search ranges">{search.ranges.map((range) => range.toString()).join('|')}</output> + <output aria-label="Search open">{String(search.isSearchOpen)}</output> + <output aria-label="Replace callback count">{replaceCallbacks}</output> + <output aria-label="Scroll call count">{scrollCalls}</output> + <button type="button" onMouseDown={preserveActiveEditor} onClick={search.openSearch}> + Open search + </button> + <button type="button" onMouseDown={preserveActiveEditor} onClick={search.closeSearch}> + Close search + </button> + <button + type="button" + onMouseDown={preserveActiveEditor} + onClick={() => { + search.setIsSearchOpen(false) + }} + > + Close with setter + </button> + <button + type="button" + onMouseDown={preserveActiveEditor} + onClick={() => { + realm.pub(searchOpen$, false) + }} + > + Close with cell + </button> + <button type="button" onMouseDown={preserveActiveEditor} onClick={search.toggleSearch}> + Toggle search + </button> + <button + type="button" + onMouseDown={preserveActiveEditor} + onClick={() => { + search.setSearch(null) + }} + > + Clear search term + </button> + <button type="button" onMouseDown={preserveActiveEditor} onClick={search.next}> + Next match + </button> + <button type="button" onMouseDown={preserveActiveEditor} onClick={search.prev}> + Previous match + </button> + <button + type="button" + onMouseDown={preserveActiveEditor} + onClick={() => { + search.replace(replacement, recordReplacement) + }} + > + Replace match + </button> + <button + type="button" + onMouseDown={preserveActiveEditor} + onClick={() => { + search.replaceAll(replacement, recordReplacement) + }} + > + Replace all + </button> + <button + type="button" + onMouseDown={preserveActiveEditor} + onClick={() => { + if (!search.currentRange) return + search.scrollToRangeOrIndex(search.currentRange, { ignoreIfInView: false, behavior: 'auto' }) + setScrollCalls((count) => count + 1) + }} + > + Scroll supplied range + </button> + <button + type="button" + onMouseDown={preserveActiveEditor} + onClick={() => { + search.scrollToRangeOrIndex(1, { ignoreIfInView: true, behavior: 'smooth' }) + setScrollCalls((count) => count + 1) + }} + > + Scroll first index + </button> + </div> + ) +} + +export const SearchReplaceHarness = () => { + const editorRef = React.useRef<MDXEditorMethods>(null) + const [mounted, setMounted] = React.useState(true) + const [markdown, setMarkdown] = React.useState('') + const [changeCount, setChangeCount] = React.useState(0) + const [error, setError] = React.useState('') + + const plugins = React.useMemo( + () => [ + headingsPlugin(), + linkPlugin(), + tablePlugin(), + thematicBreakPlugin(), + directivesPlugin({ directiveDescriptors: [searchDirectiveDescriptor] }), + jsxPlugin({ jsxComponentDescriptors }), + codeBlockPlugin({ codeBlockEditorDescriptors: [codeBlockEditorDescriptor] }), + searchPlugin(), + toolbarPlugin({ + toolbarContents: () => ( + <> + <UndoRedo /> + <BoldItalicUnderlineToggles /> + <SearchControls /> + </> + ) + }) + ], + [] + ) + + return ( + <main> + <h1>Search Replace fixture ready</h1> + <div role="group" aria-label="Fixture controls"> + <button + type="button" + onClick={() => { + setMarkdown(editorRef.current?.getMarkdown() ?? '') + }} + > + Read Markdown + </button> + <button + type="button" + onClick={() => { + editorRef.current?.setMarkdown(searchReplaceFixture) + }} + > + Set initial Markdown + </button> + <button + type="button" + onClick={() => { + editorRef.current?.setMarkdown(updatedSearchFixture) + }} + > + Set updated Markdown + </button> + <button + type="button" + onClick={() => { + setMounted(false) + }} + > + Unmount editor + </button> + </div> + + {mounted ? ( + <section aria-label="Search fixture editor" data-testid="search-root-editor"> + <MDXEditor + ref={editorRef} + markdown={searchReplaceFixture} + plugins={plugins} + onChange={() => { + setChangeCount((count) => count + 1) + }} + onError={({ error: nextError }) => { + setError(nextError) + }} + /> + </section> + ) : ( + <p>Editor unmounted</p> + )} + + <pre aria-label="Search Markdown">{markdown}</pre> + <output aria-label="Search change count">{changeCount}</output> + <pre aria-label="Search error">{error}</pre> + </main> + ) +} diff --git a/src/test/fixtures/SelectionMarkdownHarness.tsx b/src/test/fixtures/SelectionMarkdownHarness.tsx new file mode 100644 index 00000000..4c5f98fb --- /dev/null +++ b/src/test/fixtures/SelectionMarkdownHarness.tsx @@ -0,0 +1,326 @@ +import { $isTextNode, SerializedTextNode, TextNode } from 'lexical' +import type * as Mdast from 'mdast' +import type { MdxJsxFlowElement } from 'mdast-util-mdx' +import React from 'react' +import { + addExportVisitor$, + AdmonitionDirectiveDescriptor, + codeBlockPlugin, + directivesPlugin, + diffSourcePlugin, + frontmatterPlugin, + headingsPlugin, + imagePlugin, + JsxComponentDescriptor, + jsxPlugin, + LexicalExportVisitor, + linkPlugin, + listsPlugin, + MDXEditor, + MDXEditorMethods, + NestedLexicalEditor, + realmPlugin, + tablePlugin, + thematicBreakPlugin, + useCodeBlockEditorContext +} from '../..' +import type { CodeBlockEditorDescriptor, CodeBlockEditorProps } from '../..' + +export const selectionMarkdownFixture = `--- +fixture: selection +--- + +# Selection Markdown Fixture + +Plain alpha bravo charlie. + +Formatting **boldword** and *italicword* and \`codeword\`. + +Partial [linked text](https://example.com/selection). + +First block alpha. + +Second block bravo. + +1. Ordered alpha + 1. Nested ordered beta +2. Ordered gamma + +* [ ] Task pending +* [x] Task complete + +Atomic start marker. + +| Name | Value | +| ---- | ----- | +| Table | Stable | + +*** + +\`\`\`js +const selected = true +\`\`\` + +![Selection image](/favicon.svg "Selection title") + +:::note +Directive selection content. +::: + +<Grid> +Nested JSX selection content with **nested bold**. +</Grid> + +Atomic end marker. +` + +const secondaryMarkdownFixture = `Secondary plain text. + +* Secondary item +` + +const NestedGridEditor = () => ( + <div data-testid="selection-nested-editor" style={{ border: '1px solid #999', padding: 8 }}> + <NestedLexicalEditor<MdxJsxFlowElement> + block + getContent={(node) => node.children} + getUpdatedMdastNode={(node, children) => ({ ...node, children: children as MdxJsxFlowElement['children'] })} + /> + </div> +) + +const jsxComponentDescriptors: JsxComponentDescriptor[] = [ + { + name: 'Grid', + kind: 'flow', + source: './selection-components', + props: [], + hasChildren: true, + Editor: NestedGridEditor + } +] + +const SelectionCodeBlockEditor: React.FC<CodeBlockEditorProps> = ({ code }) => { + const codeBlockEditor = useCodeBlockEditorContext() + return ( + <textarea + aria-label="Selection code block" + defaultValue={code} + onChange={(event) => { + codeBlockEditor.setCode(event.target.value) + }} + onKeyDown={(event) => { + event.nativeEvent.stopImmediatePropagation() + }} + /> + ) +} + +const codeBlockEditorDescriptor: CodeBlockEditorDescriptor = { + priority: 0, + match: () => true, + Editor: SelectionCodeBlockEditor +} + +const UppercaseTextVisitor: LexicalExportVisitor<TextNode, Mdast.Text> = { + priority: 100, + testLexicalNode: $isTextNode, + visitLexicalNode: ({ lexicalNode, mdastParent, actions }) => { + actions.appendToParent(mdastParent, { type: 'text', value: lexicalNode.getTextContent().toUpperCase() }) + } +} + +class SelectionReplacementTextNode extends TextNode { + static getType(): string { + return 'selection-replacement-text' + } + + static clone(node: SelectionReplacementTextNode): SelectionReplacementTextNode { + return new SelectionReplacementTextNode(node.__text, node.__key) + } + + static importJSON(serializedNode: SerializedTextNode): SelectionReplacementTextNode { + return new SelectionReplacementTextNode(serializedNode.text).updateFromJSON(serializedNode) + } +} + +const ReplacementTextVisitor: LexicalExportVisitor<SelectionReplacementTextNode, Mdast.Text> = { + priority: 110, + testLexicalNode: (node): node is SelectionReplacementTextNode => node instanceof SelectionReplacementTextNode, + visitLexicalNode: ({ lexicalNode, mdastParent, actions }) => { + actions.appendToParent(mdastParent, { + type: 'text', + value: `replacement:${lexicalNode.getTextContent().toUpperCase()}` + }) + } +} + +const uppercaseSelectionPlugin = realmPlugin({ + init(realm) { + realm.pub(addExportVisitor$, [ReplacementTextVisitor, UppercaseTextVisitor]) + } +}) + +const preserveSelection = (event: React.MouseEvent<HTMLButtonElement>) => { + event.preventDefault() +} + +export const SelectionMarkdownHarness = () => { + const primaryRef = React.useRef<MDXEditorMethods>(null) + const secondaryRef = React.useRef<MDXEditorMethods>(null) + const sourceRef = React.useRef<MDXEditorMethods>(null) + const diffRef = React.useRef<MDXEditorMethods>(null) + const [primarySelection, setPrimarySelection] = React.useState('') + const [secondarySelection, setSecondarySelection] = React.useState('') + const [primaryMarkdown, setPrimaryMarkdown] = React.useState('') + const [secondaryFullMarkdown, setSecondaryFullMarkdown] = React.useState('') + const [sourceSelection, setSourceSelection] = React.useState('not-read') + const [diffSelection, setDiffSelection] = React.useState('not-read') + const [primaryChanges, setPrimaryChanges] = React.useState(0) + const [secondaryChanges, setSecondaryChanges] = React.useState(0) + const [error, setError] = React.useState('') + + const primaryPlugins = React.useMemo( + () => [ + headingsPlugin(), + listsPlugin(), + linkPlugin(), + tablePlugin(), + thematicBreakPlugin(), + codeBlockPlugin({ codeBlockEditorDescriptors: [codeBlockEditorDescriptor] }), + imagePlugin({ disableImageResize: true }), + frontmatterPlugin(), + directivesPlugin({ directiveDescriptors: [AdmonitionDirectiveDescriptor] }), + jsxPlugin({ jsxComponentDescriptors }), + diffSourcePlugin({ diffMarkdown: '# Previous selection fixture' }) + ], + [] + ) + + const secondaryPlugins = React.useMemo(() => [listsPlugin(), uppercaseSelectionPlugin()], []) + + return ( + <main> + <h1>Selection Markdown fixture ready</h1> + + <div role="group" aria-label="Primary selection controls"> + <button + type="button" + onMouseDown={preserveSelection} + onClick={() => { + setPrimarySelection(primaryRef.current?.getSelectionMarkdown() ?? '') + }} + > + Read primary selection + </button> + <button + type="button" + onMouseDown={preserveSelection} + onClick={() => { + setPrimaryMarkdown(primaryRef.current?.getMarkdown() ?? '') + }} + > + Read primary Markdown + </button> + </div> + + <section aria-label="Primary selection editor" data-testid="selection-primary-editor"> + <MDXEditor + ref={primaryRef} + markdown={selectionMarkdownFixture} + plugins={primaryPlugins} + onChange={() => { + setPrimaryChanges((count) => count + 1) + }} + onError={({ error: nextError }) => { + setError(nextError) + }} + /> + </section> + + <pre aria-label="Primary selection result">{primarySelection}</pre> + <pre aria-label="Primary full Markdown">{primaryMarkdown}</pre> + <output aria-label="Primary change count">{primaryChanges}</output> + + <div role="group" aria-label="Secondary selection controls"> + <button + type="button" + onMouseDown={preserveSelection} + onClick={() => { + setSecondarySelection(secondaryRef.current?.getSelectionMarkdown() ?? '') + }} + > + Read secondary selection + </button> + <button + type="button" + onMouseDown={preserveSelection} + onClick={() => { + setSecondaryFullMarkdown(secondaryRef.current?.getMarkdown() ?? '') + }} + > + Read secondary Markdown + </button> + </div> + + <section aria-label="Secondary selection editor" data-testid="selection-secondary-editor"> + <MDXEditor + ref={secondaryRef} + markdown={secondaryMarkdownFixture} + plugins={secondaryPlugins} + additionalLexicalNodes={[ + SelectionReplacementTextNode, + { + replace: TextNode, + with: (node: TextNode) => new SelectionReplacementTextNode(node.getTextContent()), + withKlass: SelectionReplacementTextNode + } + ]} + toMarkdownOptions={{ bullet: '+' }} + onChange={() => { + setSecondaryChanges((count) => count + 1) + }} + onError={({ error: nextError }) => { + setError(nextError) + }} + /> + </section> + + <pre aria-label="Secondary selection result">{secondarySelection}</pre> + <pre aria-label="Secondary full Markdown">{secondaryFullMarkdown}</pre> + <output aria-label="Secondary change count">{secondaryChanges}</output> + + <section aria-label="Source mode selection editor"> + <MDXEditor ref={sourceRef} markdown="Source mode content." plugins={[diffSourcePlugin({ viewMode: 'source' })]} /> + <button + type="button" + onClick={() => { + setSourceSelection(sourceRef.current?.getSelectionMarkdown() ?? '') + }} + > + Read source selection + </button> + <pre aria-label="Source selection result">{sourceSelection}</pre> + </section> + + <section aria-label="Diff mode selection editor"> + <MDXEditor + ref={diffRef} + markdown="Current diff content." + plugins={[diffSourcePlugin({ viewMode: 'diff', diffMarkdown: 'Previous diff content.' })]} + /> + <button + type="button" + onClick={() => { + setDiffSelection(diffRef.current?.getSelectionMarkdown() ?? '') + }} + > + Read diff selection + </button> + <pre aria-label="Diff selection result">{diffSelection}</pre> + </section> + + <pre aria-label="Selection error">{error}</pre> + </main> + ) +} diff --git a/src/test/fixtures/lexicalCompatibility.035.md b/src/test/fixtures/lexicalCompatibility.035.md new file mode 100644 index 00000000..cd6f341a --- /dev/null +++ b/src/test/fixtures/lexicalCompatibility.035.md @@ -0,0 +1,41 @@ +--- +title: Lexical compatibility +--- + +# Compatibility heading + +Root compatibility paragraph with **bold**, *italic*, [safe link](https://example.com/safe), and explicit [https://example.com/auto](https://example.com/auto) text. + +Link candidate text. + +> Compatibility quote + +* Bullet alpha +* Bullet beta + * Nested bullet + +1. Ordered alpha +2. Ordered beta + +* [ ] Task pending +* [x] Task complete + +| Feature | Status | +| :------ | -----: | +| Table | Stable | + +*** + +Decorator boundary paragraph. + +```ts compatibility-meta +const compatible = true +``` + +:::note +Admonition compatibility content. +::: + +<Grid> + Nested compatibility content. +</Grid> diff --git a/src/test/fixtures/lexicalCompatibility.input.md b/src/test/fixtures/lexicalCompatibility.input.md new file mode 100644 index 00000000..5cf9bf8c --- /dev/null +++ b/src/test/fixtures/lexicalCompatibility.input.md @@ -0,0 +1,41 @@ +--- +title: Lexical compatibility +--- + +# Compatibility heading + +Root compatibility paragraph with **bold**, _italic_, [safe link](https://example.com/safe), and explicit [https://example.com/auto](https://example.com/auto) text. + +Link candidate text. + +> Compatibility quote + +- Bullet alpha +- Bullet beta + - Nested bullet + +1. Ordered alpha +2. Ordered beta + +- [ ] Task pending +- [x] Task complete + +| Feature | Status | +| :------ | -----: | +| Table | Stable | + +--- + +Decorator boundary paragraph. + +```ts compatibility-meta +const compatible = true +``` + +:::note +Admonition compatibility content. +::: + +<Grid> + Nested compatibility content. +</Grid> diff --git a/src/test/fixtures/lexicalCompatibility.ts b/src/test/fixtures/lexicalCompatibility.ts new file mode 100644 index 00000000..0fd242c3 --- /dev/null +++ b/src/test/fixtures/lexicalCompatibility.ts @@ -0,0 +1,13 @@ +import compatibilityMarkdownSource from './lexicalCompatibility.input.md?raw' +import compatibilityMarkdown035Source from './lexicalCompatibility.035.md?raw' + +export const compatibilityMarkdown = compatibilityMarkdownSource + +export const compatibilityMarkdown035 = compatibilityMarkdown035Source.trimEnd() + +export const alternateCompatibilityMarkdown = `# Alternate compatibility document + +Alternate public-method content. +` + +export const maxLengthInitialMarkdown = '12345' diff --git a/src/test/package-consumer-utils.test.ts b/src/test/package-consumer-utils.test.ts new file mode 100644 index 00000000..8dbe0aaf --- /dev/null +++ b/src/test/package-consumer-utils.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest' +// @ts-expect-error The verification utility is JavaScript and is intentionally not part of the package declarations. +import { runInterruptCleanup as runInterruptCleanupUntyped } from '../../scripts/package-consumer-utils.mjs' + +type RunInterruptCleanup = <Process>( + processes: Iterable<Process>, + cleanup: () => void | Promise<void>, + stop?: (process: Process) => void | Promise<void> +) => Promise<void> + +const runInterruptCleanup = runInterruptCleanupUntyped as RunInterruptCleanup + +describe('package consumer interrupt cleanup', () => { + it('runs resource cleanup after process-stop failures and reports every failure', async () => { + const stopFailure = new Error('stop failed') + const cleanupFailure = new Error('cleanup failed') + const cleanup = vi.fn(() => Promise.reject(cleanupFailure)) + const stop = vi.fn((process: string) => (process === 'failing' ? Promise.reject(stopFailure) : Promise.resolve())) + + await expect(runInterruptCleanup(new Set(['failing', 'stopped']), cleanup, stop)).rejects.toEqual( + expect.objectContaining({ errors: [stopFailure, cleanupFailure] }) + ) + expect(stop).toHaveBeenCalledTimes(2) + expect(cleanup).toHaveBeenCalledOnce() + }) +}) diff --git a/src/test/search.test.tsx b/src/test/search.test.tsx new file mode 100644 index 00000000..ca0e44de --- /dev/null +++ b/src/test/search.test.tsx @@ -0,0 +1,399 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +import { Realm, useCellValue, useRealm } from '@mdxeditor/gurx' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + EmptyTextNodeIndex, + MDXEditor, + type MDXEditorMethods, + MDX_FOCUS_SEARCH_NAME, + MDX_SEARCH_NAME, + debouncedIndexer$, + editorSearchCursor$, + editorSearchRanges$, + editorSearchScrollableContent$, + editorSearchTerm$, + editorSearchTermDebounced$, + editorSearchTextNodeIndex$, + linkPlugin, + rangeSearchScan, + searchOpen$, + searchPlugin, + toolbarPlugin, + useEditorSearch +} from '../' +import { contentEditableRef$ } from '../plugins/core' + +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true + +class TestHighlight { + readonly ranges: Range[] + + constructor(...ranges: Range[]) { + this.ranges = ranges + } +} + +const highlights = new Map<string, TestHighlight>() +let originalCssDescriptor: PropertyDescriptor | undefined +let originalHighlightDescriptor: PropertyDescriptor | undefined + +beforeEach(() => { + highlights.clear() + originalCssDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'CSS') + originalHighlightDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'Highlight') + Object.defineProperty(globalThis, 'CSS', { configurable: true, value: { highlights } }) + Object.defineProperty(globalThis, 'Highlight', { configurable: true, value: TestHighlight }) +}) + +afterEach(() => { + if (originalCssDescriptor) Object.defineProperty(globalThis, 'CSS', originalCssDescriptor) + else Reflect.deleteProperty(globalThis, 'CSS') + if (originalHighlightDescriptor) Object.defineProperty(globalThis, 'Highlight', originalHighlightDescriptor) + else Reflect.deleteProperty(globalThis, 'Highlight') + vi.useRealTimers() +}) + +const SearchControls = () => { + const realm = useRealm() + const search = useEditorSearch() + const textNodeIndex = useCellValue(editorSearchTextNodeIndex$) + + return ( + <div> + <input + aria-label="Search term" + value={search.search} + onChange={(event) => { + search.setSearch(event.target.value) + }} + /> + <output aria-label="Search total">{search.total}</output> + <output aria-label="Search cursor">{search.cursor}</output> + <output aria-label="Current match">{search.currentRange?.toString() ?? ''}</output> + <output aria-label="Search open">{String(search.isSearchOpen)}</output> + <output aria-label="Search indexed text">{textNodeIndex.allText}</output> + <button type="button" onClick={search.openSearch}> + Open search + </button> + <button type="button" onClick={search.closeSearch}> + Close search + </button> + <button + type="button" + onClick={() => { + search.setIsSearchOpen(false) + }} + > + Close with setter + </button> + <button + type="button" + onClick={() => { + realm.pub(searchOpen$, false) + }} + > + Close with cell + </button> + <button type="button" onClick={search.next}> + Next match + </button> + <button type="button" onClick={search.prev}> + Previous match + </button> + <button + type="button" + onClick={() => { + search.replace('$&') + }} + > + Replace match + </button> + <button + type="button" + onClick={() => { + search.replaceAll('$&') + }} + > + Replace all + </button> + <button + type="button" + onClick={() => { + search.replaceAll('') + }} + > + Delete all + </button> + <button + type="button" + onClick={() => { + if (search.currentRange) search.scrollToRangeOrIndex(search.currentRange, { ignoreIfInView: false, behavior: 'auto' }) + }} + > + Scroll supplied range + </button> + <button + type="button" + onClick={() => { + search.scrollToRangeOrIndex(1, { ignoreIfInView: true, behavior: 'smooth' }) + }} + > + Scroll first index + </button> + </div> + ) +} + +function renderSearchEditor(markdown: string, onChange = vi.fn()) { + const ref = React.createRef<MDXEditorMethods>() + render( + <MDXEditor + ref={ref} + markdown={markdown} + onChange={onChange} + plugins={[ + linkPlugin(), + searchPlugin(), + toolbarPlugin({ + toolbarContents: () => <SearchControls /> + }) + ]} + /> + ) + fireEvent.click(screen.getByRole('button', { name: 'Open search' })) + return ref +} + +async function setSearch(term: string) { + fireEvent.change(screen.getByLabelText('Search term'), { target: { value: term } }) + await waitFor(() => { + expect(screen.getByLabelText('Search term')).toHaveValue(term) + }) +} + +async function waitForTotal(total: number) { + await waitFor(() => { + expect(screen.getByLabelText('Search total')).toHaveTextContent(String(total)) + }) +} + +describe('public search cells and helpers', () => { + it('keeps every public cell observable and preserves debounce/link behavior', async () => { + vi.useFakeTimers() + const realm = new Realm() + const termValues: string[] = [] + const rangeValues: Range[][] = [] + const cursorValues: number[] = [] + const openValues: boolean[] = [] + const indexValues: string[] = [] + const scrollValues: (HTMLElement | null)[] = [] + + realm.sub(editorSearchTerm$, (value) => termValues.push(value)) + realm.sub(editorSearchRanges$, (value) => rangeValues.push(value)) + realm.sub(editorSearchCursor$, (value) => cursorValues.push(value)) + realm.sub(searchOpen$, (value) => openValues.push(value)) + realm.sub(editorSearchTextNodeIndex$, (value) => indexValues.push(value.allText)) + realm.sub(editorSearchScrollableContent$, (value) => scrollValues.push(value)) + + const range = document.createRange() + const directIndex = { allText: 'direct', nodeIndex: [], offsetIndex: [] } + realm.pub(editorSearchRanges$, [range]) + realm.pub(editorSearchCursor$, 2) + realm.pub(searchOpen$, true) + realm.pub(editorSearchTerm$, 'direct') + realm.pub(editorSearchTextNodeIndex$, directIndex) + + const wrapper = document.createElement('div') + const editable = document.createElement('div') + wrapper.append(editable) + realm.pub(contentEditableRef$, { current: editable }) + + realm.pub(editorSearchTermDebounced$, 'debounced') + realm.pub(debouncedIndexer$, { allText: 'debounced-index', nodeIndex: [], offsetIndex: [] }) + await vi.advanceTimersByTimeAsync(251) + + expect(termValues).toContain('direct') + expect(termValues.at(-1)).toBe('debounced') + expect(rangeValues.at(-1)).toEqual([range]) + expect(cursorValues.at(-1)).toBe(2) + expect(openValues.at(-1)).toBe(true) + expect(indexValues).toContain('direct') + expect(indexValues.at(-1)).toBe('debounced-index') + expect(scrollValues.at(-1)).toBe(wrapper) + expect(realm.getValue(editorSearchTextNodeIndex$)).not.toBe(EmptyTextNodeIndex) + }) + + it('scans a consumer-created TextNodeIndex across nodes and normalized offsets', () => { + const first = document.createTextNode('alpha ') + const second = document.createTextNode('ALPHA') + const container = document.createElement('div') + container.append(first, second) + const nodeIndex = [...Array.from({ length: 6 }, () => first), ...Array.from({ length: 5 }, () => second)] + const offsetIndex = [...Array.from({ length: 6 }, (_, index) => index), ...Array.from({ length: 5 }, (_, index) => index)] + const ranges = Array.from(rangeSearchScan('alpha.*alpha', { allText: 'alpha ALPHA', nodeIndex, offsetIndex })) + + expect(ranges).toHaveLength(1) + expect(ranges[0]?.toString()).toBe('alpha ALPHA') + + const composed = document.createTextNode('é') + const [normalizedRange] = Array.from( + rangeSearchScan('e\u0301', { allText: 'e\u0301', nodeIndex: [composed, composed], offsetIndex: [0, 0] }) + ) + expect(normalizedRange?.toString()).toBe('é') + }) +}) + +describe('state-backed search and replacement', () => { + it('maps normalized expansions through the real editor without splitting source characters', async () => { + const ref = renderSearchEditor('café 🄀 🚀') + + await setSearch('e\u0301') + await waitForTotal(1) + expect(screen.getByLabelText('Current match')).toHaveTextContent('é') + + await setSearch('0') + await waitForTotal(1) + expect(screen.getByLabelText('Current match')).toHaveTextContent('🄀') + fireEvent.click(screen.getByRole('button', { name: 'Replace all' })) + + await waitFor(() => { + expect(ref.current?.getMarkdown()).toBe('café $& 🚀') + }) + }) + + it('deletes a whole-node match without leaving an empty text artifact', async () => { + const ref = renderSearchEditor('alpha') + await setSearch('alpha') + await waitForTotal(1) + + fireEvent.click(screen.getByRole('button', { name: 'Delete all' })) + + await waitFor(() => { + expect(ref.current?.getMarkdown()).toBe('') + }) + expect(screen.getByLabelText('Search total')).toHaveTextContent('0') + }) + + it('tracks programmatic mutation and recovers from invalid and zero-length regexes', async () => { + const ref = renderSearchEditor('al**pha** beta alpha KEEP') + await setSearch('alpha') + await waitForTotal(2) + expect(screen.getByLabelText('Current match')).toHaveTextContent('alpha') + expect(highlights.get(MDX_SEARCH_NAME)?.ranges).toHaveLength(2) + expect(highlights.get(MDX_FOCUS_SEARCH_NAME)?.ranges).toHaveLength(1) + + act(() => { + ref.current?.setMarkdown('prefix alpha and alpha') + }) + await waitForTotal(2) + + await setSearch('[') + await waitForTotal(0) + expect(ref.current?.getMarkdown()).toBe('prefix alpha and alpha') + + await setSearch('^') + await waitForTotal(0) + await setSearch('alpha') + await waitForTotal(2) + + fireEvent.click(screen.getByRole('button', { name: 'Close with setter' })) + expect(highlights.size).toBe(0) + fireEvent.click(screen.getByRole('button', { name: 'Open search' })) + await waitForTotal(2) + fireEvent.click(screen.getByRole('button', { name: 'Close with cell' })) + expect(highlights.size).toBe(0) + }) + + it('replaces split and repeated matches literally in one editor change', async () => { + const onChange = vi.fn() + const ref = renderSearchEditor('al**pha** beta alpha KEEP', onChange) + await setSearch('alpha') + await waitForTotal(2) + const changesBefore = onChange.mock.calls.length + + fireEvent.click(screen.getByRole('button', { name: 'Replace all' })) + await waitFor(() => { + expect(ref.current?.getMarkdown()).toBe('$& beta $& KEEP') + }) + + expect(onChange.mock.calls.length - changesBefore).toBe(1) + expect(screen.getByLabelText('Search total')).toHaveTextContent('0') + }) + + it('replaces a complete linked-text match without detaching its text node', async () => { + const ref = renderSearchEditor('[alpha](https://example.com) and alpha') + await setSearch('alpha') + await waitForTotal(2) + + fireEvent.click(screen.getByRole('button', { name: 'Replace all' })) + + await waitFor(() => { + expect(ref.current?.getMarkdown()).toBe('[$&](https://example.com) and $&') + }) + expect(screen.getByLabelText('Search total')).toHaveTextContent('0') + }) + + it('fails closed when the document changes between scan and replacement', async () => { + const ref = renderSearchEditor('alpha alpha') + await setSearch('alpha') + await waitForTotal(2) + + act(() => { + ref.current?.setMarkdown('fresh alpha') + fireEvent.click(screen.getByRole('button', { name: 'Replace all' })) + }) + + await waitFor(() => { + expect(ref.current?.getMarkdown()).toBe('fresh alpha') + }) + await waitForTotal(1) + }) + + it('replaces a match spanning adjacent blocks without changing surrounding text', async () => { + const ref = renderSearchEditor('before alpha\n\nKEEP after') + await setSearch('alphaKEEP') + await waitForTotal(1) + + fireEvent.click(screen.getByRole('button', { name: 'Replace all' })) + + await waitFor(() => { + expect(ref.current?.getMarkdown()).toBe('before $& after') + }) + expect(screen.getByLabelText('Search total')).toHaveTextContent('0') + }) + + it('supports public navigation and both external scrolling call shapes', async () => { + const originalScrollTo = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTo') + const originalGetClientRects = Object.getOwnPropertyDescriptor(Range.prototype, 'getClientRects') + const scrollTo = vi.fn() + const rect = { top: 20, bottom: 30, height: 10, left: 0, right: 10, width: 10, x: 0, y: 20, toJSON: () => ({}) } + const getClientRects = vi.fn(() => ({ + 0: rect, + length: 1, + item: (index: number) => (index === 0 ? (rect as DOMRect) : null), + [Symbol.iterator]: () => [rect as DOMRect].values() + })) + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, value: scrollTo }) + Object.defineProperty(Range.prototype, 'getClientRects', { configurable: true, value: getClientRects }) + renderSearchEditor('alpha beta alpha') + await setSearch('alpha') + await waitForTotal(2) + + fireEvent.click(screen.getByRole('button', { name: 'Next match' })) + expect(screen.getByLabelText('Search cursor')).toHaveTextContent('2') + fireEvent.click(screen.getByRole('button', { name: 'Previous match' })) + expect(screen.getByLabelText('Search cursor')).toHaveTextContent('1') + fireEvent.click(screen.getByRole('button', { name: 'Scroll supplied range' })) + fireEvent.click(screen.getByRole('button', { name: 'Scroll first index' })) + + expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'auto' })) + expect(getClientRects).toHaveBeenCalled() + if (originalScrollTo) Object.defineProperty(HTMLElement.prototype, 'scrollTo', originalScrollTo) + else Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo') + if (originalGetClientRects) Object.defineProperty(Range.prototype, 'getClientRects', originalGetClientRects) + else Reflect.deleteProperty(Range.prototype, 'getClientRects') + }) +}) diff --git a/src/test/selection-markdown.test.tsx b/src/test/selection-markdown.test.tsx index 9b8bee37..9099e93b 100644 --- a/src/test/selection-markdown.test.tsx +++ b/src/test/selection-markdown.test.tsx @@ -1,17 +1,151 @@ -import { describe, it, expect } from 'vitest' -import { render } from '@testing-library/react' +import { LinkNode, $createLinkNode } from '@lexical/link' +import { $createListItemNode, $createListNode, ListItemNode, ListNode } from '@lexical/list' +import { gfmTaskListItemToMarkdown } from 'mdast-util-gfm-task-list-item' import React from 'react' +import { render } from '@testing-library/react' +import { + $createNodeSelection, + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getSelection, + $getRoot, + $setSelection, + createEditor, + DecoratorNode, + EditorConfig, + LexicalEditor, + NodeKey, + ParagraphNode, + SerializedLexicalNode, + Spread, + TextNode +} from 'lexical' +import type * as Mdast from 'mdast' +import { describe, expect, it } from 'vitest' import { MDXEditor, MDXEditorMethods } from '../' +import type { ExportMarkdownFromLexicalOptions, LexicalExportVisitor } from '../exportMarkdownFromLexical' +import { LexicalLinebreakVisitor } from '../plugins/core/LexicalLinebreakVisitor' +import { LexicalParagraphVisitor } from '../plugins/core/LexicalParagraphVisitor' +import { LexicalRootVisitor } from '../plugins/core/LexicalRootVisitor' +import { LexicalTextVisitor } from '../plugins/core/LexicalTextVisitor' +import { LexicalLinkVisitor } from '../plugins/link/LexicalLinkVisitor' +import { LexicalListItemVisitor } from '../plugins/lists/LexicalListItemVisitor' +import { LexicalListVisitor } from '../plugins/lists/LexicalListVisitor' +import { getSelectionAsMarkdown } from '../utils/lexicalHelpers' // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true -describe('getSelectionMarkdown', () => { +type SerializedSelectionTestNode = Spread< + { + value: string + }, + SerializedLexicalNode +> + +class SelectionTestNode extends DecoratorNode<null> { + __value: string + + static getType(): string { + return 'selection-test' + } + + static clone(node: SelectionTestNode): SelectionTestNode { + return new SelectionTestNode(node.__value, node.__key) + } + + static importJSON(serializedNode: SerializedSelectionTestNode): SelectionTestNode { + return new SelectionTestNode(serializedNode.value) + } + + constructor(value: string, key?: NodeKey) { + super(key) + this.__value = value + } + + exportJSON(): SerializedSelectionTestNode { + return { type: 'selection-test', value: this.__value, version: 1 } + } + + createDOM(_config: EditorConfig): HTMLElement { + return document.createElement('div') + } + + updateDOM(): false { + return false + } + + decorate(): null { + return null + } + + getValue(): string { + return this.getLatest().__value + } +} + +const SelectionTestVisitor: LexicalExportVisitor<SelectionTestNode, Mdast.Paragraph> = { + testLexicalNode: (node): node is SelectionTestNode => node instanceof SelectionTestNode, + visitLexicalNode: ({ lexicalNode, mdastParent, actions }) => { + actions.appendToParent(mdastParent, { + type: 'paragraph', + children: [{ type: 'text', value: `consumer:${lexicalNode.getValue()}` }] + }) + } +} + +const baseVisitors = [ + LexicalRootVisitor, + LexicalParagraphVisitor, + LexicalTextVisitor, + LexicalLinebreakVisitor, + LexicalLinkVisitor, + LexicalListVisitor, + LexicalListItemVisitor +] as unknown as ExportMarkdownFromLexicalOptions['visitors'] + +function createSelectionEditor() { + return createEditor({ + namespace: 'selection-markdown-test', + nodes: [ParagraphNode, TextNode, LinkNode, ListNode, ListItemNode, SelectionTestNode], + onError(error) { + throw error + } + }) +} + +function exportParams( + overrides: Partial<Omit<ExportMarkdownFromLexicalOptions, 'root'>> = {} +): Omit<ExportMarkdownFromLexicalOptions, 'root'> { + return { + visitors: baseVisitors, + toMarkdownExtensions: [gfmTaskListItemToMarkdown()], + toMarkdownOptions: {}, + jsxComponentDescriptors: [], + jsxIsAvailable: false, + ...overrides + } +} + +function readSelection(editor: LexicalEditor) { + return editor.getEditorState().read( + () => { + const selection = $getSelection() + if (!selection) { + return null + } + return selection.getNodes().map((node) => node.getKey()) + }, + { editor } + ) +} + +describe('getSelectionMarkdown public method', () => { it('returns empty string when no selection', () => { const ref = React.createRef<MDXEditorMethods>() render(<MDXEditor ref={ref} markdown="Hello World" />) - // Without making a selection, should return empty string expect(ref.current?.getSelectionMarkdown()).toBe('') }) @@ -29,9 +163,185 @@ describe('getSelectionMarkdown', () => { expect(ref.current?.getSelectionMarkdown).toBeDefined() expect(typeof ref.current?.getSelectionMarkdown).toBe('function') }) +}) + +describe('getSelectionAsMarkdown', () => { + it('clips partial text and preserves selected boundary whitespace', () => { + const editor = createSelectionEditor() + + editor.update( + () => { + const text = $createTextNode('zero bravo tail') + $getRoot().append($createParagraphNode().append(text)) + text.select(4, 13) + }, + { discrete: true } + ) + + expect(getSelectionAsMarkdown(editor, exportParams())).toBe(' bravo ') + }) + + it('clips formatted and linked text instead of expanding to whole nodes', () => { + const editor = createSelectionEditor() + let boldKey = '' + let linkTextKey = '' + + editor.update( + () => { + const paragraph = $createParagraphNode() + const bold = $createTextNode('boldness').toggleFormat('bold') + const linkText = $createTextNode('linked text') + paragraph.append(bold, $createTextNode(' and '), $createLinkNode('https://example.com').append(linkText)) + $getRoot().append(paragraph) + boldKey = bold.getKey() + linkTextKey = linkText.getKey() + bold.select(0, 4) + }, + { discrete: true } + ) - // Note: Testing actual selections programmatically in Lexical requires - // more complex setup with editor.update() and selection manipulation. - // These tests verify the method exists and handles the no-selection case. - // Manual testing via the Ladle story is recommended for selection scenarios. + expect(getSelectionAsMarkdown(editor, exportParams())).toBe('**bold**') + + editor.update( + () => { + const range = $createRangeSelection() + range.anchor.set(linkTextKey, 1, 'text') + range.focus.set(linkTextKey, 6, 'text') + $setSelection(range) + }, + { discrete: true } + ) + + expect(boldKey).not.toBe('') + expect(getSelectionAsMarkdown(editor, exportParams())).toBe('[inked](https://example.com)') + }) + + it('normalizes forward and backward multi-block selections to document order', () => { + const editor = createSelectionEditor() + let firstKey = '' + let secondKey = '' + + editor.update( + () => { + const first = $createTextNode('alpha') + const second = $createTextNode('bravo') + $getRoot().append($createParagraphNode().append(first), $createParagraphNode().append(second)) + firstKey = first.getKey() + secondKey = second.getKey() + }, + { discrete: true } + ) + + const select = (backward: boolean) => { + editor.update( + () => { + const range = $createRangeSelection() + const start = { key: firstKey, offset: 2 } + const end = { key: secondKey, offset: 3 } + const anchor = backward ? end : start + const focus = backward ? start : end + range.anchor.set(anchor.key, anchor.offset, 'text') + range.focus.set(focus.key, focus.offset, 'text') + $setSelection(range) + }, + { discrete: true } + ) + return getSelectionAsMarkdown(editor, exportParams()) + } + + expect(select(false)).toBe('pha\n\nbra') + expect(select(true)).toBe('pha\n\nbra') + }) + + it('supports structural NodeSelection and keeps editor-local Markdown options isolated', () => { + const firstEditor = createSelectionEditor() + const secondEditor = createSelectionEditor() + + for (const editor of [firstEditor, secondEditor]) { + editor.update( + () => { + const list = $createListNode('bullet').append($createListItemNode().append($createTextNode('item'))) + $getRoot().append(list) + const selection = $createNodeSelection() + selection.add(list.getKey()) + $setSelection(selection) + }, + { discrete: true } + ) + } + + expect(getSelectionAsMarkdown(firstEditor, exportParams({ toMarkdownOptions: { bullet: '+' } }))).toBe('+ item') + expect(getSelectionAsMarkdown(secondEditor, exportParams({ toMarkdownOptions: { bullet: '-' } }))).toBe('- item') + }) + + it('inherits consumer node registration, uses its visitor, and does not mutate the source or visitor array', () => { + const editor = createSelectionEditor() + let sourceUpdateCount = 0 + + editor.update( + () => { + const node = new SelectionTestNode('payload') + $getRoot().append(node) + const selection = $createNodeSelection() + selection.add(node.getKey()) + $setSelection(selection) + }, + { discrete: true } + ) + + const unregister = editor.registerUpdateListener(() => { + sourceUpdateCount += 1 + }) + const sourceState = editor.getEditorState() + const sourceSelection = readSelection(editor) + const prioritizedTextVisitor = { ...LexicalTextVisitor, priority: 100 } + const visitors = [ + LexicalRootVisitor, + LexicalParagraphVisitor, + prioritizedTextVisitor, + SelectionTestVisitor + ] as unknown as ExportMarkdownFromLexicalOptions['visitors'] + const originalVisitorOrder = [...visitors] + const params = exportParams({ visitors }) + + expect(getSelectionAsMarkdown(editor, params)).toBe('consumer:payload') + expect(getSelectionAsMarkdown(editor, params)).toBe('consumer:payload') + expect(editor.getEditorState()).toBe(sourceState) + expect(readSelection(editor)).toEqual(sourceSelection) + expect(sourceUpdateCount).toBe(0) + expect(visitors).toEqual(originalVisitorOrder) + unregister() + }) + + it('fails synchronously when selected content has no export visitor', () => { + const editor = createSelectionEditor() + + editor.update( + () => { + const node = new SelectionTestNode('unsupported') + $getRoot().append(node) + const selection = $createNodeSelection() + selection.add(node.getKey()) + $setSelection(selection) + }, + { discrete: true } + ) + + expect(() => getSelectionAsMarkdown(editor, exportParams())).toThrow('no lexical visitor found for selection-test') + }) + + it('returns empty for a collapsed range', () => { + const editor = createSelectionEditor() + + editor.update( + () => { + const text = $createTextNode('content') + $getRoot().append($createParagraphNode().append(text)) + text.select(2, 2) + }, + { discrete: true } + ) + + expect(getSelectionAsMarkdown(editor, exportParams())).toBe('') + }) }) diff --git a/src/utils/lexicalHelpers.ts b/src/utils/lexicalHelpers.ts index 7412f7cf..a1ef14ee 100644 --- a/src/utils/lexicalHelpers.ts +++ b/src/utils/lexicalHelpers.ts @@ -1,18 +1,15 @@ import { + $createParagraphNode, $getRoot, $getSelection, $isRangeSelection, - $isTextNode, - $isElementNode, + createEditor, ElementNode, LexicalEditor, - LexicalNode, RangeSelection, TextNode } from 'lexical' -import { $isLinkNode } from '@lexical/link' -import { $isHeadingNode } from '@lexical/rich-text' -import { $isListNode, $isListItemNode } from '@lexical/list' +import { $generateJSONFromSelectedNodes, $generateNodesFromSerializedNodes } from '@lexical/clipboard' import { $isAtNodeEnd } from '@lexical/selection' import { tap } from './fp' import { ExportMarkdownFromLexicalOptions, exportMarkdownFromLexical } from '../exportMarkdownFromLexical' @@ -162,118 +159,77 @@ export function getStateAsMarkdown(editor: LexicalEditor, exportParams: Omit<Exp /** * Gets the markdown representation of the current selection in the Lexical editor. * Returns an empty string if there is no selection or if the selection is collapsed. - * Converts selected nodes to markdown by recursively processing them and preserving formatting. - * Note: Selects entire nodes, not partial selections within nodes. + * Uses the same visitors and serialization options as the editor's full markdown export. * @group Utils */ -export function getSelectionAsMarkdown(editor: LexicalEditor, _exportParams: Omit<ExportMarkdownFromLexicalOptions, 'root'>): string { +export function getSelectionAsMarkdown(editor: LexicalEditor, exportParams: Omit<ExportMarkdownFromLexicalOptions, 'root'>): string { + let temporaryEditor: LexicalEditor | null = null + let selectedNodes: ReturnType<typeof $generateJSONFromSelectedNodes>['nodes'] = [] let markdown = '' - editor.getEditorState().read(() => { - const selection = $getSelection() + editor.getEditorState().read( + () => { + const selection = $getSelection() - // Return empty if no selection or collapsed - if (!selection || !$isRangeSelection(selection) || selection.isCollapsed()) { - return - } + if (!selection || ($isRangeSelection(selection) && selection.isCollapsed()) || selection.getNodes().length === 0) { + return + } - // Get all nodes in the selection (entire nodes, not partial) - const nodes = selection.getNodes() + selectedNodes = $generateJSONFromSelectedNodes(editor, selection).nodes + if (selectedNodes.length > 0) { + // A no-config editor created in this context inherits the active editor's + // complete registered node map, including consumer nodes and replacements. + temporaryEditor = createEditor() + } + }, + { editor } + ) - if (nodes.length === 0) { - return - } + const selectedTreeEditor = temporaryEditor as LexicalEditor | null + if (!selectedTreeEditor || selectedNodes.length === 0) { + return '' + } - // Get unique block-level parent nodes to preserve structure (headings, lists, paragraphs, etc.) - const parentNodes = new Set<ElementNode>() - nodes.forEach((node) => { - let current: LexicalNode | null = node + const exportResult: { error: unknown; failed: boolean } = { error: undefined, failed: false } - // Walk up to find the nearest block-level parent (heading, paragraph, list item, etc.) - while (current) { - // Check if current node is a block-level node - if ($isHeadingNode(current) || $isListItemNode(current) || current.getType() === 'paragraph' || current.getType() === 'quote') { - if ($isElementNode(current)) { - parentNodes.add(current) + selectedTreeEditor.update( + () => { + try { + const root = $getRoot() + const nodes = $generateNodesFromSerializedNodes(selectedNodes) + let inlineContainer = $createParagraphNode() + + const appendInlineContainer = () => { + if (!inlineContainer.isEmpty()) { + root.append(inlineContainer) + inlineContainer = $createParagraphNode() } - break } - current = current.getParent() - } - }) - - // If we have parent nodes, use those instead of leaf nodes - const nodesToProcess = parentNodes.size > 0 ? Array.from(parentNodes) : nodes - - // Helper function to recursively convert a node to markdown - function nodeToMarkdown(node: LexicalNode): string { - if ($isHeadingNode(node)) { - // Handle heading nodes - const level = parseInt(node.getTag().replace('h', '')) - const children = node.getChildren() - const headingText = children.map((child) => nodeToMarkdown(child)).join('') - return '#'.repeat(level) + ' ' + headingText + '\n\n' - } else if ($isListItemNode(node)) { - // Handle list item nodes - const parent = node.getParent() - const prefix = parent && $isListNode(parent) && parent.getListType() === 'number' ? '1. ' : '- ' - const children = node.getChildren() - const itemText = children.map((child) => nodeToMarkdown(child)).join('') - return prefix + itemText + '\n' - } else if ($isListNode(node)) { - // Handle list nodes - const children = node.getChildren() - return children.map((child) => nodeToMarkdown(child)).join('') + '\n' - } else if ($isTextNode(node)) { - let text = node.getTextContent() - const format = node.getFormat() - - // Apply markdown formatting based on Lexical text format flags - // Bold: 1, Italic: 2, Strikethrough: 4, Underline: 8, Code: 16 - if (format & 16) { - // Code - return `\`${text}\`` - } - // Apply formatting in correct order (innermost to outermost) - if (format & 1) { - // Bold - text = `**${text}**` - } - if (format & 2) { - // Italic - text = `*${text}*` - } - if (format & 4) { - // Strikethrough - text = `~~${text}~~` + for (const node of nodes) { + if (node.isInline()) { + inlineContainer.append(node) + } else { + appendInlineContainer() + root.append(node) + } } + appendInlineContainer() - return text - } else if ($isLinkNode(node)) { - // Handle link nodes - const url = node.getURL() - const title = node.getTitle() - const children = node.getChildren() - const linkText = children.map((child) => nodeToMarkdown(child)).join('') - - if (title) { - return `[${linkText}](${url} "${title}")` - } - return `[${linkText}](${url})` - } else if ($isElementNode(node)) { - // For other element nodes, process their children - const children = node.getChildren() - return children.map((child) => nodeToMarkdown(child)).join('') + markdown = exportMarkdownFromLexical({ root, ...exportParams }) + } catch (error) { + exportResult.failed = true + exportResult.error = error } + }, + { discrete: true, skipTransforms: true } + ) - // Fallback: return text content - return node.getTextContent() - } - - // Convert all selected nodes to markdown and concatenate - markdown = nodesToProcess.map((node) => nodeToMarkdown(node)).join('') - }) + if (exportResult.failed) { + throw exportResult.error + } - return markdown.trim() + // Full-document export intentionally includes terminal line breaks. A + // selection result is a fragment, so remove only those structural breaks. + return markdown.replace(/\n+$/u, '') } diff --git a/tests/browser/extension-compatibility.spec.ts b/tests/browser/extension-compatibility.spec.ts new file mode 100644 index 00000000..5b4b1c70 --- /dev/null +++ b/tests/browser/extension-compatibility.spec.ts @@ -0,0 +1,347 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' + +const storyUrl = '/?story=extension-compatibility--extension-compatibility&mode=preview' +const runtimeErrors = new WeakMap<Page, string[]>() + +async function placeCaret(page: Page, locator: Locator) { + await locator.evaluate((element) => { + const editable = element.closest<HTMLElement>('[contenteditable="true"]') + if (!editable) throw new Error('The selected public DOM node is not inside a contenteditable') + editable.focus() + const range = document.createRange() + range.selectNodeContents(element) + range.collapse(false) + const selection = window.getSelection() + if (!selection) throw new Error('The browser did not expose a DOM selection') + selection.removeAllRanges() + selection.addRange(range) + document.dispatchEvent(new Event('selectionchange', { bubbles: true })) + }) +} + +async function appendText(page: Page, locator: Locator, text: string) { + await placeCaret(page, locator) + // Table activation focuses its newly active cell once; reapply the public + // DOM selection after that focus hand-off so the marker remains atomic. + await page.waitForTimeout(20) + await placeCaret(page, locator) + await page.keyboard.type(text) + await expect(locator).toContainText(text.trim()) +} + +async function readExtensionMarkdown(page: Page) { + await page.getByRole('button', { name: 'Read extension Markdown' }).click() + return (await page.getByLabel('Extension read Markdown').textContent()) ?? '' +} + +async function expectExtensionMarkdown(page: Page, assertion: (markdown: string) => void) { + await expect + .poll(async () => { + const markdown = await readExtensionMarkdown(page) + assertion(markdown) + return true + }) + .toBe(true) +} + +async function clickHistoryUntil(page: Page, control: Locator, target: Locator, expectedText: string) { + let clicks = 0 + while (((await target.textContent()) ?? '') !== expectedText && clicks < 24) { + if (!(await control.isEnabled())) { + throw new Error( + `History control became disabled before reaching ${JSON.stringify(expectedText)}; current text is ${JSON.stringify( + await target.textContent() + )}` + ) + } + await control.click() + clicks++ + await page.waitForTimeout(20) + } + expect(await target.textContent()).toBe(expectedText) + expect(clicks).toBeGreaterThan(0) +} + +async function drainHistoryEndpoint(page: Page, control: Locator) { + let clicks = 0 + while ((await control.isEnabled()) && clicks < 24) { + await control.click() + clicks++ + await page.waitForTimeout(20) + } + await expect(control).toBeDisabled() +} + +async function probeStats(page: Page) { + return JSON.parse((await page.getByLabel('Extension probe stats').textContent()) ?? '{}') as Record< + 'root' | 'nested' | 'table', + { mounts: number; cleanups: number } + > +} + +test.beforeEach(async ({ page }) => { + const errors: string[] = [] + runtimeErrors.set(page, errors) + page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(`console.error: ${message.text()}`) + }) + + await page.goto(storyUrl) + await expect(page.getByRole('heading', { name: 'Extension compatibility fixture ready' })).toBeVisible() + await expect(page.getByLabel('Extension error')).toBeEmpty() + await expect(page.getByTestId('extension-root-probe')).toBeVisible() +}) + +test.afterEach(async ({ page }) => { + expect(runtimeErrors.get(page) ?? [], 'unexpected browser runtime errors').toEqual([]) +}) + +test('CX-7a keeps consumer Gurx, node, visitor, and React child plugins extension-agnostic', async ({ page }) => { + const rootProbe = page.getByTestId('extension-root-probe') + const nestedProbes = page.getByTestId('extension-nested-probe') + const tableProbes = page.getByTestId('extension-table-probe') + await expect(nestedProbes).toHaveCount(3) + await expect(tableProbes).toHaveCount(2) + await expect(page.getByLabel('Extension layout ref ready')).toHaveText('true') + await expect(page.getByLabel('Extension effect ref ready')).toHaveText('true') + await expect(rootProbe).toContainText(/^root:realm-\d+$/) + await expect(rootProbe).toHaveAttribute('data-plugin-init-count', '1') + await expect(nestedProbes.nth(0)).toHaveAttribute('data-plugin-init-count', '1') + await expect(tableProbes.nth(0)).toHaveAttribute('data-plugin-init-count', '1') + + const editorIds = [ + await rootProbe.getAttribute('data-editor-id'), + await nestedProbes.nth(0).getAttribute('data-editor-id'), + await nestedProbes.nth(1).getAttribute('data-editor-id'), + await nestedProbes.nth(2).getAttribute('data-editor-id'), + await tableProbes.nth(0).getAttribute('data-editor-id') + ] + expect(new Set(editorIds).size).toBe(editorIds.length) + + const rootEditorId = await rootProbe.getAttribute('data-editor-id') + const nestedIdentities = await nestedProbes.evaluateAll((probes) => + probes.map((probe) => ({ + id: probe.getAttribute('data-editor-id'), + parentId: probe.getAttribute('data-parent-editor-id') + })) + ) + const nestedEditorIds = nestedIdentities.map(({ id }) => id) + const recursiveNestedIdentity = nestedIdentities.find(({ parentId }) => nestedEditorIds.includes(parentId)) + expect(recursiveNestedIdentity).toBeDefined() + expect(recursiveNestedIdentity?.parentId).not.toBe(rootEditorId) + + await page.getByRole('button', { name: 'Read extension Markdown' }).click() + await expect(page.getByLabel('Extension read Markdown')).toContainText('consumer:Custom consumer text.') + + const rootText = page.getByTestId('extension-root-editor').locator('p').filter({ hasText: 'Root history text.' }).first() + const nestedText = page.locator('p').filter({ hasText: 'Nested A text.' }).first() + const tableText = page.getByRole('cell', { name: 'Table text' }).locator('p') + await appendText(page, rootText, ' ROOT-CX7') + await appendText(page, nestedText, ' NESTED-CX7') + await appendText(page, tableText, ' TABLE-CX7') + await page.getByRole('button', { name: 'Read extension Markdown' }).click() + await expect(page.getByLabel('Extension read Markdown')).toContainText('ROOT-CX7') + await expect(page.getByLabel('Extension read Markdown')).toContainText('NESTED-CX7') + await expect(page.getByLabel('Extension read Markdown')).toContainText('TABLE-CX7') + + await page.getByRole('button', { name: 'Toggle extension read only' }).click() + await expect(page.getByTestId('extension-root-editor').locator('[contenteditable="true"]')).toHaveCount(0) + await expect(page.getByTestId('extension-root-editor').locator('[contenteditable="false"]')).not.toHaveCount(0) + await page.getByRole('button', { name: 'Toggle extension read only' }).click() + await expect(page.getByTestId('extension-root-editor').locator('[contenteditable="true"]')).not.toHaveCount(0) +}) + +test('CX-7b preserves normal, suppressed-root, and table-local history availability', async ({ page }) => { + const undo = page.getByRole('radio', { name: /Undo/ }) + const redo = page.getByRole('radio', { name: /Redo/ }) + const rootText = page.getByTestId('extension-root-editor').locator('p').filter({ hasText: 'Root history text.' }).first() + const nestedA = page.locator('p').filter({ hasText: 'Nested A text.' }).first() + const nestedB = page.locator('p').filter({ hasText: 'Nested B text.' }).first() + + await expect(undo).toBeDisabled() + await expect(redo).toBeDisabled() + await appendText(page, rootText, ' R') + await appendText(page, nestedA, ' A') + await appendText(page, nestedB, ' B') + await expect(undo).toBeEnabled() + await expect(redo).toBeDisabled() + + await clickHistoryUntil(page, undo, nestedB, 'Nested B text.') + await expect(undo).toBeEnabled() + await expect(redo).toBeEnabled() + await clickHistoryUntil(page, undo, nestedA, 'Nested A text.') + await expect(undo).toBeEnabled() + await expect(redo).toBeEnabled() + await clickHistoryUntil(page, undo, rootText, 'Root history text.') + await drainHistoryEndpoint(page, undo) + await expect(undo).toBeDisabled() + await expect(redo).toBeEnabled() + + await clickHistoryUntil(page, redo, rootText, 'Root history text. R') + await clickHistoryUntil(page, redo, nestedA, 'Nested A text. A') + await clickHistoryUntil(page, redo, nestedB, 'Nested B text. B') + await drainHistoryEndpoint(page, redo) + await expect(undo).toBeEnabled() + await expect(redo).toBeDisabled() + await expectExtensionMarkdown(page, (markdown) => { + expect(markdown).toContain('Root history text. R') + expect(markdown).toContain('Nested A text. A') + expect(markdown).toContain('Nested B text. B') + }) + + await page.getByRole('button', { name: 'Use normal history' }).click() + await expect(page.getByLabel('Extension mode')).toHaveText('normal') + await expect(undo).toBeDisabled() + await expect(redo).toBeDisabled() + const beforeTableSave = await readExtensionMarkdown(page) + const tableRootText = page.getByTestId('extension-root-editor').locator('p').filter({ hasText: 'Root history text.' }).first() + await placeCaret(page, tableRootText) + const tableText = page.getByRole('cell', { name: /Table text/ }).locator('p') + await appendText(page, tableText, ' T') + await expect(undo).toBeEnabled() + await expect(redo).toBeDisabled() + await clickHistoryUntil(page, undo, tableText, 'Table text') + await drainHistoryEndpoint(page, undo) + await expect(undo).toBeDisabled() + await expect(redo).toBeEnabled() + await clickHistoryUntil(page, redo, tableText, 'Table text T') + await drainHistoryEndpoint(page, redo) + await expect(undo).toBeEnabled() + await expect(redo).toBeDisabled() + + const afterTableSave = await readExtensionMarkdown(page) + expect(afterTableSave).toContain('Table text T') + await placeCaret(page, tableRootText) + await expect(undo).toBeEnabled() + await undo.click() + await expect(undo).toBeDisabled() + await expect(redo).toBeEnabled() + expect(await readExtensionMarkdown(page)).toBe(beforeTableSave) + await placeCaret(page, tableRootText) + await redo.click() + await expect(undo).toBeEnabled() + await expect(redo).toBeDisabled() + expect(await readExtensionMarkdown(page)).toBe(afterTableSave) + + await page.getByRole('button', { name: 'Use suppressed root history' }).click() + await expect(page.getByLabel('Extension mode')).toHaveText('suppressed') + const suppressedRoot = page.getByTestId('extension-root-editor').locator('p').filter({ hasText: 'Root history text.' }).first() + await appendText(page, suppressedRoot, ' R') + await expect(undo).toBeDisabled() + await expect(redo).toBeDisabled() + + const suppressedA = page.locator('p').filter({ hasText: 'Nested A text.' }).first() + const suppressedB = page.locator('p').filter({ hasText: 'Nested B text.' }).first() + await appendText(page, suppressedA, ' A') + await appendText(page, suppressedB, ' B') + await expect(undo).toBeEnabled() + await expect(redo).toBeDisabled() + await clickHistoryUntil(page, undo, suppressedB, 'Nested B text.') + await expect(undo).toBeEnabled() + await expect(redo).toBeEnabled() + await clickHistoryUntil(page, undo, suppressedA, 'Nested A text.') + await drainHistoryEndpoint(page, undo) + await expect(suppressedRoot).toHaveText('Root history text. R') + await expect(undo).toBeDisabled() + await expect(redo).toBeEnabled() + await clickHistoryUntil(page, redo, suppressedA, 'Nested A text. A') + await clickHistoryUntil(page, redo, suppressedB, 'Nested B text. B') + await drainHistoryEndpoint(page, redo) + await expect(undo).toBeEnabled() + await expect(redo).toBeDisabled() + await expectExtensionMarkdown(page, (markdown) => { + expect(markdown).toContain('Root history text. R') + expect(markdown).toContain('Nested A text. A') + expect(markdown).toContain('Nested B text. B') + }) +}) + +test('CX-7c recreates clean editor identities through Strict Mode unmount and remount', async ({ page }) => { + await page.getByRole('button', { name: 'Enable extension Strict Mode' }).click() + await expect(page.getByLabel('Extension React mode')).toHaveText('strict') + await expect.poll(async () => (await probeStats(page)).root.mounts).toBe(3) + await expect.poll(async () => (await probeStats(page)).root.cleanups).toBe(2) + await expect.poll(async () => (await probeStats(page)).nested.mounts).toBe(9) + await expect.poll(async () => (await probeStats(page)).nested.cleanups).toBe(6) + await expect.poll(async () => (await probeStats(page)).table.mounts).toBe(6) + await expect.poll(async () => (await probeStats(page)).table.cleanups).toBe(4) + + const initialProbe = page.getByTestId('extension-root-probe') + const initialEditorId = await initialProbe.getAttribute('data-editor-id') + const initialRealm = await initialProbe.textContent() + const initialNestedEditorIds = await page + .getByTestId('extension-nested-probe') + .evaluateAll((probes) => probes.map((probe) => probe.getAttribute('data-editor-id'))) + const initialTableEditorIds = await page + .getByTestId('extension-table-probe') + .evaluateAll((probes) => probes.map((probe) => probe.getAttribute('data-editor-id'))) + await page.getByRole('button', { name: 'Capture extension public ref' }).click() + const initialPublicRefId = await page.getByLabel('Extension public ref id').textContent() + expect(initialPublicRefId).not.toBe('') + const initialStats = await probeStats(page) + expect(initialStats.root.mounts).toBe(3) + expect(initialStats.root.cleanups).toBe(2) + + await page.getByRole('button', { name: 'Unmount extension editor' }).click() + await expect(page.getByTestId('extension-root-editor')).toHaveCount(0) + await expect.poll(async () => (await probeStats(page)).root.cleanups).toBe(initialStats.root.mounts) + await expect.poll(async () => (await probeStats(page)).nested.cleanups).toBe(initialStats.nested.mounts) + await expect.poll(async () => (await probeStats(page)).table.cleanups).toBe(initialStats.table.mounts) + + await page.getByRole('button', { name: 'Remount extension editor' }).click() + await expect(page.getByTestId('extension-root-probe')).toBeVisible() + const remountedProbe = page.getByTestId('extension-root-probe') + const remountedEditorId = await remountedProbe.getAttribute('data-editor-id') + expect(remountedEditorId).not.toBe(initialEditorId) + expect(await remountedProbe.textContent()).not.toBe(initialRealm) + await expect.poll(async () => (await probeStats(page)).root.mounts).toBe(initialStats.root.mounts + 2) + await expect.poll(async () => (await probeStats(page)).root.cleanups).toBe(initialStats.root.cleanups + 2) + await expect.poll(async () => (await probeStats(page)).nested.mounts).toBe(initialStats.nested.mounts + 6) + await expect.poll(async () => (await probeStats(page)).nested.cleanups).toBe(initialStats.nested.cleanups + 6) + await expect.poll(async () => (await probeStats(page)).table.mounts).toBe(initialStats.table.mounts + 4) + await expect.poll(async () => (await probeStats(page)).table.cleanups).toBe(initialStats.table.cleanups + 4) + const remountedNestedEditorIds = await page + .getByTestId('extension-nested-probe') + .evaluateAll((probes) => probes.map((probe) => probe.getAttribute('data-editor-id'))) + const remountedTableEditorIds = await page + .getByTestId('extension-table-probe') + .evaluateAll((probes) => probes.map((probe) => probe.getAttribute('data-editor-id'))) + expect(remountedNestedEditorIds.every((id) => !initialNestedEditorIds.includes(id))).toBe(true) + expect(remountedTableEditorIds.every((id) => !initialTableEditorIds.includes(id))).toBe(true) + await page.getByRole('button', { name: 'Capture extension public ref' }).click() + expect(await page.getByLabel('Extension public ref id').textContent()).not.toBe(initialPublicRefId) + + const rootText = page.getByTestId('extension-root-editor').locator('p').filter({ hasText: 'Root history text.' }).first() + await appendText(page, rootText, ' REMOUNTED') + await expect(remountedProbe).toHaveAttribute('data-active-editor-id', remountedEditorId ?? '') + await page.getByRole('button', { name: 'Read extension Markdown' }).click() + await expect(page.getByLabel('Extension read Markdown')).toContainText('REMOUNTED') + await page.getByRole('button', { name: 'Inspect stale extension editors' }).click() + const staleEditors = JSON.parse((await page.getByLabel('Extension stale editor snapshot').textContent()) ?? '[]') as Array<{ + id: number + text: string + }> + const staleRoot = staleEditors.find(({ id }) => String(id) === initialEditorId) + expect(staleRoot?.text).not.toContain('REMOUNTED') + + await page.getByRole('button', { name: 'Unmount extension editor' }).click() + await expect + .poll(async () => { + const stats = await probeStats(page) + return stats.root.cleanups === stats.root.mounts + }) + .toBe(true) + await expect + .poll(async () => { + const stats = await probeStats(page) + return stats.nested.cleanups === stats.nested.mounts + }) + .toBe(true) + await expect + .poll(async () => { + const stats = await probeStats(page) + return stats.table.cleanups === stats.table.mounts + }) + .toBe(true) +}) diff --git a/tests/browser/lexical-compatibility.spec.ts b/tests/browser/lexical-compatibility.spec.ts new file mode 100644 index 00000000..a3d199cf --- /dev/null +++ b/tests/browser/lexical-compatibility.spec.ts @@ -0,0 +1,370 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { readFileSync } from 'node:fs' + +const compatibilityMarkdown035 = readFileSync('src/test/fixtures/lexicalCompatibility.035.md', 'utf8').trimEnd() +const alternateCompatibilityMarkdown = '# Alternate compatibility document\n\nAlternate public-method content.\n' + +const storyUrl = '/?story=lexical-compatibility--compatibility&mode=preview' +const runtimeErrors = new WeakMap<Page, string[]>() + +const occurrences = (value: string, search: string) => value.split(search).length - 1 + +type MarkdownTreeNode = { type: string; url?: string; children?: MarkdownTreeNode[] } + +function collectMarkdownLinkUrls(markdown: string): string[] { + const urls: string[] = [] + const visit = (node: MarkdownTreeNode) => { + if (node.type === 'link' && typeof node.url === 'string') urls.push(node.url) + node.children?.forEach(visit) + } + visit(fromMarkdown(markdown) as MarkdownTreeNode) + return urls +} + +async function placeCaret(page: Page, locator: Locator, position: 'start' | 'end' = 'end') { + await locator.evaluate((element, caretPosition) => { + const editable = element.closest<HTMLElement>('[contenteditable="true"]') + if (!editable) throw new Error('The selected public DOM node is not inside a contenteditable') + editable.focus() + const range = document.createRange() + range.selectNodeContents(element) + range.collapse(caretPosition === 'start') + const selection = window.getSelection() + if (!selection) throw new Error('The browser did not expose a DOM selection') + selection.removeAllRanges() + selection.addRange(range) + document.dispatchEvent(new Event('selectionchange', { bubbles: true })) + }, position) +} + +async function readMarkdown(page: Page) { + await page.getByRole('button', { name: 'Get Markdown' }).click() + return (await page.getByLabel('Current Markdown').textContent()) ?? '' +} + +async function expectMarkdown(page: Page, assertion: (markdown: string) => void) { + await expect + .poll(async () => { + const markdown = await readMarkdown(page) + assertion(markdown) + return true + }) + .toBe(true) +} + +async function expectFocusedWithin(page: Page, testId: string) { + await expect.poll(() => page.getByTestId(testId).evaluate((element) => element.contains(document.activeElement))).toBe(true) +} + +async function undoUntilText(control: Locator, target: Locator, expectedText: string) { + let clicks = 0 + while ((await target.textContent()) !== expectedText && clicks < 32) { + await control.click() + clicks += 1 + } + expect(await target.textContent()).toBe(expectedText) + expect(clicks).toBeGreaterThan(0) + return clicks +} + +async function repeatHistoryAction(control: Locator, count: number) { + for (let index = 0; index < count; index += 1) { + await control.click() + } +} + +test.beforeEach(async ({ page }) => { + const errors: string[] = [] + runtimeErrors.set(page, errors) + page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(`console.error: ${message.text()}`) + }) + + await page.goto(storyUrl) + await expect(page.getByRole('heading', { name: 'Lexical compatibility fixture ready' })).toBeVisible() + await expect(page.getByLabel('Compatibility Error')).toBeEmpty() +}) + +test.afterEach(async ({ page }) => { + expect(runtimeErrors.get(page) ?? [], 'unexpected browser runtime errors').toEqual([]) +}) + +test('CX-2 preserves representative Markdown through get, set, and reset', async ({ page }) => { + const root = page.getByTestId('compatibility-root-editor') + const autolinkCandidate = root.locator('p').filter({ hasText: 'Link candidate text.' }) + await expect(page.getByRole('heading', { name: 'Compatibility heading' })).toBeVisible() + await expect(root.getByText('Admonition compatibility content.')).toBeVisible() + await expect(page.getByTestId('compatibility-nested-editor')).toContainText('Nested compatibility content.') + const codeBlock = page.getByTestId('compatibility-code-block') + await expect(codeBlock).toHaveValue('const compatible = true') + await expect(page.getByRole('table')).toContainText('TableStable') + + expect(await readMarkdown(page)).toBe(compatibilityMarkdown035) + + await codeBlock.fill('const compatible = false') + await expectMarkdown(page, (markdown) => expect(markdown).toContain('const compatible = false')) + + await placeCaret(page, autolinkCandidate) + await page.keyboard.type(' https://example.com/typed ') + await expect(root.getByRole('link', { name: 'https://example.com/typed' })).toBeVisible() + await expectMarkdown(page, (markdown) => expect(markdown).toContain('[https://example.com/typed](https://example.com/typed)')) + + await page.getByRole('button', { name: 'Set Alternate' }).click() + await expect(page.getByRole('heading', { name: 'Alternate compatibility document' })).toBeVisible() + expect(await readMarkdown(page)).toBe(alternateCompatibilityMarkdown.trimEnd()) + + await page.getByRole('button', { name: 'Reset Compatibility Markdown' }).click() + await expect(page.getByRole('heading', { name: 'Compatibility heading' })).toBeVisible() + expect(await readMarkdown(page)).toBe(compatibilityMarkdown035) + await expect(page.getByLabel('Compatibility Error')).toBeEmpty() +}) + +test('CX-3 synchronizes root, nested, table, decorator, and shared history edits', async ({ page }) => { + const root = page.getByTestId('compatibility-root-editor') + const initialMarkdown = await readMarkdown(page) + const undo = page.getByRole('radio', { name: /Undo/ }) + const redo = page.getByRole('radio', { name: /Redo/ }) + + const rootParagraph = root.locator('p').filter({ hasText: 'Root compatibility paragraph' }).first() + const initialRootText = (await rootParagraph.textContent()) ?? '' + await placeCaret(page, rootParagraph) + await page.keyboard.type(' ROOTEDIT') + await expectFocusedWithin(page, 'compatibility-root-editor') + await expectMarkdown(page, (markdown) => expect(occurrences(markdown, 'ROOTEDIT')).toBe(1)) + const afterRoot = await readMarkdown(page) + + await placeCaret(page, rootParagraph) + const rootUndoCount = await undoUntilText(undo, rootParagraph, initialRootText) + await expectMarkdown(page, (markdown) => expect(markdown).toBe(initialMarkdown)) + await placeCaret(page, rootParagraph) + await repeatHistoryAction(redo, rootUndoCount) + await expectMarkdown(page, (markdown) => expect(markdown).toBe(afterRoot)) + + const nestedText = page.getByTestId('compatibility-nested-editor').locator('p').filter({ hasText: 'Nested compatibility content.' }) + const initialNestedText = (await nestedText.textContent()) ?? '' + await placeCaret(page, nestedText) + await page.keyboard.type(' NESTEDEDIT') + await expectFocusedWithin(page, 'compatibility-nested-editor') + const nestedUndoCount = await undoUntilText(undo, nestedText, initialNestedText) + await repeatHistoryAction(redo, nestedUndoCount) + await expect(nestedText).toHaveText('Nested compatibility content. NESTEDEDIT') + await expectMarkdown(page, (markdown) => expect(occurrences(markdown, 'NESTEDEDIT')).toBe(1)) + const afterNested = await readMarkdown(page) + expect(afterNested).not.toBe(afterRoot) + expect(occurrences(afterNested, 'NESTEDEDIT')).toBe(1) + + const tableText = root.getByRole('cell', { name: 'Stable' }).locator('p') + const initialTableText = (await tableText.textContent()) ?? '' + await placeCaret(page, tableText) + await page.keyboard.type(' TABLEEDIT') + await expectFocusedWithin(page, 'compatibility-root-editor') + await expectMarkdown(page, (markdown) => expect(occurrences(markdown, 'TABLEEDIT')).toBe(1)) + const beforeBoundary = await readMarkdown(page) + expect(beforeBoundary).not.toBe(afterNested) + expect(occurrences(beforeBoundary, 'TABLEEDIT')).toBe(1) + + await placeCaret(page, tableText) + const tableUndoCount = await undoUntilText(undo, tableText, initialTableText) + await expectMarkdown(page, (markdown) => expect(markdown).toBe(afterNested)) + await placeCaret(page, tableText) + await repeatHistoryAction(redo, tableUndoCount) + await expectMarkdown(page, (markdown) => expect(markdown).toBe(beforeBoundary)) + + const boundaryParagraph = root.getByText('Decorator boundary paragraph.', { exact: true }) + await placeCaret(page, boundaryParagraph, 'start') + await page.keyboard.press('Backspace') + const afterBoundary = await readMarkdown(page) + expect(afterBoundary).not.toBe(beforeBoundary) + expect(afterBoundary).toContain('# Compatibility heading') + expect(occurrences(afterBoundary, 'Decorator boundary paragraph.')).toBe(1) + + await undo.click() + await expectMarkdown(page, (markdown) => expect(markdown).toBe(beforeBoundary)) + await placeCaret(page, boundaryParagraph, 'start') + await redo.click() + await expectMarkdown(page, (markdown) => expect(markdown).toBe(afterBoundary)) +}) + +test('CX-4a preserves list structure through keyboard edits, task toggle, and undo', async ({ page }) => { + const root = page.getByTestId('compatibility-root-editor') + const bulletBeta = root.getByText('Bullet beta', { exact: true }) + + await placeCaret(page, bulletBeta, 'start') + await page.keyboard.press('Tab') + await expectMarkdown(page, (markdown) => { + expect(markdown).toContain('* Bullet alpha\n * Bullet beta\n * Nested bullet') + }) + await placeCaret(page, bulletBeta, 'start') + await page.keyboard.press('Shift+Tab') + await expectMarkdown(page, (markdown) => { + expect(markdown).toContain('* Bullet alpha\n* Bullet beta\n * Nested bullet') + }) + + const listItemsBeforeEnter = await root.getByRole('listitem').count() + await placeCaret(page, bulletBeta) + await page.keyboard.press('Enter') + await expect(root.getByRole('listitem')).toHaveCount(listItemsBeforeEnter + 1) + await page.keyboard.press('Backspace') + await expect(root.getByRole('listitem')).toHaveCount(listItemsBeforeEnter) + await page.keyboard.type(' after Backspace') + await expect(root.getByText('after Backspace', { exact: true })).toBeVisible() + await expectMarkdown(page, (markdown) => { + // Lexical 0.35 merged the empty item back into "Bullet beta". Lexical 0.48 + // intentionally converts it to a paragraph and leaves both list fragments intact. + expect(markdown).not.toContain('* Bullet beta after Backspace\n * Nested bullet') + expect(markdown).toContain('* Bullet beta\n\n after Backspace\n\n* Nested bullet') + expect(occurrences(markdown, 'after Backspace')).toBe(1) + expect(occurrences(markdown, 'Nested bullet')).toBe(1) + expect(markdown).toContain('1. Ordered alpha') + expect(markdown).toContain('* [x] Task complete') + }) + + const beforeTaskToggle = await readMarkdown(page) + await root + .getByRole('checkbox') + .first() + .click({ position: { x: 5, y: 10 } }) + await expectMarkdown(page, (markdown) => { + expect(markdown).toContain('* [x] Task pending') + expect(occurrences(markdown, 'Task pending')).toBe(1) + }) + + await page.getByRole('radio', { name: /Undo/ }).click() + await expectMarkdown(page, (markdown) => expect(markdown).toBe(beforeTaskToggle)) +}) + +test('CX-4b creates and removes links, applies and undoes a heading shortcut, and exports a collapsed selection', async ({ page }) => { + const root = page.getByTestId('compatibility-root-editor') + const rootParagraph = root.locator('p').filter({ hasText: 'Root compatibility paragraph' }).first() + const linkCandidate = root.locator('p').filter({ hasText: 'Link candidate text.' }) + + await linkCandidate.selectText() + await page.getByRole('button', { name: 'Create link' }).click() + await page.getByPlaceholder('Select or paste an URL').fill('https://example.com/created') + await page.getByRole('button', { name: 'Set URL' }).click() + await expectMarkdown(page, (markdown) => expect(markdown).toContain('[Link candidate text.](https://example.com/created)')) + + const createdLink = root.getByRole('link', { name: 'Link candidate text.' }) + await createdLink.click() + await page.getByRole('button', { name: 'Remove link' }).click() + await expectMarkdown(page, (markdown) => { + expect(markdown).toContain('Link candidate text.') + expect(markdown).not.toContain('https://example.com/created') + }) + + const boundaryParagraph = root.locator('p').filter({ hasText: 'Decorator boundary paragraph.' }).first() + await placeCaret(page, boundaryParagraph) + await page.keyboard.press('Enter') + await page.keyboard.type('# ') + const shortcutBlock = boundaryParagraph.locator('xpath=following-sibling::*[1]') + await expect(shortcutBlock).toHaveJSProperty('tagName', 'H1') + + await page.getByRole('radio', { name: /Undo/ }).click() + await expect(shortcutBlock).toHaveJSProperty('tagName', 'P') + + await placeCaret(page, shortcutBlock) + await page.keyboard.type('Shortcut paragraph') + await expect(root.locator('p').filter({ hasText: 'Shortcut paragraph' })).toBeVisible() + await expectMarkdown(page, (markdown) => { + expect(markdown).toContain('\n\n\\# Shortcut paragraph\n\n') + expect(markdown).not.toContain('\n\n# Shortcut paragraph') + }) + + await placeCaret(page, rootParagraph) + await page.getByRole('button', { name: 'Get Selection Markdown' }).click() + await expect(page.getByLabel('Selection Markdown')).toBeEmpty() +}) + +test('CX-4d keeps raw link data while dangerous preview navigation fails closed', async ({ page }) => { + const vectors = [ + { url: 'https://example.com/safe', href: 'https://example.com/safe' }, + { url: 'mailto:user@example.com', href: 'mailto:user@example.com' }, + { url: '/relative', href: '/relative' }, + { url: '#anchor', href: '#anchor' }, + { url: 'javascript:alert(1)', href: 'about:blank' }, + { url: 'javascript://:99999999999/%0aalert(1)', href: 'about:blank' }, + { url: 'java\tscript:alert(1)', href: 'about:blank' }, + { url: 'data:text/html,<script>alert(1)</script>', href: 'about:blank' } + ] + + await page.evaluate(() => { + Object.defineProperty(window.navigator, 'clipboard', { + configurable: true, + value: { + writeText(value: string) { + ;(window as typeof window & { __compatibilityCopiedUrl?: string }).__compatibilityCopiedUrl = value + return Promise.resolve() + } + } + }) + }) + + for (const vector of vectors) { + await page.getByRole('button', { name: 'Reset Compatibility Markdown' }).click() + const root = page.getByTestId('compatibility-root-editor') + const linkCandidate = root.locator('p').filter({ hasText: 'Link candidate text.' }) + await linkCandidate.selectText() + await page.getByRole('button', { name: 'Create link' }).click() + await page.getByPlaceholder('Select or paste an URL').fill(vector.url) + await page.getByRole('button', { name: 'Set URL' }).click() + + const editorLink = root.getByRole('link', { name: 'Link candidate text.' }) + await expect(editorLink).toHaveAttribute('href', vector.href) + await editorLink.click() + const previewLink = page.getByTestId('link-dialog-preview') + await expect(previewLink).toHaveAttribute('href', vector.href) + expect(await previewLink.textContent()).toContain(vector.url) + + const markdown = await readMarkdown(page) + expect(collectMarkdownLinkUrls(markdown)).toContain(vector.url) + + await page.getByRole('button', { name: 'Copy to clipboard' }).click() + await expect + .poll(() => page.evaluate(() => (window as typeof window & { __compatibilityCopiedUrl?: string }).__compatibilityCopiedUrl ?? '')) + .toBe(vector.url) + + await previewLink.click() + await expect(page.getByLabel('Last Clicked URL')).toHaveText(vector.url) + } +}) + +test('CX-4c handles the DOM paste proxy, cut undo, and maximum length cap', async ({ page }) => { + const root = page.getByTestId('compatibility-root-editor') + const rootParagraph = root.locator('p').filter({ hasText: 'Root compatibility paragraph' }).first() + const cutCandidate = root.locator('p').filter({ hasText: 'Link candidate text.' }) + + await cutCandidate.selectText() + await page.keyboard.press('ControlOrMeta+x') + await expectMarkdown(page, (markdown) => expect(markdown).not.toContain('Link candidate text.')) + await placeCaret(page, root.getByText('Compatibility quote', { exact: true })) + await page.keyboard.press('ControlOrMeta+z') + await expectMarkdown(page, (markdown) => expect(markdown).toContain('Link candidate text.')) + + await placeCaret(page, rootParagraph) + await rootParagraph.evaluate((element) => { + const editable = element.closest<HTMLElement>('[contenteditable="true"]') + if (!editable) throw new Error('Paste target is not inside a contenteditable') + const data = new DataTransfer() + data.setData('text/plain', ' PASTEDONCE') + const event = new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data }) + // Firefox drops clipboardData from the ClipboardEvent initializer. Keep the + // real DOM event and DataTransfer, but restore the standards-shaped property. + if (event.clipboardData?.getData('text/plain') !== ' PASTEDONCE') { + Object.defineProperty(event, 'clipboardData', { value: data }) + } + if (event.clipboardData?.getData('text/plain') !== ' PASTEDONCE') { + throw new Error('The browser did not preserve text/plain data on the ClipboardEvent') + } + editable.dispatchEvent(event) + }) + await expectMarkdown(page, (markdown) => expect(occurrences(markdown, 'PASTEDONCE')).toBe(1)) + + const cappedEditor = page.getByTestId('compatibility-max-length-editor') + const cappedText = cappedEditor.getByText('12345', { exact: true }) + await placeCaret(page, cappedText) + await page.keyboard.type('67890EXTRA') + await page.getByRole('button', { name: 'Get Maximum Length Markdown' }).click() + await expect(page.getByLabel('Maximum Length Markdown')).toHaveText('1234567890') +}) diff --git a/tests/browser/search-replace.spec.ts b/tests/browser/search-replace.spec.ts new file mode 100644 index 00000000..428aeb19 --- /dev/null +++ b/tests/browser/search-replace.spec.ts @@ -0,0 +1,321 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' + +const storyUrl = '/?story=search-replace--search-replace&mode=preview' +const runtimeErrors = new WeakMap<Page, string[]>() + +async function placeCaret(page: Page, locator: Locator, position: 'start' | 'end' = 'end') { + await locator.evaluate((element, caretPosition) => { + const editable = element.closest<HTMLElement>('[contenteditable="true"]') + if (!editable) throw new Error('The selected public DOM node is not inside a contenteditable') + editable.focus() + const range = document.createRange() + range.selectNodeContents(element) + range.collapse(caretPosition === 'start') + const selection = window.getSelection() + if (!selection) throw new Error('The browser did not expose a DOM selection') + selection.removeAllRanges() + selection.addRange(range) + document.dispatchEvent(new Event('selectionchange', { bubbles: true })) + }, position) +} + +async function selectText(page: Page, locator: Locator, text: string) { + await locator.evaluate((element, selectedText) => { + const editable = element.closest<HTMLElement>('[contenteditable="true"]') + if (!editable) throw new Error('The selected public DOM node is not inside a contenteditable') + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) + let node: Node | null = walker.nextNode() + while (node) { + const start = (node.textContent ?? '').indexOf(selectedText) + if (start >= 0) { + editable.focus() + const range = document.createRange() + range.setStart(node, start) + range.setEnd(node, start + selectedText.length) + const selection = window.getSelection() + if (!selection) throw new Error('The browser did not expose a DOM selection') + selection.removeAllRanges() + selection.addRange(range) + document.dispatchEvent(new Event('selectionchange', { bubbles: true })) + return + } + node = walker.nextNode() + } + throw new Error(`Could not find text: ${selectedText}`) + }, text) +} + +async function setSearch(page: Page, term: string, total: number) { + await page.getByLabel('Search term').fill(term) + await expect(page.getByLabel('Search term')).toHaveValue(term) + await expect(page.getByLabel('Search total')).toHaveText(String(total)) +} + +async function readMarkdown(page: Page) { + await page.getByRole('button', { name: 'Read Markdown' }).click() + return (await page.getByLabel('Search Markdown').textContent()) ?? '' +} + +async function expectMarkdown(page: Page, assertion: (markdown: string) => void) { + await expect + .poll(async () => { + const markdown = await readMarkdown(page) + assertion(markdown) + return true + }) + .toBe(true) +} + +async function highlightTexts(page: Page, name: string) { + return page.evaluate((highlightName) => { + const highlight = CSS.highlights.get(highlightName) + return highlight ? Array.from(highlight as unknown as Range[]).map((range) => range.toString()) : [] + }, name) +} + +async function searchHighlightContexts(page: Page) { + return page.evaluate(() => { + const highlight = CSS.highlights.get('MdxSearch') + return highlight + ? Array.from(highlight as unknown as Range[]).map((range) => { + const startParent = range.startContainer.parentElement + const endParent = range.endContainer.parentElement + return { + italic: Boolean(startParent?.closest('em') || endParent?.closest('em')), + link: Boolean(startParent?.closest('a') || endParent?.closest('a')) + } + }) + : [] + }) +} + +async function expectHighlightsCleared(page: Page) { + await expect + .poll(() => + page.evaluate( + ([searchName, focusName]) => !CSS.highlights.has(searchName) && !CSS.highlights.has(focusName), + ['MdxSearch', 'MdxFocusSearch'] + ) + ) + .toBe(true) +} + +test.beforeEach(async ({ page }) => { + const errors: string[] = [] + runtimeErrors.set(page, errors) + page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(`console.error: ${message.text()}`) + }) + + await page.goto(storyUrl) + await expect(page.getByRole('heading', { name: 'Search Replace fixture ready' })).toBeVisible() + await expect(page.getByLabel('Search error')).toBeEmpty() +}) + +test.afterEach(async ({ page }) => { + expect(runtimeErrors.get(page) ?? [], 'unexpected browser runtime errors').toEqual([]) +}) + +test('CX-6a keeps navigation, highlights, and ranges current through mutations', async ({ page }) => { + const root = page.getByTestId('search-root-editor') + await page.getByRole('button', { name: 'Open search' }).click() + await setSearch(page, 'alpha', 4) + + await expect(page.getByLabel('Search cursor')).toHaveText('1') + await expect(page.getByLabel('Current match')).toHaveText('alpha') + await expect(page.getByLabel('Search ranges')).toHaveText('alpha|alpha|alpha|alpha') + expect(await highlightTexts(page, 'MdxSearch')).toEqual(['alpha', 'alpha', 'alpha', 'alpha']) + expect(await highlightTexts(page, 'MdxFocusSearch')).toEqual(['alpha']) + expect(await searchHighlightContexts(page)).toEqual([ + { italic: false, link: false }, + { italic: false, link: false }, + { italic: true, link: false }, + { italic: false, link: true } + ]) + + await page.getByRole('button', { name: 'Next match' }).click() + await expect(page.getByLabel('Search cursor')).toHaveText('2') + await page.getByRole('button', { name: 'Previous match' }).click() + await expect(page.getByLabel('Search cursor')).toHaveText('1') + await page.getByRole('button', { name: 'Scroll supplied range' }).click() + await page.getByRole('button', { name: 'Scroll first index' }).click() + await expect(page.getByLabel('Scroll call count')).toHaveText('2') + + const secondParagraph = root.locator('p').nth(1) + await expect(secondParagraph).toHaveText('Root alpha second and KEEP.') + await placeCaret(page, secondParagraph, 'start') + await page.keyboard.type('Before ') + await expect(secondParagraph).toHaveText('Before Root alpha second and KEEP.') + await expect(page.getByLabel('Search total')).toHaveText('4') + + await selectText(page, secondParagraph, 'ph') + await page.keyboard.type('ZZ') + await expect(secondParagraph).toHaveText('Before Root alZZa second and KEEP.') + await expect(page.getByLabel('Search total')).toHaveText('3') + + const formattedParagraph = root.locator('p').nth(2) + await expect(formattedParagraph).toHaveText('Root alpha italic and alpha link.') + await selectText(page, formattedParagraph, 'alpha') + await page.getByRole('radio', { name: /Bold/ }).click() + await expect(page.getByLabel('Search total')).toHaveText('3') + await expect(formattedParagraph.locator('strong')).toContainText('alpha') + + await page.getByRole('button', { name: 'Set updated Markdown' }).click() + await expect(page.getByRole('heading', { name: 'Updated Search Replace Fixture' })).toBeVisible() + await expect(page.getByLabel('Search total')).toHaveText('3') + await expect(page.getByLabel('Search ranges')).toHaveText('alpha|alpha|alpha') + expect(await highlightTexts(page, 'MdxSearch')).toEqual(['alpha', 'alpha', 'alpha']) + expect(await readMarkdown(page)).toBe('# Updated Search Replace Fixture\n\nUpdated alpha alpha alpha.') +}) + +test('CX-6b makes Replace and Replace All callback-stable and one-step undoable', async ({ page }) => { + const root = page.getByTestId('search-root-editor') + await page.getByRole('button', { name: 'Open search' }).click() + await setSearch(page, 'alpha', 4) + const initialMarkdown = await readMarkdown(page) + + await page.getByLabel('Replacement text').fill('ONE') + await page.getByRole('button', { name: 'Replace match' }).click() + await expect(page.getByLabel('Replace callback count')).toHaveText('1') + await expect(page.getByLabel('Search total')).toHaveText('3') + await expect(page.getByLabel('Search cursor')).toHaveText('1') + await expect(page.getByLabel('Current match')).toHaveText('alpha') + await expect(root.locator('p').filter({ hasText: 'Root ONE first.' })).toHaveText('Root ONE first.') + await expect(root.locator('p').filter({ hasText: 'Root alpha second and KEEP.' })).toHaveText('Root alpha second and KEEP.') + await expectMarkdown(page, (markdown) => { + expect(markdown).toContain('Root ONE first.') + expect(markdown).toContain('Root alpha second and KEEP.') + }) + + await page.getByRole('radio', { name: /Undo/ }).click() + await expectMarkdown(page, (markdown) => expect(markdown).toBe(initialMarkdown)) + await expect(page.getByLabel('Search total')).toHaveText('4') + + await page.getByLabel('Replacement text').fill('$&') + const changesBefore = Number((await page.getByLabel('Search change count').textContent()) ?? '0') + await page.getByRole('button', { name: 'Replace all' }).click() + await expect(page.getByLabel('Replace callback count')).toHaveText('2') + await expect(page.getByLabel('Search total')).toHaveText('0') + await expect(page.getByLabel('Search cursor')).toHaveText('0') + await expect(page.getByLabel('Current match')).toBeEmpty() + await expect(page.getByLabel('Search ranges')).toBeEmpty() + await expect(root.locator('p').filter({ hasText: 'Root $& first.' })).toHaveText('Root $& first.') + await expect(root.locator('p').filter({ hasText: 'Root $& second and KEEP.' })).toHaveText('Root $& second and KEEP.') + await expect(root.locator('p').filter({ hasText: 'Root $& italic and $& link.' })).toHaveText('Root $& italic and $& link.') + const replacedMarkdown = await readMarkdown(page) + expect(replacedMarkdown).toContain('Root $& first.') + expect(replacedMarkdown).toContain('Root $& second and KEEP.') + expect(replacedMarkdown).toContain('Root *$&* italic and [$&](https://example.com) link.') + expect(replacedMarkdown).toContain('Cell alpha alpha') + expect(replacedMarkdown).toContain('Directive alpha.') + expect(replacedMarkdown).toContain('Nested alpha and alpha.') + expect(replacedMarkdown).toContain('Atomic alpha alpha') + await expect(page.getByLabel('Search change count')).toHaveText(String(changesBefore + 1)) + + await page.getByRole('radio', { name: /Undo/ }).click() + await expectMarkdown(page, (markdown) => expect(markdown).toBe(initialMarkdown)) + await expect(page.getByLabel('Search total')).toHaveText('4') + await page.getByRole('radio', { name: /Redo/ }).click() + await expectMarkdown(page, (markdown) => expect(markdown).toBe(replacedMarkdown)) + await expect(page.getByLabel('Search total')).toHaveText('0') +}) + +test('CX-6b keeps table replacement history local until one parent save', async ({ page }) => { + const root = page.getByTestId('search-root-editor') + const undo = page.getByRole('radio', { name: /Undo/ }) + const redo = page.getByRole('radio', { name: /Redo/ }) + const initialMarkdown = await readMarkdown(page) + const rootParagraph = root.locator('p').filter({ hasText: 'Root alpha second and KEEP.' }) + const originalTableParagraph = root.getByRole('cell', { name: 'Cell alpha alpha' }).locator('p') + + await placeCaret(page, rootParagraph) + await page.keyboard.type(' ROOT-BASE') + const rootEditedMarkdown = await readMarkdown(page) + expect(rootEditedMarkdown).not.toBe(initialMarkdown) + expect(rootEditedMarkdown).toContain('Root alpha second and KEEP. ROOT-BASE') + await placeCaret(page, rootParagraph) + await page.getByRole('button', { name: 'Open search' }).click() + await placeCaret(page, originalTableParagraph) + await page.waitForTimeout(20) + await placeCaret(page, originalTableParagraph) + await setSearch(page, 'alpha', 2) + await page.getByLabel('Replacement text').fill('TABLE') + await page.getByRole('button', { name: 'Replace all' }).click() + const replacedTableParagraph = root.getByRole('cell', { name: 'Cell TABLE TABLE' }).locator('p') + await expect(replacedTableParagraph).toHaveText('Cell TABLE TABLE') + await expect(undo).toBeEnabled() + await expect(redo).toBeDisabled() + + await undo.click() + await expect(originalTableParagraph).toHaveText('Cell alpha alpha') + await expect(undo).toBeDisabled() + await expect(redo).toBeEnabled() + await redo.click() + await expect(replacedTableParagraph).toHaveText('Cell TABLE TABLE') + await expect(undo).toBeEnabled() + await expect(redo).toBeDisabled() + + const savedMarkdown = await readMarkdown(page) + expect(savedMarkdown).toContain('Cell TABLE TABLE') + await placeCaret(page, rootParagraph) + await expect(undo).toBeEnabled() + await undo.click() + await expectMarkdown(page, (markdown) => expect(markdown).toBe(rootEditedMarkdown)) + await expect(undo).toBeEnabled() + await expect(redo).toBeEnabled() + await redo.click() + await expectMarkdown(page, (markdown) => expect(markdown).toBe(savedMarkdown)) +}) + +test('CX-6c isolates active editors and cleans up close, recovery, switch, and unmount paths', async ({ page }) => { + const root = page.getByTestId('search-root-editor') + await page.getByRole('button', { name: 'Open search' }).click() + await setSearch(page, 'alpha', 4) + const initialMarkdown = await readMarkdown(page) + + const jsxParagraph = page.getByTestId('search-jsx-editor').locator('p').filter({ hasText: 'Nested alpha and alpha.' }) + await placeCaret(page, jsxParagraph) + await expect(page.getByLabel('Search total')).toHaveText('2') + await expect(page.getByLabel('Search ranges')).toHaveText('alpha|alpha') + expect(await highlightTexts(page, 'MdxSearch')).toEqual(['alpha', 'alpha']) + + const directiveParagraph = page.getByTestId('search-directive-editor').locator('p').filter({ hasText: 'Directive alpha.' }) + await placeCaret(page, directiveParagraph) + await expect(page.getByLabel('Search total')).toHaveText('1') + await expect(page.getByLabel('Search ranges')).toHaveText('alpha') + + const tableParagraph = root.getByRole('cell', { name: 'Cell alpha alpha' }).locator('p') + await placeCaret(page, tableParagraph) + await expect(page.getByLabel('Search total')).toHaveText('2') + await expect(page.getByLabel('Search ranges')).toHaveText('alpha|alpha') + + const rootParagraph = root.locator('p').filter({ hasText: 'Root alpha second and KEEP.' }) + await placeCaret(page, rootParagraph) + await expect(page.getByLabel('Search total')).toHaveText('4') + await page.getByLabel('Atomic code editor').click() + await expect(page.getByLabel('Search total')).toHaveText('4') + expect(await highlightTexts(page, 'MdxSearch')).toEqual(['alpha', 'alpha', 'alpha', 'alpha']) + + await setSearch(page, '[', 0) + await setSearch(page, '^', 0) + await setSearch(page, 'alpha', 4) + expect(await readMarkdown(page)).toBe(initialMarkdown) + + await page.getByRole('button', { name: 'Close search' }).click() + await expectHighlightsCleared(page) + await page.getByRole('button', { name: 'Open search' }).click() + await expect(page.getByLabel('Search total')).toHaveText('4') + await page.getByRole('button', { name: 'Close with setter' }).click() + await expectHighlightsCleared(page) + await page.getByRole('button', { name: 'Open search' }).click() + await expect(page.getByLabel('Search total')).toHaveText('4') + await page.getByRole('button', { name: 'Close with cell' }).click() + await expectHighlightsCleared(page) + await page.getByRole('button', { name: 'Open search' }).click() + await expect(page.getByLabel('Search total')).toHaveText('4') + + await page.getByRole('button', { name: 'Unmount editor' }).click() + await expect(page.getByText('Editor unmounted')).toBeVisible() + await expectHighlightsCleared(page) +}) diff --git a/tests/browser/selection-markdown.spec.ts b/tests/browser/selection-markdown.spec.ts new file mode 100644 index 00000000..239c65a8 --- /dev/null +++ b/tests/browser/selection-markdown.spec.ts @@ -0,0 +1,246 @@ +import { expect, test, type Page } from '@playwright/test' + +const storyUrl = '/?story=get-selection-markdown--get-selection-markdown&mode=preview' +const runtimeErrors = new WeakMap<Page, string[]>() + +type TextSelection = { + rootTestId: string + startText: string + startOffset: number + endText?: string + endOffset: number + backward?: boolean +} + +async function selectText(page: Page, selection: TextSelection) { + const root = page.getByTestId(selection.rootTestId) + await root.scrollIntoViewIfNeeded() + await root.getByText(selection.startText, { exact: false }).first().scrollIntoViewIfNeeded() + const points = await root.evaluate((root, options) => { + const findPoint = (needle: string, relativeOffset: number) => { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) + let current: Node | null = walker.nextNode() + while (current) { + const value = current.textContent ?? '' + const start = value.indexOf(needle) + if (start >= 0) { + const offset = start + relativeOffset + const range = document.createRange() + range.setStart(current, offset) + range.collapse(true) + const rect = range.getBoundingClientRect() + return { x: rect.left, y: rect.top + rect.height / 2 } + } + current = walker.nextNode() + } + throw new Error(`Could not find text node containing: ${needle}`) + } + + const start = findPoint(options.startText, options.startOffset) + const end = findPoint(options.endText ?? options.startText, options.endOffset) + return { start, end } + }, selection) + + await page.mouse.move(points.start.x, points.start.y) + await page.mouse.down() + await page.mouse.move(points.end.x, points.end.y, { steps: 10 }) + await page.mouse.up() + + await page.getByTestId(selection.rootTestId).evaluate((root, options) => { + const findPoint = (needle: string, relativeOffset: number) => { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) + let current: Node | null = walker.nextNode() + while (current) { + const value = current.textContent ?? '' + const start = value.indexOf(needle) + if (start >= 0) return { node: current, offset: start + relativeOffset } + current = walker.nextNode() + } + throw new Error(`Could not find text node containing: ${needle}`) + } + const start = findPoint(options.startText, options.startOffset) + const end = findPoint(options.endText ?? options.startText, options.endOffset) + const browserSelection = window.getSelection() + if (!browserSelection) throw new Error('The browser did not expose a DOM selection') + if (options.backward) browserSelection.setBaseAndExtent(end.node, end.offset, start.node, start.offset) + else browserSelection.setBaseAndExtent(start.node, start.offset, end.node, end.offset) + document.dispatchEvent(new Event('selectionchange', { bubbles: true })) + }, selection) + await page.waitForTimeout(50) +} + +async function selectAll(page: Page, rootTestId: string) { + const root = page.getByTestId(rootTestId) + await root.scrollIntoViewIfNeeded() + const editable = root.locator('[contenteditable="true"]').first() + await editable.click({ position: { x: 20, y: 20 } }) + await page.keyboard.press('ControlOrMeta+a') + await page.waitForTimeout(50) +} + +async function collapseSelection(page: Page, rootTestId: string, text: string, offset: number) { + await selectText(page, { rootTestId, startText: text, startOffset: offset, endOffset: offset }) +} + +async function readPrimarySelection(page: Page) { + await page.getByRole('button', { name: 'Read primary selection' }).click() + return (await page.getByLabel('Primary selection result').textContent()) ?? '' +} + +async function readSecondarySelection(page: Page) { + await page.getByRole('button', { name: 'Read secondary selection' }).click() + return (await page.getByLabel('Secondary selection result').textContent()) ?? '' +} + +async function readPrimaryMarkdown(page: Page) { + await page.getByRole('button', { name: 'Read primary Markdown' }).click() + return (await page.getByLabel('Primary full Markdown').textContent()) ?? '' +} + +test.beforeEach(async ({ page }) => { + const errors: string[] = [] + runtimeErrors.set(page, errors) + page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(`console.error: ${message.text()}`) + }) + + await page.goto(storyUrl) + await expect(page.getByRole('heading', { name: 'Selection Markdown fixture ready' })).toBeVisible() + await expect(page.getByLabel('Selection error')).toBeEmpty() +}) + +test.afterEach(async ({ page }) => { + expect(runtimeErrors.get(page) ?? [], 'unexpected browser runtime errors').toEqual([]) +}) + +test('CX-5 exports partial plain, formatted, code, and linked text', async ({ page }) => { + const rootTestId = 'selection-primary-editor' + + await selectText(page, { rootTestId, startText: 'Plain alpha bravo charlie.', startOffset: 12, endOffset: 17 }) + expect(await readPrimarySelection(page)).toBe('bravo') + + await selectText(page, { rootTestId, startText: 'boldword', startOffset: 0, endOffset: 4 }) + expect(await readPrimarySelection(page)).toBe('**bold**') + + await selectText(page, { rootTestId, startText: 'italicword', startOffset: 0, endOffset: 6 }) + expect(await readPrimarySelection(page)).toBe('*italic*') + + await selectText(page, { rootTestId, startText: 'codeword', startOffset: 0, endOffset: 4 }) + expect(await readPrimarySelection(page)).toBe('`code`') + + await selectText(page, { rootTestId, startText: 'linked text', startOffset: 1, endOffset: 6 }) + expect(await readPrimarySelection(page)).toBe('[inked](https://example.com/selection)') +}) + +test('CX-5 makes forward and backward multi-block selections equivalent', async ({ page }) => { + const range = { + rootTestId: 'selection-primary-editor', + startText: 'First block alpha.', + startOffset: 6, + endText: 'Second block bravo.', + endOffset: 18 + } + + await selectText(page, range) + const forward = await readPrimarySelection(page) + expect(forward).toBe('block alpha.\n\nSecond block bravo') + + await selectText(page, { ...range, backward: true }) + expect(await readPrimarySelection(page)).toBe(forward) +}) + +test('CX-5 preserves nested ordered and task-list structure', async ({ page }) => { + const rootTestId = 'selection-primary-editor' + await selectText(page, { + rootTestId, + startText: 'Ordered alpha', + startOffset: 0, + endText: 'Nested ordered beta', + endOffset: 'Nested ordered beta'.length + }) + const ordered = await readPrimarySelection(page) + expect(ordered).toContain('1. Ordered alpha') + expect(ordered).toContain('1. Nested ordered beta') + + await selectText(page, { + rootTestId, + startText: 'Task pending', + startOffset: 0, + endText: 'Task complete', + endOffset: 'Task complete'.length + }) + const tasks = await readPrimarySelection(page) + expect(tasks).toContain('[ ] Task pending') + expect(tasks).toContain('[x] Task complete') +}) + +test('CX-5 carries atomic and custom constructs through the configured exporter', async ({ page }) => { + const primary = page.getByTestId('selection-primary-editor') + await primary.locator('hr').click() + expect(await readPrimarySelection(page)).toBe('***') + + await selectAll(page, 'selection-primary-editor') + const markdown = await readPrimarySelection(page) + + expect(markdown).toContain('fixture: selection') + expect(markdown).toContain('| Table | Stable |') + expect(markdown).toContain('***') + expect(markdown).toContain('const selected = true') + expect(markdown).toContain('![Selection image](/favicon.svg "Selection title")') + expect(markdown).toContain(':::note') + expect(markdown).toContain('Directive selection content.') + expect(markdown).toContain('<Grid>') + expect(markdown).toContain("from './selection-components'") +}) + +test('CX-5 keeps repeated reads, full Markdown, callbacks, and editor configuration isolated', async ({ page }) => { + const primaryBefore = await readPrimaryMarkdown(page) + const primaryChangeCount = await page.getByLabel('Primary change count').textContent() + const secondaryChangeCount = await page.getByLabel('Secondary change count').textContent() + + await selectText(page, { + rootTestId: 'selection-primary-editor', + startText: 'Plain alpha bravo charlie.', + startOffset: 0, + endOffset: 5 + }) + expect(await readPrimarySelection(page)).toBe('Plain') + + await selectText(page, { + rootTestId: 'selection-secondary-editor', + startText: 'Secondary plain text.', + startOffset: 0, + endOffset: 9 + }) + expect(await readSecondarySelection(page)).toBe('replacement:SECONDARY') + expect(await readSecondarySelection(page)).toBe('replacement:SECONDARY') + + await selectAll(page, 'selection-secondary-editor') + expect(await readSecondarySelection(page)).toContain('+ replacement:SECONDARY ITEM') + + expect(await readPrimaryMarkdown(page)).toBe(primaryBefore) + expect(await page.getByLabel('Primary change count').textContent()).toBe(primaryChangeCount) + expect(await page.getByLabel('Secondary change count').textContent()).toBe(secondaryChangeCount) +}) + +test('CX-5 routes nested selections and returns empty for collapsed, source, and diff states', async ({ page }) => { + await selectText(page, { + rootTestId: 'selection-nested-editor', + startText: 'Nested JSX selection content with ', + startOffset: 7, + endText: 'nested bold', + endOffset: 6 + }) + const nested = await readPrimarySelection(page) + expect(nested).toContain('JSX selection content with **nested**') + expect(nested).not.toContain('<Grid>') + + await collapseSelection(page, 'selection-primary-editor', 'Plain alpha bravo charlie.', 3) + expect(await readPrimarySelection(page)).toBe('') + + await page.getByRole('button', { name: 'Read source selection' }).click() + await expect(page.getByLabel('Source selection result')).toBeEmpty() + await page.getByRole('button', { name: 'Read diff selection' }).click() + await expect(page.getByLabel('Diff selection result')).toBeEmpty() +}) diff --git a/tests/package-consumer/react-18/package.json b/tests/package-consumer/react-18/package.json new file mode 100644 index 00000000..226c0a89 --- /dev/null +++ b/tests/package-consumer/react-18/package.json @@ -0,0 +1,22 @@ +{ + "name": "mdxeditor-react-18-consumer", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@mdxeditor/editor": "file:__PACKAGE_TARBALL__", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/react": "18.3.24", + "@types/react-dom": "18.3.7", + "typescript": "5.9.3", + "vite": "5.2.8" + } +} diff --git a/tests/package-consumer/react-19/package.json b/tests/package-consumer/react-19/package.json new file mode 100644 index 00000000..cf4e61b5 --- /dev/null +++ b/tests/package-consumer/react-19/package.json @@ -0,0 +1,22 @@ +{ + "name": "mdxeditor-react-19-consumer", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@mdxeditor/editor": "file:__PACKAGE_TARBALL__", + "react": "19.2.1", + "react-dom": "19.2.1" + }, + "devDependencies": { + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "typescript": "5.9.3", + "vite": "5.2.8" + } +} diff --git a/tests/package-consumer/shared/index.html b/tests/package-consumer/shared/index.html new file mode 100644 index 00000000..bd807217 --- /dev/null +++ b/tests/package-consumer/shared/index.html @@ -0,0 +1,12 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>MDXEditor package consumer + + +
+ + + diff --git a/tests/package-consumer/shared/src/main.tsx b/tests/package-consumer/shared/src/main.tsx new file mode 100644 index 00000000..65573a2e --- /dev/null +++ b/tests/package-consumer/shared/src/main.tsx @@ -0,0 +1,105 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { + AdmonitionDirectiveDescriptor, + Cell, + MDXEditor, + type MDXEditorMethods, + type CodeBlockEditorDescriptor, + type JsxComponentDescriptor, + addComposerChild$, + codeBlockPlugin, + directivesPlugin, + frontmatterPlugin, + headingsPlugin, + jsxPlugin, + linkPlugin, + listsPlugin, + markdownShortcutPlugin, + quotePlugin, + realmPlugin, + tablePlugin, + thematicBreakPlugin, + useCellValue +} from '@mdxeditor/editor' +import '@mdxeditor/editor/style.css' +import compatibilityMarkdown from './compatibility.md?raw' + +const codeBlockEditorDescriptor: CodeBlockEditorDescriptor = { + priority: 0, + match: () => true, + Editor: ({ code }) =>
{code}
+} + +const jsxComponentDescriptors: JsxComponentDescriptor[] = [ + { + name: 'Grid', + kind: 'flow', + props: [], + hasChildren: true, + Editor: () =>
Grid content
+ } +] + +const packageRealmMarker$ = Cell('not initialized') + +const PackageRealmMarker = () => {useCellValue(packageRealmMarker$)} + +const packageRealmPlugin = realmPlugin({ + init(realm) { + realm.pub(packageRealmMarker$, 'ready') + realm.pub(addComposerChild$, PackageRealmMarker) + } +}) + +const plugins = [ + packageRealmPlugin(), + headingsPlugin({ allowedHeadingLevels: [1, 2, 3] }), + quotePlugin(), + listsPlugin(), + linkPlugin(), + tablePlugin(), + thematicBreakPlugin(), + frontmatterPlugin(), + codeBlockPlugin({ codeBlockEditorDescriptors: [codeBlockEditorDescriptor] }), + directivesPlugin({ directiveDescriptors: [AdmonitionDirectiveDescriptor] }), + jsxPlugin({ jsxComponentDescriptors }), + markdownShortcutPlugin() +] + +function App() { + const editorRef = React.useRef(null) + const [output, setOutput] = React.useState('') + const [error, setError] = React.useState('') + const [layoutRefReady, setLayoutRefReady] = React.useState(false) + const [effectRefReady, setEffectRefReady] = React.useState(false) + + React.useLayoutEffect(() => { + const methods = editorRef.current + setLayoutRefReady(methods !== null) + if (methods) { + methods.setMarkdown(methods.getMarkdown()) + } + }, []) + React.useEffect(() => { + setEffectRefReady(editorRef.current !== null) + }, []) + + return ( +
+

Package consumer ready

+ + setError(error)} /> + {String(layoutRefReady)} + {String(effectRefReady)} +
{output}
+
{error}
+
+ ) +} + +const root = document.getElementById('root') +if (!root) throw new Error('Missing root element') +ReactDOM.createRoot(root).render() diff --git a/tests/package-consumer/shared/tsconfig.json b/tests/package-consumer/shared/tsconfig.json new file mode 100644 index 00000000..287b6aaa --- /dev/null +++ b/tests/package-consumer/shared/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json index 6a73977b..78e92322 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "lib": ["dom", "dom.iterable", "esnext"], "jsx": "react-jsx", "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true,