From a1671b645e24c5041000645fa2879a614697f93b Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 28 Sep 2025 09:37:17 -0700 Subject: [PATCH 01/47] [lexical][lexical-html] Feature: Parameterize createDOM/updateDOM/exportDOM in editor config --- packages/lexical-html/src/index.ts | 13 +----- packages/lexical/flow/Lexical.js.flow | 17 ++++++++ packages/lexical/src/LexicalEditor.ts | 49 +++++++++++++++++++++-- packages/lexical/src/LexicalReconciler.ts | 13 +++++- packages/lexical/src/index.ts | 1 + 5 files changed, 75 insertions(+), 18 deletions(-) diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index fce91080c33..0e954841080 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -29,7 +29,6 @@ import { $isTextNode, ArtificialNode__DO_NOT_USE, ElementNode, - getRegisteredNode, isDocumentFragment, isDOMDocumentNode, isInlineDomNode, @@ -113,17 +112,7 @@ function $appendNodesToHTML( target = clone; } const children = $isElementNode(target) ? target.getChildren() : []; - const registeredNode = getRegisteredNode(editor, target.getType()); - let exportOutput; - - // Use HTMLConfig overrides, if available. - if (registeredNode && registeredNode.exportDOM !== undefined) { - exportOutput = registeredNode.exportDOM(editor, target); - } else { - exportOutput = target.exportDOM(editor); - } - - const {element, after} = exportOutput; + const {element, after} = editor._config.exportDOM(editor, target); if (!element) { return false; diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index c876c72cc7d..2e250336d82 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -316,6 +316,23 @@ export type EditorConfig = { theme: EditorThemeClasses, namespace: string, disableEvents?: boolean, + /** @internal @experimental */ + createDOM?: ( + editor: LexicalEditor, + node: T, + ) => HTMLElement; + /** @internal @experimental */ + exportDOM?: ( + editor: LexicalEditor, + node: T, + ) => DOMExportOutput; + /** @internal @experimental */ + updateDOM?: ( + editor: LexicalEditor, + nextNode: T, + prevNode: T, + dom: HTMLElement, + ) => boolean; }; export type CommandListenerPriority = 0 | 1 | 2 | 3 | 4; export const COMMAND_PRIORITY_EDITOR = 0; diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 5857801e6a3..7d85b667691 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -43,6 +43,7 @@ import { getCachedTypeToNodeMap, getDefaultView, getDOMSelection, + getRegisteredNode, getStaticNodeConfig, hasOwnExportDOM, hasOwnStaticMethod, @@ -188,11 +189,11 @@ export type EditorThemeClasses = { [key: string]: any; }; -export type EditorConfig = { +export interface EditorConfig extends DOMConfig { disableEvents?: boolean; namespace: string; theme: EditorThemeClasses; -}; +} export type LexicalNodeReplacement = { replace: Klass; @@ -213,7 +214,28 @@ export type HTMLConfig = { */ export type LexicalNodeConfig = Klass | LexicalNodeReplacement; -export type CreateEditorArgs = { +/** @internal @experimental */ +export interface DOMConfig { + /** @internal @experimental */ + createDOM: ( + editor: LexicalEditor, + node: T, + ) => HTMLElement; + /** @internal @experimental */ + exportDOM: ( + editor: LexicalEditor, + node: T, + ) => DOMExportOutput; + /** @internal @experimental */ + updateDOM: ( + editor: LexicalEditor, + nextNode: T, + prevNode: T, + dom: HTMLElement, + ) => boolean; +} + +export interface CreateEditorArgs { disableEvents?: boolean; editorState?: EditorState; namespace?: string; @@ -223,7 +245,8 @@ export type CreateEditorArgs = { editable?: boolean; theme?: EditorThemeClasses; html?: HTMLConfig; -}; + dom?: Partial; +} export type RegisteredNodes = Map; @@ -494,6 +517,22 @@ function initializeConversionCache( return conversionCache; } +const defaultDOMConfig: DOMConfig = { + createDOM: (editor, node) => { + return node.createDOM(editor._config, editor); + }, + exportDOM: (editor, node) => { + const registeredNode = getRegisteredNode(editor, node.getType()); + // Use HTMLConfig overrides, if available. + return registeredNode && registeredNode.exportDOM !== undefined + ? registeredNode.exportDOM(editor, node) + : node.exportDOM(editor); + }, + updateDOM: (editor, nextNode, prevNode, dom) => { + return nextNode.updateDOM(prevNode, dom, editor._config); + }, +}; + /** * Creates a new LexicalEditor attached to a single contentEditable (provided in the config). This is * the lowest-level initialization API for a LexicalEditor. If you're using React or another framework, @@ -617,6 +656,8 @@ export function createEditor(editorConfig?: CreateEditorArgs): LexicalEditor { disableEvents, namespace, theme, + ...defaultDOMConfig, + ...(editorConfig && editorConfig.dom), }, onError ? onError : console.error, initializeConversionCache(registeredNodes, html ? html.import : undefined), diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index 02277c4cd63..b5842e0dd17 100644 --- a/packages/lexical/src/LexicalReconciler.ts +++ b/packages/lexical/src/LexicalReconciler.ts @@ -7,6 +7,7 @@ */ import type { + DOMConfig, EditorConfig, LexicalEditor, MutatedNodes, @@ -62,6 +63,8 @@ let activePrevNodeMap: NodeMap; let activeNextNodeMap: NodeMap; let activePrevKeyToDOMMap: Map; let mutatedNodes: MutatedNodes; +let activeEditorCreateDOM: DOMConfig['createDOM']; +let activeEditorUpdateDOM: DOMConfig['updateDOM']; function destroyNode(key: NodeKey, parentDOM: null | HTMLElement): void { const node = activePrevNodeMap.get(key); @@ -193,7 +196,7 @@ function $createNode(key: NodeKey, slot: ElementDOMSlot | null): HTMLElement { if (node === undefined) { invariant(false, 'createNode: node does not exist in nodeMap'); } - const dom = node.createDOM(activeEditorConfig, activeEditor); + const dom = activeEditorCreateDOM(activeEditor, node); storeDOMWithKey(key, dom, activeEditor); // This helps preserve the text, and stops spell check tools from @@ -547,7 +550,7 @@ function $reconcileNode( } // Update node. If it returns true, we need to unmount and re-create the node - if (nextNode.updateDOM(prevNode, dom, activeEditorConfig)) { + if (activeEditorUpdateDOM(activeEditor, nextNode, prevNode, dom)) { const replacementDOM = $createNode(key, null); if (parentDOM === null) { @@ -773,6 +776,8 @@ export function $reconcileRoot( treatAllNodesAsDirty = dirtyType === FULL_RECONCILE; activeEditor = editor; activeEditorConfig = editor._config; + activeEditorCreateDOM = editor._config.createDOM; + activeEditorUpdateDOM = editor._config.updateDOM; activeEditorNodes = editor._nodes; activeMutationListeners = activeEditor._listeners.mutation; activeDirtyElements = dirtyElements; @@ -808,6 +813,10 @@ export function $reconcileRoot( activePrevKeyToDOMMap = undefined; // @ts-ignore mutatedNodes = undefined; + // @ts-ignore + activeEditorCreateDOM = undefined; + // @ts-ignore + activeEditorUpdateDOM = undefined; return currentMutatedNodes; } diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index b6c6e285e09..95275094a75 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -133,6 +133,7 @@ export type { CommandListenerPriority, CommandPayloadType, CreateEditorArgs, + DOMConfig, EditableListener, EditorConfig, EditorSetOptions, From 148c15803f0289c916bc8f20f8667cfbd76f4b78 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 28 Sep 2025 20:37:43 -0700 Subject: [PATCH 02/47] [WIP][lexical][lexical-html] Feature: Extensible DOM create/update/export --- examples/dev-node-state-style/README.md | 8 + examples/dev-node-state-style/index.html | 12 + .../dev-node-state-style/package-lock.json | 2633 +++++++++++++ examples/dev-node-state-style/package.json | 41 + examples/dev-node-state-style/src/App.tsx | 82 + .../dev-node-state-style/src/ExampleTheme.ts | 41 + .../dev-node-state-style/src/icons/LICENSE.md | 5 + .../src/icons/arrow-clockwise.svg | 4 + .../src/icons/arrow-counterclockwise.svg | 4 + .../src/icons/text-paragraph.svg | 3 + .../src/icons/type-bold.svg | 3 + .../src/icons/type-italic.svg | 3 + .../src/icons/type-strikethrough.svg | 3 + .../src/icons/type-underline.svg | 3 + examples/dev-node-state-style/src/main.tsx | 22 + .../src/plugins/ShikiViewPlugin.css | 5 + .../src/plugins/ShikiViewPlugin.tsx | 92 + .../src/plugins/StyleViewPlugin.css | 205 + .../src/plugins/StyleViewPlugin.tsx | 838 +++++ .../src/plugins/ToolbarPlugin.tsx | 162 + .../dev-node-state-style/src/styleState.ts | 447 +++ examples/dev-node-state-style/src/styles.css | 442 +++ .../dev-node-state-style/src/vite-env.d.ts | 1 + examples/dev-node-state-style/tsconfig.json | 26 + .../dev-node-state-style/tsconfig.node.json | 11 + .../vite.config.monorepo.ts | 15 + examples/dev-node-state-style/vite.config.ts | 14 + package-lock.json | 3305 ++++++++++++++++- package.json | 3 +- packages/lexical-html/src/index.ts | 126 +- packages/lexical/flow/Lexical.js.flow | 23 + packages/lexical/src/LexicalEditor.ts | 16 +- packages/lexical/src/LexicalNode.ts | 6 + packages/lexical/src/LexicalReconciler.ts | 18 +- packages/lexical/src/extension-core/types.ts | 4 + packages/lexical/src/index.ts | 5 +- 36 files changed, 8445 insertions(+), 186 deletions(-) create mode 100644 examples/dev-node-state-style/README.md create mode 100644 examples/dev-node-state-style/index.html create mode 100644 examples/dev-node-state-style/package-lock.json create mode 100644 examples/dev-node-state-style/package.json create mode 100644 examples/dev-node-state-style/src/App.tsx create mode 100644 examples/dev-node-state-style/src/ExampleTheme.ts create mode 100644 examples/dev-node-state-style/src/icons/LICENSE.md create mode 100644 examples/dev-node-state-style/src/icons/arrow-clockwise.svg create mode 100644 examples/dev-node-state-style/src/icons/arrow-counterclockwise.svg create mode 100644 examples/dev-node-state-style/src/icons/text-paragraph.svg create mode 100644 examples/dev-node-state-style/src/icons/type-bold.svg create mode 100644 examples/dev-node-state-style/src/icons/type-italic.svg create mode 100644 examples/dev-node-state-style/src/icons/type-strikethrough.svg create mode 100644 examples/dev-node-state-style/src/icons/type-underline.svg create mode 100644 examples/dev-node-state-style/src/main.tsx create mode 100644 examples/dev-node-state-style/src/plugins/ShikiViewPlugin.css create mode 100644 examples/dev-node-state-style/src/plugins/ShikiViewPlugin.tsx create mode 100644 examples/dev-node-state-style/src/plugins/StyleViewPlugin.css create mode 100644 examples/dev-node-state-style/src/plugins/StyleViewPlugin.tsx create mode 100644 examples/dev-node-state-style/src/plugins/ToolbarPlugin.tsx create mode 100644 examples/dev-node-state-style/src/styleState.ts create mode 100644 examples/dev-node-state-style/src/styles.css create mode 100644 examples/dev-node-state-style/src/vite-env.d.ts create mode 100644 examples/dev-node-state-style/tsconfig.json create mode 100644 examples/dev-node-state-style/tsconfig.node.json create mode 100644 examples/dev-node-state-style/vite.config.monorepo.ts create mode 100644 examples/dev-node-state-style/vite.config.ts diff --git a/examples/dev-node-state-style/README.md b/examples/dev-node-state-style/README.md new file mode 100644 index 00000000000..55ebc53bfd1 --- /dev/null +++ b/examples/dev-node-state-style/README.md @@ -0,0 +1,8 @@ +# Node State Style example + +Here we have an example that demonstrates how NodeState can be used with a +mutation listener to override behavior of any node. + +**Run it locally:** `npm i && npm run dev` + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/facebook/lexical/tree/main/examples/node-state-style?file=src/main.tsx) diff --git a/examples/dev-node-state-style/index.html b/examples/dev-node-state-style/index.html new file mode 100644 index 00000000000..27517b253ae --- /dev/null +++ b/examples/dev-node-state-style/index.html @@ -0,0 +1,12 @@ + + + + + + Lexical Node State Example + + +
+ + + diff --git a/examples/dev-node-state-style/package-lock.json b/examples/dev-node-state-style/package-lock.json new file mode 100644 index 00000000000..98f1aaf5879 --- /dev/null +++ b/examples/dev-node-state-style/package-lock.json @@ -0,0 +1,2633 @@ +{ + "name": "@lexical/node-state-style-example", + "version": "0.36.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@lexical/node-state-style-example", + "version": "0.36.1", + "dependencies": { + "@ark-ui/react": "^5.6.0", + "@lexical/clipboard": "0.36.1", + "@lexical/html": "0.36.1", + "@lexical/react": "0.36.1", + "@lexical/selection": "0.36.1", + "@lexical/utils": "0.36.1", + "@shikijs/langs": "^3.3.0", + "@shikijs/themes": "^3.3.0", + "inline-style-parser": "^0.2.4", + "lexical": "0.36.1", + "lucide-react": "^0.503.0", + "prettier": "^3.5.3", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "shiki": "^3.3.0" + }, + "devDependencies": { + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^5.0.2", + "cross-env": "^7.0.3", + "csstype": "^3.1.3", + "typescript": "^5.9.2", + "vite": "^7.1.4" + } + }, + "node_modules/@ark-ui/react": { + "version": "5.25.0", + "license": "MIT", + "dependencies": { + "@internationalized/date": "3.9.0", + "@zag-js/accordion": "1.24.1", + "@zag-js/anatomy": "1.24.1", + "@zag-js/angle-slider": "1.24.1", + "@zag-js/async-list": "1.24.1", + "@zag-js/auto-resize": "1.24.1", + "@zag-js/avatar": "1.24.1", + "@zag-js/carousel": "1.24.1", + "@zag-js/checkbox": "1.24.1", + "@zag-js/clipboard": "1.24.1", + "@zag-js/collapsible": "1.24.1", + "@zag-js/collection": "1.24.1", + "@zag-js/color-picker": "1.24.1", + "@zag-js/color-utils": "1.24.1", + "@zag-js/combobox": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/date-picker": "1.24.1", + "@zag-js/date-utils": "1.24.1", + "@zag-js/dialog": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/editable": "1.24.1", + "@zag-js/file-upload": "1.24.1", + "@zag-js/file-utils": "1.24.1", + "@zag-js/floating-panel": "1.24.1", + "@zag-js/focus-trap": "1.24.1", + "@zag-js/highlight-word": "1.24.1", + "@zag-js/hover-card": "1.24.1", + "@zag-js/i18n-utils": "1.24.1", + "@zag-js/json-tree-utils": "1.24.1", + "@zag-js/listbox": "1.24.1", + "@zag-js/menu": "1.24.1", + "@zag-js/number-input": "1.24.1", + "@zag-js/pagination": "1.24.1", + "@zag-js/password-input": "1.24.1", + "@zag-js/pin-input": "1.24.1", + "@zag-js/popover": "1.24.1", + "@zag-js/presence": "1.24.1", + "@zag-js/progress": "1.24.1", + "@zag-js/qr-code": "1.24.1", + "@zag-js/radio-group": "1.24.1", + "@zag-js/rating-group": "1.24.1", + "@zag-js/react": "1.24.1", + "@zag-js/scroll-area": "1.24.1", + "@zag-js/select": "1.24.1", + "@zag-js/signature-pad": "1.24.1", + "@zag-js/slider": "1.24.1", + "@zag-js/splitter": "1.24.1", + "@zag-js/steps": "1.24.1", + "@zag-js/switch": "1.24.1", + "@zag-js/tabs": "1.24.1", + "@zag-js/tags-input": "1.24.1", + "@zag-js/timer": "1.24.1", + "@zag-js/toast": "1.24.1", + "@zag-js/toggle": "1.24.1", + "@zag-js/toggle-group": "1.24.1", + "@zag-js/tooltip": "1.24.1", + "@zag-js/tour": "1.24.1", + "@zag-js/tree-view": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.16", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.6", + "@floating-ui/utils": "^0.2.10", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "license": "MIT" + }, + "node_modules/@internationalized/date": { + "version": "3.9.0", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.5", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lexical/clipboard": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.36.1.tgz", + "integrity": "sha512-qwOereYfHm1MgLu4Kq+nTsHwCoHtZE/PEMrs5k3lbsNk4SEZ8MLHI/Bx9TYijh24MnJhIoWdMLdAmhIaVs/IHg==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.36.1", + "@lexical/list": "0.36.1", + "@lexical/selection": "0.36.1", + "@lexical/utils": "0.36.1", + "lexical": "0.36.1" + } + }, + "node_modules/@lexical/code": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.36.1.tgz", + "integrity": "sha512-lybXf03xLGtzjYK5Wvpp8ZEdddbYzdnIE74+qXVVuN96XbpSHKkYqVkyIz1fAatEdrpdNXfqO8dZOyUS216wiA==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.36.1", + "lexical": "0.36.1", + "prismjs": "^1.30.0" + } + }, + "node_modules/@lexical/devtools-core": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.36.1.tgz", + "integrity": "sha512-SRbYta2DZKZH+gCc0MaeqR/XLfbydbfMjwn7+XIUUTUE2BZEtYhKqN58kA9N50tJZF4jcHp7MzBIddCfQJqlOg==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.36.1", + "@lexical/link": "0.36.1", + "@lexical/mark": "0.36.1", + "@lexical/table": "0.36.1", + "@lexical/utils": "0.36.1", + "lexical": "0.36.1" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/dragon": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.36.1.tgz", + "integrity": "sha512-XIG/GnAMPQJXB+HFfHE2H+3pCeXumbL+7WcNWG18U8v4K9Zosd815ub4Cy9QR6VqWqILP9prCi+RhCrdY3RY2Q==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.36.1", + "lexical": "0.36.1" + } + }, + "node_modules/@lexical/extension": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/extension/-/extension-0.36.1.tgz", + "integrity": "sha512-8QBYPda+tFpSkXZ6DFHk01gpqbri0Q8UvuivJfHsSBso6OVzub9q/7xg+0b7bezY9mjajAK8b5rqIQvhAsSlTg==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.36.1", + "@preact/signals-core": "^1.11.0", + "lexical": "0.36.1" + } + }, + "node_modules/@lexical/hashtag": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.36.1.tgz", + "integrity": "sha512-Qr4WOVGSK/N5X2HQNTZsEI1p2O9S4RfFzeYJpMQJmvbmQlRg5qE5GZrtqH8VgaiD8Hp4ztYfCOUDCmu9gdw+AQ==", + "license": "MIT", + "dependencies": { + "@lexical/text": "0.36.1", + "@lexical/utils": "0.36.1", + "lexical": "0.36.1" + } + }, + "node_modules/@lexical/history": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.36.1.tgz", + "integrity": "sha512-3gUCS4tvhmRPkR5CLosIrLNmKIycOBFnHdek7e1Hpwh8dKPqMF2/NIBKS202ikSwnA/5mD2m6wbELmHBEdtXkg==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.36.1", + "@lexical/utils": "0.36.1", + "lexical": "0.36.1" + } + }, + "node_modules/@lexical/html": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.36.1.tgz", + "integrity": "sha512-Uk83ot7033YRJmrnERgqg7Z0wT2T2VtCTcBYTdagLq3PGrka6B8eCBpj2ALmwAANU40Cy3JyL+p83m4bldObYA==", + "license": "MIT", + "dependencies": { + "@lexical/selection": "0.36.1", + "@lexical/utils": "0.36.1", + "lexical": "0.36.1" + } + }, + "node_modules/@lexical/link": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.36.1.tgz", + "integrity": "sha512-l9C4Vcm05eYeBk31oCmJBX4HX7mUSYrXW0v/5FNNabkoyA7m0HLYX7pR9truTl3E2IuWjHmeszkLMd0wKjVxyw==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.36.1", + "@lexical/utils": "0.36.1", + "lexical": "0.36.1" + } + }, + "node_modules/@lexical/list": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.36.1.tgz", + "integrity": "sha512-7/9dxrAZyCU7CpEUhOxRIDjaYIjKVt4KJrZrd2BsgQKhruSH3pdB62kyObRVXMeq1ZJiOz+u43/1DUVHTqZJrw==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.36.1", + "@lexical/selection": "0.36.1", + "@lexical/utils": "0.36.1", + "lexical": "0.36.1" + } + }, + "node_modules/@lexical/mark": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.36.1.tgz", + "integrity": "sha512-PwwuNU1KvXtAmgFECUOdt9GYj6ncUs4TbmsjHHjVnBEGFJBITm7vJq7uGf89vDRgA5Bhb5iaVV2uK4pnk9c7VA==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.36.1", + "lexical": "0.36.1" + } + }, + "node_modules/@lexical/markdown": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.36.1.tgz", + "integrity": "sha512-Ue63kqdiUaWskICh50k3Da/4eANP7pJkKvnkw+qdyhIEMHR45w41ktnqfQViQQttOymBNEk6opTxbSgWFJu9hQ==", + "license": "MIT", + "dependencies": { + "@lexical/code": "0.36.1", + "@lexical/link": "0.36.1", + "@lexical/list": "0.36.1", + "@lexical/rich-text": "0.36.1", + "@lexical/text": "0.36.1", + "@lexical/utils": "0.36.1", + "lexical": "0.36.1" + } + }, + "node_modules/@lexical/offset": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.36.1.tgz", + "integrity": "sha512-4WZVcrME5+GGV7KenJCD1AavJZjKHBAiMUe6yuxOUr5z59V1MEp9Uthol+qeY/WV2/LWZPugrRaONrlFB+FfCg==", + "license": "MIT", + "dependencies": { + "lexical": "0.36.1" + } + }, + "node_modules/@lexical/overflow": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.36.1.tgz", + "integrity": "sha512-Brc3sHPMbH742HzojpJ8DXoEaZEBA3f66Opp4sUhb+B3jIPMgcqxkmP8fDjNrTMBXuWQgjrrgWXZudTHQdjwug==", + "license": "MIT", + "dependencies": { + "lexical": "0.36.1" + } + }, + "node_modules/@lexical/plain-text": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.36.1.tgz", + "integrity": "sha512-lCrVQOQ5+ZMff7+9f7B2TIYbkbkZAKbcA/J9HAoVAXh0eN5XKh1SIDWJvoWjAJK9v/xs2pbfS2YCczOEQOFuaA==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.36.1", + "@lexical/dragon": "0.36.1", + "@lexical/selection": "0.36.1", + "@lexical/utils": "0.36.1", + "lexical": "0.36.1" + } + }, + "node_modules/@lexical/react": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.36.1.tgz", + "integrity": "sha512-+vgkGybbfFlgcYJMWp7tRjbWlsdbiOILJfj+VUpCgoqOEX7ouheIflaXwiNF16LPJAwSKiTfkXrRWhWHrF8fVA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.16", + "@lexical/devtools-core": "0.36.1", + "@lexical/dragon": "0.36.1", + "@lexical/extension": "0.36.1", + "@lexical/hashtag": "0.36.1", + "@lexical/history": "0.36.1", + "@lexical/link": "0.36.1", + "@lexical/list": "0.36.1", + "@lexical/mark": "0.36.1", + "@lexical/markdown": "0.36.1", + "@lexical/overflow": "0.36.1", + "@lexical/plain-text": "0.36.1", + "@lexical/rich-text": "0.36.1", + "@lexical/table": "0.36.1", + "@lexical/text": "0.36.1", + "@lexical/utils": "0.36.1", + "@lexical/yjs": "0.36.1", + "lexical": "0.36.1", + "react-error-boundary": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/rich-text": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.36.1.tgz", + "integrity": "sha512-XBTGecp60S0RdoHRYpEpwAoJFlXbd1Y4DSHdCCx2hzOIgMdlq/PE4RXXTPvqNWE7uIXFLNMkVFdlpdhcQSg8Eg==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.36.1", + "@lexical/dragon": "0.36.1", + "@lexical/selection": "0.36.1", + "@lexical/utils": "0.36.1", + "lexical": "0.36.1" + } + }, + "node_modules/@lexical/selection": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.36.1.tgz", + "integrity": "sha512-wIZJsTneNhmB61BiOb0y6scgrS4GOiGUVoBQCpP++xfesh0Vife8Zq0oc7NaD0l0E6tvFW3CjMd8ITZ66Zg78A==", + "license": "MIT", + "dependencies": { + "lexical": "0.36.1" + } + }, + "node_modules/@lexical/table": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.36.1.tgz", + "integrity": "sha512-7+wP0249pk+TnbzhX459Txu0JA66PXQK7zGJJLBS1f6JXycYba0hfaBhGCsTKQMOXkgCSMkVyhA2pkbRrUP+Hg==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.36.1", + "@lexical/extension": "0.36.1", + "@lexical/utils": "0.36.1", + "lexical": "0.36.1" + } + }, + "node_modules/@lexical/text": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.36.1.tgz", + "integrity": "sha512-XboXh15srB1eHJ30x1D7sRH5NlW2GKWER8BpoLGJK9Q5j1QoS6gpL4EAEw6ID8NXgJhplKLtTYX/92JRDOSASg==", + "license": "MIT", + "dependencies": { + "lexical": "0.36.1" + } + }, + "node_modules/@lexical/utils": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.36.1.tgz", + "integrity": "sha512-Uv3Mr4cFktJKEcMb8NLMo1mhYzcpOkwu2oGEhsFeYgSnWSb1BrVYsDp2yfkWfI8gZzqXH1v9s82NW2Skg42RPQ==", + "license": "MIT", + "dependencies": { + "@lexical/list": "0.36.1", + "@lexical/selection": "0.36.1", + "@lexical/table": "0.36.1", + "lexical": "0.36.1" + } + }, + "node_modules/@lexical/yjs": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.36.1.tgz", + "integrity": "sha512-qBqIN/WJHmU739zjH/IczBMpzJ8u2uXvrPpG1bbH2zqnY3kd6nL96NHL96P9cTlE+u4XWgHugVxnki3yjnUNMg==", + "license": "MIT", + "dependencies": { + "@lexical/offset": "0.36.1", + "@lexical/selection": "0.36.1", + "lexical": "0.36.1" + }, + "peerDependencies": { + "yjs": ">=13.5.22" + } + }, + "node_modules/@preact/signals-core": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.12.1.tgz", + "integrity": "sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.35", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.0", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@shikijs/core": { + "version": "3.13.0", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.13.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.13.0", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.13.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.3" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.13.0", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.13.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.13.0", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.13.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.13.0", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.13.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.13.0", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/react": { + "version": "19.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.9", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.35", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@zag-js/accordion": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/anatomy": { + "version": "1.24.1", + "license": "MIT" + }, + "node_modules/@zag-js/angle-slider": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/rect-utils": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/aria-hidden": { + "version": "1.24.1", + "license": "MIT" + }, + "node_modules/@zag-js/async-list": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/core": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/auto-resize": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.1" + } + }, + "node_modules/@zag-js/avatar": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/carousel": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/scroll-snap": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/checkbox": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/focus-visible": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/clipboard": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/collapsible": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/collection": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/color-picker": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/color-utils": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dismissable": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/popper": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/color-utils": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/combobox": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/aria-hidden": "1.24.1", + "@zag-js/collection": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dismissable": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/popper": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/core": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/date-picker": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/date-utils": "1.24.1", + "@zag-js/dismissable": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/live-region": "1.24.1", + "@zag-js/popper": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + }, + "peerDependencies": { + "@internationalized/date": ">=3.0.0" + } + }, + "node_modules/@zag-js/date-utils": { + "version": "1.24.1", + "license": "MIT", + "peerDependencies": { + "@internationalized/date": ">=3.0.0" + } + }, + "node_modules/@zag-js/dialog": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/aria-hidden": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dismissable": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/focus-trap": "1.24.1", + "@zag-js/remove-scroll": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/dismissable": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.1", + "@zag-js/interact-outside": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/dom-query": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.1" + } + }, + "node_modules/@zag-js/editable": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/interact-outside": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/file-upload": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/file-utils": "1.24.1", + "@zag-js/i18n-utils": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/file-utils": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/i18n-utils": "1.24.1" + } + }, + "node_modules/@zag-js/floating-panel": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/popper": "1.24.1", + "@zag-js/rect-utils": "1.24.1", + "@zag-js/store": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/focus-trap": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.1" + } + }, + "node_modules/@zag-js/focus-visible": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.1" + } + }, + "node_modules/@zag-js/highlight-word": { + "version": "1.24.1", + "license": "MIT" + }, + "node_modules/@zag-js/hover-card": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dismissable": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/popper": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/i18n-utils": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.1" + } + }, + "node_modules/@zag-js/interact-outside": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/json-tree-utils": { + "version": "1.24.1", + "license": "MIT" + }, + "node_modules/@zag-js/listbox": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/collection": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/focus-visible": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/live-region": { + "version": "1.24.1", + "license": "MIT" + }, + "node_modules/@zag-js/menu": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dismissable": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/popper": "1.24.1", + "@zag-js/rect-utils": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/number-input": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@internationalized/number": "3.6.5", + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/pagination": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/password-input": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/pin-input": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/popover": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/aria-hidden": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dismissable": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/focus-trap": "1.24.1", + "@zag-js/popper": "1.24.1", + "@zag-js/remove-scroll": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/popper": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "1.7.4", + "@zag-js/dom-query": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/presence": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1" + } + }, + "node_modules/@zag-js/progress": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/qr-code": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1", + "proxy-memoize": "3.0.1", + "uqr": "0.1.2" + } + }, + "node_modules/@zag-js/radio-group": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/focus-visible": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/rating-group": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/react": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/core": "1.24.1", + "@zag-js/store": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@zag-js/rect-utils": { + "version": "1.24.1", + "license": "MIT" + }, + "node_modules/@zag-js/remove-scroll": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.1" + } + }, + "node_modules/@zag-js/scroll-area": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/scroll-snap": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.1" + } + }, + "node_modules/@zag-js/select": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/collection": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dismissable": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/popper": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/signature-pad": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1", + "perfect-freehand": "^1.2.2" + } + }, + "node_modules/@zag-js/slider": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/splitter": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/steps": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/store": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "proxy-compare": "3.0.1" + } + }, + "node_modules/@zag-js/switch": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/focus-visible": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/tabs": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/tags-input": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/auto-resize": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/interact-outside": "1.24.1", + "@zag-js/live-region": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/timer": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/toast": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dismissable": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/toggle": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/toggle-group": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/tooltip": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/focus-visible": "1.24.1", + "@zag-js/popper": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/tour": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dismissable": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/focus-trap": "1.24.1", + "@zag-js/interact-outside": "1.24.1", + "@zag-js/popper": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/tree-view": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.1", + "@zag-js/collection": "1.24.1", + "@zag-js/core": "1.24.1", + "@zag-js/dom-query": "1.24.1", + "@zag-js/types": "1.24.1", + "@zag-js/utils": "1.24.1" + } + }, + "node_modules/@zag-js/types": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "csstype": "3.1.3" + } + }, + "node_modules/@zag-js/utils": { + "version": "1.24.1", + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.6", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.26.2", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001741", + "electron-to-chromium": "^1.5.218", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001743", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.222", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.10", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.4", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lexical": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.36.1.tgz", + "integrity": "sha512-VD/rxRp40IvaKGgD+AfWAEPzovm7RTEp++j0P96iaBgiUAq8tDdW3GvPwA5pLk83aQZ5IV10jzEIvzAJkLw+pA==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.503.0", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.0", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.21", + "dev": true, + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.3", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/perfect-freehand": { + "version": "1.2.2", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "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/property-information": { + "version": "7.1.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-compare": { + "version": "3.0.1", + "license": "MIT" + }, + "node_modules/proxy-memoize": { + "version": "3.0.1", + "license": "MIT", + "dependencies": { + "proxy-compare": "^3.0.0" + } + }, + "node_modules/react": { + "version": "19.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, + "node_modules/react-error-boundary": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/regex": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.52.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.0", + "@rollup/rollup-android-arm64": "4.52.0", + "@rollup/rollup-darwin-arm64": "4.52.0", + "@rollup/rollup-darwin-x64": "4.52.0", + "@rollup/rollup-freebsd-arm64": "4.52.0", + "@rollup/rollup-freebsd-x64": "4.52.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.0", + "@rollup/rollup-linux-arm-musleabihf": "4.52.0", + "@rollup/rollup-linux-arm64-gnu": "4.52.0", + "@rollup/rollup-linux-arm64-musl": "4.52.0", + "@rollup/rollup-linux-loong64-gnu": "4.52.0", + "@rollup/rollup-linux-ppc64-gnu": "4.52.0", + "@rollup/rollup-linux-riscv64-gnu": "4.52.0", + "@rollup/rollup-linux-riscv64-musl": "4.52.0", + "@rollup/rollup-linux-s390x-gnu": "4.52.0", + "@rollup/rollup-linux-x64-gnu": "4.52.0", + "@rollup/rollup-linux-x64-musl": "4.52.0", + "@rollup/rollup-openharmony-arm64": "4.52.0", + "@rollup/rollup-win32-arm64-msvc": "4.52.0", + "@rollup/rollup-win32-ia32-msvc": "4.52.0", + "@rollup/rollup-win32-x64-gnu": "4.52.0", + "@rollup/rollup-win32-x64-msvc": "4.52.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shiki": { + "version": "3.13.0", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.13.0", + "@shikijs/engine-javascript": "3.13.0", + "@shikijs/engine-oniguruma": "3.13.0", + "@shikijs/langs": "3.13.0", + "@shikijs/themes": "3.13.0", + "@shikijs/types": "3.13.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tabbable": { + "version": "6.2.0", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.2", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uqr": { + "version": "0.1.2", + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "7.1.6", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/examples/dev-node-state-style/package.json b/examples/dev-node-state-style/package.json new file mode 100644 index 00000000000..b3f4de74a12 --- /dev/null +++ b/examples/dev-node-state-style/package.json @@ -0,0 +1,41 @@ +{ + "name": "@lexical/dev-node-state-style-example", + "private": true, + "version": "0.36.1", + "type": "module", + "scripts": { + "dev": "vite -c vite.config.monorepo.ts", + "monorepo:dev": "vite -c vite.config.monorepo.ts", + "build": "tsc && vite build -c vite.config.monorepo.ts", + "preview": "vite preview" + }, + "dependencies": { + "@ark-ui/react": "^5.6.0", + "@lexical/clipboard": "0.36.1", + "@lexical/extension": "0.36.1", + "@lexical/history": "0.36.1", + "@lexical/html": "0.36.1", + "@lexical/react": "0.36.1", + "@lexical/rich-text": "0.36.1", + "@lexical/selection": "0.36.1", + "@lexical/utils": "0.36.1", + "@shikijs/langs": "^3.3.0", + "@shikijs/themes": "^3.3.0", + "inline-style-parser": "^0.2.4", + "lexical": "0.36.1", + "lucide-react": "^0.503.0", + "prettier": "^3.5.3", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "shiki": "^3.3.0" + }, + "devDependencies": { + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^5.0.2", + "cross-env": "^7.0.3", + "csstype": "^3.1.3", + "typescript": "^5.9.2", + "vite": "^7.1.4" + } +} diff --git a/examples/dev-node-state-style/src/App.tsx b/examples/dev-node-state-style/src/App.tsx new file mode 100644 index 00000000000..16fb6777edf --- /dev/null +++ b/examples/dev-node-state-style/src/App.tsx @@ -0,0 +1,82 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {Tabs} from '@ark-ui/react/tabs'; +import {AutoFocusExtension} from '@lexical/extension'; +import {HistoryExtension} from '@lexical/history'; +import {ContentEditable} from '@lexical/react/LexicalContentEditable'; +import {LexicalExtensionComposer} from '@lexical/react/LexicalExtensionComposer'; +import {RichTextExtension} from '@lexical/rich-text'; +import {defineExtension, ParagraphNode, TextNode} from 'lexical'; + +import ExampleTheme from './ExampleTheme'; +import {ShikiViewPlugin} from './plugins/ShikiViewPlugin'; +import {StyleViewPlugin} from './plugins/StyleViewPlugin'; +import {ToolbarPlugin} from './plugins/ToolbarPlugin'; +import {StyleStateExtension} from './styleState'; + +const placeholder = 'Enter some rich text...'; + +const editorExtension = defineExtension({ + dependencies: [ + RichTextExtension, + HistoryExtension, + AutoFocusExtension, + StyleStateExtension, + ], + name: '@lexical/examples/node-state-style', + namespace: 'NodeState Demo', + nodes: [ParagraphNode, TextNode], + onError(error: Error) { + throw error; + }, + theme: ExampleTheme, +}); + +export default function App() { + return ( + +
+ +
+ {placeholder}
+ } + /> +
+ + + + Style Tree + HTML + JSON + + + + + + + + + + + + +
+ ); +} diff --git a/examples/dev-node-state-style/src/ExampleTheme.ts b/examples/dev-node-state-style/src/ExampleTheme.ts new file mode 100644 index 00000000000..234afe01563 --- /dev/null +++ b/examples/dev-node-state-style/src/ExampleTheme.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export default { + code: 'editor-code', + heading: { + h1: 'editor-heading-h1', + h2: 'editor-heading-h2', + h3: 'editor-heading-h3', + h4: 'editor-heading-h4', + h5: 'editor-heading-h5', + }, + image: 'editor-image', + link: 'editor-link', + list: { + listitem: 'editor-listitem', + nested: { + listitem: 'editor-nested-listitem', + }, + ol: 'editor-list-ol', + ul: 'editor-list-ul', + }, + paragraph: 'editor-paragraph', + placeholder: 'editor-placeholder', + quote: 'editor-quote', + text: { + bold: 'editor-text-bold', + code: 'editor-text-code', + hashtag: 'editor-text-hashtag', + italic: 'editor-text-italic', + overflowed: 'editor-text-overflowed', + strikethrough: 'editor-text-strikethrough', + underline: 'editor-text-underline', + underlineStrikethrough: 'editor-text-underlineStrikethrough', + }, +}; diff --git a/examples/dev-node-state-style/src/icons/LICENSE.md b/examples/dev-node-state-style/src/icons/LICENSE.md new file mode 100644 index 00000000000..ce74f6abeed --- /dev/null +++ b/examples/dev-node-state-style/src/icons/LICENSE.md @@ -0,0 +1,5 @@ +Bootstrap Icons +https://icons.getbootstrap.com + +Licensed under MIT license +https://github.com/twbs/icons/blob/main/LICENSE.md diff --git a/examples/dev-node-state-style/src/icons/arrow-clockwise.svg b/examples/dev-node-state-style/src/icons/arrow-clockwise.svg new file mode 100644 index 00000000000..b072eb097ab --- /dev/null +++ b/examples/dev-node-state-style/src/icons/arrow-clockwise.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/examples/dev-node-state-style/src/icons/arrow-counterclockwise.svg b/examples/dev-node-state-style/src/icons/arrow-counterclockwise.svg new file mode 100644 index 00000000000..b0b23b9bbc4 --- /dev/null +++ b/examples/dev-node-state-style/src/icons/arrow-counterclockwise.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/examples/dev-node-state-style/src/icons/text-paragraph.svg b/examples/dev-node-state-style/src/icons/text-paragraph.svg new file mode 100644 index 00000000000..9779beabf1c --- /dev/null +++ b/examples/dev-node-state-style/src/icons/text-paragraph.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/dev-node-state-style/src/icons/type-bold.svg b/examples/dev-node-state-style/src/icons/type-bold.svg new file mode 100644 index 00000000000..276d133c25c --- /dev/null +++ b/examples/dev-node-state-style/src/icons/type-bold.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/dev-node-state-style/src/icons/type-italic.svg b/examples/dev-node-state-style/src/icons/type-italic.svg new file mode 100644 index 00000000000..3ac6b09f02a --- /dev/null +++ b/examples/dev-node-state-style/src/icons/type-italic.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/dev-node-state-style/src/icons/type-strikethrough.svg b/examples/dev-node-state-style/src/icons/type-strikethrough.svg new file mode 100644 index 00000000000..1c940e42a87 --- /dev/null +++ b/examples/dev-node-state-style/src/icons/type-strikethrough.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/dev-node-state-style/src/icons/type-underline.svg b/examples/dev-node-state-style/src/icons/type-underline.svg new file mode 100644 index 00000000000..c299b8bf2f0 --- /dev/null +++ b/examples/dev-node-state-style/src/icons/type-underline.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/dev-node-state-style/src/main.tsx b/examples/dev-node-state-style/src/main.tsx new file mode 100644 index 00000000000..efedcf1fced --- /dev/null +++ b/examples/dev-node-state-style/src/main.tsx @@ -0,0 +1,22 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import './styles.css'; + +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +import App from './App.tsx'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + +
+

NodeState Style Example

+ +
+
, +); diff --git a/examples/dev-node-state-style/src/plugins/ShikiViewPlugin.css b/examples/dev-node-state-style/src/plugins/ShikiViewPlugin.css new file mode 100644 index 00000000000..c2f75f2d89a --- /dev/null +++ b/examples/dev-node-state-style/src/plugins/ShikiViewPlugin.css @@ -0,0 +1,5 @@ +.shiki-view-plugin > pre.shiki { + margin: 0; + padding: 10px; + white-space: pre-wrap; +} diff --git a/examples/dev-node-state-style/src/plugins/ShikiViewPlugin.tsx b/examples/dev-node-state-style/src/plugins/ShikiViewPlugin.tsx new file mode 100644 index 00000000000..d7b30435e4f --- /dev/null +++ b/examples/dev-node-state-style/src/plugins/ShikiViewPlugin.tsx @@ -0,0 +1,92 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import './ShikiViewPlugin.css'; + +import {$generateHtmlFromNodes} from '@lexical/html'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {EditorState, LexicalEditor} from 'lexical'; +import * as prettier from 'prettier'; +import {useEffect, useMemo, useState} from 'react'; +import {createHighlighterCore} from 'shiki/core'; +import {createJavaScriptRegexEngine} from 'shiki/engine/javascript'; + +const jsEngine = createJavaScriptRegexEngine({target: 'ES2024'}); + +const shikiPromise = createHighlighterCore({ + engine: jsEngine, + langs: [import('@shikijs/langs/html'), import('@shikijs/langs/json')], + themes: [import('@shikijs/themes/nord')], +}); +const prettierPlugins = [ + import('prettier/plugins/babel'), + import('prettier/plugins/estree'), + import('prettier/plugins/html'), +]; + +function editorHTML(editor: LexicalEditor, editorState: EditorState): string { + return editorState.read(() => $generateHtmlFromNodes(editor, null), {editor}); +} + +function editorJSON(_editor: LexicalEditor, editorState: EditorState): string { + return JSON.stringify(editorState.toJSON(), null, 2); +} + +const langs = { + html: editorHTML, + json: editorJSON, +} as const; + +export interface ShikiViewPluginProps { + lang: keyof typeof langs; +} + +export function ShikiViewPlugin({lang}: ShikiViewPluginProps) { + const [editor] = useLexicalComposerContext(); + const [editorState, setEditorState] = useState(() => editor.getEditorState()); + useEffect( + () => + editor.registerUpdateListener((payload) => + setEditorState(payload.editorState), + ), + [editor], + ); + const rawCode = useMemo( + () => langs[lang](editor, editorState), + [lang, editor, editorState], + ); + const htmlPromise = useMemo( + () => + (async () => { + const prettified = await prettier.format(rawCode, { + parser: lang, + plugins: (await Promise.all(prettierPlugins)).map( + (mod) => mod.default, + ), + }); + return (await shikiPromise).codeToHtml(prettified, { + lang, + theme: 'nord', + }); + })(), + [lang, rawCode], + ); + const [html, setHtml] = useState(''); + useEffect(() => { + let canceled = false; + htmlPromise.then((formatted) => canceled || setHtml(formatted)); + return () => { + canceled = true; + }; + }, [htmlPromise]); + return ( +
+ ); +} diff --git a/examples/dev-node-state-style/src/plugins/StyleViewPlugin.css b/examples/dev-node-state-style/src/plugins/StyleViewPlugin.css new file mode 100644 index 00000000000..8db74fd35f8 --- /dev/null +++ b/examples/dev-node-state-style/src/plugins/StyleViewPlugin.css @@ -0,0 +1,205 @@ +/* tree-view */ +[data-scope='tree-view'][data-part='tree'] { + width: 240px; +} + +[data-scope='tree-view'][data-part='item'], +[data-scope='tree-view'][data-part='branch-control'] { + user-select: none; + --padding-inline: 16px; + padding-inline-start: calc(var(--depth) * var(--padding-inline)); + padding-inline-end: var(--padding-inline); + display: flex; + align-items: center; + gap: 8px; + border-radius: 2px; + + & svg { + width: 16px; + height: 16px; + opacity: 0.5; + position: relative; + top: 3px; + } + + &:hover { + background: rgb(243, 243, 243); + } + + &[data-selected] { + background: rgb(226, 226, 226); + } + + &:focus { + outline: 1px solid rgb(148, 148, 148); + outline-offset: -1px; + } +} + +[data-scope='tree-view'][data-part='item-text'], +[data-scope='tree-view'][data-part='branch-text'] { + flex: 1; +} + +[data-scope='tree-view'][data-part='branch-content'] { + position: relative; + isolation: isolate; +} + +[data-scope='tree-view'][data-part='branch-indent-guide'] { + position: absolute; + content: ''; + border-left: 1px solid rgb(226, 226, 226); + height: 100%; + translate: calc(var(--depth) * 1.25rem); + z-index: 0; +} + +[data-scope='tree-view'][data-part='branch-indicator'] { + display: flex; + /* align-items: center; */ + &[data-state='open'] svg { + transform: rotate(90deg); + } +} + +@keyframes slideDown { + from { + opacity: 0.01; + height: 0; + } + to { + opacity: 1; + height: var(--height); + } +} + +@keyframes slideUp { + from { + opacity: 1; + height: var(--height); + } + to { + opacity: 0.01; + height: 0; + } +} + +[data-scope='tree-view'][data-part='branch-content'] { + overflow: hidden; + max-width: 400px; +} + +[data-scope='tree-view'][data-part='branch-content'][data-state='open'] { + animation: slideDown 250ms cubic-bezier(0, 0, 0.38, 0.9); +} + +[data-scope='tree-view'][data-part='branch-content'][data-state='closed'] { + animation: slideUp 200ms cubic-bezier(0, 0, 0.38, 0.9); +} + +/* splitter */ +[data-scope='splitter'][data-part='root'] { + gap: 4px; +} + +[data-scope='splitter'][data-part='root'][data-orientation='horizontal'] { + min-height: 300px; +} + +[data-scope='splitter'][data-part='root'][data-orientation='vertical'] { + min-height: 300px; +} + +[data-scope='splitter'][data-part='panel'] { + display: flex; + /* align-items: center; */ + /* justify-content: center; */ + border: 1px solid lightgray; + overflow: auto; + padding: 10px; +} + +[data-scope='splitter'][data-part='panel']:has( + [data-scope='splitter'][data-part='panel'] + ) { + border: none; +} + +[data-scope='splitter'][data-part='resize-trigger'][data-orientation='vertical'] { + min-height: 12px; +} + +.style-view-node-button-delete { + padding: 0; + display: inline-flex; + position: absolute; + top: 0.125em; + left: 0; + border: none; +} +.style-view-node-button-delete svg { + height: 1em; + width: 1em; +} + +.style-view-node-button { + cursor: pointer; +} + +.style-view-node-text-contents::before, +.style-view-node-text-contents::after { + content: '"'; +} + +.style-view-node-text-contents { + max-width: 75ch; + text-overflow: ellipsis; + white-space: nowrap; +} + +.style-view-text-pane { + font-family: monospace; +} + +.style-view-entry { + position: relative; + padding-left: 4ch; + text-indent: -2ch; +} + +.style-view-key { + color: #0288d1; +} + +.style-view-style-heading { + color: #616161; +} + +.style-view-value, +.style-view-value > p { + display: inline; +} +.style-view-value:focus-visible { + outline: 1px auto rgba(20, 20, 20, 0.2); + outline-offset: 4px; +} + +.style-view-actions { + padding-left: 2ch; +} + +[data-scope='combobox'][data-part='item-group'] { + padding: 2px; +} +[data-scope='combobox'][data-part='content'] { + border: 1px solid #000; + background-color: #fff; +} +[data-scope='combobox'][data-part='item'] { + cursor: pointer; + padding: 0 0.5rem; +} +[data-scope='combobox'][data-part='item'][data-highlighted] { + background-color: rgba(0, 0, 0, 0.2); +} diff --git a/examples/dev-node-state-style/src/plugins/StyleViewPlugin.tsx b/examples/dev-node-state-style/src/plugins/StyleViewPlugin.tsx new file mode 100644 index 00000000000..933b5f411f5 --- /dev/null +++ b/examples/dev-node-state-style/src/plugins/StyleViewPlugin.tsx @@ -0,0 +1,838 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import './StyleViewPlugin.css'; + +import { + Combobox, + createListCollection, + useCombobox, +} from '@ark-ui/react/combobox'; +import {Portal} from '@ark-ui/react/portal'; +import {Splitter, useSplitter} from '@ark-ui/react/splitter'; +import { + createTreeCollection, + TreeCollection, + TreeView, + useTreeView, + UseTreeViewReturn, +} from '@ark-ui/react/tree-view'; +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {ContentEditable} from '@lexical/react/LexicalContentEditable'; +import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary'; +import {PlainTextPlugin} from '@lexical/react/LexicalPlainTextPlugin'; +import {$getAdjacentCaret, mergeRegister} from '@lexical/utils'; +import {type SelectionDetails} from '@zag-js/combobox'; +import { + $addUpdateTag, + $createLineBreakNode, + $createParagraphNode, + $createTabNode, + $createTextNode, + $getCaretRange, + $getChildCaret, + $getNodeByKey, + $getPreviousSelection, + $getRoot, + $getSelection, + $getSiblingCaret, + $isDecoratorNode, + $isElementNode, + $isLineBreakNode, + $isParagraphNode, + $isRangeSelection, + $isRootNode, + $isTabNode, + $isTextNode, + $normalizeCaret, + $setSelection, + $setSelectionFromCaretRange, + BLUR_COMMAND, + COMMAND_PRIORITY_LOW, + type EditorState, + ElementNode, + KEY_DOWN_COMMAND, + LexicalEditor, + LexicalNode, + NodeCaret, + NodeKey, +} from 'lexical'; +import { + ChevronRightIcon, + CircleXIcon, + CodeXmlIcon, + CornerDownLeftIcon, + FolderIcon, + FolderRoot, + TextIcon, +} from 'lucide-react'; +import React, { + createContext, + Fragment, + type JSX, + KeyboardEventHandler, + use, + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react'; + +import { + $removeStyleProperty, + $setStyleProperty, + getStyleObjectDirect, + StyleObject, + styleObjectToArray, +} from '../styleState'; + +const SKIP_DOM_SELECTION_TAG = 'skip-dom-selection'; +const SKIP_SCROLL_INTO_VIEW_TAG = 'skip-scroll-into-view'; + +function $preserveSelection(): void { + const selection = $getSelection(); + if (!selection) { + const prevSelection = $getPreviousSelection(); + if (prevSelection) { + $setSelection(prevSelection.clone()); + } + } +} + +const EditorStateContext = createContext(undefined); +function useEditorState() { + const editorState = use(EditorStateContext); + if (editorState === undefined) { + throw new Error('Missing EditorStateContext'); + } + return editorState; +} + +const NodeTreeViewContext = createContext< + undefined | UseTreeViewReturn +>(undefined); +function useNodeTreeViewContext() { + const ctx = use(NodeTreeViewContext); + if (ctx === undefined) { + throw new Error('Missing NodeTreeViewContext'); + } + return ctx; +} + +export function StyleViewPlugin(): JSX.Element { + const [editor] = useLexicalComposerContext(); + const [editorState, setEditorState] = useState(() => editor.getEditorState()); + useEffect( + () => + editor.registerUpdateListener(() => { + setEditorState(editor.getEditorState()); + }), + [editor], + ); + return ( + + + + ); +} + +function NodeLabel({node}: {node: LexicalNode}) { + const [editor] = useLexicalComposerContext(); + const key = node.getKey(); + const type = node.getType(); + const reactLabel = ( + <> + {' '} + {type} + + ); + if ($isTextNode(node)) { + const text = node.__text; + return ( + <> + {reactLabel}{' '} + + {text} + + + ); + } + return reactLabel; +} + +function describeNode(node: LexicalNode): [string, React.ReactNode] { + return [`(${node.getKey()}) ${node.getType()}`, ]; +} + +function LexicalNodeTreeViewItem(props: TreeView.NodeProviderProps) { + const id = props.node; + const editorState = useEditorState(); + const node = + typeof id === 'string' + ? editorState.read(() => $getNodeByKey(id, editorState)) + : null; + const indexPathString = JSON.stringify(props.indexPath); + return useMemo(() => { + if (!node) { + return null; + } + const indexPath = JSON.parse(indexPathString); + const [_ariaLabel, label] = describeNode(node); + const nextNode = node.__next && ( + + ); + const icon = $isRootNode(node) ? ( + + ) : $isElementNode(node) ? ( + + ) : $isTextNode(node) ? ( + + ) : $isDecoratorNode(node) ? ( + + ) : $isLineBreakNode(node) ? ( + + ) : null; + let content: React.ReactNode; + if ($isElementNode(node)) { + content = ( + + + + {icon} {label} + + + {node.__first ? : null} + + + + + {node.__first ? ( + + ) : null} + + + ); + } else { + content = ( + + + {icon} {label} + + + ); + } + return ( + + + {content} + + {nextNode} + + ); + }, [id, node, indexPathString]); +} + +function getSelectedNodeKey( + api: UseTreeViewReturn, +): undefined | NodeKey { + return api.selectedValue.at(0); +} + +interface SelectedNodeStateAction { + panelNodeKey: undefined | NodeKey; + editorState: EditorState; +} +interface SelectedNodeState extends SelectedNodeStateAction { + panelNodeKey: NodeKey; + selectionNodeKey: NodeKey | null; + panelNode: LexicalNode | null; + cached: React.ReactNode; +} +interface InitialSelectedNodeState extends SelectedNodeStateAction { + selectionNodeKey?: undefined; + panelNode?: undefined; + cached?: undefined; +} + +interface StyleValueEditorProps { + ref?: React.Ref; + prop: keyof StyleObject; + value: string; + onChange: (prop: keyof StyleObject, value: string) => void; +} + +type ParsedChunk = '\n' | '\r\n' | '\t' | string; + +function parseRawText(text: string): ParsedChunk[] { + return text.split(/(\r?\n|\t)/); +} + +function $patchNodes( + parent: T, + nodes: LexicalNode[], +): T { + const childrenSize = parent.getChildrenSize(); + if ( + childrenSize === nodes.length && + parent.getChildren().every((node, i) => node === nodes[i]) + ) { + // no-op, do not mark as dirty + return parent; + } + return $getChildCaret(parent, 'next').splice(childrenSize, nodes).origin; +} + +function $patchParsedText( + parent: T, + chunks: readonly ParsedChunk[], +): T { + let caret: null | NodeCaret<'next'> = $getChildCaret(parent, 'next'); + const nodes: LexicalNode[] = []; + for (const chunk of chunks) { + caret = $getAdjacentCaret(caret); + const node = caret ? caret.origin : null; + if (chunk === '\r\n' || chunk === '\n') { + nodes.push($isLineBreakNode(node) ? node : $createLineBreakNode()); + } else if (chunk === '\t') { + nodes.push($isTabNode(node) ? node : $createTabNode()); + } else if (chunk) { + nodes.push( + $isTextNode(node) + ? node.getTextContent() === chunk + ? node + : node.setTextContent(chunk) + : $createTextNode(chunk), + ); + } + } + return $patchNodes(parent, nodes); +} + +function $patchParsedTextAtRoot(chunks: readonly ParsedChunk[]): void { + const root = $getRoot(); + const firstNode = root.getFirstChild(); + const p = $isParagraphNode(firstNode) ? firstNode : $createParagraphNode(); + $getChildCaret(root, 'next').splice(root.getChildrenSize(), [p]); + $patchParsedText(p, chunks); +} + +function StyleValuePlugin(props: StyleValueEditorProps) { + const [editor] = useLexicalComposerContext(); + const {prop, onChange, ref} = props; + const valueRef = useRef(props.value); + useEffect(() => { + const setRef = + typeof ref === 'function' + ? ref + : ref + ? (value: LexicalEditor | null) => { + ref.current = value; + } + : () => {}; + setRef(editor); + return () => { + setRef(null); + }; + }, [editor, ref]); + useEffect(() => { + valueRef.current = props.value; + }, [props.value]); + useEffect(() => { + let timer: undefined | ReturnType; + function clearTimer() { + if (timer) { + clearTimeout(timer); + timer = undefined; + } + } + function handleInput() { + clearTimer(); + setTimeout(handleFlush, 300); + } + function handleFlush() { + clearTimer(); + const value = editor + .getEditorState() + .read(() => $getRoot().getTextContent()); + if (valueRef.current !== value) { + onChange(prop, value); + } + } + return mergeRegister( + editor.registerUpdateListener((payload) => { + if (payload.editorState !== payload.prevEditorState) { + handleInput(); + } + }), + editor.registerCommand( + BLUR_COMMAND, + () => { + handleFlush(); + return true; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + KEY_DOWN_COMMAND, + (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + editor.blur(); + return true; + } + return false; + }, + COMMAND_PRIORITY_LOW, + ), + ); + }, [editor, prop, onChange]); + return ( + + } + ErrorBoundary={LexicalErrorBoundary} + /> + ); +} + +function StyleValueEditor(props: StyleValueEditorProps) { + return ( + { + $patchParsedTextAtRoot(parseRawText(props.value)); + }, + namespace: 'style-view-value', + onError: (err) => { + throw err; + }, + }}> + + + ); +} + +function LexicalTextSelectionPaneContents({node}: {node: LexicalNode}) { + const [editor] = useLexicalComposerContext(); + const [registeredNodes] = useState( + () => new Map(), + ); + const styles = getStyleObjectDirect(node); + const focusPropertyRef = useRef(''); + + const nodeRef = useRef(node); + useEffect(() => { + nodeRef.current = node; + }, [node]); + const {handleChange, handleAddProperty} = useMemo(() => { + // eslint-disable-next-line no-shadow + const handleAddProperty = (prop: keyof StyleObject) => { + const reg = registeredNodes.get(prop); + if (reg) { + reg.focus(); + } else { + focusPropertyRef.current = prop; + editor.update( + () => { + $setStyleProperty(nodeRef.current, prop, ''); + }, + {tag: [SKIP_DOM_SELECTION_TAG, SKIP_SCROLL_INTO_VIEW_TAG]}, + ); + } + }; + // eslint-disable-next-line no-shadow + const handleChange = ( + prop: keyof StyleObject, + textContent: string | null, + ) => { + editor.update( + () => { + $preserveSelection(); + $addUpdateTag(SKIP_DOM_SELECTION_TAG); + $addUpdateTag(SKIP_SCROLL_INTO_VIEW_TAG); + $setStyleProperty(nodeRef.current, prop, textContent || undefined); + }, + {tag: [SKIP_DOM_SELECTION_TAG, SKIP_SCROLL_INTO_VIEW_TAG]}, + ); + }; + return {handleAddProperty, handleChange}; + }, [editor, registeredNodes]); + + const rows = useMemo( + () => + styleObjectToArray(styles).map(([k, v]) => ( +
+ + {k}: + { + if (el === null) { + registeredNodes.delete(k); + return; + } + if (focusPropertyRef.current === k) { + el.focus(); + focusPropertyRef.current = ''; + } + registeredNodes.set(k, el); + }} + /> +
+ )), + [editor, registeredNodes, styles, handleChange], + ); + return ( +
+ {describeNode(node)[1]} +
+
+ style {'{'} +
+ {rows} +
+ +
+
{'}'}
+
+
+ ); +} + +function initTextSelectionPaneReducer(action: SelectedNodeStateAction) { + return textSelectionPaneReducer(action, action); +} + +function textSelectionPaneReducer( + state: InitialSelectedNodeState | SelectedNodeState, + action: SelectedNodeStateAction, +): SelectedNodeState { + return action.editorState.read(() => { + const selection = $getSelection(); + const selectionNodeKey = $isRangeSelection(selection) + ? selection.focus.key + : null; + const { + // selectionNodeKey: prevSelectionNodeKey = null, + panelNode: prevPanelNode = null, + cached: prevCached = null, + } = state; + let panelNodeKey = + selectionNodeKey || action.panelNodeKey || state.panelNodeKey; + if (selectionNodeKey) { + panelNodeKey = selectionNodeKey; + } else if (!panelNodeKey) { + panelNodeKey = 'root'; + } + const panelNode = $getNodeByKey(panelNodeKey); + if (panelNode === prevPanelNode && state.cached) { + return state; + } + const cached = + panelNode === prevPanelNode && prevCached ? ( + prevCached + ) : panelNode === null ? ( + Node {panelNodeKey} no longer in the document + ) : ( + + ); + return {...action, cached, panelNode, panelNodeKey, selectionNodeKey}; + }); +} + +function getSuggestedStyleKeys(): readonly (keyof StyleObject)[] { + const keys = new Set(); + if (typeof document !== 'undefined') { + const {style} = document.body; + for (const k in style) { + if (typeof style[k] === 'string') { + const kebab = k + .replace(/[A-Z]/g, (s) => '-' + s.toLowerCase()) + .replace(/^(webkit|moz|ms|o)-/, '-$1-') + .replace(/^css-/, ''); + keys.add(kebab as keyof StyleObject); + } + } + } + return [...keys].sort(); +} + +function isNotVendorProperty(item: string): boolean { + return !item.startsWith('-'); +} + +function useSuggestedStylesCombobox(props: CSSPropertyComboBoxProps) { + const initialItems = useMemo(getSuggestedStyleKeys, []); + const [items, setItems] = useState(() => + initialItems.filter(isNotVendorProperty).join('\n'), + ); + const collection = useMemo( + () => createListCollection({items: items.split(/\n/g)}), + [items], + ); + const handleInputValueChange = ( + details: Combobox.InputValueChangeDetails, + ) => { + const search = details.inputValue.toLowerCase(); + setItems( + initialItems + .filter( + search + ? (item) => item.toLowerCase().startsWith(search) + : isNotVendorProperty, + ) + .join('\n'), + ); + }; + const handleSelect = (details: SelectionDetails) => { + props.onAddProperty(details.itemValue as keyof StyleObject); + combobox.setInputValue(''); + }; + + const combobox = useCombobox({ + allowCustomValue: true, + collection: collection, + inputBehavior: 'autocomplete', + onInputValueChange: handleInputValueChange, + onSelect: handleSelect, + placeholder: 'Add CSS Property', + positioning: { + placement: 'bottom-start', + sameWidth: false, + }, + }); + return combobox; +} + +interface CSSPropertyComboBoxProps { + onAddProperty: (property: keyof StyleObject) => void; +} + +const CSSPropertyComboBox = (props: CSSPropertyComboBoxProps) => { + const {onAddProperty} = props; + const combobox = useSuggestedStylesCombobox(props); + const handleKeydown = useCallback>( + (event) => { + const {inputValue} = combobox; + if (event.key === 'Enter') { + event.preventDefault(); + if (inputValue) { + onAddProperty(inputValue as keyof StyleObject); + combobox.setInputValue(''); + } + } else if (event.key === 'Tab') { + event.preventDefault(); + if (inputValue) { + const [autocomplete] = combobox.collection.items; + if (autocomplete) { + onAddProperty(autocomplete as keyof StyleObject); + combobox.setInputValue(''); + } + } + } + }, + [combobox, onAddProperty], + ); + + return ( + + + + + + + + + {combobox.collection.items.map((item) => ( + + {item} + ✓ + + ))} + + + + + + ); +}; + +function LexicalTextSelectionPane() { + const editorState = useEditorState(); + const api = useNodeTreeViewContext(); + const panelNodeKey = getSelectedNodeKey(api); + const [state, dispatch] = useReducer( + textSelectionPaneReducer, + {editorState, panelNodeKey}, + initTextSelectionPaneReducer, + ); + useEffect(() => { + dispatch({editorState, panelNodeKey}); + }, [panelNodeKey, editorState]); + return state.cached || null; +} + +function LexicalTreeView() { + const collectionState = useEditorCollectionState(); + const {collection, focusNodeKey} = collectionState; + const [editor] = useLexicalComposerContext(); + const editorRef = useRef(editor); + useEffect(() => { + editorRef.current = editor; + }, [editor]); + const treeView = useTreeView({ + collection, + defaultExpandedValue: ['root'], + }); + useEffect(() => { + if ( + focusNodeKey !== null && + !treeView.expandedValue.includes(focusNodeKey) + ) { + treeView.expand([focusNodeKey]); + } + }, [treeView, focusNodeKey]); + const splitter = useSplitter({ + defaultSize: [50, 50], + panels: [{id: 'tree'}, {id: 'node'}], + }); + + return ( + + ); +} + +interface EditorCollectionState { + editor: LexicalEditor; + editorState: EditorState; + collection: TreeCollection; + focusNodeKey: null | NodeKey; +} + +function nextFocusNodeKey(state: EditorCollectionState): null | NodeKey { + return state.editorState.read(() => { + const selection = $getSelection(); + return selection && $isRangeSelection(selection) + ? selection.focus.getNode().getKey() + : null; + }); +} + +function initEditorCollection( + state: Omit & + Partial>, +): EditorCollectionState { + return Object.assign(state, { + collection: createTreeCollection({ + isNodeDisabled: () => false, + nodeToChildren: (nodeKey) => + state.editorState.read(() => { + const node = $getNodeByKey(nodeKey); + return $isElementNode(node) ? node.getChildrenKeys() : []; + }), + nodeToString: (nodeKey) => nodeKey, + nodeToValue: (nodeKey) => nodeKey, + rootNode: 'root', + }), + focusNodeKey: null, + }); +} + +function editorCollectionReducer( + state: EditorCollectionState, + action: Partial, +) { + let nextState = {...state, ...action}; + if (action.editor && action.editor !== state.editor) { + nextState = initEditorCollection(nextState); + } + nextState.focusNodeKey = nextFocusNodeKey(nextState); + return nextState; +} + +function useEditorCollectionState() { + const [editor] = useLexicalComposerContext(); + const editorState = useEditorState(); + const [state, dispatch] = useReducer( + editorCollectionReducer, + {editor, editorState}, + initEditorCollection, + ); + useEffect(() => { + dispatch({editor, editorState}); + }, [editor, editorState]); + return state; +} diff --git a/examples/dev-node-state-style/src/plugins/ToolbarPlugin.tsx b/examples/dev-node-state-style/src/plugins/ToolbarPlugin.tsx new file mode 100644 index 00000000000..bc3af4583cb --- /dev/null +++ b/examples/dev-node-state-style/src/plugins/ToolbarPlugin.tsx @@ -0,0 +1,162 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {mergeRegister} from '@lexical/utils'; +import { + $getSelection, + $isRangeSelection, + CAN_REDO_COMMAND, + CAN_UNDO_COMMAND, + COMMAND_PRIORITY_LOW, + FORMAT_TEXT_COMMAND, + REDO_COMMAND, + SELECTION_CHANGE_COMMAND, + UNDO_COMMAND, +} from 'lexical'; +import {useCallback, useEffect, useRef, useState} from 'react'; + +import { + $selectionHasStyle, + NO_STYLE, + PATCH_TEXT_STYLE_COMMAND, +} from '../styleState'; + +function Divider() { + return
; +} + +export function ToolbarPlugin() { + const [editor] = useLexicalComposerContext(); + const toolbarRef = useRef(null); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + const [isBold, setIsBold] = useState(false); + const [isItalic, setIsItalic] = useState(false); + const [isUnderline, setIsUnderline] = useState(false); + const [isStrikethrough, setIsStrikethrough] = useState(false); + const [isStyled, setIsStyled] = useState(false); + + const $updateToolbar = useCallback(() => { + const selection = $getSelection(); + setIsStyled($selectionHasStyle()); + if ($isRangeSelection(selection)) { + // Update text format + setIsBold(selection.hasFormat('bold')); + setIsItalic(selection.hasFormat('italic')); + setIsUnderline(selection.hasFormat('underline')); + setIsStrikethrough(selection.hasFormat('strikethrough')); + } + }, []); + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(({editorState}) => { + editorState.read(() => { + $updateToolbar(); + }); + }), + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_payload, _newEditor) => { + $updateToolbar(); + return false; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + CAN_UNDO_COMMAND, + (payload) => { + setCanUndo(payload); + return false; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + CAN_REDO_COMMAND, + (payload) => { + setCanRedo(payload); + return false; + }, + COMMAND_PRIORITY_LOW, + ), + ); + }, [editor, $updateToolbar]); + + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/examples/dev-node-state-style/src/styleState.ts b/examples/dev-node-state-style/src/styleState.ts new file mode 100644 index 00000000000..e125bae0aed --- /dev/null +++ b/examples/dev-node-state-style/src/styleState.ts @@ -0,0 +1,447 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {PropertiesHyphenFallback} from 'csstype'; + +import {DOMExtension} from '@lexical/html'; +import {$forEachSelectedTextNode} from '@lexical/selection'; +import InlineStyleParser from 'inline-style-parser'; +import { + $caretRangeFromSelection, + $getPreviousSelection, + $getSelection, + $getState, + $isRangeSelection, + $isTextNode, + $setSelection, + $setState, + COMMAND_PRIORITY_EDITOR, + configExtension, + createCommand, + createState, + defineExtension, + DOMConversionMap, + isHTMLElement, + LexicalNode, + TextNode, + ValueOrUpdater, +} from 'lexical'; + +/** + * Creates an object containing all the styles and their values provided in the CSS string. + * @param css - The CSS string of styles and their values. + * @returns The styleObject containing all the styles and their values. + */ +export function getStyleObjectFromRawCSS(css: string): StyleObject { + let styleObject: undefined | Record; + for (const token of InlineStyleParser(css, {silent: true})) { + if (token.type === 'declaration' && token.value) { + styleObject = styleObject || {}; + styleObject[token.property] = token.value; + } + } + return styleObject || NO_STYLE; +} + +export type Prettify = {[K in keyof T]: T[K]} & {}; + +export type StyleObject = Prettify<{ + [K in keyof PropertiesHyphenFallback]?: + | undefined + // This is simplified to not deal with arrays or numbers. + // This is an example after all! + | Extract; +}>; + +export type StyleTuple = Exclude< + { + [K in keyof StyleObject]: [K, null | Exclude]; + }[keyof StyleObject], + undefined +>; + +export const NO_STYLE: StyleObject = Object.freeze({}); + +function parse(v: unknown): StyleObject { + return typeof v === 'string' ? getStyleObjectFromRawCSS(v) : NO_STYLE; +} + +function unparse(style: StyleObject): string { + const styles: string[] = []; + for (const [k, v] of Object.entries(style)) { + if (k && v) { + styles.push(`${k}: ${v};`); + } + } + return styles.sort().join(' '); +} + +function isEqualValue( + a: StyleObject[keyof StyleObject], + b: StyleObject[keyof StyleObject], +): boolean { + return a === b || (!a && !b); +} + +function isEqual(a: StyleObject, b: StyleObject): boolean { + if (a === b) { + return true; + } + for (const k in a) { + if ( + !( + k in b && + isEqualValue(a[k as keyof StyleObject], b[k as keyof StyleObject]) + ) + ) { + return false; + } + } + for (const k in b) { + if (!(k in a)) { + return false; + } + } + return true; +} + +export const styleState = createState('style', { + isEqual, + parse, + unparse, +}); + +export function $getStyleProperty( + node: LexicalNode, + prop: Prop, +): undefined | StyleObject[Prop] { + return $getStyleObject(node)[prop]; +} + +// eslint-disable-next-line @lexical/rules-of-lexical +export function getStyleObjectDirect(node: LexicalNode): StyleObject { + return $getState(node, styleState, 'direct'); +} + +export function $getStyleObject(node: LexicalNode): StyleObject { + return $getState(node, styleState); +} + +export function $setStyleObject( + node: T, + valueOrUpdater: ValueOrUpdater, +): T { + return $setState(node, styleState, valueOrUpdater); +} + +export function $removeStyleProperty< + T extends LexicalNode, + Prop extends keyof StyleObject, +>(node: T, prop: Prop): T { + return $setStyleObject(node, (prevStyle) => { + if (prop in prevStyle) { + const {[prop]: _ignore, ...nextStyle} = prevStyle; + return nextStyle; + } + return prevStyle; + }); +} + +export function $setStyleProperty< + T extends LexicalNode, + Prop extends keyof StyleObject, +>(node: T, prop: Prop, value: ValueOrUpdater): T { + return $setStyleObject(node, (prevStyle) => { + const prevValue = prevStyle[prop]; + const nextValue = typeof value === 'function' ? value(prevValue) : value; + return prevValue === nextValue + ? prevStyle + : {...prevStyle, [prop]: nextValue}; + }); +} + +export function applyStyle( + element: HTMLElement, + styleObject: StyleObject, +): void { + for (const k_ in styleObject) { + const k = k_ as keyof StyleObject; + element.style.setProperty(k, styleObject[k] ?? null); + } +} + +export function diffStyleObjects( + prevStyles: StyleObject, + nextStyles: StyleObject, +): StyleObject { + let styleDiff: undefined | Record; + if (prevStyles !== nextStyles) { + for (const k_ in nextStyles) { + const k = k_ as keyof StyleObject; + const nextV = nextStyles[k]; + const prevV = prevStyles[k]; + if (!isEqualValue(nextV, prevV)) { + styleDiff = styleDiff || {}; + styleDiff[k] = nextV; + } + } + for (const k in prevStyles) { + if (!(k in nextStyles)) { + styleDiff = styleDiff || {}; + styleDiff[k] = undefined; + } + } + } + return styleDiff || NO_STYLE; +} + +export function mergeStyleObjects( + prevStyles: StyleObject, + nextStyles: StyleObject, +): StyleObject { + return prevStyles === NO_STYLE || prevStyles === nextStyles + ? nextStyles + : {...prevStyles, ...nextStyles}; +} + +export function styleObjectToArray(styleObject: StyleObject): StyleTuple[] { + const entries: StyleTuple[] = []; + for (const k_ in styleObject) { + const k = k_ as keyof StyleObject; + entries.push([k, styleObject[k] ?? null] as StyleTuple); + } + entries.sort(([a], [b]) => a.localeCompare(b)); + return entries; +} + +export const PATCH_TEXT_STYLE_COMMAND = createCommand< + StyleObject | ((prevStyles: StyleObject) => StyleObject) +>('PATCH_TEXT_STYLE_COMMAND'); + +function $nodeHasStyle(node: LexicalNode): boolean { + return !isEqual(NO_STYLE, $getStyleObject(node)); +} + +export function $selectionHasStyle(): boolean { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const caretRange = $caretRangeFromSelection(selection); + for (const slice of caretRange.getTextSlices()) { + if (slice && $nodeHasStyle(slice.caret.origin)) { + return true; + } + } + for (const caret of caretRange.iterNodeCarets('root')) { + if ($isTextNode(caret.origin) && $nodeHasStyle(caret.origin)) { + return true; + } + } + } + return false; +} + +export function $patchSelectedTextStyle( + styleObjectOrCallback: + | StyleObject + | ((prevStyles: StyleObject) => StyleObject), +): boolean { + let selection = $getSelection(); + if (!selection) { + const prevSelection = $getPreviousSelection(); + if (!prevSelection) { + return false; + } + selection = prevSelection.clone(); + $setSelection(selection); + } + const styleCallback = + typeof styleObjectOrCallback === 'function' + ? styleObjectOrCallback + : (prevStyles: StyleObject) => + mergeStyleObjects(prevStyles, styleObjectOrCallback); + if ($isRangeSelection(selection) && selection.isCollapsed()) { + const node = selection.focus.getNode(); + if ($isTextNode(node)) { + $setStyleObject(node, styleCallback); + } + } else { + $forEachSelectedTextNode((node) => $setStyleObject(node, styleCallback)); + } + return true; +} + +const PREV_STYLE_STATE = Symbol.for('styleState'); +interface HTMLElementWithManagedStyle extends HTMLElement { + // Store the last reconciled style object directly on the DOM + // so we don't have to track the previous DOM + // which can happen even when nodeMutation is 'updated' + [PREV_STYLE_STATE]?: StyleObject; +} + +interface LexicalNodeWithUnknownStyle extends LexicalNode { + // This property exists on all TextNode and ElementNode + // and likely also some DecoratorNode by convention. + // We use it as a heuristic to see if the style has likely + // been overwritten to see if we should apply a diff + // or all styles. + __style?: unknown; +} + +function styleStringChanged( + node: LexicalNodeWithUnknownStyle, + prevNode: LexicalNodeWithUnknownStyle, +): boolean { + return typeof node.__style === 'string' && prevNode.__style !== node.__style; +} + +function getPreviousStyleObject( + node: LexicalNode, + prevNode: null | LexicalNode, + dom: HTMLElementWithManagedStyle, +): StyleObject { + const prevStyleObject = dom[PREV_STYLE_STATE]; + return prevStyleObject && prevNode && !styleStringChanged(node, prevNode) + ? prevStyleObject + : NO_STYLE; +} + +const IGNORE_STYLES: Set = new Set([ + 'font-weight', + 'text-decoration', + 'font-style', + 'vertical-align', +]); + +export type StyleMapping = (input: StyleObject) => StyleObject; + +// TODO there's no reasonable way to hook into importDOM from a plug-in https://github.com/facebook/lexical/issues/7259 +export function constructStyleImportMap( + styleMapping: StyleMapping = (input) => input, +): DOMConversionMap { + const importMap: DOMConversionMap = {}; + + // Wrap all TextNode importers with a function that also imports + // styles that are not otherwise imported + for (const [tag, fn] of Object.entries(TextNode.importDOM() || {})) { + importMap[tag] = (importNode) => { + const importer = fn(importNode); + if (!importer) { + return null; + } + return { + ...importer, + conversion: (element) => { + const output = importer.conversion(element); + if ( + output === null || + output.forChild === undefined || + output.after !== undefined || + output.node !== null || + !element.hasAttribute('style') + ) { + return output; + } + let extraStyles: undefined | Record; + for (const k of element.style) { + if (IGNORE_STYLES.has(k as keyof StyleObject)) { + continue; + } + extraStyles = extraStyles || {}; + extraStyles[k] = element.style.getPropertyValue(k); + } + if (extraStyles) { + const {forChild} = output; + return { + ...output, + forChild: (child, parent) => { + const node = forChild(child, parent); + return $isTextNode(node) + ? $setStyleObject( + node, + styleMapping(extraStyles as StyleObject), + ) + : node; + }, + }; + } + return output; + }, + }; + }; + } + return importMap; +} + +export const StyleStateExtension = defineExtension({ + dependencies: [ + configExtension(DOMExtension, { + overrides: [ + { + createDOM(_editor, node, next) { + const dom: HTMLElementWithManagedStyle = next(); + const nextStyleObject = $getStyleObject(node); + dom[PREV_STYLE_STATE] = nextStyleObject; + applyStyle(dom, nextStyleObject); + return dom; + }, + exportDOM(_editor, node, next) { + const output = next(); + const style = $getStyleObject(node); + if (output.element && style !== NO_STYLE) { + return { + ...output, + after: (generatedElement) => { + const el = output.after + ? output.after(generatedElement) + : generatedElement; + if (isHTMLElement(el)) { + applyStyle(el, style); + } + return el; + }, + }; + } + return output; + }, + nodes: ['*'], + updateDOM( + _editor, + nextNode, + prevNode, + dom: HTMLElementWithManagedStyle, + next, + ) { + if (next()) { + return true; + } + const prevStyleObject = getPreviousStyleObject( + nextNode, + prevNode, + dom, + ); + const nextStyleObject = $getStyleObject(nextNode); + dom[PREV_STYLE_STATE] = nextStyleObject; + applyStyle(dom, diffStyleObjects(prevStyleObject, nextStyleObject)); + return false; + }, + }, + ], + }), + ], + html: { + import: constructStyleImportMap(), + }, + name: '@lexical/examples/node-state-style/StyleState', + register(editor) { + return editor.registerCommand( + PATCH_TEXT_STYLE_COMMAND, + $patchSelectedTextStyle, + COMMAND_PRIORITY_EDITOR, + ); + }, +}); diff --git a/examples/dev-node-state-style/src/styles.css b/examples/dev-node-state-style/src/styles.css new file mode 100644 index 00000000000..7921f7ed21e --- /dev/null +++ b/examples/dev-node-state-style/src/styles.css @@ -0,0 +1,442 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +body { + margin: 0; + background: #eee; + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + '.SFNSText-Regular', + sans-serif; + font-weight: 500; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.other h2 { + font-size: 18px; + color: #444; + margin-bottom: 7px; +} + +.other a { + color: #777; + text-decoration: underline; + font-size: 14px; +} + +.other ul { + padding: 0; + margin: 0; + list-style-type: none; +} + +.App { + font-family: sans-serif; +} +.App > h1 { + text-align: center; +} + +h1 { + font-size: 24px; + color: #333; +} + +.editor-container { + margin: 20px auto 20px auto; + border-radius: 2px; + max-width: 600px; + color: #000; + position: relative; + line-height: 20px; + font-weight: 400; + text-align: left; + border-top-left-radius: 10px; + border-top-right-radius: 10px; +} + +.editor-inner { + background: #fff; + position: relative; +} + +.editor-input { + min-height: 150px; + resize: none; + font-size: 15px; + caret-color: rgb(5, 5, 5); + position: relative; + tab-size: 1; + outline: 0; + padding: 15px 10px; + caret-color: #444; +} + +.editor-placeholder { + color: #999; + overflow: hidden; + position: absolute; + text-overflow: ellipsis; + top: 15px; + left: 10px; + font-size: 15px; + user-select: none; + display: inline-block; + pointer-events: none; +} + +.editor-text-bold { + font-weight: bold; +} + +.editor-text-italic { + font-style: italic; +} + +.editor-text-underline { + text-decoration: underline; +} + +.editor-text-strikethrough { + text-decoration: line-through; +} + +.editor-text-underlineStrikethrough { + text-decoration: underline line-through; +} + +.editor-text-code { + background-color: rgb(240, 242, 245); + padding: 1px 0.25rem; + font-family: Menlo, Consolas, Monaco, monospace; + font-size: 94%; +} + +.editor-link { + color: rgb(33, 111, 219); + text-decoration: none; +} + +.tree-view-output { + display: block; + background: #222; + color: #fff; + padding: 5px; + font-size: 12px; + white-space: pre-wrap; + margin: 1px auto 10px auto; + max-height: 250px; + position: relative; + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; + overflow: auto; + line-height: 14px; +} + +.editor-code { + background-color: rgb(240, 242, 245); + font-family: Menlo, Consolas, Monaco, monospace; + display: block; + padding: 8px 8px 8px 52px; + line-height: 1.53; + font-size: 13px; + margin: 0; + margin-top: 8px; + margin-bottom: 8px; + tab-size: 2; + /* white-space: pre; */ + overflow-x: auto; + position: relative; +} + +.editor-code:before { + content: attr(data-gutter); + position: absolute; + background-color: #eee; + left: 0; + top: 0; + border-right: 1px solid #ccc; + padding: 8px; + color: #777; + white-space: pre-wrap; + text-align: right; + min-width: 25px; +} +.editor-code:after { + content: attr(data-highlight-language); + top: 0; + right: 3px; + padding: 3px; + font-size: 10px; + text-transform: uppercase; + position: absolute; + color: rgba(0, 0, 0, 0.5); +} + +.editor-tokenComment { + color: slategray; +} + +.editor-tokenPunctuation { + color: #999; +} + +.editor-tokenProperty { + color: #905; +} + +.editor-tokenSelector { + color: #690; +} + +.editor-tokenOperator { + color: #9a6e3a; +} + +.editor-tokenAttr { + color: #07a; +} + +.editor-tokenVariable { + color: #e90; +} + +.editor-tokenFunction { + color: #dd4a68; +} + +.editor-paragraph { + margin: 0; + margin-bottom: 8px; + position: relative; +} + +.editor-paragraph:last-child { + margin-bottom: 0; +} + +.editor-heading-h1 { + font-size: 24px; + color: rgb(5, 5, 5); + font-weight: 400; + margin: 0; + margin-bottom: 12px; + padding: 0; +} + +.editor-heading-h2 { + font-size: 15px; + color: rgb(101, 103, 107); + font-weight: 700; + margin: 0; + margin-top: 10px; + padding: 0; + text-transform: uppercase; +} + +.editor-quote { + margin: 0; + margin-left: 20px; + font-size: 15px; + color: rgb(101, 103, 107); + border-left-color: rgb(206, 208, 212); + border-left-width: 4px; + border-left-style: solid; + padding-left: 16px; +} + +.editor-list-ol { + padding: 0; + margin: 0; + margin-left: 16px; +} + +.editor-list-ul { + padding: 0; + margin: 0; + margin-left: 16px; +} + +.editor-listitem { + margin: 8px 32px 8px 32px; +} + +.editor-nested-listitem { + list-style-type: none; +} + +pre::-webkit-scrollbar { + background: transparent; + width: 10px; +} + +pre::-webkit-scrollbar-thumb { + background: #999; +} + +.debug-timetravel-panel { + overflow: hidden; + padding: 0 0 10px 0; + margin: auto; + display: flex; +} + +.debug-timetravel-panel-slider { + padding: 0; + flex: 8; +} + +.debug-timetravel-panel-button { + padding: 0; + border: 0; + background: none; + flex: 1; + color: #fff; + font-size: 12px; +} + +.debug-timetravel-panel-button:hover { + text-decoration: underline; +} + +.debug-timetravel-button { + border: 0; + padding: 0; + font-size: 12px; + top: 10px; + right: 15px; + position: absolute; + background: none; + color: #fff; +} + +.debug-timetravel-button:hover { + text-decoration: underline; +} + +.toolbar { + display: flex; + margin-bottom: 1px; + background: #fff; + padding: 4px; + border-top-left-radius: 10px; + border-top-right-radius: 10px; + vertical-align: middle; +} + +.toolbar button.toolbar-item { + border: 0; + display: flex; + background: none; + border-radius: 10px; + padding: 8px; + cursor: pointer; + vertical-align: middle; +} + +.toolbar button.toolbar-item:disabled { + cursor: not-allowed; +} + +.toolbar button.toolbar-item.spaced { + margin-right: 2px; +} + +.toolbar button.toolbar-item i.format { + background-size: contain; + display: inline-block; + height: 18px; + width: 18px; + margin-top: 2px; + vertical-align: -0.25em; + display: flex; + opacity: 0.6; +} + +.toolbar button.toolbar-item:disabled i.format { + opacity: 0.2; +} + +.toolbar button.toolbar-item.active { + background-color: rgba(223, 232, 250, 0.3); +} + +.toolbar button.toolbar-item.active i { + opacity: 1; +} + +.toolbar .toolbar-item:hover:not([disabled]) { + background-color: #eee; +} + +.toolbar .divider { + width: 1px; + background-color: #eee; + margin: 0 4px; +} + +.toolbar .toolbar-item .text { + display: flex; + line-height: 20px; + width: 200px; + vertical-align: middle; + font-size: 14px; + color: #777; + text-overflow: ellipsis; + width: 70px; + overflow: hidden; + height: 20px; + text-align: left; +} + +.toolbar .toolbar-item .icon { + display: flex; + width: 20px; + height: 20px; + user-select: none; + margin-right: 8px; + line-height: 16px; + background-size: contain; +} + +i.undo { + background-image: url(./icons/arrow-counterclockwise.svg); +} + +i.redo { + background-image: url(./icons/arrow-clockwise.svg); +} + +i.bold { + background-image: url(./icons/type-bold.svg); +} + +i.italic { + background-image: url(./icons/type-italic.svg); +} + +i.underline { + background-image: url(./icons/type-underline.svg); +} + +i.strikethrough { + background-image: url(./icons/type-strikethrough.svg); +} + +i.text-shadow::before { + content: '✨'; + filter: contrast(0); +} + +i.text-shadow.active::before { + filter: contrast(1); + text-shadow: 1px solid black; +} diff --git a/examples/dev-node-state-style/src/vite-env.d.ts b/examples/dev-node-state-style/src/vite-env.d.ts new file mode 100644 index 00000000000..11f02fe2a00 --- /dev/null +++ b/examples/dev-node-state-style/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/dev-node-state-style/tsconfig.json b/examples/dev-node-state-style/tsconfig.json new file mode 100644 index 00000000000..8ea07038b64 --- /dev/null +++ b/examples/dev-node-state-style/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "extends": ["../../tsconfig.json"], + "include": ["src", "../../libdefs/*.d.ts"], + "references": [{"path": "./tsconfig.node.json"}] +} diff --git a/examples/dev-node-state-style/tsconfig.node.json b/examples/dev-node-state-style/tsconfig.node.json new file mode 100644 index 00000000000..97ede7ee6f2 --- /dev/null +++ b/examples/dev-node-state-style/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/dev-node-state-style/vite.config.monorepo.ts b/examples/dev-node-state-style/vite.config.monorepo.ts new file mode 100644 index 00000000000..140511d5b57 --- /dev/null +++ b/examples/dev-node-state-style/vite.config.monorepo.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {mergeConfig} from 'vite'; + +import lexicalMonorepoPlugin from '../../packages/shared/lexicalMonorepoPlugin'; +import config from './vite.config'; + +export default mergeConfig(config, { + plugins: [lexicalMonorepoPlugin()], +}); diff --git a/examples/dev-node-state-style/vite.config.ts b/examples/dev-node-state-style/vite.config.ts new file mode 100644 index 00000000000..2294526fc4f --- /dev/null +++ b/examples/dev-node-state-style/vite.config.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import react from '@vitejs/plugin-react'; +import {defineConfig} from 'vite'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}); diff --git a/package-lock.json b/package-lock.json index 032ea261b1e..a486d58862c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.36.2", "license": "MIT", "workspaces": [ - "packages/*" + "packages/*", + "examples/dev-*" ], "dependencies": { "semver": "^7.7.2", @@ -111,6 +112,45 @@ "eslint": "^7.31.0 || ^8.0.0" } }, + "examples/dev-node-state-style": { + "name": "@lexical/dev-node-state-style-example", + "version": "0.36.1", + "dependencies": { + "@ark-ui/react": "^5.6.0", + "@lexical/clipboard": "0.36.1", + "@lexical/extension": "0.36.1", + "@lexical/history": "0.36.1", + "@lexical/html": "0.36.1", + "@lexical/react": "0.36.1", + "@lexical/rich-text": "0.36.1", + "@lexical/selection": "0.36.1", + "@lexical/utils": "0.36.1", + "@shikijs/langs": "^3.3.0", + "@shikijs/themes": "^3.3.0", + "inline-style-parser": "^0.2.4", + "lexical": "0.36.1", + "lucide-react": "^0.503.0", + "prettier": "^3.5.3", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "shiki": "^3.3.0" + }, + "devDependencies": { + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^5.0.2", + "cross-env": "^7.0.3", + "csstype": "^3.1.3", + "typescript": "^5.9.2", + "vite": "^7.1.4" + } + }, + "examples/dev-node-state-style/node_modules/inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", + "license": "MIT" + }, "node_modules/@1natsu/wait-element": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@1natsu/wait-element/-/wait-element-4.1.2.tgz", @@ -530,6 +570,88 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@ark-ui/react": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/@ark-ui/react/-/react-5.25.1.tgz", + "integrity": "sha512-5ncHjwMuCmCDz+a3PD2voFJk+3VDmGWZ3Hs6WQIxEztIWbXC9sEj86BkpcSV84s57ELCALIXm9fQk4TUXGoZlw==", + "license": "MIT", + "dependencies": { + "@internationalized/date": "3.9.0", + "@zag-js/accordion": "1.24.2", + "@zag-js/anatomy": "1.24.2", + "@zag-js/angle-slider": "1.24.2", + "@zag-js/async-list": "1.24.2", + "@zag-js/auto-resize": "1.24.2", + "@zag-js/avatar": "1.24.2", + "@zag-js/bottom-sheet": "1.24.2", + "@zag-js/carousel": "1.24.2", + "@zag-js/checkbox": "1.24.2", + "@zag-js/clipboard": "1.24.2", + "@zag-js/collapsible": "1.24.2", + "@zag-js/collection": "1.24.2", + "@zag-js/color-picker": "1.24.2", + "@zag-js/color-utils": "1.24.2", + "@zag-js/combobox": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/date-picker": "1.24.2", + "@zag-js/date-utils": "1.24.2", + "@zag-js/dialog": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/editable": "1.24.2", + "@zag-js/file-upload": "1.24.2", + "@zag-js/file-utils": "1.24.2", + "@zag-js/floating-panel": "1.24.2", + "@zag-js/focus-trap": "1.24.2", + "@zag-js/highlight-word": "1.24.2", + "@zag-js/hover-card": "1.24.2", + "@zag-js/i18n-utils": "1.24.2", + "@zag-js/json-tree-utils": "1.24.2", + "@zag-js/listbox": "1.24.2", + "@zag-js/menu": "1.24.2", + "@zag-js/number-input": "1.24.2", + "@zag-js/pagination": "1.24.2", + "@zag-js/password-input": "1.24.2", + "@zag-js/pin-input": "1.24.2", + "@zag-js/popover": "1.24.2", + "@zag-js/presence": "1.24.2", + "@zag-js/progress": "1.24.2", + "@zag-js/qr-code": "1.24.2", + "@zag-js/radio-group": "1.24.2", + "@zag-js/rating-group": "1.24.2", + "@zag-js/react": "1.24.2", + "@zag-js/scroll-area": "1.24.2", + "@zag-js/select": "1.24.2", + "@zag-js/signature-pad": "1.24.2", + "@zag-js/slider": "1.24.2", + "@zag-js/splitter": "1.24.2", + "@zag-js/steps": "1.24.2", + "@zag-js/switch": "1.24.2", + "@zag-js/tabs": "1.24.2", + "@zag-js/tags-input": "1.24.2", + "@zag-js/timer": "1.24.2", + "@zag-js/toast": "1.24.2", + "@zag-js/toggle": "1.24.2", + "@zag-js/toggle-group": "1.24.2", + "@zag-js/tooltip": "1.24.2", + "@zag-js/tour": "1.24.2", + "@zag-js/tree-view": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@ark-ui/react/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -7600,48 +7722,6 @@ "@shikijs/vscode-textmate": "^10.0.2" } }, - "node_modules/@gerrit0/mini-shiki/node_modules/@shikijs/engine-oniguruma": { - "version": "3.12.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.12.2.tgz", - "integrity": "sha512-hozwnFHsLvujK4/CPVHNo3Bcg2EsnG8krI/ZQ2FlBlCRpPZW4XAEQmEwqegJsypsTAN9ehu2tEYe30lYKSZW/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.12.2", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, - "node_modules/@gerrit0/mini-shiki/node_modules/@shikijs/langs": { - "version": "3.12.2", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.12.2.tgz", - "integrity": "sha512-bVx5PfuZHDSHoBal+KzJZGheFuyH4qwwcwG/n+MsWno5cTlKmaNtTsGzJpHYQ8YPbB5BdEdKU1rga5/6JGY8ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.12.2" - } - }, - "node_modules/@gerrit0/mini-shiki/node_modules/@shikijs/themes": { - "version": "3.12.2", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.12.2.tgz", - "integrity": "sha512-fTR3QAgnwYpfGczpIbzPjlRnxyONJOerguQv1iwpyQZ9QXX4qy/XFQqXlf17XTsorxnHoJGbH/LXBvwtqDsF5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.12.2" - } - }, - "node_modules/@gerrit0/mini-shiki/node_modules/@shikijs/types": { - "version": "3.12.2", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.12.2.tgz", - "integrity": "sha512-K5UIBzxCyv0YoxN3LMrKB9zuhp1bV+LgewxuVwHdl4Gz5oePoUFrr9EfgJlGlDeXCU1b/yhdnXeuRvAnz8HN8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -7731,6 +7811,24 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/@internationalized/date": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.9.0.tgz", + "integrity": "sha512-yaN3brAnHRD+4KyyOsJyk49XUvj2wtbNACSqg0bz3u8t2VuzhC8Q5dfRnrSxjnnbDb+ienBnkn1TzQfE154vyg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.5.tgz", + "integrity": "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -8739,6 +8837,10 @@ "resolved": "packages/lexical-code-shiki", "link": true }, + "node_modules/@lexical/dev-node-state-style-example": { + "resolved": "examples/dev-node-state-style", + "link": true + }, "node_modules/@lexical/devtools": { "resolved": "packages/lexical-devtools", "link": true @@ -11699,55 +11801,61 @@ } }, "node_modules/@shikijs/core": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.7.0.tgz", - "integrity": "sha512-yilc0S9HvTPyahHpcum8eonYrQtmGTU0lbtwxhA6jHv4Bm1cAdlPFRCJX4AHebkCm75aKTjjRAW+DezqD1b/cg==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.13.0.tgz", + "integrity": "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA==", + "license": "MIT", "dependencies": { - "@shikijs/types": "3.7.0", + "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "node_modules/@shikijs/engine-javascript": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.7.0.tgz", - "integrity": "sha512-0t17s03Cbv+ZcUvv+y33GtX75WBLQELgNdVghnsdhTgU3hVcWcMsoP6Lb0nDTl95ZJfbP1mVMO0p3byVh3uuzA==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.13.0.tgz", + "integrity": "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg==", + "license": "MIT", "dependencies": { - "@shikijs/types": "3.7.0", + "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.7.0.tgz", - "integrity": "sha512-5BxcD6LjVWsGu4xyaBC5bu8LdNgPCVBnAkWTtOCs/CZxcB22L8rcoWfv7Hh/3WooVjBZmFtyxhgvkQFedPGnFw==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.13.0.tgz", + "integrity": "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg==", + "license": "MIT", "dependencies": { - "@shikijs/types": "3.7.0", + "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "node_modules/@shikijs/langs": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.7.0.tgz", - "integrity": "sha512-1zYtdfXLr9xDKLTGy5kb7O0zDQsxXiIsw1iIBcNOO8Yi5/Y1qDbJ+0VsFoqTlzdmneO8Ij35g7QKF8kcLyznCQ==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.13.0.tgz", + "integrity": "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ==", + "license": "MIT", "dependencies": { - "@shikijs/types": "3.7.0" + "@shikijs/types": "3.13.0" } }, "node_modules/@shikijs/themes": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.7.0.tgz", - "integrity": "sha512-VJx8497iZPy5zLiiCTSIaOChIcKQwR0FebwE9S3rcN0+J/GTWwQ1v/bqhTbpbY3zybPKeO8wdammqkpXc4NVjQ==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.13.0.tgz", + "integrity": "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg==", + "license": "MIT", "dependencies": { - "@shikijs/types": "3.7.0" + "@shikijs/types": "3.13.0" } }, "node_modules/@shikijs/types": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.7.0.tgz", - "integrity": "sha512-MGaLeaRlSWpnP0XSAum3kP3a8vtcTsITqoEPYdt3lQG3YCdQH4DnEhodkYcNMcU0uW0RffhoD1O3e0vG5eSBBg==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.13.0.tgz", + "integrity": "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw==", + "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" @@ -12392,6 +12500,15 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", "license": "Apache-2.0" }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@swc/html": { "version": "1.13.20", "resolved": "https://registry.npmjs.org/@swc/html/-/html-1.13.20.tgz", @@ -15131,16 +15248,519 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "license": "Apache-2.0" }, + "node_modules/@zag-js/accordion": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/accordion/-/accordion-1.24.2.tgz", + "integrity": "sha512-sGNhbWR85oAiMyQLk+dliRhNQGP59T56M1gAkQ7bwJJZ7l++hFEQpYcr/FbAHJshXWpvUKm0wV18wHR/56Y30w==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/accordion/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/anatomy": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/anatomy/-/anatomy-1.24.2.tgz", + "integrity": "sha512-qWmxopxVHMjP9UGoUdxqKtrot8MaU0UvoJWh0O03b9eOPgLwMydkcwuGAc8s3x6GDREu3D3GvcRgrQ5JteITjg==", + "license": "MIT" + }, + "node_modules/@zag-js/angle-slider": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/angle-slider/-/angle-slider-1.24.2.tgz", + "integrity": "sha512-QPBWxji84sEyB519uU+n07IkowvDaLSpon1oDQvNc3wFKz35F5IAXQo85pDQPPDAzEHG4oJ2W3cXPRWmkVuTrg==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/rect-utils": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/angle-slider/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/aria-hidden": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/aria-hidden/-/aria-hidden-1.24.2.tgz", + "integrity": "sha512-btbVDdfHq2wcvV2KmpBlkKN+36XIMkXTIy7zvQniBQ/V6X5WGKBaXGvllqrLLaJVU2HmnhM/IYUBQW4H0BLWBA==", + "license": "MIT" + }, + "node_modules/@zag-js/async-list": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/async-list/-/async-list-1.24.2.tgz", + "integrity": "sha512-W2720aU4ANdhcrFtOQX+AVYFSxxuH+TT8kAPtIZtnd6ffOTuiR8j8PV7U45gC5KUvixIBQ+oot11m/n3YVilUw==", + "license": "MIT", + "dependencies": { + "@zag-js/core": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/auto-resize": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/auto-resize/-/auto-resize-1.24.2.tgz", + "integrity": "sha512-oOzFY+4mif6PXpVMR+db8+b33C8uplAkiuDwZhNEaQDj97S4kxySlQkFovbmY55DYAlBRguaQKXjn0pboqwIqA==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.2" + } + }, + "node_modules/@zag-js/auto-resize/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/avatar": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/avatar/-/avatar-1.24.2.tgz", + "integrity": "sha512-qSqJQLjscmWCMPosWKoTwSdFL6/hHyLeBAL4iyLcVby5Y7tz4o3u5zjkcLGPoZTH1DxicwEDBaTkONHvMthaLw==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/avatar/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/bottom-sheet": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/bottom-sheet/-/bottom-sheet-1.24.2.tgz", + "integrity": "sha512-T1nWvpNUvzE29ODZhkjsZCE4yHzkWQ7dNohrDMvMOc7jNlxUOy0SRd4LuiT4ioAg2FUvMZrfcTBBVagc9kv9SQ==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/aria-hidden": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/focus-trap": "1.24.2", + "@zag-js/remove-scroll": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/bottom-sheet/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/carousel": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/carousel/-/carousel-1.24.2.tgz", + "integrity": "sha512-gQYZ4+UyCk1vXYYkIBGHWxAZNQBlV1/e1XdQTT1CApKmUAjNwDWf91fcQ733mB9n7MEDTS/vtgGtWQFQNVVh1g==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/scroll-snap": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/carousel/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/checkbox": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/checkbox/-/checkbox-1.24.2.tgz", + "integrity": "sha512-+ibuzfVW9Nx84r04cd1SxdI3P19/bnexmMzw7zZu/17pSvO4u5v6HSKi24ARVv15sw1ujjcfHd1qlDmtWZFyJg==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/focus-visible": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/checkbox/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/checkbox/node_modules/@zag-js/focus-visible": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/focus-visible/-/focus-visible-1.24.2.tgz", + "integrity": "sha512-/A8CEy+2w0xCTIbDuCB4nfdBxgVQYP6oaFb1zmYXmf8HWFlSXDxuUXb1oXrA3vaoNG/X8sJJilpacYdV1dVhxg==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.2" + } + }, + "node_modules/@zag-js/clipboard": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/clipboard/-/clipboard-1.24.2.tgz", + "integrity": "sha512-3E8c3IJubkJGxGJRAB7nmoFGLxM6LiaANz0JH4WxOt7lIu+5jxIxzLVRGAQI3/Vtn1Qxm34+Ffqqw2PW/J+6qA==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/clipboard/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/collapsible": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/collapsible/-/collapsible-1.24.2.tgz", + "integrity": "sha512-nmMRljiM2AfcmGW04dgPOQjjuteGj4wbaURJiN8uyFnDKavUZH7BIT8knu8iA59Nj8m6UctDIcasRreUd6smxA==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/collapsible/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/collection": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/collection/-/collection-1.24.2.tgz", + "integrity": "sha512-vqNnn9nAmz5lz8pHhvjNdCrPHj76aZpoaRFe7DQdcnwlrbNjASUKPiN4lG5ZgspOMnQqZ0teR4fyjaCa+cH+xQ==", + "license": "MIT", + "dependencies": { + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/color-picker": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/color-picker/-/color-picker-1.24.2.tgz", + "integrity": "sha512-FKK4tNATGKjAfwpxqgAyM8uecZa1ebTP8HSLc2c02hL5C7VUEcMrGP7SYBT/eOYXME1iHIA01R6ArmIzVhVdvQ==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/color-utils": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/color-picker/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/color-utils": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/color-utils/-/color-utils-1.24.2.tgz", + "integrity": "sha512-p/mkEA7lAH9YFyAHIEAbCXJW0cUkJNDXcq8OAkx/pq+mL+dDPLgjN4GKfb+Hhur+n/e2Jd6UTYEDmCEFUgC3fg==", + "license": "MIT", + "dependencies": { + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/combobox": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/combobox/-/combobox-1.24.2.tgz", + "integrity": "sha512-o/jkby5ry4IUAm8GT04RbMdd3r8xk7RTAdnNRdvv4R8ZJtaDDL+wJVFq5ITeFhtnvmAl3GD+Isn2kJ3fut0fHQ==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/aria-hidden": "1.24.2", + "@zag-js/collection": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/combobox/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/core": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/core/-/core-1.24.2.tgz", + "integrity": "sha512-LBJBNpaEixfIKLjyfcuudTdtnVJmj60iLK9flI3D7BeziU/nGu3CsNxh0miLCR2Sdl7jFEseyGrK7HLhTaRMLw==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/core/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/date-picker": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/date-picker/-/date-picker-1.24.2.tgz", + "integrity": "sha512-8vpztt7RwrreqXdV4vwotQ0susaqQfOmqdMh++j31UMsvmRinNiFo75hL7/5AvPwpBnt9lmtWZYBw2WY5DFnQQ==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/date-utils": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/live-region": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "peerDependencies": { + "@internationalized/date": ">=3.0.0" + } + }, + "node_modules/@zag-js/date-picker/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/date-utils": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/date-utils/-/date-utils-1.24.2.tgz", + "integrity": "sha512-wtrbE4LTs8h+9U9mqJPFg+RqdRofTISwMCY1nD1gApCwMdp9Gov9me0CcV+JshXgOEBSsFoZXt8fvSINN0gs+A==", + "license": "MIT", + "peerDependencies": { + "@internationalized/date": ">=3.0.0" + } + }, + "node_modules/@zag-js/dialog": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dialog/-/dialog-1.24.2.tgz", + "integrity": "sha512-70dvyikN/f3qqhI9mGB23oMGeTmjjZmhy5ZXDCwNL1ZmZ0SnGB3QdsHEa/DDyoXC2qSO8XhOUgEp3hNukXujXA==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/aria-hidden": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/focus-trap": "1.24.2", + "@zag-js/remove-scroll": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/dialog/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/dismissable": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dismissable/-/dismissable-1.24.2.tgz", + "integrity": "sha512-POmhCyjm8cRIThuK3icXjt9ic3OrYjN3N0jQ7uT5xAitX5eyGR+Tb7Mdf5J1R6iuX19t0t6ova+h3XIx6YDWHQ==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.2", + "@zag-js/interact-outside": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/dismissable/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, "node_modules/@zag-js/dom-query": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-0.16.0.tgz", "integrity": "sha512-Oqhd6+biWyKnhKwFFuZrrf6lxBz2tX2pRQe6grUnYwO6HJ8BcbqZomy2lpOdr+3itlaUqx+Ywj5E5ZZDr/LBfQ==" }, + "node_modules/@zag-js/editable": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/editable/-/editable-1.24.2.tgz", + "integrity": "sha512-ZSvBcVi3OMuChGj6IGCnX7QoF9UVAhdHelKp3q2/qfWq6ykwFUtO8erYBkEFmkdGqRqdlnbtidBRBoh0e9dLdg==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/interact-outside": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/editable/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, "node_modules/@zag-js/element-size": { "version": "0.10.5", "resolved": "https://registry.npmjs.org/@zag-js/element-size/-/element-size-0.10.5.tgz", "integrity": "sha512-uQre5IidULANvVkNOBQ1tfgwTQcGl4hliPSe69Fct1VfYb2Fd0jdAcGzqQgPhfrXFpR62MxLPB7erxJ/ngtL8w==" }, + "node_modules/@zag-js/file-upload": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/file-upload/-/file-upload-1.24.2.tgz", + "integrity": "sha512-hQHTwbAXDYOjLatRDcoLY8nrxDrctiIMWnzQmbz/53PYbfH5i93KGM7SvUwWX1Zvfwp5IVwpkLA7ARWGNqDOUQ==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/file-utils": "1.24.2", + "@zag-js/i18n-utils": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/file-upload/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/file-utils": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/file-utils/-/file-utils-1.24.2.tgz", + "integrity": "sha512-feQ9nMOZwuGae2VXyFGbqzb5DEej2Eo498o2KZVHmyA216vVx1ZOQpTsVrrvJV8KRxS6ULoKZNGe5QuHfwBWYg==", + "license": "MIT", + "dependencies": { + "@zag-js/i18n-utils": "1.24.2" + } + }, + "node_modules/@zag-js/floating-panel": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/floating-panel/-/floating-panel-1.24.2.tgz", + "integrity": "sha512-pI+YQGsJwGn4lTHx67qVLgeLotP87F5PQToUv6t1CsuVJkmL6fxP3vQNflMSL7XNAX06jVCaug7RW7V+SehHIg==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/rect-utils": "1.24.2", + "@zag-js/store": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/floating-panel/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/focus-trap": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/focus-trap/-/focus-trap-1.24.2.tgz", + "integrity": "sha512-ztqxOaB7Z8zOZH4HvHnMpKREhrTAcJICRmHgwx1Dfq5SqymlMBnFD0zwS/F0mlbZqzz9eV9yYLAd1Xy54OdeGw==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.2" + } + }, + "node_modules/@zag-js/focus-trap/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, "node_modules/@zag-js/focus-visible": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@zag-js/focus-visible/-/focus-visible-0.16.0.tgz", @@ -15149,6 +15769,873 @@ "@zag-js/dom-query": "0.16.0" } }, + "node_modules/@zag-js/highlight-word": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/highlight-word/-/highlight-word-1.24.2.tgz", + "integrity": "sha512-5aBL0vp8zQ/v2bNOL9SBMxpwM95Sn/TascpceOG3HU66NfqaJmStg38+UvZNIl51QuKZ4Uo6gauNJkbnYcc+hg==", + "license": "MIT" + }, + "node_modules/@zag-js/hover-card": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/hover-card/-/hover-card-1.24.2.tgz", + "integrity": "sha512-qfdLbprSKr3aiBW3guG7lmCGZ2PuJP99if4dl7TZ3BA/zisr3s2YgZOX2bfmHVtpCjeuhrEE0RPqLZ0iqXFrLQ==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/hover-card/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/i18n-utils": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/i18n-utils/-/i18n-utils-1.24.2.tgz", + "integrity": "sha512-4Y9w7WDJpfy17SI3Ey9h3FZ448KsfqmFu7BshsWWCPJTGAPvkomZurpiU6CHc1sk+v2YKvUKqnJ2jj9aUu4PGw==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.2" + } + }, + "node_modules/@zag-js/i18n-utils/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/interact-outside": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/interact-outside/-/interact-outside-1.24.2.tgz", + "integrity": "sha512-/DH1b58szQgTqz3fL+cbg51X94DohahPkuCgiGs6wdPK3JwMFlPJHkmU3SDUXQJTpwLOsDIqMVq9sO4jo7fiGg==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/interact-outside/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/json-tree-utils": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/json-tree-utils/-/json-tree-utils-1.24.2.tgz", + "integrity": "sha512-SFHBmOujTIlG2uVUOaywme2G1N8ych9RJGrUh2CBwRaIIGjc8La9GSMJcx4qIm0UtvMx7I18O2Cx29Zvrc3Bmg==", + "license": "MIT" + }, + "node_modules/@zag-js/listbox": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/listbox/-/listbox-1.24.2.tgz", + "integrity": "sha512-RjXxLfBJ52Kod7vqmsMFOimWD4wV+90dzs6itehnAPxtpIVS5hlXM9USCnDda97DQuyL4Ke2j7+NRJ/ikkgSpg==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/collection": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/focus-visible": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/listbox/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/listbox/node_modules/@zag-js/focus-visible": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/focus-visible/-/focus-visible-1.24.2.tgz", + "integrity": "sha512-/A8CEy+2w0xCTIbDuCB4nfdBxgVQYP6oaFb1zmYXmf8HWFlSXDxuUXb1oXrA3vaoNG/X8sJJilpacYdV1dVhxg==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.2" + } + }, + "node_modules/@zag-js/live-region": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/live-region/-/live-region-1.24.2.tgz", + "integrity": "sha512-X5gp7m5/o7VeQ8hI2ffi9nEVkdCDcCw5wtSx9gFww6WeD6HJMkY/4HbTdi7ALvGVg9gNIMr5F9zrrzpvgc9DXg==", + "license": "MIT" + }, + "node_modules/@zag-js/menu": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/menu/-/menu-1.24.2.tgz", + "integrity": "sha512-vBsFMcEoXfSyN+v1LlLtP2x4aRabLo41/e7ATWjCL41vYSSEyX0dxkpKh5pvJUZebO2SVoQPJ91WGhBCCW9GPQ==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/rect-utils": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/menu/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/number-input": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/number-input/-/number-input-1.24.2.tgz", + "integrity": "sha512-FkgT0cvkDdT2UmDoKtgEkGeJTMV7PhYSwVfnm4mIf4/f6rzSngbQWDw1pUgeQgtQkwyHnONWX9KcUT4bMSe1Zw==", + "license": "MIT", + "dependencies": { + "@internationalized/number": "3.6.5", + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/number-input/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/pagination": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/pagination/-/pagination-1.24.2.tgz", + "integrity": "sha512-ULWw+RyOiJ/2OTCo6STxTwlSXoGbQp2cvNDF0gs6G+hukqlITlNWM0cPpgsRYfuFrtb6B6Lw0C1Z3JYbd8uj5Q==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/pagination/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/password-input": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/password-input/-/password-input-1.24.2.tgz", + "integrity": "sha512-O/mu3oIp3H5TPG7CKfGhvlYRfoHXQA7d65feKd/iyPAnq6U6/nFYMmrrsLnEZ8FUSXYNGRTZnL5eFUKfph7WRA==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/password-input/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/pin-input": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/pin-input/-/pin-input-1.24.2.tgz", + "integrity": "sha512-SQ8StSG/XrWbZetYuSmUWpmEphSKfmYAUZ5SItE4fd61Q1UbrahUDANU6pKhz01sLTIpga0Mjld6avod07qXCw==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/pin-input/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/popover": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/popover/-/popover-1.24.2.tgz", + "integrity": "sha512-BBMBxjMTfeDHCg0wg8ohStk6MPTrmCa0PuOSNDeK0Mr/0i0Vv0EjsSiXuu5Wt1LS237XV1BIaLxt322dW+RfVg==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/aria-hidden": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/focus-trap": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/remove-scroll": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/popover/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/popper": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/popper/-/popper-1.24.2.tgz", + "integrity": "sha512-rimqYBOcM5Aj0AntZFIS2hXv96/QnVASIhFx4GoaiHd3DxadMdJVZ3EsKC1JSNveFEjS/0z7IuuAATTF5x61kQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "1.7.4", + "@zag-js/dom-query": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/popper/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/presence": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/presence/-/presence-1.24.2.tgz", + "integrity": "sha512-aA1P3pe07cLHnGmMpVyAoBY/e38IQYvqFv3cjLT8B8KVUacjXKnv8AhJf4D/60+XzyZCv79HcTwcFU6EGUrVng==", + "license": "MIT", + "dependencies": { + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/presence/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/progress": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/progress/-/progress-1.24.2.tgz", + "integrity": "sha512-8A8Cy7b+EOYxoR0tVXa0RiNBVNVcVtWP875QrA5D1/Hj4KnAkj3W3Ee5NOicpRKkORCELHRy5fRcDTUzbsjeRQ==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/progress/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/qr-code": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/qr-code/-/qr-code-1.24.2.tgz", + "integrity": "sha512-CCdZ2Wch1inyR5dLb7Kh8QqnlzY28R2Elv19W+O2Yvkdbkp16widXGXPoHMt9tIWqaMhXwIXkNEp1JmoiGnfhg==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2", + "proxy-memoize": "3.0.1", + "uqr": "0.1.2" + } + }, + "node_modules/@zag-js/qr-code/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/radio-group": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/radio-group/-/radio-group-1.24.2.tgz", + "integrity": "sha512-ffPeO+P4RvNoGoM1ZsBoiIwJ4zKXbhm8QuHMdKRl34A4TzipzHC2ppAq1cIZ7q+ZubefH0QtZbsvmz1pAo0Z3w==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/focus-visible": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/radio-group/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/radio-group/node_modules/@zag-js/focus-visible": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/focus-visible/-/focus-visible-1.24.2.tgz", + "integrity": "sha512-/A8CEy+2w0xCTIbDuCB4nfdBxgVQYP6oaFb1zmYXmf8HWFlSXDxuUXb1oXrA3vaoNG/X8sJJilpacYdV1dVhxg==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.2" + } + }, + "node_modules/@zag-js/rating-group": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/rating-group/-/rating-group-1.24.2.tgz", + "integrity": "sha512-ZpKayCuPX7ysiZWv+JlNIWnPtflSJlJ5C9lHozTtFqCvyELp5ZAtoO5EQN5cM+aVJe2RNgNsMQf37Fmdv4XaiA==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/rating-group/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/react": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/react/-/react-1.24.2.tgz", + "integrity": "sha512-s6wV2gzd7AIndJ7rrkka+M3OAuKqUqak3xRj/Q6fZdtq2fBY5n6DupcQLCkFoj6eJhp8LUfFO70D7Z71Jvk57w==", + "license": "MIT", + "dependencies": { + "@zag-js/core": "1.24.2", + "@zag-js/store": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@zag-js/rect-utils": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/rect-utils/-/rect-utils-1.24.2.tgz", + "integrity": "sha512-Iuk/diClriGtYL2PGhProuZhb7SWJ+8IdyaOUP3fgeLORKEQsLcM0bYp1pUO5K5rf/ZnlzGBnkb3/ewkkO/kFA==", + "license": "MIT" + }, + "node_modules/@zag-js/remove-scroll": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/remove-scroll/-/remove-scroll-1.24.2.tgz", + "integrity": "sha512-PLXXBw3NxVAfm7MbNnM0TVlv70Kn+xYFEbKxUBWhD0d/qoqsKJV/xS7N4yO5QPZg9UgXMvtRDtINGWoJvyuAlw==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.2" + } + }, + "node_modules/@zag-js/remove-scroll/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/scroll-area": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/scroll-area/-/scroll-area-1.24.2.tgz", + "integrity": "sha512-upT0IorrklcUwsQSzR9UPMYnqP4lbRTUMT0vJtKpKXboPOUL7Fj8mBffQnL6vNBfD9CAMUBV/hPS6SzUFe71og==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/scroll-area/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/scroll-snap": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/scroll-snap/-/scroll-snap-1.24.2.tgz", + "integrity": "sha512-NPJIOEALQYetbaulHvGjnwlbewmmrEvfP/CcB6I5/YrMJDN73nJi+f1vSymdw4uhxBnKDLnxmPkb+oOpyI0Ceg==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.2" + } + }, + "node_modules/@zag-js/scroll-snap/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/select": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/select/-/select-1.24.2.tgz", + "integrity": "sha512-1HSr+2XlymRUgtchTTD7c/+Shwiloei7BIzcEXQvYaUzVMTc6yjs2fSpemF76SX0KwiqlNSY5++HV3R1Szkvpg==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/collection": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/select/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/signature-pad": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/signature-pad/-/signature-pad-1.24.2.tgz", + "integrity": "sha512-NMb+g6wj06JvW1dES0Rk4Q1PNQQM1Y11MGW+rRX1KMsP8ieH6DdS5BvofOvNhxQ6+/Y8p+XWD0tGxudOTU2j/Q==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2", + "perfect-freehand": "^1.2.2" + } + }, + "node_modules/@zag-js/signature-pad/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/signature-pad/node_modules/perfect-freehand": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.2.tgz", + "integrity": "sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ==", + "license": "MIT" + }, + "node_modules/@zag-js/slider": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/slider/-/slider-1.24.2.tgz", + "integrity": "sha512-QxdSIRW96lPT/zJS/1pr2aj1rGujXBZ2ypUyb6JYZizFG55cZtmJqjJaZ81jU251peLuTdhug34C52vAvXyFZA==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/slider/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/splitter": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/splitter/-/splitter-1.24.2.tgz", + "integrity": "sha512-EQchsOU4cFOFlBZ3Zql0rmAbUumu/mw0CAg7sjNNUPIDJ7NAOpZSwKtsnfT8pDWeOVIAxTLrlALWW6uY2g4UyQ==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/splitter/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/steps": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/steps/-/steps-1.24.2.tgz", + "integrity": "sha512-nunbmsl4WTkZw6955xeWo9D6XORt1ay2tDsUYHyTJ3rUCeh11Itzmwi7IrJTTSeMtbokYgnF5uoYFHj/pSc7fQ==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/steps/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/store": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/store/-/store-1.24.2.tgz", + "integrity": "sha512-PZViq7LD4y+6iEB/0tvbqywoEccNXGy6jcOGIg5lwdY0CiPulPMeoLFi0+Etx1/Wom398NqlzecPqT4ZbICYMQ==", + "license": "MIT", + "dependencies": { + "proxy-compare": "3.0.1" + } + }, + "node_modules/@zag-js/switch": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/switch/-/switch-1.24.2.tgz", + "integrity": "sha512-NRyWy43Npzjwc15flHRB6eVqKPeu7uw4pjdfmTjhj87185ZM/VYDEQHC6G+teXvl+4LW19QV9aDcTAzg1g5bbg==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/focus-visible": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/switch/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/switch/node_modules/@zag-js/focus-visible": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/focus-visible/-/focus-visible-1.24.2.tgz", + "integrity": "sha512-/A8CEy+2w0xCTIbDuCB4nfdBxgVQYP6oaFb1zmYXmf8HWFlSXDxuUXb1oXrA3vaoNG/X8sJJilpacYdV1dVhxg==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.2" + } + }, + "node_modules/@zag-js/tabs": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/tabs/-/tabs-1.24.2.tgz", + "integrity": "sha512-UCVfUSDlIk+EAL0kgDkWZAj55PSRUXVAhuqqlDnB8t3GO9UsvmxG93US7wEQglpYBydyPqUpDpmpNUs16egd/Q==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/tabs/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/tags-input": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/tags-input/-/tags-input-1.24.2.tgz", + "integrity": "sha512-zl9DffAvHdTY11Qdm4rowG5TMNLCreJ/dOqj4nTxoLV2xYAzVAgejgxKzv0jbB11/GiuT6omcslVcoCh5w/hRw==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/auto-resize": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/interact-outside": "1.24.2", + "@zag-js/live-region": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/tags-input/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/timer": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/timer/-/timer-1.24.2.tgz", + "integrity": "sha512-Hp7cMGZ7UE04VtBWprINZFSX578pyoyiHLLIgSPm9pbUkywVmjL14oKmO64fnOqrdwXSkTnumxo8O0X2P4reoA==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/timer/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/toast": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/toast/-/toast-1.24.2.tgz", + "integrity": "sha512-mH9iVrAr8asJZNNSWsrCSmtCfzn/aC64fVU610B/pHOiDY6HN0ANYmC+TgSZ29a3Jlts1OTZiKsygWyBuJQ3Og==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/toast/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/toggle": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/toggle/-/toggle-1.24.2.tgz", + "integrity": "sha512-sD8/di9RP4cISkp5tml5qJKTmZSCBOHIP/4gwnm2eVeUaT4LOqqH1dRFZWv/3FIQ719xcIo6LX8we2vKYxYCCw==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/toggle-group": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/toggle-group/-/toggle-group-1.24.2.tgz", + "integrity": "sha512-71idkWYohYONDD/8FSr/VR3mjFrsTGtsJXBEIGEUfQpgorr+11ryaHbKqq2EMMRLnHs+oyzwegW+laOyvoj0bA==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/toggle-group/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/toggle/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/tooltip": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/tooltip/-/tooltip-1.24.2.tgz", + "integrity": "sha512-GYjoZkCR9UMPutaJa24LnUrZSWnFfXbuiLoUPlp8Xg+HkVSM25zOe7IgiNgvXqf9shp7dE0AaVnXiuDjjdDm1w==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/focus-visible": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/tooltip/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/tooltip/node_modules/@zag-js/focus-visible": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/focus-visible/-/focus-visible-1.24.2.tgz", + "integrity": "sha512-/A8CEy+2w0xCTIbDuCB4nfdBxgVQYP6oaFb1zmYXmf8HWFlSXDxuUXb1oXrA3vaoNG/X8sJJilpacYdV1dVhxg==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.24.2" + } + }, + "node_modules/@zag-js/tour": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/tour/-/tour-1.24.2.tgz", + "integrity": "sha512-lsgTr+A+/AK1ct6CPRAFNBQy/PypkVtBmQSsPXkTcwdWWvd8lYp6uuyHapXABLLrfqSWQI7rHokhr7QknCN90A==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/focus-trap": "1.24.2", + "@zag-js/interact-outside": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/tour/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/tree-view": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/tree-view/-/tree-view-1.24.2.tgz", + "integrity": "sha512-96P8uOTLKTwO3RzGd5X9ZEVy9JBrIoM5RptiMrk2yqN97HdzXint59A06whCBe7wlQVkJyJK58R0fOKDxjTQaQ==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/collection": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "node_modules/@zag-js/tree-view/node_modules/@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.24.2" + } + }, + "node_modules/@zag-js/types": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/types/-/types-1.24.2.tgz", + "integrity": "sha512-sXL8JHx8yrj8qGwCl/EhydaHoCCEfYwbg1rPWcCwqrpkvhic0KEZAJZMUhcU4dLdx+Oajbv2QeFz6Fk5U2Nn5A==", + "license": "MIT", + "dependencies": { + "csstype": "3.1.3" + } + }, + "node_modules/@zag-js/utils": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/utils/-/utils-1.24.2.tgz", + "integrity": "sha512-3U8aYXjktpDmQV4M7nAOj7E4x1XSifG7PrbHqJbTYRm7/EPbwCQEEDPckkkWBmj4UrvltbkXPi6nzcP4Qpw5bA==", + "license": "MIT" + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -30950,6 +32437,15 @@ "integrity": "sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==", "optional": true }, + "node_modules/lucide-react": { + "version": "0.503.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.503.0.tgz", + "integrity": "sha512-HGGkdlPWQ0vTF8jJ5TdIqhQXZi6uh3LnNgfZ8MHiuxFfX3RZeA79r2MW2tHAZKlAVfoNE8esm3p+O6VkIvpj6w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lunr": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", @@ -37835,6 +39331,21 @@ "node": ">= 0.10" } }, + "node_modules/proxy-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz", + "integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==", + "license": "MIT" + }, + "node_modules/proxy-memoize": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/proxy-memoize/-/proxy-memoize-3.0.1.tgz", + "integrity": "sha512-VDdG/VYtOgdGkWJx7y0o7p+zArSf2383Isci8C+BP3YXgMYDoPd3cCBjw0JdWb6YBb9sFiOPbAADDVTPJnh+9g==", + "license": "MIT", + "dependencies": { + "proxy-compare": "^3.0.0" + } + }, "node_modules/prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -40836,6 +42347,22 @@ "dev": true, "license": "MIT" }, + "node_modules/shiki": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.13.0.tgz", + "integrity": "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.13.0", + "@shikijs/engine-javascript": "3.13.0", + "@shikijs/engine-oniguruma": "3.13.0", + "@shikijs/langs": "3.13.0", + "@shikijs/themes": "3.13.0", + "@shikijs/types": "3.13.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -42655,9 +44182,9 @@ } }, "node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, "node_modules/tunnel-rat": { @@ -43633,6 +45160,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/uqr": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz", + "integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==", + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -46322,21 +47855,6 @@ "@shikijs/types": "^3.7.0" } }, - "packages/lexical-code-shiki/node_modules/shiki": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.7.0.tgz", - "integrity": "sha512-ZcI4UT9n6N2pDuM2n3Jbk0sR4Swzq43nLPgS/4h0E3B/NrFn2HKElrDtceSf8Zx/OWYOo7G1SAtBLypCp+YXqg==", - "dependencies": { - "@shikijs/core": "3.7.0", - "@shikijs/engine-javascript": "3.7.0", - "@shikijs/engine-oniguruma": "3.7.0", - "@shikijs/langs": "3.7.0", - "@shikijs/themes": "3.7.0", - "@shikijs/types": "3.7.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, "packages/lexical-devtools": { "name": "@lexical/devtools", "version": "0.36.2", @@ -47318,6 +48836,84 @@ "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-8.1.1.tgz", "integrity": "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==" }, + "@ark-ui/react": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/@ark-ui/react/-/react-5.25.1.tgz", + "integrity": "sha512-5ncHjwMuCmCDz+a3PD2voFJk+3VDmGWZ3Hs6WQIxEztIWbXC9sEj86BkpcSV84s57ELCALIXm9fQk4TUXGoZlw==", + "requires": { + "@internationalized/date": "3.9.0", + "@zag-js/accordion": "1.24.2", + "@zag-js/anatomy": "1.24.2", + "@zag-js/angle-slider": "1.24.2", + "@zag-js/async-list": "1.24.2", + "@zag-js/auto-resize": "1.24.2", + "@zag-js/avatar": "1.24.2", + "@zag-js/bottom-sheet": "1.24.2", + "@zag-js/carousel": "1.24.2", + "@zag-js/checkbox": "1.24.2", + "@zag-js/clipboard": "1.24.2", + "@zag-js/collapsible": "1.24.2", + "@zag-js/collection": "1.24.2", + "@zag-js/color-picker": "1.24.2", + "@zag-js/color-utils": "1.24.2", + "@zag-js/combobox": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/date-picker": "1.24.2", + "@zag-js/date-utils": "1.24.2", + "@zag-js/dialog": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/editable": "1.24.2", + "@zag-js/file-upload": "1.24.2", + "@zag-js/file-utils": "1.24.2", + "@zag-js/floating-panel": "1.24.2", + "@zag-js/focus-trap": "1.24.2", + "@zag-js/highlight-word": "1.24.2", + "@zag-js/hover-card": "1.24.2", + "@zag-js/i18n-utils": "1.24.2", + "@zag-js/json-tree-utils": "1.24.2", + "@zag-js/listbox": "1.24.2", + "@zag-js/menu": "1.24.2", + "@zag-js/number-input": "1.24.2", + "@zag-js/pagination": "1.24.2", + "@zag-js/password-input": "1.24.2", + "@zag-js/pin-input": "1.24.2", + "@zag-js/popover": "1.24.2", + "@zag-js/presence": "1.24.2", + "@zag-js/progress": "1.24.2", + "@zag-js/qr-code": "1.24.2", + "@zag-js/radio-group": "1.24.2", + "@zag-js/rating-group": "1.24.2", + "@zag-js/react": "1.24.2", + "@zag-js/scroll-area": "1.24.2", + "@zag-js/select": "1.24.2", + "@zag-js/signature-pad": "1.24.2", + "@zag-js/slider": "1.24.2", + "@zag-js/splitter": "1.24.2", + "@zag-js/steps": "1.24.2", + "@zag-js/switch": "1.24.2", + "@zag-js/tabs": "1.24.2", + "@zag-js/tags-input": "1.24.2", + "@zag-js/timer": "1.24.2", + "@zag-js/toast": "1.24.2", + "@zag-js/toggle": "1.24.2", + "@zag-js/toggle-group": "1.24.2", + "@zag-js/tooltip": "1.24.2", + "@zag-js/tour": "1.24.2", + "@zag-js/tree-view": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, "@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -51724,46 +53320,6 @@ "@shikijs/themes": "^3.12.2", "@shikijs/types": "^3.12.2", "@shikijs/vscode-textmate": "^10.0.2" - }, - "dependencies": { - "@shikijs/engine-oniguruma": { - "version": "3.12.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.12.2.tgz", - "integrity": "sha512-hozwnFHsLvujK4/CPVHNo3Bcg2EsnG8krI/ZQ2FlBlCRpPZW4XAEQmEwqegJsypsTAN9ehu2tEYe30lYKSZW/w==", - "dev": true, - "requires": { - "@shikijs/types": "3.12.2", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, - "@shikijs/langs": { - "version": "3.12.2", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.12.2.tgz", - "integrity": "sha512-bVx5PfuZHDSHoBal+KzJZGheFuyH4qwwcwG/n+MsWno5cTlKmaNtTsGzJpHYQ8YPbB5BdEdKU1rga5/6JGY8ww==", - "dev": true, - "requires": { - "@shikijs/types": "3.12.2" - } - }, - "@shikijs/themes": { - "version": "3.12.2", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.12.2.tgz", - "integrity": "sha512-fTR3QAgnwYpfGczpIbzPjlRnxyONJOerguQv1iwpyQZ9QXX4qy/XFQqXlf17XTsorxnHoJGbH/LXBvwtqDsF5A==", - "dev": true, - "requires": { - "@shikijs/types": "3.12.2" - } - }, - "@shikijs/types": { - "version": "3.12.2", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.12.2.tgz", - "integrity": "sha512-K5UIBzxCyv0YoxN3LMrKB9zuhp1bV+LgewxuVwHdl4Gz5oePoUFrr9EfgJlGlDeXCU1b/yhdnXeuRvAnz8HN8Q==", - "dev": true, - "requires": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - } } }, "@hapi/hoek": { @@ -51837,6 +53393,22 @@ } } }, + "@internationalized/date": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.9.0.tgz", + "integrity": "sha512-yaN3brAnHRD+4KyyOsJyk49XUvj2wtbNACSqg0bz3u8t2VuzhC8Q5dfRnrSxjnnbDb+ienBnkn1TzQfE154vyg==", + "requires": { + "@swc/helpers": "^0.5.0" + } + }, + "@internationalized/number": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.5.tgz", + "integrity": "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==", + "requires": { + "@swc/helpers": "^0.5.0" + } + }, "@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -52580,22 +54152,42 @@ "@shikijs/types": "^3.7.0", "lexical": "0.36.2", "shiki": "^3.7.0" + } + }, + "@lexical/dev-node-state-style-example": { + "version": "file:examples/dev-node-state-style", + "requires": { + "@ark-ui/react": "^5.6.0", + "@lexical/clipboard": "0.36.1", + "@lexical/extension": "0.36.1", + "@lexical/history": "0.36.1", + "@lexical/html": "0.36.1", + "@lexical/react": "0.36.1", + "@lexical/rich-text": "0.36.1", + "@lexical/selection": "0.36.1", + "@lexical/utils": "0.36.1", + "@shikijs/langs": "^3.3.0", + "@shikijs/themes": "^3.3.0", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^5.0.2", + "cross-env": "^7.0.3", + "csstype": "^3.1.3", + "inline-style-parser": "^0.2.4", + "lexical": "0.36.1", + "lucide-react": "^0.503.0", + "prettier": "^3.5.3", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "shiki": "^3.3.0", + "typescript": "^5.9.2", + "vite": "^7.1.4" }, "dependencies": { - "shiki": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.7.0.tgz", - "integrity": "sha512-ZcI4UT9n6N2pDuM2n3Jbk0sR4Swzq43nLPgS/4h0E3B/NrFn2HKElrDtceSf8Zx/OWYOo7G1SAtBLypCp+YXqg==", - "requires": { - "@shikijs/core": "3.7.0", - "@shikijs/engine-javascript": "3.7.0", - "@shikijs/engine-oniguruma": "3.7.0", - "@shikijs/langs": "3.7.0", - "@shikijs/themes": "3.7.0", - "@shikijs/types": "3.7.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } + "inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==" } } }, @@ -54495,55 +56087,55 @@ "integrity": "sha512-VynGOEsVw2s8TAlLf/uESfrgfrq2+rcXB1muPJYBWbsm1Oa6r5qVQhjA5ggM6z/coYPrsVMgovl3Ff7Q7OCp1w==" }, "@shikijs/core": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.7.0.tgz", - "integrity": "sha512-yilc0S9HvTPyahHpcum8eonYrQtmGTU0lbtwxhA6jHv4Bm1cAdlPFRCJX4AHebkCm75aKTjjRAW+DezqD1b/cg==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.13.0.tgz", + "integrity": "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA==", "requires": { - "@shikijs/types": "3.7.0", + "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "@shikijs/engine-javascript": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.7.0.tgz", - "integrity": "sha512-0t17s03Cbv+ZcUvv+y33GtX75WBLQELgNdVghnsdhTgU3hVcWcMsoP6Lb0nDTl95ZJfbP1mVMO0p3byVh3uuzA==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.13.0.tgz", + "integrity": "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg==", "requires": { - "@shikijs/types": "3.7.0", + "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "@shikijs/engine-oniguruma": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.7.0.tgz", - "integrity": "sha512-5BxcD6LjVWsGu4xyaBC5bu8LdNgPCVBnAkWTtOCs/CZxcB22L8rcoWfv7Hh/3WooVjBZmFtyxhgvkQFedPGnFw==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.13.0.tgz", + "integrity": "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg==", "requires": { - "@shikijs/types": "3.7.0", + "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "@shikijs/langs": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.7.0.tgz", - "integrity": "sha512-1zYtdfXLr9xDKLTGy5kb7O0zDQsxXiIsw1iIBcNOO8Yi5/Y1qDbJ+0VsFoqTlzdmneO8Ij35g7QKF8kcLyznCQ==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.13.0.tgz", + "integrity": "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ==", "requires": { - "@shikijs/types": "3.7.0" + "@shikijs/types": "3.13.0" } }, "@shikijs/themes": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.7.0.tgz", - "integrity": "sha512-VJx8497iZPy5zLiiCTSIaOChIcKQwR0FebwE9S3rcN0+J/GTWwQ1v/bqhTbpbY3zybPKeO8wdammqkpXc4NVjQ==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.13.0.tgz", + "integrity": "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg==", "requires": { - "@shikijs/types": "3.7.0" + "@shikijs/types": "3.13.0" } }, "@shikijs/types": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.7.0.tgz", - "integrity": "sha512-MGaLeaRlSWpnP0XSAum3kP3a8vtcTsITqoEPYdt3lQG3YCdQH4DnEhodkYcNMcU0uW0RffhoD1O3e0vG5eSBBg==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.13.0.tgz", + "integrity": "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw==", "requires": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" @@ -54868,6 +56460,14 @@ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" }, + "@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "requires": { + "tslib": "^2.8.0" + } + }, "@swc/html": { "version": "1.13.20", "resolved": "https://registry.npmjs.org/@swc/html/-/html-1.13.20.tgz", @@ -56878,16 +58478,505 @@ "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" }, + "@zag-js/accordion": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/accordion/-/accordion-1.24.2.tgz", + "integrity": "sha512-sGNhbWR85oAiMyQLk+dliRhNQGP59T56M1gAkQ7bwJJZ7l++hFEQpYcr/FbAHJshXWpvUKm0wV18wHR/56Y30w==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/anatomy": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/anatomy/-/anatomy-1.24.2.tgz", + "integrity": "sha512-qWmxopxVHMjP9UGoUdxqKtrot8MaU0UvoJWh0O03b9eOPgLwMydkcwuGAc8s3x6GDREu3D3GvcRgrQ5JteITjg==" + }, + "@zag-js/angle-slider": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/angle-slider/-/angle-slider-1.24.2.tgz", + "integrity": "sha512-QPBWxji84sEyB519uU+n07IkowvDaLSpon1oDQvNc3wFKz35F5IAXQo85pDQPPDAzEHG4oJ2W3cXPRWmkVuTrg==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/rect-utils": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/aria-hidden": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/aria-hidden/-/aria-hidden-1.24.2.tgz", + "integrity": "sha512-btbVDdfHq2wcvV2KmpBlkKN+36XIMkXTIy7zvQniBQ/V6X5WGKBaXGvllqrLLaJVU2HmnhM/IYUBQW4H0BLWBA==" + }, + "@zag-js/async-list": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/async-list/-/async-list-1.24.2.tgz", + "integrity": "sha512-W2720aU4ANdhcrFtOQX+AVYFSxxuH+TT8kAPtIZtnd6ffOTuiR8j8PV7U45gC5KUvixIBQ+oot11m/n3YVilUw==", + "requires": { + "@zag-js/core": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "@zag-js/auto-resize": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/auto-resize/-/auto-resize-1.24.2.tgz", + "integrity": "sha512-oOzFY+4mif6PXpVMR+db8+b33C8uplAkiuDwZhNEaQDj97S4kxySlQkFovbmY55DYAlBRguaQKXjn0pboqwIqA==", + "requires": { + "@zag-js/dom-query": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/avatar": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/avatar/-/avatar-1.24.2.tgz", + "integrity": "sha512-qSqJQLjscmWCMPosWKoTwSdFL6/hHyLeBAL4iyLcVby5Y7tz4o3u5zjkcLGPoZTH1DxicwEDBaTkONHvMthaLw==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/bottom-sheet": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/bottom-sheet/-/bottom-sheet-1.24.2.tgz", + "integrity": "sha512-T1nWvpNUvzE29ODZhkjsZCE4yHzkWQ7dNohrDMvMOc7jNlxUOy0SRd4LuiT4ioAg2FUvMZrfcTBBVagc9kv9SQ==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/aria-hidden": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/focus-trap": "1.24.2", + "@zag-js/remove-scroll": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/carousel": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/carousel/-/carousel-1.24.2.tgz", + "integrity": "sha512-gQYZ4+UyCk1vXYYkIBGHWxAZNQBlV1/e1XdQTT1CApKmUAjNwDWf91fcQ733mB9n7MEDTS/vtgGtWQFQNVVh1g==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/scroll-snap": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/checkbox": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/checkbox/-/checkbox-1.24.2.tgz", + "integrity": "sha512-+ibuzfVW9Nx84r04cd1SxdI3P19/bnexmMzw7zZu/17pSvO4u5v6HSKi24ARVv15sw1ujjcfHd1qlDmtWZFyJg==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/focus-visible": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + }, + "@zag-js/focus-visible": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/focus-visible/-/focus-visible-1.24.2.tgz", + "integrity": "sha512-/A8CEy+2w0xCTIbDuCB4nfdBxgVQYP6oaFb1zmYXmf8HWFlSXDxuUXb1oXrA3vaoNG/X8sJJilpacYdV1dVhxg==", + "requires": { + "@zag-js/dom-query": "1.24.2" + } + } + } + }, + "@zag-js/clipboard": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/clipboard/-/clipboard-1.24.2.tgz", + "integrity": "sha512-3E8c3IJubkJGxGJRAB7nmoFGLxM6LiaANz0JH4WxOt7lIu+5jxIxzLVRGAQI3/Vtn1Qxm34+Ffqqw2PW/J+6qA==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/collapsible": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/collapsible/-/collapsible-1.24.2.tgz", + "integrity": "sha512-nmMRljiM2AfcmGW04dgPOQjjuteGj4wbaURJiN8uyFnDKavUZH7BIT8knu8iA59Nj8m6UctDIcasRreUd6smxA==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/collection": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/collection/-/collection-1.24.2.tgz", + "integrity": "sha512-vqNnn9nAmz5lz8pHhvjNdCrPHj76aZpoaRFe7DQdcnwlrbNjASUKPiN4lG5ZgspOMnQqZ0teR4fyjaCa+cH+xQ==", + "requires": { + "@zag-js/utils": "1.24.2" + } + }, + "@zag-js/color-picker": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/color-picker/-/color-picker-1.24.2.tgz", + "integrity": "sha512-FKK4tNATGKjAfwpxqgAyM8uecZa1ebTP8HSLc2c02hL5C7VUEcMrGP7SYBT/eOYXME1iHIA01R6ArmIzVhVdvQ==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/color-utils": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/color-utils": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/color-utils/-/color-utils-1.24.2.tgz", + "integrity": "sha512-p/mkEA7lAH9YFyAHIEAbCXJW0cUkJNDXcq8OAkx/pq+mL+dDPLgjN4GKfb+Hhur+n/e2Jd6UTYEDmCEFUgC3fg==", + "requires": { + "@zag-js/utils": "1.24.2" + } + }, + "@zag-js/combobox": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/combobox/-/combobox-1.24.2.tgz", + "integrity": "sha512-o/jkby5ry4IUAm8GT04RbMdd3r8xk7RTAdnNRdvv4R8ZJtaDDL+wJVFq5ITeFhtnvmAl3GD+Isn2kJ3fut0fHQ==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/aria-hidden": "1.24.2", + "@zag-js/collection": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/core": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/core/-/core-1.24.2.tgz", + "integrity": "sha512-LBJBNpaEixfIKLjyfcuudTdtnVJmj60iLK9flI3D7BeziU/nGu3CsNxh0miLCR2Sdl7jFEseyGrK7HLhTaRMLw==", + "requires": { + "@zag-js/dom-query": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/date-picker": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/date-picker/-/date-picker-1.24.2.tgz", + "integrity": "sha512-8vpztt7RwrreqXdV4vwotQ0susaqQfOmqdMh++j31UMsvmRinNiFo75hL7/5AvPwpBnt9lmtWZYBw2WY5DFnQQ==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/date-utils": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/live-region": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/date-utils": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/date-utils/-/date-utils-1.24.2.tgz", + "integrity": "sha512-wtrbE4LTs8h+9U9mqJPFg+RqdRofTISwMCY1nD1gApCwMdp9Gov9me0CcV+JshXgOEBSsFoZXt8fvSINN0gs+A==" + }, + "@zag-js/dialog": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dialog/-/dialog-1.24.2.tgz", + "integrity": "sha512-70dvyikN/f3qqhI9mGB23oMGeTmjjZmhy5ZXDCwNL1ZmZ0SnGB3QdsHEa/DDyoXC2qSO8XhOUgEp3hNukXujXA==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/aria-hidden": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/focus-trap": "1.24.2", + "@zag-js/remove-scroll": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/dismissable": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dismissable/-/dismissable-1.24.2.tgz", + "integrity": "sha512-POmhCyjm8cRIThuK3icXjt9ic3OrYjN3N0jQ7uT5xAitX5eyGR+Tb7Mdf5J1R6iuX19t0t6ova+h3XIx6YDWHQ==", + "requires": { + "@zag-js/dom-query": "1.24.2", + "@zag-js/interact-outside": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, "@zag-js/dom-query": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-0.16.0.tgz", "integrity": "sha512-Oqhd6+biWyKnhKwFFuZrrf6lxBz2tX2pRQe6grUnYwO6HJ8BcbqZomy2lpOdr+3itlaUqx+Ywj5E5ZZDr/LBfQ==" }, + "@zag-js/editable": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/editable/-/editable-1.24.2.tgz", + "integrity": "sha512-ZSvBcVi3OMuChGj6IGCnX7QoF9UVAhdHelKp3q2/qfWq6ykwFUtO8erYBkEFmkdGqRqdlnbtidBRBoh0e9dLdg==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/interact-outside": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, "@zag-js/element-size": { "version": "0.10.5", "resolved": "https://registry.npmjs.org/@zag-js/element-size/-/element-size-0.10.5.tgz", "integrity": "sha512-uQre5IidULANvVkNOBQ1tfgwTQcGl4hliPSe69Fct1VfYb2Fd0jdAcGzqQgPhfrXFpR62MxLPB7erxJ/ngtL8w==" }, + "@zag-js/file-upload": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/file-upload/-/file-upload-1.24.2.tgz", + "integrity": "sha512-hQHTwbAXDYOjLatRDcoLY8nrxDrctiIMWnzQmbz/53PYbfH5i93KGM7SvUwWX1Zvfwp5IVwpkLA7ARWGNqDOUQ==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/file-utils": "1.24.2", + "@zag-js/i18n-utils": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/file-utils": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/file-utils/-/file-utils-1.24.2.tgz", + "integrity": "sha512-feQ9nMOZwuGae2VXyFGbqzb5DEej2Eo498o2KZVHmyA216vVx1ZOQpTsVrrvJV8KRxS6ULoKZNGe5QuHfwBWYg==", + "requires": { + "@zag-js/i18n-utils": "1.24.2" + } + }, + "@zag-js/floating-panel": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/floating-panel/-/floating-panel-1.24.2.tgz", + "integrity": "sha512-pI+YQGsJwGn4lTHx67qVLgeLotP87F5PQToUv6t1CsuVJkmL6fxP3vQNflMSL7XNAX06jVCaug7RW7V+SehHIg==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/rect-utils": "1.24.2", + "@zag-js/store": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/focus-trap": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/focus-trap/-/focus-trap-1.24.2.tgz", + "integrity": "sha512-ztqxOaB7Z8zOZH4HvHnMpKREhrTAcJICRmHgwx1Dfq5SqymlMBnFD0zwS/F0mlbZqzz9eV9yYLAd1Xy54OdeGw==", + "requires": { + "@zag-js/dom-query": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, "@zag-js/focus-visible": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@zag-js/focus-visible/-/focus-visible-0.16.0.tgz", @@ -56896,6 +58985,856 @@ "@zag-js/dom-query": "0.16.0" } }, + "@zag-js/highlight-word": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/highlight-word/-/highlight-word-1.24.2.tgz", + "integrity": "sha512-5aBL0vp8zQ/v2bNOL9SBMxpwM95Sn/TascpceOG3HU66NfqaJmStg38+UvZNIl51QuKZ4Uo6gauNJkbnYcc+hg==" + }, + "@zag-js/hover-card": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/hover-card/-/hover-card-1.24.2.tgz", + "integrity": "sha512-qfdLbprSKr3aiBW3guG7lmCGZ2PuJP99if4dl7TZ3BA/zisr3s2YgZOX2bfmHVtpCjeuhrEE0RPqLZ0iqXFrLQ==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/i18n-utils": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/i18n-utils/-/i18n-utils-1.24.2.tgz", + "integrity": "sha512-4Y9w7WDJpfy17SI3Ey9h3FZ448KsfqmFu7BshsWWCPJTGAPvkomZurpiU6CHc1sk+v2YKvUKqnJ2jj9aUu4PGw==", + "requires": { + "@zag-js/dom-query": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/interact-outside": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/interact-outside/-/interact-outside-1.24.2.tgz", + "integrity": "sha512-/DH1b58szQgTqz3fL+cbg51X94DohahPkuCgiGs6wdPK3JwMFlPJHkmU3SDUXQJTpwLOsDIqMVq9sO4jo7fiGg==", + "requires": { + "@zag-js/dom-query": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/json-tree-utils": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/json-tree-utils/-/json-tree-utils-1.24.2.tgz", + "integrity": "sha512-SFHBmOujTIlG2uVUOaywme2G1N8ych9RJGrUh2CBwRaIIGjc8La9GSMJcx4qIm0UtvMx7I18O2Cx29Zvrc3Bmg==" + }, + "@zag-js/listbox": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/listbox/-/listbox-1.24.2.tgz", + "integrity": "sha512-RjXxLfBJ52Kod7vqmsMFOimWD4wV+90dzs6itehnAPxtpIVS5hlXM9USCnDda97DQuyL4Ke2j7+NRJ/ikkgSpg==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/collection": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/focus-visible": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + }, + "@zag-js/focus-visible": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/focus-visible/-/focus-visible-1.24.2.tgz", + "integrity": "sha512-/A8CEy+2w0xCTIbDuCB4nfdBxgVQYP6oaFb1zmYXmf8HWFlSXDxuUXb1oXrA3vaoNG/X8sJJilpacYdV1dVhxg==", + "requires": { + "@zag-js/dom-query": "1.24.2" + } + } + } + }, + "@zag-js/live-region": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/live-region/-/live-region-1.24.2.tgz", + "integrity": "sha512-X5gp7m5/o7VeQ8hI2ffi9nEVkdCDcCw5wtSx9gFww6WeD6HJMkY/4HbTdi7ALvGVg9gNIMr5F9zrrzpvgc9DXg==" + }, + "@zag-js/menu": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/menu/-/menu-1.24.2.tgz", + "integrity": "sha512-vBsFMcEoXfSyN+v1LlLtP2x4aRabLo41/e7ATWjCL41vYSSEyX0dxkpKh5pvJUZebO2SVoQPJ91WGhBCCW9GPQ==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/rect-utils": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/number-input": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/number-input/-/number-input-1.24.2.tgz", + "integrity": "sha512-FkgT0cvkDdT2UmDoKtgEkGeJTMV7PhYSwVfnm4mIf4/f6rzSngbQWDw1pUgeQgtQkwyHnONWX9KcUT4bMSe1Zw==", + "requires": { + "@internationalized/number": "3.6.5", + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/pagination": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/pagination/-/pagination-1.24.2.tgz", + "integrity": "sha512-ULWw+RyOiJ/2OTCo6STxTwlSXoGbQp2cvNDF0gs6G+hukqlITlNWM0cPpgsRYfuFrtb6B6Lw0C1Z3JYbd8uj5Q==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/password-input": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/password-input/-/password-input-1.24.2.tgz", + "integrity": "sha512-O/mu3oIp3H5TPG7CKfGhvlYRfoHXQA7d65feKd/iyPAnq6U6/nFYMmrrsLnEZ8FUSXYNGRTZnL5eFUKfph7WRA==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/pin-input": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/pin-input/-/pin-input-1.24.2.tgz", + "integrity": "sha512-SQ8StSG/XrWbZetYuSmUWpmEphSKfmYAUZ5SItE4fd61Q1UbrahUDANU6pKhz01sLTIpga0Mjld6avod07qXCw==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/popover": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/popover/-/popover-1.24.2.tgz", + "integrity": "sha512-BBMBxjMTfeDHCg0wg8ohStk6MPTrmCa0PuOSNDeK0Mr/0i0Vv0EjsSiXuu5Wt1LS237XV1BIaLxt322dW+RfVg==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/aria-hidden": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/focus-trap": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/remove-scroll": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/popper": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/popper/-/popper-1.24.2.tgz", + "integrity": "sha512-rimqYBOcM5Aj0AntZFIS2hXv96/QnVASIhFx4GoaiHd3DxadMdJVZ3EsKC1JSNveFEjS/0z7IuuAATTF5x61kQ==", + "requires": { + "@floating-ui/dom": "1.7.4", + "@zag-js/dom-query": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/presence": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/presence/-/presence-1.24.2.tgz", + "integrity": "sha512-aA1P3pe07cLHnGmMpVyAoBY/e38IQYvqFv3cjLT8B8KVUacjXKnv8AhJf4D/60+XzyZCv79HcTwcFU6EGUrVng==", + "requires": { + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/progress": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/progress/-/progress-1.24.2.tgz", + "integrity": "sha512-8A8Cy7b+EOYxoR0tVXa0RiNBVNVcVtWP875QrA5D1/Hj4KnAkj3W3Ee5NOicpRKkORCELHRy5fRcDTUzbsjeRQ==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/qr-code": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/qr-code/-/qr-code-1.24.2.tgz", + "integrity": "sha512-CCdZ2Wch1inyR5dLb7Kh8QqnlzY28R2Elv19W+O2Yvkdbkp16widXGXPoHMt9tIWqaMhXwIXkNEp1JmoiGnfhg==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2", + "proxy-memoize": "3.0.1", + "uqr": "0.1.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/radio-group": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/radio-group/-/radio-group-1.24.2.tgz", + "integrity": "sha512-ffPeO+P4RvNoGoM1ZsBoiIwJ4zKXbhm8QuHMdKRl34A4TzipzHC2ppAq1cIZ7q+ZubefH0QtZbsvmz1pAo0Z3w==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/focus-visible": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + }, + "@zag-js/focus-visible": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/focus-visible/-/focus-visible-1.24.2.tgz", + "integrity": "sha512-/A8CEy+2w0xCTIbDuCB4nfdBxgVQYP6oaFb1zmYXmf8HWFlSXDxuUXb1oXrA3vaoNG/X8sJJilpacYdV1dVhxg==", + "requires": { + "@zag-js/dom-query": "1.24.2" + } + } + } + }, + "@zag-js/rating-group": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/rating-group/-/rating-group-1.24.2.tgz", + "integrity": "sha512-ZpKayCuPX7ysiZWv+JlNIWnPtflSJlJ5C9lHozTtFqCvyELp5ZAtoO5EQN5cM+aVJe2RNgNsMQf37Fmdv4XaiA==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/react": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/react/-/react-1.24.2.tgz", + "integrity": "sha512-s6wV2gzd7AIndJ7rrkka+M3OAuKqUqak3xRj/Q6fZdtq2fBY5n6DupcQLCkFoj6eJhp8LUfFO70D7Z71Jvk57w==", + "requires": { + "@zag-js/core": "1.24.2", + "@zag-js/store": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + } + }, + "@zag-js/rect-utils": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/rect-utils/-/rect-utils-1.24.2.tgz", + "integrity": "sha512-Iuk/diClriGtYL2PGhProuZhb7SWJ+8IdyaOUP3fgeLORKEQsLcM0bYp1pUO5K5rf/ZnlzGBnkb3/ewkkO/kFA==" + }, + "@zag-js/remove-scroll": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/remove-scroll/-/remove-scroll-1.24.2.tgz", + "integrity": "sha512-PLXXBw3NxVAfm7MbNnM0TVlv70Kn+xYFEbKxUBWhD0d/qoqsKJV/xS7N4yO5QPZg9UgXMvtRDtINGWoJvyuAlw==", + "requires": { + "@zag-js/dom-query": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/scroll-area": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/scroll-area/-/scroll-area-1.24.2.tgz", + "integrity": "sha512-upT0IorrklcUwsQSzR9UPMYnqP4lbRTUMT0vJtKpKXboPOUL7Fj8mBffQnL6vNBfD9CAMUBV/hPS6SzUFe71og==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/scroll-snap": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/scroll-snap/-/scroll-snap-1.24.2.tgz", + "integrity": "sha512-NPJIOEALQYetbaulHvGjnwlbewmmrEvfP/CcB6I5/YrMJDN73nJi+f1vSymdw4uhxBnKDLnxmPkb+oOpyI0Ceg==", + "requires": { + "@zag-js/dom-query": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/select": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/select/-/select-1.24.2.tgz", + "integrity": "sha512-1HSr+2XlymRUgtchTTD7c/+Shwiloei7BIzcEXQvYaUzVMTc6yjs2fSpemF76SX0KwiqlNSY5++HV3R1Szkvpg==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/collection": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/signature-pad": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/signature-pad/-/signature-pad-1.24.2.tgz", + "integrity": "sha512-NMb+g6wj06JvW1dES0Rk4Q1PNQQM1Y11MGW+rRX1KMsP8ieH6DdS5BvofOvNhxQ6+/Y8p+XWD0tGxudOTU2j/Q==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2", + "perfect-freehand": "^1.2.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + }, + "perfect-freehand": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.2.tgz", + "integrity": "sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ==" + } + } + }, + "@zag-js/slider": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/slider/-/slider-1.24.2.tgz", + "integrity": "sha512-QxdSIRW96lPT/zJS/1pr2aj1rGujXBZ2ypUyb6JYZizFG55cZtmJqjJaZ81jU251peLuTdhug34C52vAvXyFZA==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/splitter": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/splitter/-/splitter-1.24.2.tgz", + "integrity": "sha512-EQchsOU4cFOFlBZ3Zql0rmAbUumu/mw0CAg7sjNNUPIDJ7NAOpZSwKtsnfT8pDWeOVIAxTLrlALWW6uY2g4UyQ==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/steps": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/steps/-/steps-1.24.2.tgz", + "integrity": "sha512-nunbmsl4WTkZw6955xeWo9D6XORt1ay2tDsUYHyTJ3rUCeh11Itzmwi7IrJTTSeMtbokYgnF5uoYFHj/pSc7fQ==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/store": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/store/-/store-1.24.2.tgz", + "integrity": "sha512-PZViq7LD4y+6iEB/0tvbqywoEccNXGy6jcOGIg5lwdY0CiPulPMeoLFi0+Etx1/Wom398NqlzecPqT4ZbICYMQ==", + "requires": { + "proxy-compare": "3.0.1" + } + }, + "@zag-js/switch": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/switch/-/switch-1.24.2.tgz", + "integrity": "sha512-NRyWy43Npzjwc15flHRB6eVqKPeu7uw4pjdfmTjhj87185ZM/VYDEQHC6G+teXvl+4LW19QV9aDcTAzg1g5bbg==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/focus-visible": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + }, + "@zag-js/focus-visible": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/focus-visible/-/focus-visible-1.24.2.tgz", + "integrity": "sha512-/A8CEy+2w0xCTIbDuCB4nfdBxgVQYP6oaFb1zmYXmf8HWFlSXDxuUXb1oXrA3vaoNG/X8sJJilpacYdV1dVhxg==", + "requires": { + "@zag-js/dom-query": "1.24.2" + } + } + } + }, + "@zag-js/tabs": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/tabs/-/tabs-1.24.2.tgz", + "integrity": "sha512-UCVfUSDlIk+EAL0kgDkWZAj55PSRUXVAhuqqlDnB8t3GO9UsvmxG93US7wEQglpYBydyPqUpDpmpNUs16egd/Q==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/tags-input": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/tags-input/-/tags-input-1.24.2.tgz", + "integrity": "sha512-zl9DffAvHdTY11Qdm4rowG5TMNLCreJ/dOqj4nTxoLV2xYAzVAgejgxKzv0jbB11/GiuT6omcslVcoCh5w/hRw==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/auto-resize": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/interact-outside": "1.24.2", + "@zag-js/live-region": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/timer": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/timer/-/timer-1.24.2.tgz", + "integrity": "sha512-Hp7cMGZ7UE04VtBWprINZFSX578pyoyiHLLIgSPm9pbUkywVmjL14oKmO64fnOqrdwXSkTnumxo8O0X2P4reoA==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/toast": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/toast/-/toast-1.24.2.tgz", + "integrity": "sha512-mH9iVrAr8asJZNNSWsrCSmtCfzn/aC64fVU610B/pHOiDY6HN0ANYmC+TgSZ29a3Jlts1OTZiKsygWyBuJQ3Og==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/toggle": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/toggle/-/toggle-1.24.2.tgz", + "integrity": "sha512-sD8/di9RP4cISkp5tml5qJKTmZSCBOHIP/4gwnm2eVeUaT4LOqqH1dRFZWv/3FIQ719xcIo6LX8we2vKYxYCCw==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/toggle-group": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/toggle-group/-/toggle-group-1.24.2.tgz", + "integrity": "sha512-71idkWYohYONDD/8FSr/VR3mjFrsTGtsJXBEIGEUfQpgorr+11ryaHbKqq2EMMRLnHs+oyzwegW+laOyvoj0bA==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/tooltip": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/tooltip/-/tooltip-1.24.2.tgz", + "integrity": "sha512-GYjoZkCR9UMPutaJa24LnUrZSWnFfXbuiLoUPlp8Xg+HkVSM25zOe7IgiNgvXqf9shp7dE0AaVnXiuDjjdDm1w==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/focus-visible": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + }, + "@zag-js/focus-visible": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/focus-visible/-/focus-visible-1.24.2.tgz", + "integrity": "sha512-/A8CEy+2w0xCTIbDuCB4nfdBxgVQYP6oaFb1zmYXmf8HWFlSXDxuUXb1oXrA3vaoNG/X8sJJilpacYdV1dVhxg==", + "requires": { + "@zag-js/dom-query": "1.24.2" + } + } + } + }, + "@zag-js/tour": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/tour/-/tour-1.24.2.tgz", + "integrity": "sha512-lsgTr+A+/AK1ct6CPRAFNBQy/PypkVtBmQSsPXkTcwdWWvd8lYp6uuyHapXABLLrfqSWQI7rHokhr7QknCN90A==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dismissable": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/focus-trap": "1.24.2", + "@zag-js/interact-outside": "1.24.2", + "@zag-js/popper": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/tree-view": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/tree-view/-/tree-view-1.24.2.tgz", + "integrity": "sha512-96P8uOTLKTwO3RzGd5X9ZEVy9JBrIoM5RptiMrk2yqN97HdzXint59A06whCBe7wlQVkJyJK58R0fOKDxjTQaQ==", + "requires": { + "@zag-js/anatomy": "1.24.2", + "@zag-js/collection": "1.24.2", + "@zag-js/core": "1.24.2", + "@zag-js/dom-query": "1.24.2", + "@zag-js/types": "1.24.2", + "@zag-js/utils": "1.24.2" + }, + "dependencies": { + "@zag-js/dom-query": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.24.2.tgz", + "integrity": "sha512-CrjxXni9S9sxuz64uveHDGsvXcZPuN8ydg5+UFZh0MTXCCpS2nFdSWJ1ZN4uyak+X0CdyIEvvbzdxmEhBi33dQ==", + "requires": { + "@zag-js/types": "1.24.2" + } + } + } + }, + "@zag-js/types": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/types/-/types-1.24.2.tgz", + "integrity": "sha512-sXL8JHx8yrj8qGwCl/EhydaHoCCEfYwbg1rPWcCwqrpkvhic0KEZAJZMUhcU4dLdx+Oajbv2QeFz6Fk5U2Nn5A==", + "requires": { + "csstype": "3.1.3" + } + }, + "@zag-js/utils": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/@zag-js/utils/-/utils-1.24.2.tgz", + "integrity": "sha512-3U8aYXjktpDmQV4M7nAOj7E4x1XSifG7PrbHqJbTYRm7/EPbwCQEEDPckkkWBmj4UrvltbkXPi6nzcP4Qpw5bA==" + }, "abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -67738,6 +70677,11 @@ "integrity": "sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==", "optional": true }, + "lucide-react": { + "version": "0.503.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.503.0.tgz", + "integrity": "sha512-HGGkdlPWQ0vTF8jJ5TdIqhQXZi6uh3LnNgfZ8MHiuxFfX3RZeA79r2MW2tHAZKlAVfoNE8esm3p+O6VkIvpj6w==" + }, "lunr": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", @@ -71788,6 +74732,19 @@ } } }, + "proxy-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz", + "integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==" + }, + "proxy-memoize": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/proxy-memoize/-/proxy-memoize-3.0.1.tgz", + "integrity": "sha512-VDdG/VYtOgdGkWJx7y0o7p+zArSf2383Isci8C+BP3YXgMYDoPd3cCBjw0JdWb6YBb9sFiOPbAADDVTPJnh+9g==", + "requires": { + "proxy-compare": "^3.0.0" + } + }, "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -73874,6 +76831,21 @@ "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", "dev": true }, + "shiki": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.13.0.tgz", + "integrity": "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g==", + "requires": { + "@shikijs/core": "3.13.0", + "@shikijs/engine-javascript": "3.13.0", + "@shikijs/engine-oniguruma": "3.13.0", + "@shikijs/langs": "3.13.0", + "@shikijs/themes": "3.13.0", + "@shikijs/types": "3.13.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, "side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -75168,9 +78140,9 @@ } }, "tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "tunnel-rat": { "version": "0.1.2", @@ -75819,6 +78791,11 @@ } } }, + "uqr": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz", + "integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==" + }, "uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 2e338a295f9..2ba6f77c919 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "license": "MIT", "private": true, "workspaces": [ - "packages/*" + "packages/*", + "examples/dev-*" ], "engines": { "npm": ">=8.2.3", diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 0e954841080..0376a7577ca 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -11,7 +11,10 @@ import type { DOMChildConversion, DOMConversion, DOMConversionFn, + DOMExportOutput, + EditorDOMConfig, ElementFormatType, + Klass, LexicalEditor, LexicalNode, } from 'lexical'; @@ -28,10 +31,14 @@ import { $isRootOrShadowRoot, $isTextNode, ArtificialNode__DO_NOT_USE, + DEFAULT_EDITOR_DOM_CONFIG, + defineExtension, ElementNode, isDocumentFragment, isDOMDocumentNode, isInlineDomNode, + safeCast, + shallowMergeConfig, } from 'lexical'; /** @@ -66,6 +73,10 @@ export function $generateNodesFromDOM( return lexicalNodes; } +function getEditorDOMConfig(editor: LexicalEditor): EditorDOMConfig { + return editor._config.dom || DEFAULT_EDITOR_DOM_CONFIG; +} + export function $generateHtmlFromNodes( editor: LexicalEditor, selection?: BaseSelection | null, @@ -82,10 +93,11 @@ export function $generateHtmlFromNodes( const container = document.createElement('div'); const root = $getRoot(); const topLevelChildren = root.getChildren(); + const domConfig = getEditorDOMConfig(editor); for (let i = 0; i < topLevelChildren.length; i++) { const topLevelNode = topLevelChildren[i]; - $appendNodesToHTML(editor, topLevelNode, container, selection); + $appendNodesToHTML(editor, topLevelNode, container, selection, domConfig); } return container.innerHTML; @@ -96,6 +108,7 @@ function $appendNodesToHTML( currentNode: LexicalNode, parentElement: HTMLElement | DocumentFragment, selection: BaseSelection | null = null, + domConfig: EditorDOMConfig = getEditorDOMConfig(editor), ): boolean { let shouldInclude = selection !== null ? currentNode.isSelected(selection) : true; @@ -112,7 +125,7 @@ function $appendNodesToHTML( target = clone; } const children = $isElementNode(target) ? target.getChildren() : []; - const {element, after} = editor._config.exportDOM(editor, target); + const {element, after} = domConfig.exportDOM(editor, target); if (!element) { return false; @@ -127,6 +140,7 @@ function $appendNodesToHTML( childNode, fragment, selection, + domConfig, ); if ( @@ -373,3 +387,111 @@ function isDomNodeBetweenTwoInlineNodes(node: Node): boolean { isInlineDomNode(node.nextSibling) && isInlineDomNode(node.previousSibling) ); } + +/** @internal @experimental */ +export interface DOMConfig { + overrides: AnyDOMConfigMatch[]; +} +/** @internal @experimental */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyDOMConfigMatch = DOMConfigMatch; + +type NodeMatch = + | Klass + | ((node: LexicalNode) => node is T); + +/** @internal @experimental */ +export interface DOMConfigMatch { + nodes: ('*' | NodeMatch)[]; + createDOM?: ( + editor: LexicalEditor, + node: T, + next: () => HTMLElement, + ) => HTMLElement; + updateDOM?: ( + editor: LexicalEditor, + nextNode: T, + prevNode: T, + dom: HTMLElement, + next: () => boolean, + ) => boolean; + exportDOM?: ( + editor: LexicalEditor, + node: T, + next: () => DOMExportOutput, + ) => DOMExportOutput; +} + +function mergeDOMConfigMatch( + acc: EditorDOMConfig, + match: AnyDOMConfigMatch, +): EditorDOMConfig { + // TODO Consider using a node type map to make this more efficient when + // there are more overrides + const {nodes, createDOM, updateDOM, exportDOM} = match; + const matcher = (node: LexicalNode): boolean => { + for (const predicate of nodes) { + if (predicate === '*') { + return true; + } else if ('getType' in predicate || '$config' in predicate.prototype) { + if (node instanceof predicate) { + return true; + } + } else if (predicate(node)) { + return true; + } + } + return false; + }; + return { + createDOM: createDOM + ? (editor, node) => { + const next = () => acc.createDOM(editor, node); + return matcher(node) ? createDOM(editor, node, next) : next(); + } + : acc.createDOM, + exportDOM: exportDOM + ? (editor, node) => { + const next = () => acc.exportDOM(editor, node); + return matcher(node) ? exportDOM(editor, node, next) : next(); + } + : acc.exportDOM, + updateDOM: updateDOM + ? (editor, nextNode, prevNode, dom) => { + const next = () => acc.updateDOM(editor, nextNode, prevNode, dom); + return matcher(nextNode) + ? updateDOM(editor, nextNode, prevNode, dom, next) + : next(); + } + : acc.updateDOM, + }; +} + +function compileOverrides( + {overrides}: DOMConfig, + defaults: EditorDOMConfig, +): EditorDOMConfig { + // The beginning of the array will be the overrides towards the top + // of the tree so should be higher precedence, so we compose the functions + // from the right + return overrides.reduceRight(mergeDOMConfigMatch, defaults); +} + +/** @internal @experimental */ +export const DOMExtension = defineExtension({ + config: safeCast({ + overrides: [], + }), + init(editorConfig, config) { + const defaults = {...DEFAULT_EDITOR_DOM_CONFIG, ...editorConfig.dom}; + editorConfig.dom = compileOverrides(config, defaults); + }, + mergeConfig(config, partial) { + const merged = shallowMergeConfig(config, partial); + if (partial.overrides) { + merged.overrides = [...merged.overrides, ...partial.overrides]; + } + return merged; + }, + name: '@lexical/html/DOM', +}); diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index 2e250336d82..6dae5e35015 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -146,7 +146,29 @@ type DOMConversionCache = Map< Array<(node: Node) => DOMConversion | null>, >; +export type EditorDOMConfig = { + /** @internal @experimental */ + createDOM: ( + editor: LexicalEditor, + node: T, + ) => HTMLElement; + /** @internal @experimental */ + exportDOM: ( + editor: LexicalEditor, + node: T, + ) => DOMExportOutput; + /** @internal @experimental */ + updateDOM: ( + editor: LexicalEditor, + nextNode: T, + prevNode: T, + dom: HTMLElement, + ) => boolean; +} + export type CreateEditorArgs = { + /** @internal @experimental */ + dom?: Partial; disableEvents?: boolean; editorState?: EditorState; namespace?: string; @@ -1585,6 +1607,7 @@ export interface ExtensionRegisterState getOutput: () => Output; } export interface InitialEditorConfig { + dom?: CreateEditorArgs['dom']; disableEvents?: CreateEditorArgs['disableEvents']; parentEditor?: CreateEditorArgs['parentEditor']; namespace?: CreateEditorArgs['namespace']; diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 7d85b667691..05c40f0c41f 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -189,7 +189,8 @@ export type EditorThemeClasses = { [key: string]: any; }; -export interface EditorConfig extends DOMConfig { +export interface EditorConfig { + dom?: EditorDOMConfig; disableEvents?: boolean; namespace: string; theme: EditorThemeClasses; @@ -215,7 +216,7 @@ export type HTMLConfig = { export type LexicalNodeConfig = Klass | LexicalNodeReplacement; /** @internal @experimental */ -export interface DOMConfig { +export interface EditorDOMConfig { /** @internal @experimental */ createDOM: ( editor: LexicalEditor, @@ -245,7 +246,7 @@ export interface CreateEditorArgs { editable?: boolean; theme?: EditorThemeClasses; html?: HTMLConfig; - dom?: Partial; + dom?: Partial; } export type RegisteredNodes = Map; @@ -517,7 +518,8 @@ function initializeConversionCache( return conversionCache; } -const defaultDOMConfig: DOMConfig = { +/** @internal */ +export const DEFAULT_EDITOR_DOM_CONFIG: EditorDOMConfig = { createDOM: (editor, node) => { return node.createDOM(editor._config, editor); }, @@ -654,10 +656,12 @@ export function createEditor(editorConfig?: CreateEditorArgs): LexicalEditor { registeredNodes, { disableEvents, + dom: { + ...DEFAULT_EDITOR_DOM_CONFIG, + ...(editorConfig && editorConfig.dom), + }, namespace, theme, - ...defaultDOMConfig, - ...(editorConfig && editorConfig.dom), }, onError ? onError : console.error, initializeConversionCache(registeredNodes, html ? html.import : undefined), diff --git a/packages/lexical/src/LexicalNode.ts b/packages/lexical/src/LexicalNode.ts index 9c79e31acfb..07c39d73364 100644 --- a/packages/lexical/src/LexicalNode.ts +++ b/packages/lexical/src/LexicalNode.ts @@ -1504,3 +1504,9 @@ export function insertRangeAfter( currentNode = currentNode.insertAfter(nodeToInsert); } } + +export function $isLexicalNode( + node: null | undefined | LexicalNode, +): node is LexicalNode { + return node instanceof LexicalNode; +} diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index b5842e0dd17..0ed67c134a1 100644 --- a/packages/lexical/src/LexicalReconciler.ts +++ b/packages/lexical/src/LexicalReconciler.ts @@ -7,8 +7,8 @@ */ import type { - DOMConfig, EditorConfig, + EditorDOMConfig, LexicalEditor, MutatedNodes, MutationListeners, @@ -25,6 +25,7 @@ import { $isLineBreakNode, $isRootNode, $isTextNode, + DEFAULT_EDITOR_DOM_CONFIG, } from '.'; import { DOUBLE_LINE_BREAK, @@ -63,8 +64,7 @@ let activePrevNodeMap: NodeMap; let activeNextNodeMap: NodeMap; let activePrevKeyToDOMMap: Map; let mutatedNodes: MutatedNodes; -let activeEditorCreateDOM: DOMConfig['createDOM']; -let activeEditorUpdateDOM: DOMConfig['updateDOM']; +let activeEditorDOMConfig: EditorDOMConfig; function destroyNode(key: NodeKey, parentDOM: null | HTMLElement): void { const node = activePrevNodeMap.get(key); @@ -196,7 +196,7 @@ function $createNode(key: NodeKey, slot: ElementDOMSlot | null): HTMLElement { if (node === undefined) { invariant(false, 'createNode: node does not exist in nodeMap'); } - const dom = activeEditorCreateDOM(activeEditor, node); + const dom = activeEditorDOMConfig.createDOM(activeEditor, node); storeDOMWithKey(key, dom, activeEditor); // This helps preserve the text, and stops spell check tools from @@ -550,7 +550,7 @@ function $reconcileNode( } // Update node. If it returns true, we need to unmount and re-create the node - if (activeEditorUpdateDOM(activeEditor, nextNode, prevNode, dom)) { + if (activeEditorDOMConfig.updateDOM(activeEditor, nextNode, prevNode, dom)) { const replacementDOM = $createNode(key, null); if (parentDOM === null) { @@ -776,8 +776,7 @@ export function $reconcileRoot( treatAllNodesAsDirty = dirtyType === FULL_RECONCILE; activeEditor = editor; activeEditorConfig = editor._config; - activeEditorCreateDOM = editor._config.createDOM; - activeEditorUpdateDOM = editor._config.updateDOM; + activeEditorDOMConfig = editor._config.dom || DEFAULT_EDITOR_DOM_CONFIG; activeEditorNodes = editor._nodes; activeMutationListeners = activeEditor._listeners.mutation; activeDirtyElements = dirtyElements; @@ -813,10 +812,7 @@ export function $reconcileRoot( activePrevKeyToDOMMap = undefined; // @ts-ignore mutatedNodes = undefined; - // @ts-ignore - activeEditorCreateDOM = undefined; - // @ts-ignore - activeEditorUpdateDOM = undefined; + activeEditorDOMConfig = DEFAULT_EDITOR_DOM_CONFIG; return currentMutatedNodes; } diff --git a/packages/lexical/src/extension-core/types.ts b/packages/lexical/src/extension-core/types.ts index 59257084d30..b096146e133 100644 --- a/packages/lexical/src/extension-core/types.ts +++ b/packages/lexical/src/extension-core/types.ts @@ -352,6 +352,10 @@ export interface InitialEditorConfig { * @internal Disable root element events (for internal Meta use) */ disableEvents?: CreateEditorArgs['disableEvents']; + /** + * @internal @experimental + */ + dom?: CreateEditorArgs['dom']; /** * Used when this editor is nested inside of another editor */ diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 95275094a75..c7253a753c1 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -133,9 +133,9 @@ export type { CommandListenerPriority, CommandPayloadType, CreateEditorArgs, - DOMConfig, EditableListener, EditorConfig, + EditorDOMConfig, EditorSetOptions, EditorThemeClasses, EditorThemeClassName, @@ -163,6 +163,7 @@ export { COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_NORMAL, createEditor, + DEFAULT_EDITOR_DOM_CONFIG, } from './LexicalEditor'; export type { EditorState, @@ -193,7 +194,7 @@ export type { StaticNodeConfigRecord, StaticNodeConfigValue, } from './LexicalNode'; -export {buildImportMap} from './LexicalNode'; +export {$isLexicalNode, buildImportMap} from './LexicalNode'; export { $getState, $getStateChange, From 6c73d897f2e1b486a8190820cc6eab6a98e32bda Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 1 Oct 2025 09:44:39 -0700 Subject: [PATCH 03/47] WIP --- packages/lexical-html/src/index.ts | 272 +++++++++++++++++------ packages/lexical/src/LexicalNodeState.ts | 18 ++ packages/lexical/src/index.ts | 1 + 3 files changed, 228 insertions(+), 63 deletions(-) diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 0376a7577ca..145c93aa6f0 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -17,27 +17,33 @@ import type { Klass, LexicalEditor, LexicalNode, + StateConfig, } from 'lexical'; +import { + getExtensionDependencyFromEditor, + LexicalBuilder, +} from '@lexical/extension'; import {$sliceSelectedTextNodeContent} from '@lexical/selection'; import {isBlockDomNode, isHTMLElement} from '@lexical/utils'; import { $cloneWithProperties, $createLineBreakNode, $createParagraphNode, + $getEditor, $getRoot, $isBlockElementNode, $isElementNode, $isRootOrShadowRoot, $isTextNode, ArtificialNode__DO_NOT_USE, + createState, DEFAULT_EDITOR_DOM_CONFIG, defineExtension, ElementNode, isDocumentFragment, isDOMDocumentNode, isInlineDomNode, - safeCast, shallowMergeConfig, } from 'lexical'; @@ -90,17 +96,27 @@ export function $generateHtmlFromNodes( ); } - const container = document.createElement('div'); - const root = $getRoot(); - const topLevelChildren = root.getChildren(); - const domConfig = getEditorDOMConfig(editor); - - for (let i = 0; i < topLevelChildren.length; i++) { - const topLevelNode = topLevelChildren[i]; - $appendNodesToHTML(editor, topLevelNode, container, selection, domConfig); - } + return withDOMContextIfAvailable(editor, [DOMContextExport.pair(true)])( + () => { + const container = document.createElement('div'); + const root = $getRoot(); + const topLevelChildren = root.getChildren(); + const domConfig = getEditorDOMConfig(editor); + + for (let i = 0; i < topLevelChildren.length; i++) { + const topLevelNode = topLevelChildren[i]; + $appendNodesToHTML( + editor, + topLevelNode, + container, + selection, + domConfig, + ); + } - return container.innerHTML; + return container.innerHTML; + }, + ); } function $appendNodesToHTML( @@ -391,7 +407,9 @@ function isDomNodeBetweenTwoInlineNodes(node: Node): boolean { /** @internal @experimental */ export interface DOMConfig { overrides: AnyDOMConfigMatch[]; + contextDefaults: AnyStateConfigPair[]; } + /** @internal @experimental */ // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyDOMConfigMatch = DOMConfigMatch; @@ -407,6 +425,7 @@ export interface DOMConfigMatch { editor: LexicalEditor, node: T, next: () => HTMLElement, + getContextValue: (stateConfig: StateConfig) => V, ) => HTMLElement; updateDOM?: ( editor: LexicalEditor, @@ -414,84 +433,211 @@ export interface DOMConfigMatch { prevNode: T, dom: HTMLElement, next: () => boolean, + getContextValue: (stateConfig: StateConfig) => V, ) => boolean; exportDOM?: ( editor: LexicalEditor, node: T, next: () => DOMExportOutput, + getContextValue: (stateConfig: StateConfig) => V, ) => DOMExportOutput; } -function mergeDOMConfigMatch( - acc: EditorDOMConfig, - match: AnyDOMConfigMatch, -): EditorDOMConfig { - // TODO Consider using a node type map to make this more efficient when - // there are more overrides - const {nodes, createDOM, updateDOM, exportDOM} = match; - const matcher = (node: LexicalNode): boolean => { - for (const predicate of nodes) { - if (predicate === '*') { - return true; - } else if ('getType' in predicate || '$config' in predicate.prototype) { - if (node instanceof predicate) { - return true; - } - } else if (predicate(node)) { - return true; - } - } - return false; - }; - return { - createDOM: createDOM - ? (editor, node) => { - const next = () => acc.createDOM(editor, node); - return matcher(node) ? createDOM(editor, node, next) : next(); - } - : acc.createDOM, - exportDOM: exportDOM - ? (editor, node) => { - const next = () => acc.exportDOM(editor, node); - return matcher(node) ? exportDOM(editor, node, next) : next(); - } - : acc.exportDOM, - updateDOM: updateDOM - ? (editor, nextNode, prevNode, dom) => { - const next = () => acc.updateDOM(editor, nextNode, prevNode, dom); - return matcher(nextNode) - ? updateDOM(editor, nextNode, prevNode, dom, next) - : next(); - } - : acc.updateDOM, - }; -} - function compileOverrides( {overrides}: DOMConfig, defaults: EditorDOMConfig, + contextRef: ContextRef, ): EditorDOMConfig { + function getContextValue(cfg: StateConfig): V { + const rec = contextRef.current; + return rec && cfg.key in rec ? (rec[cfg.key] as V) : cfg.defaultValue; + } + function mergeDOMConfigMatch( + acc: EditorDOMConfig, + match: AnyDOMConfigMatch, + ): EditorDOMConfig { + // TODO Consider using a node type map to make this more efficient when + // there are more overrides + const {nodes, createDOM, updateDOM, exportDOM} = match; + const matcher = (node: LexicalNode): boolean => { + for (const predicate of nodes) { + if (predicate === '*') { + return true; + } else if ('getType' in predicate || '$config' in predicate.prototype) { + if (node instanceof predicate) { + return true; + } + } else if (predicate(node)) { + return true; + } + } + return false; + }; + return { + createDOM: createDOM + ? (editor, node) => { + const next = () => acc.createDOM(editor, node); + return matcher(node) + ? createDOM(editor, node, next, getContextValue) + : next(); + } + : acc.createDOM, + exportDOM: exportDOM + ? (editor, node) => { + const next = () => acc.exportDOM(editor, node); + return matcher(node) + ? exportDOM(editor, node, next, getContextValue) + : next(); + } + : acc.exportDOM, + updateDOM: updateDOM + ? (editor, nextNode, prevNode, dom) => { + const next = () => acc.updateDOM(editor, nextNode, prevNode, dom); + return matcher(nextNode) + ? updateDOM( + editor, + nextNode, + prevNode, + dom, + next, + getContextValue, + ) + : next(); + } + : acc.updateDOM, + }; + } // The beginning of the array will be the overrides towards the top // of the tree so should be higher precedence, so we compose the functions // from the right return overrides.reduceRight(mergeDOMConfigMatch, defaults); } +/** true if this is an export operation ($generateHtmlFromNodes) */ +export const DOMContextExport = createState('@lexical/html/export', { + parse: (v) => !!v, +}); +/** true if the DOM is for or from the clipboard */ +export const DOMContextClipboard = createState('@lexical/html/clipboard', { + parse: (v) => !!v, +}); + +interface ContextRef { + current: ContextRecord; +} + +export type StateConfigPair = readonly [ + StateConfig, + V, +]; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyStateConfigPair = StateConfigPair; + +export interface DOMExtensionOutput { + withContext: (contexts: Iterable) => (f: () => T) => T; + getContextValue: (cfg: StateConfig) => V; +} + +type ContextRecord = Record; + +function setContextDefaults( + ctx: ContextRecord, + pairs: Iterable, +): ContextRecord { + for (const [cfg, value] of pairs) { + ctx[cfg.key] = value; + } + return ctx; +} + +function getDOMExtensionOutputIfAvailable( + editor: LexicalEditor, +): undefined | DOMExtensionOutput { + const builder = LexicalBuilder.maybeFromEditor(editor); + return builder && builder.hasExtensionByName(DOMExtensionName) + ? getExtensionDependencyFromEditor(editor, DOMExtension).output + : undefined; +} + +export function $getDOMContextValue( + cfg: StateConfig, + editor: LexicalEditor = $getEditor(), +): V { + return getExtensionDependencyFromEditor( + editor, + DOMExtension, + ).output.getContextValue(cfg); +} + +/** @internal */ +export function withDOMContextIfAvailable( + editor: LexicalEditor, + cfg: Iterable, +): (f: () => T) => T { + const output = getDOMExtensionOutputIfAvailable(editor); + return output ? output.withContext(cfg) : (f) => f(); +} + +export function $withDOMContext( + cfg: Iterable, + editor = $getEditor(), +): (f: () => T) => T { + return getExtensionDependencyFromEditor( + editor, + DOMExtension, + ).output.withContext(cfg); +} + +const DOMExtensionName = '@lexical/html/DOM'; /** @internal @experimental */ -export const DOMExtension = defineExtension({ - config: safeCast({ +export const DOMExtension = defineExtension< + DOMConfig, + typeof DOMExtensionName, + DOMExtensionOutput, + ContextRef +>({ + build(editor, config, state) { + const contextRef = state.getInitResult(); + function getContextValue(cfg: StateConfig): V { + const {current} = contextRef; + return current && cfg.key in current + ? (current[cfg.key] as V) + : cfg.defaultValue; + } + function withContext(contexts: Iterable) { + const overrides = setContextDefaults({}, contexts); + return (f: () => T): T => { + const prevCurrent = contextRef.current; + contextRef.current = {...prevCurrent, ...overrides}; + try { + return f(); + } finally { + contextRef.current = prevCurrent; + } + }; + } + return {getContextValue, withContext}; + }, + config: { + contextDefaults: [], overrides: [], - }), + }, init(editorConfig, config) { const defaults = {...DEFAULT_EDITOR_DOM_CONFIG, ...editorConfig.dom}; - editorConfig.dom = compileOverrides(config, defaults); + const contextRef: ContextRef = { + current: setContextDefaults({}, config.contextDefaults), + }; + editorConfig.dom = compileOverrides(config, defaults, contextRef); + return contextRef; }, mergeConfig(config, partial) { const merged = shallowMergeConfig(config, partial); - if (partial.overrides) { - merged.overrides = [...merged.overrides, ...partial.overrides]; + for (const k of ['overrides', 'contextDefaults'] as const) { + if (partial[k]) { + (merged[k] as unknown[]) = [...merged[k], ...partial[k]]; + } } return merged; }, - name: '@lexical/html/DOM', + name: DOMExtensionName, }); diff --git a/packages/lexical/src/LexicalNodeState.ts b/packages/lexical/src/LexicalNodeState.ts index eca7bc6ca03..7a0a3a9f4a9 100644 --- a/packages/lexical/src/LexicalNodeState.ts +++ b/packages/lexical/src/LexicalNodeState.ts @@ -225,6 +225,15 @@ export interface StateValueConfig { isEqual?: (a: V, b: V) => boolean; } +/** + * A tuple of a StateConfig and a value, used for contexts outside of + * NodeState such as DOMExtension. + */ +export type StateConfigPair = readonly [ + StateConfig, + V, +]; + /** * The return value of {@link createState}, for use with * {@link $getState} and {@link $setState}. @@ -251,6 +260,7 @@ export class StateConfig { * the `defaultValue`, it will not be serialized to JSON. */ readonly defaultValue: V; + constructor(key: K, stateValueConfig: StateValueConfig) { this.key = key; this.parse = stateValueConfig.parse.bind(stateValueConfig); @@ -262,6 +272,14 @@ export class StateConfig { ); this.defaultValue = this.parse(undefined); } + + /** + * Convenience method to produce a tuple of a StateConfig and a value + * of that StateConfig (skipping the parse step). + */ + pair(value: V): StateConfigPair { + return [this, value]; + } } /** diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index c7253a753c1..b5800330af5 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -206,6 +206,7 @@ export { type NodeStateJSON, type StateConfig, type StateConfigKey, + type StateConfigPair, type StateConfigValue, type StateValueConfig, type StateValueOrUpdater, From f6e049d68d27cc804572c77f105e8e846d378341 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 1 Oct 2025 11:17:05 -0700 Subject: [PATCH 04/47] WIP --- packages/lexical-html/src/index.ts | 53 +++++++++++++++++++----------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 145c93aa6f0..51668e67d2c 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -46,6 +46,7 @@ import { isInlineDomNode, shallowMergeConfig, } from 'lexical'; +import invariant from 'shared/invariant'; /** * How you parse your html string to get a document is left up to you. In the browser you can use the native @@ -85,36 +86,25 @@ function getEditorDOMConfig(editor: LexicalEditor): EditorDOMConfig { export function $generateHtmlFromNodes( editor: LexicalEditor, - selection?: BaseSelection | null, + selection: BaseSelection | null = null, ): string { if ( typeof document === 'undefined' || (typeof window === 'undefined' && typeof global.window === 'undefined') ) { - throw new Error( - 'To use $generateHtmlFromNodes in headless mode please initialize a headless browser implementation such as JSDom before calling this function.', + invariant( + false, + 'To use $generateHtmlFromNodes in headless mode please initialize a headless browser implementation such as JSDom or use withDOM from @lexical/headless/dom before calling this function.', ); } return withDOMContextIfAvailable(editor, [DOMContextExport.pair(true)])( () => { - const container = document.createElement('div'); - const root = $getRoot(); - const topLevelChildren = root.getChildren(); - const domConfig = getEditorDOMConfig(editor); - - for (let i = 0; i < topLevelChildren.length; i++) { - const topLevelNode = topLevelChildren[i]; - $appendNodesToHTML( - editor, - topLevelNode, - container, - selection, - domConfig, - ); - } - - return container.innerHTML; + return INTERNAL_$generateDOMFromNodes( + editor, + selection, + document.createElement('div'), + ).innerHTML; }, ); } @@ -588,6 +578,29 @@ export function $withDOMContext( ).output.withContext(cfg); } +export function $generateDOMFromNodes( + container: T, + selection: null | BaseSelection = null, +): T { + return $withDOMContext([DOMContextExport.pair(true)])(() => + INTERNAL_$generateDOMFromNodes($getEditor(), selection, container), + ); +} + +function INTERNAL_$generateDOMFromNodes< + T extends HTMLElement | DocumentFragment, +>(editor: LexicalEditor, selection: null | BaseSelection, container: T): T { + const root = $getRoot(); + const topLevelChildren = root.getChildren(); + const domConfig = getEditorDOMConfig(editor); + + for (let i = 0; i < topLevelChildren.length; i++) { + const topLevelNode = topLevelChildren[i]; + $appendNodesToHTML(editor, topLevelNode, container, selection, domConfig); + } + return container; +} + const DOMExtensionName = '@lexical/html/DOM'; /** @internal @experimental */ export const DOMExtension = defineExtension< From 375b58b76bf11d780ecbb2576a4fd0aca7cc0b39 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 1 Oct 2025 23:04:46 -0700 Subject: [PATCH 05/47] WIP --- packages/lexical-html/src/index.ts | 165 +++++++++++------------------ 1 file changed, 63 insertions(+), 102 deletions(-) diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 51668e67d2c..0a40aafc192 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -97,16 +97,8 @@ export function $generateHtmlFromNodes( 'To use $generateHtmlFromNodes in headless mode please initialize a headless browser implementation such as JSDom or use withDOM from @lexical/headless/dom before calling this function.', ); } - - return withDOMContextIfAvailable(editor, [DOMContextExport.pair(true)])( - () => { - return INTERNAL_$generateDOMFromNodes( - editor, - selection, - document.createElement('div'), - ).innerHTML; - }, - ); + return $generateDOMFromNodes(document.createElement('div'), selection, editor) + .innerHTML; } function $appendNodesToHTML( @@ -415,7 +407,6 @@ export interface DOMConfigMatch { editor: LexicalEditor, node: T, next: () => HTMLElement, - getContextValue: (stateConfig: StateConfig) => V, ) => HTMLElement; updateDOM?: ( editor: LexicalEditor, @@ -423,25 +414,18 @@ export interface DOMConfigMatch { prevNode: T, dom: HTMLElement, next: () => boolean, - getContextValue: (stateConfig: StateConfig) => V, ) => boolean; exportDOM?: ( editor: LexicalEditor, node: T, next: () => DOMExportOutput, - getContextValue: (stateConfig: StateConfig) => V, ) => DOMExportOutput; } function compileOverrides( {overrides}: DOMConfig, defaults: EditorDOMConfig, - contextRef: ContextRef, ): EditorDOMConfig { - function getContextValue(cfg: StateConfig): V { - const rec = contextRef.current; - return rec && cfg.key in rec ? (rec[cfg.key] as V) : cfg.defaultValue; - } function mergeDOMConfigMatch( acc: EditorDOMConfig, match: AnyDOMConfigMatch, @@ -467,31 +451,20 @@ function compileOverrides( createDOM: createDOM ? (editor, node) => { const next = () => acc.createDOM(editor, node); - return matcher(node) - ? createDOM(editor, node, next, getContextValue) - : next(); + return matcher(node) ? createDOM(editor, node, next) : next(); } : acc.createDOM, exportDOM: exportDOM ? (editor, node) => { const next = () => acc.exportDOM(editor, node); - return matcher(node) - ? exportDOM(editor, node, next, getContextValue) - : next(); + return matcher(node) ? exportDOM(editor, node, next) : next(); } : acc.exportDOM, updateDOM: updateDOM ? (editor, nextNode, prevNode, dom) => { const next = () => acc.updateDOM(editor, nextNode, prevNode, dom); return matcher(nextNode) - ? updateDOM( - editor, - nextNode, - prevNode, - dom, - next, - getContextValue, - ) + ? updateDOM(editor, nextNode, prevNode, dom, next) : next(); } : acc.updateDOM, @@ -512,10 +485,6 @@ export const DOMContextClipboard = createState('@lexical/html/clipboard', { parse: (v) => !!v, }); -interface ContextRef { - current: ContextRecord; -} - export type StateConfigPair = readonly [ StateConfig, V, @@ -524,16 +493,13 @@ export type StateConfigPair = readonly [ export type AnyStateConfigPair = StateConfigPair; export interface DOMExtensionOutput { - withContext: (contexts: Iterable) => (f: () => T) => T; - getContextValue: (cfg: StateConfig) => V; + defaults: ContextRecord; } type ContextRecord = Record; -function setContextDefaults( - ctx: ContextRecord, - pairs: Iterable, -): ContextRecord { +function contextFromPairs(pairs: Iterable): ContextRecord { + const ctx: ContextRecord = {}; for (const [cfg, value] of pairs) { ctx[cfg.key] = value; } @@ -549,57 +515,71 @@ function getDOMExtensionOutputIfAvailable( : undefined; } -export function $getDOMContextValue( +export function getContextValueFromRecord( + context: ContextRecord, cfg: StateConfig, - editor: LexicalEditor = $getEditor(), ): V { - return getExtensionDependencyFromEditor( - editor, - DOMExtension, - ).output.getContextValue(cfg); + return cfg.key in context ? (context[cfg.key] as V) : cfg.defaultValue; } -/** @internal */ -export function withDOMContextIfAvailable( - editor: LexicalEditor, - cfg: Iterable, -): (f: () => T) => T { - const output = getDOMExtensionOutputIfAvailable(editor); - return output ? output.withContext(cfg) : (f) => f(); +export function $getDOMContextValue( + cfg: StateConfig, + editor: LexicalEditor = $getEditor(), +): V { + const context = + activeDOMContext && activeDOMContext.editor === editor + ? activeDOMContext.context + : getExtensionDependencyFromEditor(editor, DOMExtension).output.defaults; + return getContextValueFromRecord(context, cfg); } export function $withDOMContext( cfg: Iterable, editor = $getEditor(), ): (f: () => T) => T { - return getExtensionDependencyFromEditor( - editor, - DOMExtension, - ).output.withContext(cfg); + const updates = contextFromPairs(cfg); + return (f) => { + const prevDOMContext = activeDOMContext; + let context: ContextRecord; + if (prevDOMContext && prevDOMContext.editor === editor) { + context = {...prevDOMContext.context, ...updates}; + } else { + const ext = getDOMExtensionOutputIfAvailable(editor); + context = ext ? {...ext.defaults, ...updates} : updates; + } + try { + activeDOMContext = {context, editor}; + return f(); + } finally { + activeDOMContext = prevDOMContext; + } + }; } export function $generateDOMFromNodes( container: T, selection: null | BaseSelection = null, + editor: LexicalEditor = $getEditor(), ): T { - return $withDOMContext([DOMContextExport.pair(true)])(() => - INTERNAL_$generateDOMFromNodes($getEditor(), selection, container), - ); + return $withDOMContext( + [DOMContextExport.pair(true)], + editor, + )(() => { + const root = $getRoot(); + const topLevelChildren = root.getChildren(); + const domConfig = getEditorDOMConfig(editor); + + for (let i = 0; i < topLevelChildren.length; i++) { + const topLevelNode = topLevelChildren[i]; + $appendNodesToHTML(editor, topLevelNode, container, selection, domConfig); + } + return container; + }); } -function INTERNAL_$generateDOMFromNodes< - T extends HTMLElement | DocumentFragment, ->(editor: LexicalEditor, selection: null | BaseSelection, container: T): T { - const root = $getRoot(); - const topLevelChildren = root.getChildren(); - const domConfig = getEditorDOMConfig(editor); - - for (let i = 0; i < topLevelChildren.length; i++) { - const topLevelNode = topLevelChildren[i]; - $appendNodesToHTML(editor, topLevelNode, container, selection, domConfig); - } - return container; -} +let activeDOMContext: + | undefined + | {editor: LexicalEditor; context: ContextRecord}; const DOMExtensionName = '@lexical/html/DOM'; /** @internal @experimental */ @@ -607,41 +587,22 @@ export const DOMExtension = defineExtension< DOMConfig, typeof DOMExtensionName, DOMExtensionOutput, - ContextRef + void >({ build(editor, config, state) { - const contextRef = state.getInitResult(); - function getContextValue(cfg: StateConfig): V { - const {current} = contextRef; - return current && cfg.key in current - ? (current[cfg.key] as V) - : cfg.defaultValue; - } - function withContext(contexts: Iterable) { - const overrides = setContextDefaults({}, contexts); - return (f: () => T): T => { - const prevCurrent = contextRef.current; - contextRef.current = {...prevCurrent, ...overrides}; - try { - return f(); - } finally { - contextRef.current = prevCurrent; - } - }; - } - return {getContextValue, withContext}; + return { + defaults: contextFromPairs(config.contextDefaults), + }; }, config: { contextDefaults: [], overrides: [], }, init(editorConfig, config) { - const defaults = {...DEFAULT_EDITOR_DOM_CONFIG, ...editorConfig.dom}; - const contextRef: ContextRef = { - current: setContextDefaults({}, config.contextDefaults), - }; - editorConfig.dom = compileOverrides(config, defaults, contextRef); - return contextRef; + editorConfig.dom = compileOverrides(config, { + ...DEFAULT_EDITOR_DOM_CONFIG, + ...editorConfig.dom, + }); }, mergeConfig(config, partial) { const merged = shallowMergeConfig(config, partial); From ddfd1a515c3c11cbe319bea358bc8434eb34df14 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 1 Oct 2025 23:33:02 -0700 Subject: [PATCH 06/47] update-packages --- examples/dev-node-state-style/package.json | 20 ++++++------ package-lock.json | 38 +++++++++++----------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/examples/dev-node-state-style/package.json b/examples/dev-node-state-style/package.json index b3f4de74a12..90e75bf1c0d 100644 --- a/examples/dev-node-state-style/package.json +++ b/examples/dev-node-state-style/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/dev-node-state-style-example", "private": true, - "version": "0.36.1", + "version": "0.36.2", "type": "module", "scripts": { "dev": "vite -c vite.config.monorepo.ts", @@ -11,18 +11,18 @@ }, "dependencies": { "@ark-ui/react": "^5.6.0", - "@lexical/clipboard": "0.36.1", - "@lexical/extension": "0.36.1", - "@lexical/history": "0.36.1", - "@lexical/html": "0.36.1", - "@lexical/react": "0.36.1", - "@lexical/rich-text": "0.36.1", - "@lexical/selection": "0.36.1", - "@lexical/utils": "0.36.1", + "@lexical/clipboard": "0.36.2", + "@lexical/extension": "0.36.2", + "@lexical/history": "0.36.2", + "@lexical/html": "0.36.2", + "@lexical/react": "0.36.2", + "@lexical/rich-text": "0.36.2", + "@lexical/selection": "0.36.2", + "@lexical/utils": "0.36.2", "@shikijs/langs": "^3.3.0", "@shikijs/themes": "^3.3.0", "inline-style-parser": "^0.2.4", - "lexical": "0.36.1", + "lexical": "0.36.2", "lucide-react": "^0.503.0", "prettier": "^3.5.3", "react": "^19.1.0", diff --git a/package-lock.json b/package-lock.json index a486d58862c..794a4ce32be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -114,21 +114,21 @@ }, "examples/dev-node-state-style": { "name": "@lexical/dev-node-state-style-example", - "version": "0.36.1", + "version": "0.36.2", "dependencies": { "@ark-ui/react": "^5.6.0", - "@lexical/clipboard": "0.36.1", - "@lexical/extension": "0.36.1", - "@lexical/history": "0.36.1", - "@lexical/html": "0.36.1", - "@lexical/react": "0.36.1", - "@lexical/rich-text": "0.36.1", - "@lexical/selection": "0.36.1", - "@lexical/utils": "0.36.1", + "@lexical/clipboard": "0.36.2", + "@lexical/extension": "0.36.2", + "@lexical/history": "0.36.2", + "@lexical/html": "0.36.2", + "@lexical/react": "0.36.2", + "@lexical/rich-text": "0.36.2", + "@lexical/selection": "0.36.2", + "@lexical/utils": "0.36.2", "@shikijs/langs": "^3.3.0", "@shikijs/themes": "^3.3.0", "inline-style-parser": "^0.2.4", - "lexical": "0.36.1", + "lexical": "0.36.2", "lucide-react": "^0.503.0", "prettier": "^3.5.3", "react": "^19.1.0", @@ -54158,14 +54158,14 @@ "version": "file:examples/dev-node-state-style", "requires": { "@ark-ui/react": "^5.6.0", - "@lexical/clipboard": "0.36.1", - "@lexical/extension": "0.36.1", - "@lexical/history": "0.36.1", - "@lexical/html": "0.36.1", - "@lexical/react": "0.36.1", - "@lexical/rich-text": "0.36.1", - "@lexical/selection": "0.36.1", - "@lexical/utils": "0.36.1", + "@lexical/clipboard": "0.36.2", + "@lexical/extension": "0.36.2", + "@lexical/history": "0.36.2", + "@lexical/html": "0.36.2", + "@lexical/react": "0.36.2", + "@lexical/rich-text": "0.36.2", + "@lexical/selection": "0.36.2", + "@lexical/utils": "0.36.2", "@shikijs/langs": "^3.3.0", "@shikijs/themes": "^3.3.0", "@types/react": "^19.1.2", @@ -54174,7 +54174,7 @@ "cross-env": "^7.0.3", "csstype": "^3.1.3", "inline-style-parser": "^0.2.4", - "lexical": "0.36.1", + "lexical": "0.36.2", "lucide-react": "^0.503.0", "prettier": "^3.5.3", "react": "^19.1.0", From ee8a2c883af3d7da9d1ef8cf9844541686084d63 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Thu, 2 Oct 2025 11:32:52 -0700 Subject: [PATCH 07/47] add extension to @lexical/html --- package-lock.json | 2 ++ packages/lexical-html/package.json | 1 + 2 files changed, 3 insertions(+) diff --git a/package-lock.json b/package-lock.json index 794a4ce32be..61f4403fa54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47981,6 +47981,7 @@ "version": "0.36.2", "license": "MIT", "dependencies": { + "@lexical/extension": "0.36.2", "@lexical/selection": "0.36.2", "@lexical/utils": "0.36.2", "lexical": "0.36.2" @@ -54284,6 +54285,7 @@ "@lexical/html": { "version": "file:packages/lexical-html", "requires": { + "@lexical/extension": "0.36.2", "@lexical/selection": "0.36.2", "@lexical/utils": "0.36.2", "lexical": "0.36.2" diff --git a/packages/lexical-html/package.json b/packages/lexical-html/package.json index 0d4a946fb9c..15aa15a741d 100644 --- a/packages/lexical-html/package.json +++ b/packages/lexical-html/package.json @@ -17,6 +17,7 @@ "directory": "packages/lexical-html" }, "dependencies": { + "@lexical/extension": "0.36.2", "@lexical/selection": "0.36.2", "@lexical/utils": "0.36.2", "lexical": "0.36.2" From b3414dd91ac91e7ba812af232d6760766b130105 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Thu, 2 Oct 2025 15:35:47 -0700 Subject: [PATCH 08/47] WIP --- packages/lexical-clipboard/package.json | 1 + packages/lexical-clipboard/src/clipboard.ts | 25 +++++++++++++++++++++ packages/lexical-html/src/index.ts | 23 +++++++++++++------ 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/lexical-clipboard/package.json b/packages/lexical-clipboard/package.json index 8297c778f54..513fc06b766 100644 --- a/packages/lexical-clipboard/package.json +++ b/packages/lexical-clipboard/package.json @@ -13,6 +13,7 @@ "main": "LexicalClipboard.js", "types": "index.d.ts", "dependencies": { + "@lexical/extension": "0.36.2", "@lexical/html": "0.36.2", "@lexical/list": "0.36.2", "@lexical/selection": "0.36.2", diff --git a/packages/lexical-clipboard/src/clipboard.ts b/packages/lexical-clipboard/src/clipboard.ts index 7c1cecc99db..ee8af1a024f 100644 --- a/packages/lexical-clipboard/src/clipboard.ts +++ b/packages/lexical-clipboard/src/clipboard.ts @@ -26,10 +26,12 @@ import { BaseSelection, COMMAND_PRIORITY_CRITICAL, COPY_COMMAND, + defineExtension, getDOMSelection, isSelectionWithinEditor, LexicalEditor, LexicalNode, + safeCast, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, SerializedElementNode, SerializedTextNode, @@ -580,6 +582,29 @@ export function $getClipboardDataFromSelection( return clipboardData; } +export interface GetClipboardDataConfig { + $getMimeType: Record< + keyof LexicalClipboardData | (string & {}), + ( + selection: null | BaseSelection, + next: () => null | string, + ) => null | string + >; +} + +export const GetClipboardDataExtension = defineExtension({ + config: safeCast({ + $getMimeType: { + 'application/x-lexical-editor': (sel, next) => + sel ? $getLexicalContent($getEditor(), sel) : next(), + 'text/html': (sel, next) => + sel ? $getHtmlContent($getEditor(), sel) : next(), + 'text/plain': (sel, next) => (sel ? sel.getTextContent() : next()), + }, + }), + name: '@lexical/clipboard/GetClipboardData', +}); + /** * Call setData on the given clipboardData for each MIME type present * in the given data (from {@link $getClipboardDataFromSelection}) diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 0a40aafc192..78aacba6067 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -7,6 +7,7 @@ */ import type { + AnyStateConfig, BaseSelection, DOMChildConversion, DOMConversion, @@ -496,12 +497,19 @@ export interface DOMExtensionOutput { defaults: ContextRecord; } -type ContextRecord = Record; +type ContextRecord = Map; function contextFromPairs(pairs: Iterable): ContextRecord { - const ctx: ContextRecord = {}; - for (const [cfg, value] of pairs) { - ctx[cfg.key] = value; + return new Map(pairs); +} + +function mergeContext( + defaults: ContextRecord, + overrides: ContextRecord | Iterable, +) { + const ctx = new Map(defaults); + for (const [k, v] of overrides) { + ctx.set(k, v); } return ctx; } @@ -519,7 +527,8 @@ export function getContextValueFromRecord( context: ContextRecord, cfg: StateConfig, ): V { - return cfg.key in context ? (context[cfg.key] as V) : cfg.defaultValue; + const v = context.get(cfg); + return v !== undefined || context.has(cfg) ? (v as V) : cfg.defaultValue; } export function $getDOMContextValue( @@ -542,10 +551,10 @@ export function $withDOMContext( const prevDOMContext = activeDOMContext; let context: ContextRecord; if (prevDOMContext && prevDOMContext.editor === editor) { - context = {...prevDOMContext.context, ...updates}; + context = mergeContext(prevDOMContext.context, updates); } else { const ext = getDOMExtensionOutputIfAvailable(editor); - context = ext ? {...ext.defaults, ...updates} : updates; + context = ext ? mergeContext(ext.defaults, updates) : updates; } try { activeDOMContext = {context, editor}; From f3d97f99cd5266febb975b8d5b1945cb9a2ecf7e Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Thu, 2 Oct 2025 17:29:52 -0700 Subject: [PATCH 09/47] hook into clipboard export --- packages/lexical-clipboard/src/clipboard.ts | 160 ++++++++++++++------ 1 file changed, 111 insertions(+), 49 deletions(-) diff --git a/packages/lexical-clipboard/src/clipboard.ts b/packages/lexical-clipboard/src/clipboard.ts index ee8af1a024f..713c1f0b6de 100644 --- a/packages/lexical-clipboard/src/clipboard.ts +++ b/packages/lexical-clipboard/src/clipboard.ts @@ -6,6 +6,10 @@ * */ +import { + getExtensionDependencyFromEditor, + LexicalBuilder, +} from '@lexical/extension'; import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; import {$addNodeStyle, $sliceSelectedTextNodeContent} from '@lexical/selection'; import {objectKlassEquals} from '@lexical/utils'; @@ -35,6 +39,7 @@ import { SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, SerializedElementNode, SerializedTextNode, + shallowMergeConfig, } from 'lexical'; import invariant from 'shared/invariant'; @@ -42,6 +47,7 @@ export interface LexicalClipboardData { 'text/html'?: string | undefined; 'application/x-lexical-editor'?: string | undefined; 'text/plain': string; + [mimeType: string]: string | undefined; } /** @@ -556,55 +562,6 @@ const clipboardDataFunctions = [ ['application/x-lexical-editor', $getLexicalContent], ] as const; -/** - * Serialize the content of the current selection to strings in - * text/plain, text/html, and application/x-lexical-editor (Lexical JSON) - * formats (as available). - * - * @param selection the selection to serialize (defaults to $getSelection()) - * @returns LexicalClipboardData - */ -export function $getClipboardDataFromSelection( - selection: BaseSelection | null = $getSelection(), -): LexicalClipboardData { - const clipboardData: LexicalClipboardData = { - 'text/plain': selection ? selection.getTextContent() : '', - }; - if (selection) { - const editor = $getEditor(); - for (const [mimeType, $editorFn] of clipboardDataFunctions) { - const v = $editorFn(editor, selection); - if (v !== null) { - clipboardData[mimeType] = v; - } - } - } - return clipboardData; -} - -export interface GetClipboardDataConfig { - $getMimeType: Record< - keyof LexicalClipboardData | (string & {}), - ( - selection: null | BaseSelection, - next: () => null | string, - ) => null | string - >; -} - -export const GetClipboardDataExtension = defineExtension({ - config: safeCast({ - $getMimeType: { - 'application/x-lexical-editor': (sel, next) => - sel ? $getLexicalContent($getEditor(), sel) : next(), - 'text/html': (sel, next) => - sel ? $getHtmlContent($getEditor(), sel) : next(), - 'text/plain': (sel, next) => (sel ? sel.getTextContent() : next()), - }, - }), - name: '@lexical/clipboard/GetClipboardData', -}); - /** * Call setData on the given clipboardData for each MIME type present * in the given data (from {@link $getClipboardDataFromSelection}) @@ -628,3 +585,108 @@ export function setLexicalClipboardDataTransfer( } } } + +/** + * Serialize the content of the current selection to strings in + * text/plain, text/html, and application/x-lexical-editor (Lexical JSON) + * formats (as available). + * + * @param selection the selection to serialize (defaults to $getSelection()) + * @returns LexicalClipboardData + */ +export function $getClipboardDataFromSelection( + selection: BaseSelection | null = $getSelection(), +): LexicalClipboardData { + return $getClipboardDataWithConfigFromSelection( + $getExportConfig(), + selection, + ); +} + +export type ExportMimeTypeFunction = ( + selection: null | BaseSelection, + next: () => null | string, +) => null | string; + +export interface GetClipboardDataConfig { + $exportMimeType: ExportMimeTypeConfig; +} + +export type ExportMimeTypeConfig = Record< + keyof LexicalClipboardData | (string & {}), + ExportMimeTypeFunction[] +>; + +function $getExportConfig() { + const editor = $getEditor(); + const builder = LexicalBuilder.maybeFromEditor(editor); + if (builder && builder.hasExtensionByName(GetClipboardDataExtension.name)) { + return getExtensionDependencyFromEditor(editor, GetClipboardDataExtension) + .output; + } + return DEFAULT_EXPORT_MIME_TYPE; +} + +const DEFAULT_EXPORT_MIME_TYPE: ExportMimeTypeConfig = { + 'application/x-lexical-editor': [ + (sel, next) => (sel ? $getLexicalContent($getEditor(), sel) : next()), + ], + 'text/html': [ + (sel, next) => (sel ? $getHtmlContent($getEditor(), sel) : next()), + ], + 'text/plain': [(sel, next) => (sel ? sel.getTextContent() : next())], +}; + +function $getClipboardDataWithConfigFromSelection( + $exportMimeType: ExportMimeTypeConfig, + selection: null | BaseSelection, +): LexicalClipboardData { + const clipboardData: LexicalClipboardData = {'text/plain': ''}; + for (const [k, fns] of Object.entries($exportMimeType)) { + const v = callExportMimeTypeFunctionStack(fns, selection); + if (v !== null) { + clipboardData[k] = v; + } + } + return clipboardData; +} + +function callExportMimeTypeFunctionStack( + fns: ExportMimeTypeFunction[], + selection: null | BaseSelection, +) { + const callAt = (i: number): string | null => + fns[i] ? fns[i](selection, callAt.bind(null, i - 1)) : null; + return callAt(fns.length - 1); +} + +export function $exportMimeTypeFromSelection( + mimeType: keyof ExportMimeTypeConfig, + selection: null | BaseSelection = $getSelection(), +): string | null { + return callExportMimeTypeFunctionStack( + $getExportConfig()[mimeType] || [], + selection, + ); +} + +export const GetClipboardDataExtension = defineExtension({ + build(editor, config, state) { + return config.$exportMimeType; + }, + config: safeCast({ + $exportMimeType: DEFAULT_EXPORT_MIME_TYPE, + }), + mergeConfig(config, partial) { + const merged = shallowMergeConfig(config, partial); + if (partial.$exportMimeType) { + const $exportMimeType = {...config.$exportMimeType}; + for (const [k, v] of Object.entries(partial.$exportMimeType)) { + $exportMimeType[k] = [...$exportMimeType[k], ...v]; + } + merged.$exportMimeType = $exportMimeType; + } + return merged; + }, + name: '@lexical/clipboard/GetClipboardData', +}); From 24485af521e82051b2f626f184ba39bec6198839 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Thu, 2 Oct 2025 22:15:34 -0700 Subject: [PATCH 10/47] add some DOMExtension create/update/export tests --- packages/lexical-html/src/index.ts | 80 +++++++++++++++++++---- packages/lexical/flow/Lexical.js.flow | 6 +- packages/lexical/src/LexicalEditor.ts | 12 ++-- packages/lexical/src/LexicalReconciler.ts | 4 +- 4 files changed, 77 insertions(+), 25 deletions(-) diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 78aacba6067..d8ad84871dd 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -45,6 +45,7 @@ import { isDocumentFragment, isDOMDocumentNode, isInlineDomNode, + RootNode, shallowMergeConfig, } from 'lexical'; import invariant from 'shared/invariant'; @@ -124,7 +125,7 @@ function $appendNodesToHTML( target = clone; } const children = $isElementNode(target) ? target.getChildren() : []; - const {element, after} = domConfig.exportDOM(editor, target); + const {element, after} = domConfig.exportDOM(target, editor); if (!element) { return false; @@ -403,23 +404,23 @@ type NodeMatch = /** @internal @experimental */ export interface DOMConfigMatch { - nodes: ('*' | NodeMatch)[]; + readonly nodes: '*' | readonly NodeMatch[]; createDOM?: ( - editor: LexicalEditor, node: T, next: () => HTMLElement, + editor: LexicalEditor, ) => HTMLElement; updateDOM?: ( - editor: LexicalEditor, nextNode: T, prevNode: T, dom: HTMLElement, next: () => boolean, + editor: LexicalEditor, ) => boolean; exportDOM?: ( - editor: LexicalEditor, node: T, next: () => DOMExportOutput, + editor: LexicalEditor, ) => DOMExportOutput; } @@ -450,22 +451,22 @@ function compileOverrides( }; return { createDOM: createDOM - ? (editor, node) => { - const next = () => acc.createDOM(editor, node); - return matcher(node) ? createDOM(editor, node, next) : next(); + ? (node, editor) => { + const next = () => acc.createDOM(node, editor); + return matcher(node) ? createDOM(node, next, editor) : next(); } : acc.createDOM, exportDOM: exportDOM - ? (editor, node) => { - const next = () => acc.exportDOM(editor, node); - return matcher(node) ? exportDOM(editor, node, next) : next(); + ? (node, editor) => { + const next = () => acc.exportDOM(node, editor); + return matcher(node) ? exportDOM(node, next, editor) : next(); } : acc.exportDOM, updateDOM: updateDOM - ? (editor, nextNode, prevNode, dom) => { - const next = () => acc.updateDOM(editor, nextNode, prevNode, dom); + ? (nextNode, prevNode, dom, editor) => { + const next = () => acc.updateDOM(nextNode, prevNode, dom, editor); return matcher(nextNode) - ? updateDOM(editor, nextNode, prevNode, dom, next) + ? updateDOM(nextNode, prevNode, dom, next, editor) : next(); } : acc.updateDOM, @@ -477,6 +478,11 @@ function compileOverrides( return overrides.reduceRight(mergeDOMConfigMatch, defaults); } +/** true if this is a whole document export operation ($generateDOMFromRoot) */ +export const DOMContextRoot = createState('@lexical/html/root', { + parse: (v) => !!v, +}); + /** true if this is an export operation ($generateHtmlFromNodes) */ export const DOMContextExport = createState('@lexical/html/export', { parse: (v) => !!v, @@ -586,10 +592,44 @@ export function $generateDOMFromNodes( }); } +export function $generateDOMFromRoot( + container: T, + root: LexicalNode = $getRoot(), +): T { + const editor = $getEditor(); + return $withDOMContext( + [DOMContextExport.pair(true), DOMContextRoot.pair(true)], + editor, + )(() => { + const selection = null; + const domConfig = getEditorDOMConfig(editor); + $appendNodesToHTML(editor, root, container, selection, domConfig); + return container; + }); +} + let activeDOMContext: | undefined | {editor: LexicalEditor; context: ContextRecord}; +/** + * @__NO_SIDE_EFFECTS__ + */ +export function domOverride( + nodes: '*', + config: Omit, 'nodes'>, +): DOMConfigMatch; +export function domOverride( + nodes: readonly NodeMatch[], + config: Omit, 'nodes'>, +): DOMConfigMatch; +export function domOverride( + nodes: AnyDOMConfigMatch['nodes'], + config: Omit, +): AnyDOMConfigMatch { + return {...config, nodes}; +} + const DOMExtensionName = '@lexical/html/DOM'; /** @internal @experimental */ export const DOMExtension = defineExtension< @@ -607,6 +647,18 @@ export const DOMExtension = defineExtension< contextDefaults: [], overrides: [], }, + html: { + export: new Map([ + [ + RootNode, + () => { + const element = document.createElement('div'); + element.role = 'textbox'; + return {element}; + }, + ], + ]), + }, init(editorConfig, config) { editorConfig.dom = compileOverrides(config, { ...DEFAULT_EDITOR_DOM_CONFIG, diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index 6dae5e35015..a230383da2e 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -149,20 +149,20 @@ type DOMConversionCache = Map< export type EditorDOMConfig = { /** @internal @experimental */ createDOM: ( - editor: LexicalEditor, node: T, + editor: LexicalEditor, ) => HTMLElement; /** @internal @experimental */ exportDOM: ( - editor: LexicalEditor, node: T, + editor: LexicalEditor, ) => DOMExportOutput; /** @internal @experimental */ updateDOM: ( - editor: LexicalEditor, nextNode: T, prevNode: T, dom: HTMLElement, + editor: LexicalEditor, ) => boolean; } diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 05c40f0c41f..c98c871e090 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -219,20 +219,20 @@ export type LexicalNodeConfig = Klass | LexicalNodeReplacement; export interface EditorDOMConfig { /** @internal @experimental */ createDOM: ( - editor: LexicalEditor, node: T, + editor: LexicalEditor, ) => HTMLElement; /** @internal @experimental */ exportDOM: ( - editor: LexicalEditor, node: T, + editor: LexicalEditor, ) => DOMExportOutput; /** @internal @experimental */ updateDOM: ( - editor: LexicalEditor, nextNode: T, prevNode: T, dom: HTMLElement, + editor: LexicalEditor, ) => boolean; } @@ -520,17 +520,17 @@ function initializeConversionCache( /** @internal */ export const DEFAULT_EDITOR_DOM_CONFIG: EditorDOMConfig = { - createDOM: (editor, node) => { + createDOM: (node, editor) => { return node.createDOM(editor._config, editor); }, - exportDOM: (editor, node) => { + exportDOM: (node, editor) => { const registeredNode = getRegisteredNode(editor, node.getType()); // Use HTMLConfig overrides, if available. return registeredNode && registeredNode.exportDOM !== undefined ? registeredNode.exportDOM(editor, node) : node.exportDOM(editor); }, - updateDOM: (editor, nextNode, prevNode, dom) => { + updateDOM: (nextNode, prevNode, dom, editor) => { return nextNode.updateDOM(prevNode, dom, editor._config); }, }; diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index 0ed67c134a1..3af8399ae58 100644 --- a/packages/lexical/src/LexicalReconciler.ts +++ b/packages/lexical/src/LexicalReconciler.ts @@ -196,7 +196,7 @@ function $createNode(key: NodeKey, slot: ElementDOMSlot | null): HTMLElement { if (node === undefined) { invariant(false, 'createNode: node does not exist in nodeMap'); } - const dom = activeEditorDOMConfig.createDOM(activeEditor, node); + const dom = activeEditorDOMConfig.createDOM(node, activeEditor); storeDOMWithKey(key, dom, activeEditor); // This helps preserve the text, and stops spell check tools from @@ -550,7 +550,7 @@ function $reconcileNode( } // Update node. If it returns true, we need to unmount and re-create the node - if (activeEditorDOMConfig.updateDOM(activeEditor, nextNode, prevNode, dom)) { + if (activeEditorDOMConfig.updateDOM(nextNode, prevNode, dom, activeEditor)) { const replacementDOM = $createNode(key, null); if (parentDOM === null) { From da2de65a3afb5187d6c020d6a5e44e4a4ecfd593 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Thu, 2 Oct 2025 22:17:16 -0700 Subject: [PATCH 11/47] add some DOMExtension create/update/export tests --- .../src/__tests__/unit/DOMExtension.test.ts | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts diff --git a/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts b/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts new file mode 100644 index 00000000000..8bcfeab69f4 --- /dev/null +++ b/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts @@ -0,0 +1,200 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {buildEditorFromExtensions} from '@lexical/extension'; +import { + $generateDOMFromRoot, + $getDOMContextValue, + DOMContextRoot, + DOMExtension, + domOverride, +} from '@lexical/html'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getState, + $getStateChange, + $setState, + configExtension, + createState, + defineExtension, + isHTMLElement, + TextNode, +} from 'lexical'; +import {expectHtmlToBeEqual, html} from 'lexical/src/__tests__/utils'; +import {describe, expect, test} from 'vitest'; + +const idState = createState('id', { + parse: (v) => (typeof v === 'string' ? v : null), +}); + +describe('DOMExtension', () => { + test('can override DOM create + update', () => { + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: () => { + const root = $getRoot(); + $setState(root, idState, 'root').append( + $setState($createParagraphNode(), idState, 'paragraph').append( + $setState($createTextNode('text!'), idState, 'text'), + ), + ); + }, + dependencies: [ + configExtension(DOMExtension, { + overrides: [ + domOverride('*', { + createDOM(node, next) { + const result = next(); + const id = $getState(node, idState); + if (id) { + result.setAttribute('id', id); + } + return result; + }, + updateDOM(nextNode, prevNode, dom, next) { + if (next()) { + return true; + } + const change = $getStateChange(nextNode, prevNode, idState); + if (change) { + const [id] = change; + if (id) { + dom.setAttribute('id', id); + } else { + dom.removeAttribute('id'); + } + } + return false; + }, + }), + ], + }), + ], + name: 'root', + }), + ); + const root = document.createElement('div'); + editor.setRootElement(root); + expect( + editor.read(() => { + expectHtmlToBeEqual( + root.innerHTML, + html` +

+ text! +

+ `, + ); + }), + ); + editor.update( + () => + $getRoot() + .getAllTextNodes() + .forEach((node) => + $setState(node, idState, (prev) => `${prev}-updated`), + ), + {discrete: true}, + ); + // Update works too + expect( + editor.read(() => { + expectHtmlToBeEqual( + root.innerHTML, + html` +

+ text! +

+ `, + ); + }), + ); + editor.update( + () => + $getRoot() + .getAllTextNodes() + .forEach((node) => $setState(node, idState, null)), + {discrete: true}, + ); + expect( + editor.read(() => { + expectHtmlToBeEqual( + root.innerHTML, + html` +

+ text! +

+ `, + ); + }), + ); + }); + test('can override DOM export', () => { + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: () => { + const root = $getRoot(); + $setState(root, idState, 'root').append( + $setState($createParagraphNode(), idState, 'paragraph').append( + $setState($createTextNode('text!'), idState, 'text'), + ), + ); + }, + dependencies: [ + configExtension(DOMExtension, { + overrides: [ + domOverride('*', { + exportDOM(node, next) { + const result = next(); + const id = $getState(node, idState); + if (id && isHTMLElement(result.element)) { + result.element.setAttribute('id', id); + } + return result; + }, + }), + domOverride([TextNode], { + exportDOM(node, next) { + const result = next(); + if ( + $getDOMContextValue(DOMContextRoot) && + isHTMLElement(result.element) && + result.element.style.getPropertyValue('white-space') + ) { + result.element.style.setProperty('white-space', null); + if (result.element.style.cssText === '') { + result.element.removeAttribute('style'); + } + } + return result; + }, + }), + ], + }), + ], + name: 'root', + }), + ); + expect( + editor.read(() => { + expectHtmlToBeEqual( + $generateDOMFromRoot(document.createElement('div')).innerHTML, + html` +
+

+ text! +

+
+ `, + ); + }), + ); + }); +}); From 038bc203aeafc6c1a071a2029b17ccf81eb51310 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 3 Oct 2025 11:48:13 -0700 Subject: [PATCH 12/47] $prefixes and more hooks --- .../dev-node-state-style/src/styleState.ts | 22 +- .../src/__tests__/unit/DOMExtension.test.ts | 16 +- packages/lexical-html/src/index.ts | 191 +++++++++++++----- .../lexical-selection/src/lexical-node.ts | 2 +- packages/lexical/src/LexicalEditor.ts | 50 ++++- packages/lexical/src/LexicalNode.ts | 6 +- packages/lexical/src/LexicalReconciler.ts | 4 +- 7 files changed, 207 insertions(+), 84 deletions(-) diff --git a/examples/dev-node-state-style/src/styleState.ts b/examples/dev-node-state-style/src/styleState.ts index e125bae0aed..987ffbe2bd6 100644 --- a/examples/dev-node-state-style/src/styleState.ts +++ b/examples/dev-node-state-style/src/styleState.ts @@ -8,7 +8,7 @@ import type {PropertiesHyphenFallback} from 'csstype'; -import {DOMExtension} from '@lexical/html'; +import {DOMExtension, domOverride} from '@lexical/html'; import {$forEachSelectedTextNode} from '@lexical/selection'; import InlineStyleParser from 'inline-style-parser'; import { @@ -381,16 +381,16 @@ export const StyleStateExtension = defineExtension({ dependencies: [ configExtension(DOMExtension, { overrides: [ - { - createDOM(_editor, node, next) { - const dom: HTMLElementWithManagedStyle = next(); + domOverride('*', { + $createDOM(node, $next) { + const dom: HTMLElementWithManagedStyle = $next(); const nextStyleObject = $getStyleObject(node); dom[PREV_STYLE_STATE] = nextStyleObject; applyStyle(dom, nextStyleObject); return dom; }, - exportDOM(_editor, node, next) { - const output = next(); + $exportDOM(node, $next) { + const output = $next(); const style = $getStyleObject(node); if (output.element && style !== NO_STYLE) { return { @@ -408,15 +408,13 @@ export const StyleStateExtension = defineExtension({ } return output; }, - nodes: ['*'], - updateDOM( - _editor, + $updateDOM( nextNode, prevNode, dom: HTMLElementWithManagedStyle, - next, + $next, ) { - if (next()) { + if ($next()) { return true; } const prevStyleObject = getPreviousStyleObject( @@ -429,7 +427,7 @@ export const StyleStateExtension = defineExtension({ applyStyle(dom, diffStyleObjects(prevStyleObject, nextStyleObject)); return false; }, - }, + }), ], }), ], diff --git a/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts b/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts index 8bcfeab69f4..97a2c3ec86a 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts @@ -50,16 +50,16 @@ describe('DOMExtension', () => { configExtension(DOMExtension, { overrides: [ domOverride('*', { - createDOM(node, next) { - const result = next(); + $createDOM(node, $next) { + const result = $next(); const id = $getState(node, idState); if (id) { result.setAttribute('id', id); } return result; }, - updateDOM(nextNode, prevNode, dom, next) { - if (next()) { + $updateDOM(nextNode, prevNode, dom, $next) { + if ($next()) { return true; } const change = $getStateChange(nextNode, prevNode, idState); @@ -151,8 +151,8 @@ describe('DOMExtension', () => { configExtension(DOMExtension, { overrides: [ domOverride('*', { - exportDOM(node, next) { - const result = next(); + $exportDOM(node, $next) { + const result = $next(); const id = $getState(node, idState); if (id && isHTMLElement(result.element)) { result.element.setAttribute('id', id); @@ -161,8 +161,8 @@ describe('DOMExtension', () => { }, }), domOverride([TextNode], { - exportDOM(node, next) { - const result = next(); + $exportDOM(node, $next) { + const result = $next(); if ( $getDOMContextValue(DOMContextRoot) && isHTMLElement(result.element) && diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index d8ad84871dd..139da448077 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -106,48 +106,63 @@ export function $generateHtmlFromNodes( function $appendNodesToHTML( editor: LexicalEditor, currentNode: LexicalNode, - parentElement: HTMLElement | DocumentFragment, + parentElementAppend: (element: Node) => void, selection: BaseSelection | null = null, domConfig: EditorDOMConfig = getEditorDOMConfig(editor), ): boolean { - let shouldInclude = - selection !== null ? currentNode.isSelected(selection) : true; - const shouldExclude = - $isElementNode(currentNode) && currentNode.excludeFromCopy('html'); + let shouldInclude = domConfig.$shouldInclude(currentNode, selection, editor); + const shouldExclude = domConfig.$shouldExclude( + currentNode, + selection, + editor, + ); let target = currentNode; - if (selection !== null) { - let clone = $cloneWithProperties(currentNode); - clone = - $isTextNode(clone) && selection !== null - ? $sliceSelectedTextNodeContent(selection, clone) - : clone; - target = clone; + if (selection && $isTextNode(currentNode)) { + for (const pt of selection.getStartEndPoints() || []) { + if (pt.key === currentNode.getKey()) { + target = $sliceSelectedTextNodeContent( + selection, + $cloneWithProperties(currentNode), + ); + break; + } + } } - const children = $isElementNode(target) ? target.getChildren() : []; - const {element, after} = domConfig.exportDOM(target, editor); + const exportProps = domConfig.$exportDOM(target, editor); + const {element, after, append, $getChildNodes} = exportProps; if (!element) { return false; } const fragment = document.createDocumentFragment(); - - for (let i = 0; i < children.length; i++) { - const childNode = children[i]; + const children = $getChildNodes + ? $getChildNodes() + : $isElementNode(target) + ? target.getChildren() + : []; + + const fragmentAppend = fragment.append.bind(fragment); + for (const childNode of children) { const shouldIncludeChild = $appendNodesToHTML( editor, childNode, - fragment, + fragmentAppend, selection, domConfig, ); if ( !shouldInclude && - $isElementNode(currentNode) && shouldIncludeChild && - currentNode.extractWithChild(childNode, selection, 'html') + domConfig.$extractWithChild( + currentNode, + childNode, + selection, + 'html', + editor, + ) ) { shouldInclude = true; } @@ -155,9 +170,13 @@ function $appendNodesToHTML( if (shouldInclude && !shouldExclude) { if (isHTMLElement(element) || isDocumentFragment(element)) { - element.append(fragment); + if (append) { + append(fragment); + } else { + element.append(fragment); + } } - parentElement.append(element); + parentElementAppend(element); if (after) { const newElement = after.call(target, element); @@ -170,7 +189,7 @@ function $appendNodesToHTML( } } } else { - parentElement.append(fragment); + parentElementAppend(fragment); } return shouldInclude; @@ -405,23 +424,43 @@ type NodeMatch = /** @internal @experimental */ export interface DOMConfigMatch { readonly nodes: '*' | readonly NodeMatch[]; - createDOM?: ( + $createDOM?: ( node: T, - next: () => HTMLElement, + $next: () => HTMLElement, editor: LexicalEditor, ) => HTMLElement; - updateDOM?: ( + $updateDOM?: ( nextNode: T, prevNode: T, dom: HTMLElement, - next: () => boolean, + $next: () => boolean, editor: LexicalEditor, ) => boolean; - exportDOM?: ( + $exportDOM?: ( node: T, - next: () => DOMExportOutput, + $next: () => DOMExportOutput, editor: LexicalEditor, ) => DOMExportOutput; + $shouldExclude?: ( + node: T, + selection: null | BaseSelection, + $next: () => boolean, + editor: LexicalEditor, + ) => boolean; + $shouldInclude?: ( + node: T, + selection: null | BaseSelection, + $next: () => boolean, + editor: LexicalEditor, + ) => boolean; + $extractWithChild?: ( + node: T, + childNode: LexicalNode, + selection: null | BaseSelection, + destination: 'clone' | 'html', + $next: () => boolean, + editor: LexicalEditor, + ) => boolean; } function compileOverrides( @@ -434,7 +473,15 @@ function compileOverrides( ): EditorDOMConfig { // TODO Consider using a node type map to make this more efficient when // there are more overrides - const {nodes, createDOM, updateDOM, exportDOM} = match; + const { + nodes, + $createDOM, + $updateDOM, + $exportDOM, + $shouldExclude, + $shouldInclude, + $extractWithChild, + } = match; const matcher = (node: LexicalNode): boolean => { for (const predicate of nodes) { if (predicate === '*') { @@ -450,26 +497,64 @@ function compileOverrides( return false; }; return { - createDOM: createDOM + $createDOM: $createDOM ? (node, editor) => { - const next = () => acc.createDOM(node, editor); - return matcher(node) ? createDOM(node, next, editor) : next(); + const $next = () => acc.$createDOM(node, editor); + return matcher(node) ? $createDOM(node, $next, editor) : $next(); } - : acc.createDOM, - exportDOM: exportDOM + : acc.$createDOM, + $exportDOM: $exportDOM ? (node, editor) => { - const next = () => acc.exportDOM(node, editor); - return matcher(node) ? exportDOM(node, next, editor) : next(); + const $next = () => acc.$exportDOM(node, editor); + return matcher(node) ? $exportDOM(node, $next, editor) : $next(); } - : acc.exportDOM, - updateDOM: updateDOM + : acc.$exportDOM, + $extractWithChild: $extractWithChild + ? (node, childNode, selection, destination, editor) => { + const $next = () => + acc.$extractWithChild( + node, + childNode, + selection, + destination, + editor, + ); + return matcher(node) + ? $extractWithChild( + node, + childNode, + selection, + destination, + $next, + editor, + ) + : $next(); + } + : acc.$extractWithChild, + $shouldExclude: $shouldExclude + ? (node, selection, editor) => { + const $next = () => acc.$shouldExclude(node, selection, editor); + return matcher(node) + ? $shouldExclude(node, selection, $next, editor) + : $next(); + } + : acc.$shouldExclude, + $shouldInclude: $shouldInclude + ? (node, selection, editor) => { + const $next = () => acc.$shouldInclude(node, selection, editor); + return matcher(node) + ? $shouldInclude(node, selection, $next, editor) + : $next(); + } + : acc.$shouldInclude, + $updateDOM: $updateDOM ? (nextNode, prevNode, dom, editor) => { - const next = () => acc.updateDOM(nextNode, prevNode, dom, editor); + const $next = () => acc.$updateDOM(nextNode, prevNode, dom, editor); return matcher(nextNode) - ? updateDOM(nextNode, prevNode, dom, next, editor) - : next(); + ? $updateDOM(nextNode, prevNode, dom, $next, editor) + : $next(); } - : acc.updateDOM, + : acc.$updateDOM, }; } // The beginning of the array will be the overrides towards the top @@ -581,12 +666,17 @@ export function $generateDOMFromNodes( editor, )(() => { const root = $getRoot(); - const topLevelChildren = root.getChildren(); const domConfig = getEditorDOMConfig(editor); - for (let i = 0; i < topLevelChildren.length; i++) { - const topLevelNode = topLevelChildren[i]; - $appendNodesToHTML(editor, topLevelNode, container, selection, domConfig); + const parentElementAppend = container.append.bind(container); + for (const topLevelNode of root.getChildren()) { + $appendNodesToHTML( + editor, + topLevelNode, + parentElementAppend, + selection, + domConfig, + ); } return container; }); @@ -603,7 +693,8 @@ export function $generateDOMFromRoot( )(() => { const selection = null; const domConfig = getEditorDOMConfig(editor); - $appendNodesToHTML(editor, root, container, selection, domConfig); + const parentElementAppend = container.append.bind(container); + $appendNodesToHTML(editor, root, parentElementAppend, selection, domConfig); return container; }); } @@ -613,6 +704,9 @@ let activeDOMContext: | {editor: LexicalEditor; context: ContextRecord}; /** + * A convenience function for type inference when constructing DOM overrides for + * use with {@link DOMExtension}. + * * @__NO_SIDE_EFFECTS__ */ export function domOverride( @@ -648,6 +742,7 @@ export const DOMExtension = defineExtension< overrides: [], }, html: { + // Define a RootNode export for $generateDOMFromRoot export: new Map([ [ RootNode, diff --git a/packages/lexical-selection/src/lexical-node.ts b/packages/lexical-selection/src/lexical-node.ts index 14a8d6542ef..98b0389f1fb 100644 --- a/packages/lexical-selection/src/lexical-node.ts +++ b/packages/lexical-selection/src/lexical-node.ts @@ -45,7 +45,7 @@ import { export function $sliceSelectedTextNodeContent( selection: BaseSelection, textNode: TextNode, -): LexicalNode { +): TextNode { const anchorAndFocus = selection.getStartEndPoints(); if ( textNode.isSelected(selection) && diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index c98c871e090..a692386fbf9 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -17,7 +17,13 @@ import type { import invariant from 'shared/invariant'; -import {$getRoot, $getSelection, TextNode} from '.'; +import { + $getRoot, + $getSelection, + $isElementNode, + BaseSelection, + TextNode, +} from '.'; import {FULL_RECONCILE, NO_DIRTY_NODES} from './LexicalConstants'; import {cloneEditorState, createEmptyEditorState} from './LexicalEditorState'; import {addRootElementEvents, removeRootElementEvents} from './LexicalEvents'; @@ -218,22 +224,40 @@ export type LexicalNodeConfig = Klass | LexicalNodeReplacement; /** @internal @experimental */ export interface EditorDOMConfig { /** @internal @experimental */ - createDOM: ( + $createDOM: ( node: T, editor: LexicalEditor, ) => HTMLElement; /** @internal @experimental */ - exportDOM: ( + $exportDOM: ( node: T, editor: LexicalEditor, ) => DOMExportOutput; /** @internal @experimental */ - updateDOM: ( + $extractWithChild: ( + node: T, + childNode: LexicalNode, + selection: null | BaseSelection, + destination: 'clone' | 'html', + editor: LexicalEditor, + ) => boolean; + /** @internal @experimental */ + $updateDOM: ( nextNode: T, prevNode: T, dom: HTMLElement, editor: LexicalEditor, ) => boolean; + $shouldInclude: ( + node: T, + selection: null | BaseSelection, + editor: LexicalEditor, + ) => boolean; + $shouldExclude: ( + node: T, + selection: null | BaseSelection, + editor: LexicalEditor, + ) => boolean; } export interface CreateEditorArgs { @@ -520,19 +544,23 @@ function initializeConversionCache( /** @internal */ export const DEFAULT_EDITOR_DOM_CONFIG: EditorDOMConfig = { - createDOM: (node, editor) => { - return node.createDOM(editor._config, editor); - }, - exportDOM: (node, editor) => { + $createDOM: (node, editor) => node.createDOM(editor._config, editor), + $exportDOM: (node, editor) => { const registeredNode = getRegisteredNode(editor, node.getType()); // Use HTMLConfig overrides, if available. return registeredNode && registeredNode.exportDOM !== undefined ? registeredNode.exportDOM(editor, node) : node.exportDOM(editor); }, - updateDOM: (nextNode, prevNode, dom, editor) => { - return nextNode.updateDOM(prevNode, dom, editor._config); - }, + $extractWithChild: (node, childNode, selection, destination, _editor) => + $isElementNode(node) && + node.extractWithChild(childNode, selection, destination), + $shouldExclude: (node, _selection, _editor) => + $isElementNode(node) && node.excludeFromCopy('html'), + $shouldInclude: (node, selection, _editor) => + selection ? node.isSelected(selection) : true, + $updateDOM: (nextNode, prevNode, dom, editor) => + nextNode.updateDOM(prevNode, dom, editor._config), }; /** diff --git a/packages/lexical/src/LexicalNode.ts b/packages/lexical/src/LexicalNode.ts index 07c39d73364..cac4e3ef0ee 100644 --- a/packages/lexical/src/LexicalNode.ts +++ b/packages/lexical/src/LexicalNode.ts @@ -372,12 +372,14 @@ export type DOMExportOutputMap = Map< (editor: LexicalEditor, target: LexicalNode) => DOMExportOutput >; -export type DOMExportOutput = { +export interface DOMExportOutput { after?: ( generatedElement: HTMLElement | DocumentFragment | Text | null | undefined, ) => HTMLElement | DocumentFragment | Text | null | undefined; element: HTMLElement | DocumentFragment | Text | null; -}; + append?: (element: HTMLElement | DocumentFragment | Text) => void; + $getChildNodes?: () => Iterable; +} export type NodeKey = string; diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index 3af8399ae58..c6328a1a2d0 100644 --- a/packages/lexical/src/LexicalReconciler.ts +++ b/packages/lexical/src/LexicalReconciler.ts @@ -196,7 +196,7 @@ function $createNode(key: NodeKey, slot: ElementDOMSlot | null): HTMLElement { if (node === undefined) { invariant(false, 'createNode: node does not exist in nodeMap'); } - const dom = activeEditorDOMConfig.createDOM(node, activeEditor); + const dom = activeEditorDOMConfig.$createDOM(node, activeEditor); storeDOMWithKey(key, dom, activeEditor); // This helps preserve the text, and stops spell check tools from @@ -550,7 +550,7 @@ function $reconcileNode( } // Update node. If it returns true, we need to unmount and re-create the node - if (activeEditorDOMConfig.updateDOM(nextNode, prevNode, dom, activeEditor)) { + if (activeEditorDOMConfig.$updateDOM(nextNode, prevNode, dom, activeEditor)) { const replacementDOM = $createNode(key, null); if (parentDOM === null) { From e7fdfc926ac366d954f0264572e23a5b07f84e50 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 3 Oct 2025 17:01:52 -0700 Subject: [PATCH 13/47] use after only if necessary --- .../dev-node-state-style/src/styleState.ts | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/examples/dev-node-state-style/src/styleState.ts b/examples/dev-node-state-style/src/styleState.ts index 987ffbe2bd6..8efcf1d7610 100644 --- a/examples/dev-node-state-style/src/styleState.ts +++ b/examples/dev-node-state-style/src/styleState.ts @@ -393,18 +393,22 @@ export const StyleStateExtension = defineExtension({ const output = $next(); const style = $getStyleObject(node); if (output.element && style !== NO_STYLE) { - return { - ...output, - after: (generatedElement) => { - const el = output.after - ? output.after(generatedElement) - : generatedElement; - if (isHTMLElement(el)) { - applyStyle(el, style); - } - return el; - }, - }; + if (output.after) { + return { + ...output, + after: (generatedElement) => { + const el = output.after + ? output.after(generatedElement) + : generatedElement; + if (isHTMLElement(el)) { + applyStyle(el, style); + } + return el; + }, + }; + } else if (isHTMLElement(output.element)) { + applyStyle(output.element, style); + } } return output; }, From c70706c8704bbbac4fc42a1b590d03ea6b0ad737 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 6 Oct 2025 13:33:26 -0700 Subject: [PATCH 14/47] $getDOMSlot --- .../package-lock.json | 41 ++++++++----------- package-lock.json | 2 + .../lexical-table/src/LexicalTableNode.ts | 4 +- .../src/LexicalTableSelectionHelpers.ts | 31 +++++++++----- packages/lexical-utils/src/markSelection.ts | 13 +++--- packages/lexical/src/LexicalEditor.ts | 9 ++++ packages/lexical/src/LexicalReconciler.ts | 24 ++++++++--- packages/lexical/src/LexicalSelection.ts | 6 ++- packages/lexical/src/LexicalUtils.ts | 11 +++++ packages/lexical/src/index.ts | 1 + .../lexical/src/nodes/LexicalElementNode.ts | 3 +- 11 files changed, 94 insertions(+), 51 deletions(-) diff --git a/examples/extension-vanilla-tailwind/package-lock.json b/examples/extension-vanilla-tailwind/package-lock.json index 5112a81e166..01d21c878d7 100644 --- a/examples/extension-vanilla-tailwind/package-lock.json +++ b/examples/extension-vanilla-tailwind/package-lock.json @@ -824,6 +824,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/minimatch": { "version": "3.1.2", "dev": true, @@ -928,11 +941,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -1153,17 +1168,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "dev": true, @@ -1310,17 +1314,6 @@ } } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/which": { "version": "2.0.2", "dev": true, diff --git a/package-lock.json b/package-lock.json index 61f4403fa54..f8910bd51f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47817,6 +47817,7 @@ "version": "0.36.2", "license": "MIT", "dependencies": { + "@lexical/extension": "0.36.2", "@lexical/html": "0.36.2", "@lexical/list": "0.36.2", "@lexical/selection": "0.36.2", @@ -54125,6 +54126,7 @@ "@lexical/clipboard": { "version": "file:packages/lexical-clipboard", "requires": { + "@lexical/extension": "0.36.2", "@lexical/html": "0.36.2", "@lexical/list": "0.36.2", "@lexical/selection": "0.36.2", diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts index 41b4d23c915..1485fc36509 100644 --- a/packages/lexical-table/src/LexicalTableNode.ts +++ b/packages/lexical-table/src/LexicalTableNode.ts @@ -40,6 +40,7 @@ import {TableDOMCell, TableDOMTable} from './LexicalTableObserver'; import {$isTableRowNode, type TableRowNode} from './LexicalTableRowNode'; import { $getNearestTableCellInTableFromDOMNode, + $getTableElement, getTable, isHTMLTableElement, } from './LexicalTableSelectionHelpers'; @@ -338,8 +339,7 @@ export class TableNode extends ElementNode { } updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { - const slot = this.getDOMSlot(dom); - const tableElement = slot.element; + const tableElement = $getTableElement(this, dom); if ((dom === tableElement) === $isScrollableTablesActive()) { return true; } diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index 6c591e1e88e..a90233a4a4b 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -46,6 +46,7 @@ import { $extendCaretToRange, $getAdjacentChildCaret, $getChildCaret, + $getEditorDOMConfig, $getNearestNodeFromDOMNode, $getPreviousSelection, $getSelection, @@ -121,7 +122,7 @@ export function isHTMLTableElement(el: unknown): el is HTMLTableElement { return isHTMLElement(el) && el.nodeName === 'TABLE'; } -export function getTableElement( +export function $getTableElement( tableNode: TableNode, dom: T, ): HTMLTableElementWithWithTableSelectionState | (T & null) { @@ -129,7 +130,9 @@ export function getTableElement( return dom as T & null; } const element = ( - isHTMLTableElement(dom) ? dom : tableNode.getDOMSlot(dom).element + isHTMLTableElement(dom) + ? dom + : $getEditorDOMConfig().$getDOMSlot(tableNode, dom).element ) as HTMLTableElementWithWithTableSelectionState; invariant( element.nodeName === 'TABLE', @@ -138,6 +141,8 @@ export function getTableElement( ); return element; } +/** @deprecated renamed to {@link $getTableElement} by @lexical/eslint-plugin rules-of-lexical */ +export const getTableElement = $getTableElement; export function getEditorWindow(editor: LexicalEditor): Window | null { return editor._window; @@ -177,7 +182,7 @@ const DELETE_KEY_COMMANDS = [ KEY_DELETE_COMMAND, ] as const; -export function applyTableHandlers( +export function $applyTableHandlers( tableNode: TableNode, element: HTMLElement, editor: LexicalEditor, @@ -192,7 +197,7 @@ export function applyTableHandlers( const tableObserver = new TableObserver(editor, tableNode.getKey()); - const tableElement = getTableElement(tableNode, element); + const tableElement = $getTableElement(tableNode, element); attachTableObserverToTableElement(tableElement, tableObserver); tableObserver.listenersToRemove.add(() => detachTableObserverFromTableElement(tableElement, tableObserver), @@ -1243,6 +1248,8 @@ export function applyTableHandlers( return tableObserver; } +/** @deprecated renamed to {@link $applyTableHandlers} by @lexical/eslint-plugin rules-of-lexical */ +export const applyTableHandlers = $applyTableHandlers; export type HTMLTableElementWithWithTableSelectionState = HTMLTableElement & { [LEXICAL_ELEMENT_KEY]?: TableObserver | undefined; @@ -1335,11 +1342,11 @@ export function doesTargetContainText(node: Node): boolean { return false; } -export function getTable( +export function $getTable( tableNode: TableNode, dom: HTMLElement, ): TableDOMTable { - const tableElement = getTableElement(tableNode, dom); + const tableElement = $getTableElement(tableNode, dom); const domRows: TableDOMRows = []; const grid = { columns: 0, @@ -1410,6 +1417,8 @@ export function getTable( return grid; } +/** @deprecated renamed to {@link $getTable} by @lexical/eslint-plugin rules-of-lexical */ +export const getTable = $getTable; export function $updateDOMForSelection( editor: LexicalEditor, @@ -2193,12 +2202,12 @@ function $handleArrowKey( } const anchorCellTable = $findTableNode(anchorCellNode); if (anchorCellTable !== tableNode && anchorCellTable != null) { - const anchorCellTableElement = getTableElement( + const anchorCellTableElement = $getTableElement( anchorCellTable, editor.getElementByKey(anchorCellTable.getKey()), ); if (anchorCellTableElement != null) { - tableObserver.table = getTable( + tableObserver.table = $getTable( anchorCellTable, anchorCellTableElement, ); @@ -2297,7 +2306,7 @@ function $handleArrowKey( $isTableNode(tableNodeFromSelection), '$handleArrowKey: TableSelection.getNodes()[0] expected to be TableNode', ); - const tableElement = getTableElement( + const tableElement = $getTableElement( tableNodeFromSelection, editor.getElementByKey(tableNodeFromSelection.getKey()), ); @@ -2311,7 +2320,7 @@ function $handleArrowKey( } tableObserver.$updateTableTableSelection(selection); - const grid = getTable(tableNodeFromSelection, tableElement); + const grid = $getTable(tableNodeFromSelection, tableElement); const cordsAnchor = tableNode.getCordsFromCellNode(anchorCellNode, grid); const anchorCell = tableNode.getDOMCellFromCordsOrThrow( cordsAnchor.x, @@ -2396,7 +2405,7 @@ function $getTableEdgeCursorPosition( } const domAnchorNode = domSelection.anchorNode; const tableNodeParentDOM = editor.getElementByKey(tableNodeParent.getKey()); - const tableElement = getTableElement( + const tableElement = $getTableElement( tableNode, editor.getElementByKey(tableNode.getKey()), ); diff --git a/packages/lexical-utils/src/markSelection.ts b/packages/lexical-utils/src/markSelection.ts index f1b7242d4e5..a9e9b4f2d68 100644 --- a/packages/lexical-utils/src/markSelection.ts +++ b/packages/lexical-utils/src/markSelection.ts @@ -7,6 +7,7 @@ */ import { + $getEditorDOMConfig, $getSelection, $isElementNode, $isRangeSelection, @@ -28,7 +29,7 @@ function $getOrderedSelectionPoints(selection: RangeSelection): [Point, Point] { return selection.isBackward() ? [points[1], points[0]] : points; } -function rangeTargetFromPoint( +function $rangeTargetFromPoint( point: Point, node: ElementNode | TextNode, dom: HTMLElement, @@ -37,12 +38,12 @@ function rangeTargetFromPoint( const textDOM = getDOMTextNode(dom) || dom; return [textDOM, point.offset]; } else { - const slot = node.getDOMSlot(dom); + const slot = $getEditorDOMConfig().$getDOMSlot(node, dom); return [slot.element, slot.getFirstChildOffset() + point.offset]; } } -function rangeFromPoints( +function $rangeFromPoints( editor: LexicalEditor, start: Point, startNode: ElementNode | TextNode, @@ -53,8 +54,8 @@ function rangeFromPoints( ): Range { const editorDocument = editor._window ? editor._window.document : document; const range = editorDocument.createRange(); - range.setStart(...rangeTargetFromPoint(start, startNode, startDOM)); - range.setEnd(...rangeTargetFromPoint(end, endNode, endDOM)); + range.setStart(...$rangeTargetFromPoint(start, startNode, startDOM)); + range.setEnd(...$rangeTargetFromPoint(end, endNode, endDOM)); return range; } /** @@ -113,7 +114,7 @@ export default function markSelection( currentStartNodeDOM !== null && currentEndNodeDOM !== null ) { - const range = rangeFromPoints( + const range = $rangeFromPoints( editor, start, currentStartNode, diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index a692386fbf9..7ad7ff09975 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -14,6 +14,7 @@ import type { DOMExportOutputMap, NodeKey, } from './LexicalNode'; +import type {ElementDOMSlot, ElementNode} from './nodes/LexicalElementNode'; import invariant from 'shared/invariant'; @@ -229,6 +230,11 @@ export interface EditorDOMConfig { editor: LexicalEditor, ) => HTMLElement; /** @internal @experimental */ + $getDOMSlot: ( + node: T, + dom: HTMLElement, + ) => ElementDOMSlot; + /** @internal @experimental */ $exportDOM: ( node: T, editor: LexicalEditor, @@ -248,11 +254,13 @@ export interface EditorDOMConfig { dom: HTMLElement, editor: LexicalEditor, ) => boolean; + /** @internal @experimental */ $shouldInclude: ( node: T, selection: null | BaseSelection, editor: LexicalEditor, ) => boolean; + /** @internal @experimental */ $shouldExclude: ( node: T, selection: null | BaseSelection, @@ -555,6 +563,7 @@ export const DEFAULT_EDITOR_DOM_CONFIG: EditorDOMConfig = { $extractWithChild: (node, childNode, selection, destination, _editor) => $isElementNode(node) && node.extractWithChild(childNode, selection, destination), + $getDOMSlot: (node, dom) => node.getDOMSlot(dom), $shouldExclude: (node, _selection, _editor) => $isElementNode(node) && node.excludeFromCopy('html'), $shouldInclude: (node, selection, _editor) => diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index c6328a1a2d0..6c84ef33e60 100644 --- a/packages/lexical/src/LexicalReconciler.ts +++ b/packages/lexical/src/LexicalReconciler.ts @@ -218,7 +218,13 @@ function $createNode(key: NodeKey, slot: ElementDOMSlot | null): HTMLElement { if (childrenSize !== 0) { const endIndex = childrenSize - 1; const children = createChildrenArray(node, activeNextNodeMap); - $createChildren(children, node, 0, endIndex, node.getDOMSlot(dom)); + $createChildren( + children, + node, + 0, + endIndex, + activeEditorDOMConfig.$getDOMSlot(node, dom), + ); } const format = node.__format; @@ -226,7 +232,7 @@ function $createNode(key: NodeKey, slot: ElementDOMSlot | null): HTMLElement { setElementFormat(dom, format); } if (!node.isInline()) { - reconcileElementTerminatingLineBreak(null, node, dom); + $reconcileElementTerminatingLineBreak(null, node, dom); } if ($textContentRequiresDoubleLinebreakAtEnd(node)) { subTreeTextContent += DOUBLE_LINE_BREAK; @@ -321,7 +327,7 @@ function isLastChildLineBreakOrDecorator( } // If we end an element with a LineBreakNode, then we need to add an additional
-function reconcileElementTerminatingLineBreak( +function $reconcileElementTerminatingLineBreak( prevElement: null | ElementNode, nextElement: ElementNode, dom: HTMLElement & LexicalPrivateDOM, @@ -335,7 +341,9 @@ function reconcileElementTerminatingLineBreak( activeNextNodeMap, ); if (prevLineBreak !== nextLineBreak) { - nextElement.getDOMSlot(dom).setManagedLineBreak(nextLineBreak); + activeEditorDOMConfig + .$getDOMSlot(nextElement, dom) + .setManagedLineBreak(nextLineBreak); } } @@ -366,7 +374,11 @@ function $reconcileChildrenWithDirection( ): void { subTreeTextFormat = null; subTreeTextStyle = ''; - $reconcileChildren(prevElement, nextElement, nextElement.getDOMSlot(dom)); + $reconcileChildren( + prevElement, + nextElement, + activeEditorDOMConfig.$getDOMSlot(nextElement, dom), + ); reconcileTextFormat(nextElement); reconcileTextStyle(nextElement); } @@ -577,7 +589,7 @@ function $reconcileNode( if (isDirty) { $reconcileChildrenWithDirection(prevNode, nextNode, dom); if (!$isRootNode(nextNode) && !nextNode.isInline()) { - reconcileElementTerminatingLineBreak(prevNode, nextNode, dom); + $reconcileElementTerminatingLineBreak(prevNode, nextNode, dom); } } diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index f49f9bb1590..6327e2d843e 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -70,6 +70,7 @@ import {SKIP_SELECTION_FOCUS_TAG} from './LexicalUpdateTags'; import { $findMatchingParent, $getCompositionKey, + $getEditorDOMConfig, $getNearestRootOrShadowRoot, $getNodeByKey, $getNodeFromDOM, @@ -2301,7 +2302,10 @@ function $internalResolveSelectionPoint( elementDOM !== null, '$internalResolveSelectionPoint: node in DOM but not keyToDOMMap', ); - const slot = resolvedElement.getDOMSlot(elementDOM); + const slot = $getEditorDOMConfig().$getDOMSlot( + resolvedElement, + elementDOM, + ); [resolvedElement, resolvedOffset] = slot.resolveChildIndex( resolvedElement, elementDOM, diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index d5da4722289..8ff83d323d2 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -9,6 +9,7 @@ import type { CommandPayloadType, EditorConfig, + EditorDOMConfig, EditorThemeClasses, Klass, LexicalCommand, @@ -45,6 +46,7 @@ import { $isTabNode, $isTextNode, DecoratorNode, + DEFAULT_EDITOR_DOM_CONFIG, ElementNode, HISTORY_MERGE_TAG, LineBreakNode, @@ -1869,6 +1871,15 @@ export function $getEditor(): LexicalEditor { return getActiveEditor(); } +/** + * @internal @experimental + */ +export function $getEditorDOMConfig( + editor: LexicalEditor = $getEditor(), +): EditorDOMConfig { + return editor._config.dom || DEFAULT_EDITOR_DOM_CONFIG; +} + /** @internal */ export type TypeToNodeMap = Map; /** diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index b5800330af5..b3546db6df2 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -246,6 +246,7 @@ export { $findMatchingParent, $getAdjacentNode, $getEditor, + $getEditorDOMConfig, $getNearestNodeFromDOMNode, $getNearestRootOrShadowRoot, $getNodeByKey, diff --git a/packages/lexical/src/nodes/LexicalElementNode.ts b/packages/lexical/src/nodes/LexicalElementNode.ts index 61f7dc4765c..de34e0870fd 100644 --- a/packages/lexical/src/nodes/LexicalElementNode.ts +++ b/packages/lexical/src/nodes/LexicalElementNode.ts @@ -44,6 +44,7 @@ import { } from '../LexicalSelection'; import {errorOnReadOnly, getActiveEditor} from '../LexicalUpdates'; import { + $getEditorDOMConfig, $getNodeByKey, $isRootOrShadowRoot, isHTMLElement, @@ -968,7 +969,7 @@ export class ElementNode extends LexicalNode { /** @internal */ reconcileObservedMutation(dom: HTMLElement, editor: LexicalEditor): void { - const slot = this.getDOMSlot(dom); + const slot = $getEditorDOMConfig(editor).$getDOMSlot(this, dom); let currentDOM = slot.getFirstChild(); for ( let currentNode = this.getFirstChild(); From 157e70ad94937f85e29626b9c8fdc4cffb74b7b7 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 6 Oct 2025 13:50:57 -0700 Subject: [PATCH 15/47] $getDOMSlot --- packages/lexical-html/src/index.ts | 15 +++++++++++++++ .../src/LexicalTableSelectionHelpers.ts | 4 +++- packages/lexical-utils/src/markSelection.ts | 4 +++- packages/lexical/src/LexicalEditor.ts | 15 ++++++++++++--- packages/lexical/src/LexicalReconciler.ts | 6 +++--- packages/lexical/src/LexicalSelection.ts | 3 ++- packages/lexical/src/nodes/LexicalElementNode.ts | 2 +- 7 files changed, 39 insertions(+), 10 deletions(-) diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 139da448077..ecb02f49906 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -14,6 +14,7 @@ import type { DOMConversionFn, DOMExportOutput, EditorDOMConfig, + ElementDOMSlot, ElementFormatType, Klass, LexicalEditor, @@ -424,6 +425,11 @@ type NodeMatch = /** @internal @experimental */ export interface DOMConfigMatch { readonly nodes: '*' | readonly NodeMatch[]; + $getDOMSlot?: ( + node: N, + $next: () => ElementDOMSlot, + editor: LexicalEditor, + ) => ElementDOMSlot; $createDOM?: ( node: T, $next: () => HTMLElement, @@ -475,6 +481,7 @@ function compileOverrides( // there are more overrides const { nodes, + $getDOMSlot, $createDOM, $updateDOM, $exportDOM, @@ -531,6 +538,14 @@ function compileOverrides( : $next(); } : acc.$extractWithChild, + $getDOMSlot: $getDOMSlot + ? (node, dom, editor) => { + const $next = () => acc.$getDOMSlot(node, dom, editor); + return $isElementNode(node) && matcher(node) + ? $getDOMSlot(node, $next, editor) + : $next(); + } + : acc.$getDOMSlot, $shouldExclude: $shouldExclude ? (node, selection, editor) => { const $next = () => acc.$shouldExclude(node, selection, editor); diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index a90233a4a4b..e60a29a2fc4 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -46,6 +46,7 @@ import { $extendCaretToRange, $getAdjacentChildCaret, $getChildCaret, + $getEditor, $getEditorDOMConfig, $getNearestNodeFromDOMNode, $getPreviousSelection, @@ -129,10 +130,11 @@ export function $getTableElement( if (!dom) { return dom as T & null; } + const editor = $getEditor(); const element = ( isHTMLTableElement(dom) ? dom - : $getEditorDOMConfig().$getDOMSlot(tableNode, dom).element + : $getEditorDOMConfig(editor).$getDOMSlot(tableNode, dom, editor).element ) as HTMLTableElementWithWithTableSelectionState; invariant( element.nodeName === 'TABLE', diff --git a/packages/lexical-utils/src/markSelection.ts b/packages/lexical-utils/src/markSelection.ts index a9e9b4f2d68..0a4640d9196 100644 --- a/packages/lexical-utils/src/markSelection.ts +++ b/packages/lexical-utils/src/markSelection.ts @@ -7,6 +7,7 @@ */ import { + $getEditor, $getEditorDOMConfig, $getSelection, $isElementNode, @@ -38,7 +39,8 @@ function $rangeTargetFromPoint( const textDOM = getDOMTextNode(dom) || dom; return [textDOM, point.offset]; } else { - const slot = $getEditorDOMConfig().$getDOMSlot(node, dom); + const editor = $getEditor(); + const slot = $getEditorDOMConfig(editor).$getDOMSlot(node, dom, editor); return [slot.element, slot.getFirstChildOffset() + point.offset]; } } diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 7ad7ff09975..e7626cbee52 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -14,7 +14,7 @@ import type { DOMExportOutputMap, NodeKey, } from './LexicalNode'; -import type {ElementDOMSlot, ElementNode} from './nodes/LexicalElementNode'; +import type {ElementDOMSlot} from './nodes/LexicalElementNode'; import invariant from 'shared/invariant'; @@ -230,9 +230,10 @@ export interface EditorDOMConfig { editor: LexicalEditor, ) => HTMLElement; /** @internal @experimental */ - $getDOMSlot: ( + $getDOMSlot: ( node: T, dom: HTMLElement, + editor: LexicalEditor, ) => ElementDOMSlot; /** @internal @experimental */ $exportDOM: ( @@ -563,7 +564,15 @@ export const DEFAULT_EDITOR_DOM_CONFIG: EditorDOMConfig = { $extractWithChild: (node, childNode, selection, destination, _editor) => $isElementNode(node) && node.extractWithChild(childNode, selection, destination), - $getDOMSlot: (node, dom) => node.getDOMSlot(dom), + $getDOMSlot: (node, dom, _editor) => { + invariant( + $isElementNode(node), + '$getDOMSlot called on a non-ElementNode (key %s type %s)', + node.getKey(), + node.getType(), + ); + return node.getDOMSlot(dom); + }, $shouldExclude: (node, _selection, _editor) => $isElementNode(node) && node.excludeFromCopy('html'), $shouldInclude: (node, selection, _editor) => diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index 6c84ef33e60..9002f4a33be 100644 --- a/packages/lexical/src/LexicalReconciler.ts +++ b/packages/lexical/src/LexicalReconciler.ts @@ -223,7 +223,7 @@ function $createNode(key: NodeKey, slot: ElementDOMSlot | null): HTMLElement { node, 0, endIndex, - activeEditorDOMConfig.$getDOMSlot(node, dom), + activeEditorDOMConfig.$getDOMSlot(node, dom, activeEditor), ); } const format = node.__format; @@ -342,7 +342,7 @@ function $reconcileElementTerminatingLineBreak( ); if (prevLineBreak !== nextLineBreak) { activeEditorDOMConfig - .$getDOMSlot(nextElement, dom) + .$getDOMSlot(nextElement, dom, activeEditor) .setManagedLineBreak(nextLineBreak); } } @@ -377,7 +377,7 @@ function $reconcileChildrenWithDirection( $reconcileChildren( prevElement, nextElement, - activeEditorDOMConfig.$getDOMSlot(nextElement, dom), + activeEditorDOMConfig.$getDOMSlot(nextElement, dom, activeEditor), ); reconcileTextFormat(nextElement); reconcileTextStyle(nextElement); diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 6327e2d843e..185cc91ce66 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -2302,9 +2302,10 @@ function $internalResolveSelectionPoint( elementDOM !== null, '$internalResolveSelectionPoint: node in DOM but not keyToDOMMap', ); - const slot = $getEditorDOMConfig().$getDOMSlot( + const slot = $getEditorDOMConfig(editor).$getDOMSlot( resolvedElement, elementDOM, + editor, ); [resolvedElement, resolvedOffset] = slot.resolveChildIndex( resolvedElement, diff --git a/packages/lexical/src/nodes/LexicalElementNode.ts b/packages/lexical/src/nodes/LexicalElementNode.ts index de34e0870fd..6a93e613805 100644 --- a/packages/lexical/src/nodes/LexicalElementNode.ts +++ b/packages/lexical/src/nodes/LexicalElementNode.ts @@ -969,7 +969,7 @@ export class ElementNode extends LexicalNode { /** @internal */ reconcileObservedMutation(dom: HTMLElement, editor: LexicalEditor): void { - const slot = $getEditorDOMConfig(editor).$getDOMSlot(this, dom); + const slot = $getEditorDOMConfig(editor).$getDOMSlot(this, dom, editor); let currentDOM = slot.getFirstChild(); for ( let currentNode = this.getFirstChild(); From 5821b5185645be8f65773bb11a20639a956c945e Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 6 Oct 2025 16:06:58 -0700 Subject: [PATCH 16/47] pass editor to getEditorState().read() --- packages/lexical-link/src/ClickableLinkExtension.ts | 2 +- .../src/plugins/TableActionMenuPlugin/index.tsx | 2 +- packages/lexical-react/src/shared/useCharacterLimit.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/lexical-link/src/ClickableLinkExtension.ts b/packages/lexical-link/src/ClickableLinkExtension.ts index 5c6378bd4f8..88b4f95676e 100644 --- a/packages/lexical-link/src/ClickableLinkExtension.ts +++ b/packages/lexical-link/src/ClickableLinkExtension.ts @@ -86,7 +86,7 @@ export function registerClickableLink( } // Allow user to select link text without following url - const selection = editor.getEditorState().read($getSelection); + const selection = editor.getEditorState().read($getSelection, {editor}); if ($isRangeSelection(selection) && !selection.isCollapsed()) { event.preventDefault(); return; diff --git a/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx b/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx index fcd10a41e29..b4438d70927 100644 --- a/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx @@ -849,7 +849,7 @@ function TableCellActionMenuContainer({ let timeoutId: ReturnType | undefined = undefined; const callback = () => { timeoutId = undefined; - editor.getEditorState().read($moveMenu); + editor.getEditorState().read($moveMenu, {editor}); }; const delayedCallback = () => { if (timeoutId === undefined) { diff --git a/packages/lexical-react/src/shared/useCharacterLimit.ts b/packages/lexical-react/src/shared/useCharacterLimit.ts index ce22b19a7ba..aa8fce62324 100644 --- a/packages/lexical-react/src/shared/useCharacterLimit.ts +++ b/packages/lexical-react/src/shared/useCharacterLimit.ts @@ -62,7 +62,7 @@ export function useCharacterLimit( }, [editor]); useEffect(() => { - let text = editor.getEditorState().read($rootTextContent); + let text = editor.getEditorState().read($rootTextContent, {editor}); let lastComputedTextLength = 0; return mergeRegister( From c01d2bb383113064dbab0fbb014f1c6b3be23691 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 6 Oct 2025 16:47:16 -0700 Subject: [PATCH 17/47] Add another hint to the getActiveEditor invariant --- packages/lexical/src/LexicalUpdates.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index f216e1caed0..42962a8d91d 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -120,7 +120,8 @@ export function getActiveEditor(): LexicalEditor { 'Unable to find an active editor. ' + 'This method can only be used ' + 'synchronously during the callback of ' + - 'editor.update() or editor.read().%s', + 'editor.update(), editor.read(), or ' + + 'editor.getEditorState().read(..., {editor}).%s', collectBuildInformation(), ); } From b1ca96759cfae05920c2f2d9b7d93139d38b2586 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 6 Oct 2025 17:07:31 -0700 Subject: [PATCH 18/47] tsc compliance --- examples/dev-node-state-style/README.md | 11 ++++++++--- examples/dev-node-state-style/tsconfig.json | 4 ++-- packages/lexical-react/src/LexicalErrorBoundary.tsx | 1 - packages/lexical/src/LexicalNodeState.ts | 2 +- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/examples/dev-node-state-style/README.md b/examples/dev-node-state-style/README.md index 55ebc53bfd1..a4a9daeb17b 100644 --- a/examples/dev-node-state-style/README.md +++ b/examples/dev-node-state-style/README.md @@ -1,8 +1,13 @@ -# Node State Style example +# DEV Node State Style example Here we have an example that demonstrates how NodeState can be used with a -mutation listener to override behavior of any node. +DOMExtension to override create and export behavior of any node. + +This example currently depends on unreleased features (v0.37+) and will not +work outside of the monorepo. **Run it locally:** `npm i && npm run dev` -[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/facebook/lexical/tree/main/examples/node-state-style?file=src/main.tsx) +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/facebook/lexical/tree/main?file=examples/dev-node-state-style/src/main.tsx&startCommand=npm%20run%20start:example%20dev-node-state-style) + +[![Open in StackBlitz-PR](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/etrepum/lexical/tree/extensible-dom-export?file=examples/dev-node-state-style/src/main.tsx&startCommand=npm%20run%20start:example%20dev-node-state-style) diff --git a/examples/dev-node-state-style/tsconfig.json b/examples/dev-node-state-style/tsconfig.json index 8ea07038b64..ccf1249ccd6 100644 --- a/examples/dev-node-state-style/tsconfig.json +++ b/examples/dev-node-state-style/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2020", "DOM", "DOM.Iterable", "ESNext.Disposable"], "module": "ESNext", "skipLibCheck": true, @@ -17,7 +17,7 @@ /* Linting */ "strict": true, "noUnusedLocals": true, - "noUnusedParameters": true, + // "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, "extends": ["../../tsconfig.json"], diff --git a/packages/lexical-react/src/LexicalErrorBoundary.tsx b/packages/lexical-react/src/LexicalErrorBoundary.tsx index a10e26bcc33..59c9ad347be 100644 --- a/packages/lexical-react/src/LexicalErrorBoundary.tsx +++ b/packages/lexical-react/src/LexicalErrorBoundary.tsx @@ -8,7 +8,6 @@ import type {JSX} from 'react'; -import * as React from 'react'; import {ErrorBoundary as ReactErrorBoundary} from 'react-error-boundary'; export type LexicalErrorBoundaryProps = { diff --git a/packages/lexical/src/LexicalNodeState.ts b/packages/lexical/src/LexicalNodeState.ts index 7a0a3a9f4a9..cc09bca0f04 100644 --- a/packages/lexical/src/LexicalNodeState.ts +++ b/packages/lexical/src/LexicalNodeState.ts @@ -903,7 +903,7 @@ function computeSize( */ function undefinedIfEmpty(obj: undefined | T): undefined | T { if (obj) { - for (const key in obj) { + for (const _key in obj) { return obj; } } From aacbc2f730998791dc92de18711912f3adff7075 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 7 Oct 2025 08:56:15 -0700 Subject: [PATCH 19/47] WIP import --- packages/lexical-html/src/index.ts | 394 ++++++++++++++++++++++++++++- 1 file changed, 389 insertions(+), 5 deletions(-) diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index ecb02f49906..6d1ce41fda3 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -12,6 +12,7 @@ import type { DOMChildConversion, DOMConversion, DOMConversionFn, + DOMConversionOutput, DOMExportOutput, EditorDOMConfig, ElementDOMSlot, @@ -469,7 +470,7 @@ export interface DOMConfigMatch { ) => boolean; } -function compileOverrides( +function compileDOMConfigOverrides( {overrides}: DOMConfig, defaults: EditorDOMConfig, ): EditorDOMConfig { @@ -580,16 +581,35 @@ function compileOverrides( /** true if this is a whole document export operation ($generateDOMFromRoot) */ export const DOMContextRoot = createState('@lexical/html/root', { - parse: (v) => !!v, + parse: Boolean, }); /** true if this is an export operation ($generateHtmlFromNodes) */ export const DOMContextExport = createState('@lexical/html/export', { - parse: (v) => !!v, + parse: Boolean, }); /** true if the DOM is for or from the clipboard */ export const DOMContextClipboard = createState('@lexical/html/clipboard', { - parse: (v) => !!v, + parse: Boolean, +}); + +const DOMContextForChildMap = createState('@lexical/htm/forChildMap', { + parse: (): null | Map => null, +}); +const DOMContextParentLexicalNode = createState( + '@lexical/html/parentLexicalNode', + { + parse: (): null | LexicalNode => null, + }, +); +const DOMContextHasBlockAncestorLexicalNode = createState( + '@lexical/html/hasBlockAncestorLexicalNode', + { + parse: Boolean, + }, +); +const DOMContextArtificialNodes = createState('@lexical/html/ArtificialNodes', { + parse: (): null | ArtificialNode__DO_NOT_USE[] => null, }); export type StateConfigPair = readonly [ @@ -648,6 +668,8 @@ export function $getDOMContextValue( return getContextValueFromRecord(context, cfg); } +export const $getDOMImportContextValue = $getDOMContextValue; + export function $withDOMContext( cfg: Iterable, editor = $getEditor(), @@ -770,7 +792,7 @@ export const DOMExtension = defineExtension< ]), }, init(editorConfig, config) { - editorConfig.dom = compileOverrides(config, { + editorConfig.dom = compileDOMConfigOverrides(config, { ...DEFAULT_EDITOR_DOM_CONFIG, ...editorConfig.dom, }); @@ -786,3 +808,365 @@ export const DOMExtension = defineExtension< }, name: DOMExtensionName, }); + +/** @internal @experimental */ +export interface DOMImportOutput { + node: null | LexicalNode | LexicalNode[]; + getChildren?: () => Iterable; + childContext?: AnyStateConfigPair[]; + $appendChild?: (node: LexicalNode, dom: ChildNode) => void; + $finalize?: ( + node: null | LexicalNode | LexicalNode[], + ) => null | LexicalNode | LexicalNode[]; +} + +export type DOMImportFunction = ( + node: T, + $next: () => null | undefined | DOMImportOutput, + editor: LexicalEditor, +) => null | undefined | DOMImportOutput; + +export interface NodeNameMap extends HTMLElementTagNameMap { + '*': Node; + '#text': Text; + '#document': Document; + '#comment': Comment; + '#cdata-section': CDATASection; +} + +export type NodeNameToType = T extends keyof NodeNameMap + ? NodeNameMap[T] + : Node; + +/** + * A convenience function for type inference when constructing DOM overrides for + * use with {@link DOMImportExtension}. + * + * @__NO_SIDE_EFFECTS__ + */ +export function importOverride( + tag: T, + $import: DOMImportFunction>, + options: Omit = {}, +): DOMImportConfigMatch { + return { + ...options, + $import: $import as DOMImportFunction, + tag: tag.toLowerCase(), + }; +} + +/** @internal @experimental */ +export interface DOMImportConfig { + overrides: DOMImportConfigMatch[]; +} +export interface DOMImportConfigMatch { + tag: '*' | '#text' | '#cdata-section' | '#comment' | (string & {}); + selector?: string; + priority?: 0 | 1 | 2 | 3 | 4; + $import: ( + node: Node, + $next: () => null | undefined | DOMImportOutput, + editor: LexicalEditor, + ) => null | undefined | DOMImportOutput; +} + +export type DOMImportNodeFunction = DOMImportExtensionOutput['$importNode']; +export interface DOMImportExtensionOutput { + $importNode: (node: Node) => null | undefined | DOMImportOutput; + $importNodes: (node: Node) => LexicalNode[]; +} + +const DOMImportExtensionName = '@lexical/html/DOMImport'; + +class MatchesImport { + tag: string; + matches: DOMImportConfigMatch[] = []; + constructor(tag: string) { + this.tag = tag; + } + push(match: DOMImportConfigMatch) { + invariant( + match.tag === this.tag, + 'MatchesImport requires all to use the same tag', + ); + this.matches.push(match); + } + compile( + $nextImport: (node: Node) => null | undefined | DOMImportOutput, + editor: LexicalEditor, + ): DOMImportExtensionOutput['$importNode'] { + const {matches, tag} = this; + return (node) => { + const el = isHTMLElement(node) ? node : null; + const $importAt = (start: number): null | undefined | DOMImportOutput => { + let rval: undefined | null | DOMImportOutput; + for (let i = start; !rval && i >= 0; i--) { + const match = matches[i]; + if (match) { + const {$import, selector} = matches[i]; + if (!selector || (el && el.matches(selector))) { + rval = $import(node, $importAt.bind(null, i - 1), editor); + } + } + } + return rval; + }; + return ( + ((tag === node.nodeName.toLowerCase() || (el && tag === '*')) && + $importAt(matches.length - 1)) || + $nextImport(node) + ); + }; + } +} + +class TagImport { + tags: Map = new Map(); + push(match: DOMImportConfigMatch) { + invariant(match.tag !== '*', 'TagImport can not handle wildcards'); + const matches = this.tags.get(match.tag) || new MatchesImport(match.tag); + this.tags.set(match.tag, matches); + matches.push(match); + } + compile( + $nextImport: (node: Node) => null | undefined | DOMImportOutput, + editor: LexicalEditor, + ): DOMImportExtensionOutput['$importNode'] { + const compiled = new Map(); + for (const [tag, matches] of this.tags.entries()) { + compiled.set(tag, matches.compile($nextImport, editor)); + } + return compiled.size === 0 + ? $nextImport + : (node: Node) => { + const $import = compiled.get(node.nodeName.toLowerCase()); + return $import ? $import(node) : $nextImport(node); + }; + } +} + +const EMPTY_ARRAY = [] as const; +const emptyGetChildren = () => EMPTY_ARRAY; + +function compileLegacyImportDOM( + editor: LexicalEditor, +): DOMImportExtensionOutput['$importNode'] { + return (node) => { + if (IGNORE_TAGS.has(node.nodeName)) { + return {getChildren: emptyGetChildren, node: null}; + } + // If the DOM node doesn't have a transformer, we don't know what + // to do with it but we still need to process any childNodes. + let childLexicalNodes: LexicalNode[] = []; + let postTransform: DOMConversionOutput['after']; + let hasBlockAncestorLexicalNodeForChildren = false; + const output: DOMImportOutput & {node: LexicalNode[]} = { + $appendChild: (childNode) => childLexicalNodes.push(childNode), + $finalize: (nodeOrNodes) => { + const finalLexicalNodes = Array.isArray(nodeOrNodes) + ? nodeOrNodes + : nodeOrNodes + ? [nodeOrNodes] + : []; + const finalLexicalNode: null | LexicalNode = + finalLexicalNodes[finalLexicalNodes.length - 1] || null; + if (postTransform) { + childLexicalNodes = postTransform(childLexicalNodes); + } + if (isBlockDomNode(node)) { + if (!hasBlockAncestorLexicalNodeForChildren) { + childLexicalNodes = wrapContinuousInlines( + node, + childLexicalNodes, + $createParagraphNode, + ); + } else { + const allArtificialNodes = $getDOMImportContextValue( + DOMContextArtificialNodes, + ); + invariant( + allArtificialNodes !== null, + 'Missing DOMContextArtificialNodes', + ); + childLexicalNodes = wrapContinuousInlines( + node, + childLexicalNodes, + () => { + const artificialNode = new ArtificialNode__DO_NOT_USE(); + allArtificialNodes.push(artificialNode); + return artificialNode; + }, + ); + } + } + + if (finalLexicalNode == null) { + if (childLexicalNodes.length > 0) { + // If it hasn't been converted to a LexicalNode, we hoist its children + // up to the same level as it. + finalLexicalNodes.push(...childLexicalNodes); + } else { + if (isBlockDomNode(node) && isDomNodeBetweenTwoInlineNodes(node)) { + // Empty block dom node that hasnt been converted, we replace it with a linebreak if its between inline nodes + finalLexicalNodes.push($createLineBreakNode()); + } + } + } else { + if ($isElementNode(finalLexicalNode)) { + // If the current node is a ElementNode after conversion, + // we can append all the children to it. + finalLexicalNode.splice( + finalLexicalNode.getChildrenSize(), + 0, + childLexicalNodes, + ); + } + } + + return finalLexicalNodes; + }, + node: [], + }; + let currentLexicalNode = null; + const transformFunction = getConversionFunction(node, editor); + const transformOutput = transformFunction + ? transformFunction(node as HTMLElement) + : null; + const addChildContext = (cfg: AnyStateConfigPair) => { + output.childContext = output.childContext || []; + output.childContext.push(cfg); + }; + + if (transformOutput !== null) { + const forChildMap = $getDOMImportContextValue( + DOMContextForChildMap, + editor, + ); + const parentLexicalNode = $getDOMImportContextValue( + DOMContextParentLexicalNode, + editor, + ); + postTransform = transformOutput.after; + const transformNodes = transformOutput.node; + currentLexicalNode = Array.isArray(transformNodes) + ? transformNodes[transformNodes.length - 1] + : transformNodes; + + if (currentLexicalNode !== null && forChildMap) { + for (const forChildFunction of forChildMap.values()) { + currentLexicalNode = forChildFunction( + currentLexicalNode, + parentLexicalNode, + ); + + if (!currentLexicalNode) { + break; + } + } + + if (currentLexicalNode) { + output.node.push( + ...(Array.isArray(transformNodes) + ? transformNodes + : [currentLexicalNode]), + ); + } + } + + if (transformOutput.forChild != null) { + addChildContext( + DOMContextForChildMap.pair( + new Map(forChildMap || []).set( + node.nodeName, + transformOutput.forChild, + ), + ), + ); + } + } + + const hasBlockAncestorLexicalNode = $getDOMImportContextValue( + DOMContextHasBlockAncestorLexicalNode, + ); + hasBlockAncestorLexicalNodeForChildren = + currentLexicalNode != null && $isRootOrShadowRoot(currentLexicalNode) + ? false + : (currentLexicalNode != null && + $isBlockElementNode(currentLexicalNode)) || + $getDOMImportContextValue(DOMContextHasBlockAncestorLexicalNode); + if ( + hasBlockAncestorLexicalNode !== hasBlockAncestorLexicalNodeForChildren + ) { + addChildContext( + DOMContextHasBlockAncestorLexicalNode.pair( + hasBlockAncestorLexicalNodeForChildren, + ), + ); + } + return output; + }; +} + +function importOverrideSort( + a: DOMImportConfigMatch, + b: DOMImportConfigMatch, +): number { + // Lowest priority and non-wildcards first + return ( + (a.priority || 0) - (b.priority || 0) || + Number(a.tag === '*') - Number(b.tag === '*') + ); +} + +function compileImportOverrides( + editor: LexicalEditor, + config: DOMImportConfig, +): DOMImportExtensionOutput { + function $importNodes(): LexicalNode[] { + return []; + } + let $importNode = compileLegacyImportDOM(editor); + let importer: TagImport | MatchesImport = new TagImport(); + const sortedOverrides = config.overrides.sort(importOverrideSort); + for (const match of sortedOverrides) { + if (match.tag === '*') { + if (!(importer instanceof MatchesImport && importer.tag === match.tag)) { + $importNode = importer.compile($importNode, editor); + importer = new MatchesImport(match.tag); + } + } else if (importer instanceof MatchesImport) { + $importNode = importer.compile($importNode, editor); + importer = new TagImport(); + } + importer.push(match); + } + $importNode = importer.compile($importNode, editor); + + return { + $importNode, + $importNodes, + }; +} + +/** @internal @experimental */ +export const DOMImportExtension = defineExtension< + DOMImportConfig, + typeof DOMImportExtensionName, + DOMImportExtensionOutput, + null +>({ + build: compileImportOverrides, + config: {overrides: []}, + dependencies: [DOMExtension], + mergeConfig(config, partial) { + const merged = shallowMergeConfig(config, partial); + for (const k of ['overrides'] as const) { + if (partial[k]) { + (merged[k] as unknown[]) = [...merged[k], ...partial[k]]; + } + } + return merged; + }, + name: DOMImportExtensionName, +}); From 43f69588d4600cb27ac01d422c62dc6d68338ebd Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 7 Oct 2025 13:20:47 -0700 Subject: [PATCH 20/47] modularize lexical-html source --- .../lexical-html/src/$createNodesFromDOM.ts | 149 +++ .../lexical-html/src/$generateDOMFromNodes.ts | 171 +++ .../lexical-html/src/$generateNodesFromDOM.ts | 50 + .../src/$unwrapArtificialNodes.ts | 26 + packages/lexical-html/src/ContextRecord.ts | 156 +++ packages/lexical-html/src/DOMExtension.ts | 178 +++ .../lexical-html/src/DOMImportExtension.ts | 395 ++++++ .../src/__tests__/unit/DOMExtension.test.ts | 12 +- packages/lexical-html/src/constants.ts | 10 + packages/lexical-html/src/domOverride.ts | 31 + .../lexical-html/src/getConversionFunction.ts | 36 + packages/lexical-html/src/importOverride.ts | 31 + packages/lexical-html/src/index.ts | 1186 +---------------- .../src/isDomNodeBetweenTwoInlineNodes.ts | 17 + packages/lexical-html/src/types.ts | 133 ++ .../lexical-html/src/wrapContinuousInlines.ts | 47 + 16 files changed, 1469 insertions(+), 1159 deletions(-) create mode 100644 packages/lexical-html/src/$createNodesFromDOM.ts create mode 100644 packages/lexical-html/src/$generateDOMFromNodes.ts create mode 100644 packages/lexical-html/src/$generateNodesFromDOM.ts create mode 100644 packages/lexical-html/src/$unwrapArtificialNodes.ts create mode 100644 packages/lexical-html/src/ContextRecord.ts create mode 100644 packages/lexical-html/src/DOMExtension.ts create mode 100644 packages/lexical-html/src/DOMImportExtension.ts create mode 100644 packages/lexical-html/src/constants.ts create mode 100644 packages/lexical-html/src/domOverride.ts create mode 100644 packages/lexical-html/src/getConversionFunction.ts create mode 100644 packages/lexical-html/src/importOverride.ts create mode 100644 packages/lexical-html/src/isDomNodeBetweenTwoInlineNodes.ts create mode 100644 packages/lexical-html/src/types.ts create mode 100644 packages/lexical-html/src/wrapContinuousInlines.ts diff --git a/packages/lexical-html/src/$createNodesFromDOM.ts b/packages/lexical-html/src/$createNodesFromDOM.ts new file mode 100644 index 00000000000..d72668e2aa4 --- /dev/null +++ b/packages/lexical-html/src/$createNodesFromDOM.ts @@ -0,0 +1,149 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import { + $createLineBreakNode, + $createParagraphNode, + $isBlockElementNode, + $isElementNode, + $isRootOrShadowRoot, + ArtificialNode__DO_NOT_USE, + type DOMChildConversion, + isBlockDomNode, + type LexicalEditor, + type LexicalNode, +} from 'lexical'; + +import {IGNORE_TAGS} from './constants'; +import {getConversionFunction} from './getConversionFunction'; +import {isDomNodeBetweenTwoInlineNodes} from './isDomNodeBetweenTwoInlineNodes'; +import {$wrapContinuousInlines} from './wrapContinuousInlines'; + +export function $createNodesFromDOM( + node: Node, + editor: LexicalEditor, + allArtificialNodes: Array, + hasBlockAncestorLexicalNode: boolean, + forChildMap: Map = new Map(), + parentLexicalNode?: LexicalNode | null | undefined, +): Array { + let lexicalNodes: Array = []; + + if (IGNORE_TAGS.has(node.nodeName)) { + return lexicalNodes; + } + + let currentLexicalNode = null; + const transformFunction = getConversionFunction(node, editor); + const transformOutput = transformFunction + ? transformFunction(node as HTMLElement) + : null; + let postTransform = null; + + if (transformOutput !== null) { + postTransform = transformOutput.after; + const transformNodes = transformOutput.node; + currentLexicalNode = Array.isArray(transformNodes) + ? transformNodes[transformNodes.length - 1] + : transformNodes; + + if (currentLexicalNode !== null) { + for (const [, forChildFunction] of forChildMap) { + currentLexicalNode = forChildFunction( + currentLexicalNode, + parentLexicalNode, + ); + + if (!currentLexicalNode) { + break; + } + } + + if (currentLexicalNode) { + lexicalNodes.push( + ...(Array.isArray(transformNodes) + ? transformNodes + : [currentLexicalNode]), + ); + } + } + + if (transformOutput.forChild != null) { + forChildMap.set(node.nodeName, transformOutput.forChild); + } + } + + // If the DOM node doesn't have a transformer, we don't know what + // to do with it but we still need to process any childNodes. + const children = node.childNodes; + let childLexicalNodes = []; + + const hasBlockAncestorLexicalNodeForChildren = + currentLexicalNode != null && $isRootOrShadowRoot(currentLexicalNode) + ? false + : (currentLexicalNode != null && + $isBlockElementNode(currentLexicalNode)) || + hasBlockAncestorLexicalNode; + + for (let i = 0; i < children.length; i++) { + childLexicalNodes.push( + ...$createNodesFromDOM( + children[i], + editor, + allArtificialNodes, + hasBlockAncestorLexicalNodeForChildren, + new Map(forChildMap), + currentLexicalNode, + ), + ); + } + + if (postTransform != null) { + childLexicalNodes = postTransform(childLexicalNodes); + } + + if (isBlockDomNode(node)) { + if (!hasBlockAncestorLexicalNodeForChildren) { + childLexicalNodes = $wrapContinuousInlines( + node, + childLexicalNodes, + $createParagraphNode, + ); + } else { + childLexicalNodes = $wrapContinuousInlines( + node, + childLexicalNodes, + () => { + const artificialNode = new ArtificialNode__DO_NOT_USE(); + allArtificialNodes.push(artificialNode); + return artificialNode; + }, + ); + } + } + + if (currentLexicalNode == null) { + if (childLexicalNodes.length > 0) { + // If it hasn't been converted to a LexicalNode, we hoist its children + // up to the same level as it. + lexicalNodes = lexicalNodes.concat(childLexicalNodes); + } else { + if (isBlockDomNode(node) && isDomNodeBetweenTwoInlineNodes(node)) { + // Empty block dom node that hasnt been converted, we replace it with a linebreak if its between inline nodes + lexicalNodes = lexicalNodes.concat($createLineBreakNode()); + } + } + } else { + if ($isElementNode(currentLexicalNode)) { + // If the current node is a ElementNode after conversion, + // we can append all the children to it. + currentLexicalNode.append(...childLexicalNodes); + } + } + + return lexicalNodes; +} diff --git a/packages/lexical-html/src/$generateDOMFromNodes.ts b/packages/lexical-html/src/$generateDOMFromNodes.ts new file mode 100644 index 00000000000..b530794ddc0 --- /dev/null +++ b/packages/lexical-html/src/$generateDOMFromNodes.ts @@ -0,0 +1,171 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {$sliceSelectedTextNodeContent} from '@lexical/selection'; +import { + $getEditor, + $getEditorDOMConfig, + $getRoot, + $isElementNode, + $isTextNode, + type BaseSelection, + type EditorDOMConfig, + isDocumentFragment, + isHTMLElement, + type LexicalEditor, + type LexicalNode, +} from 'lexical'; +import invariant from 'shared/invariant'; + +import { + $withDOMContext, + DOMContextExport, + DOMContextRoot, +} from './ContextRecord'; + +export function $generateDOMFromNodes( + container: T, + selection: null | BaseSelection = null, + editor: LexicalEditor = $getEditor(), +): T { + return $withDOMContext( + [DOMContextExport.pair(true)], + editor, + )(() => { + const root = $getRoot(); + const domConfig = $getEditorDOMConfig(editor); + + const parentElementAppend = container.append.bind(container); + for (const topLevelNode of root.getChildren()) { + $appendNodesToHTML( + editor, + topLevelNode, + parentElementAppend, + selection, + domConfig, + ); + } + return container; + }); +} + +export function $generateDOMFromRoot( + container: T, + root: LexicalNode = $getRoot(), +): T { + const editor = $getEditor(); + return $withDOMContext( + [DOMContextExport.pair(true), DOMContextRoot.pair(true)], + editor, + )(() => { + const selection = null; + const domConfig = $getEditorDOMConfig(editor); + const parentElementAppend = container.append.bind(container); + $appendNodesToHTML(editor, root, parentElementAppend, selection, domConfig); + return container; + }); +} +function $appendNodesToHTML( + editor: LexicalEditor, + currentNode: LexicalNode, + parentElementAppend: (element: Node) => void, + selection: BaseSelection | null = null, + domConfig: EditorDOMConfig = $getEditorDOMConfig(editor), +): boolean { + let shouldInclude = domConfig.$shouldInclude(currentNode, selection, editor); + const shouldExclude = domConfig.$shouldExclude( + currentNode, + selection, + editor, + ); + let target = currentNode; + + if (selection !== null && $isTextNode(currentNode)) { + target = $sliceSelectedTextNodeContent(selection, currentNode, 'clone'); + } + const exportProps = domConfig.$exportDOM(target, editor); + const {element, after, append, $getChildNodes} = exportProps; + + if (!element) { + return false; + } + + const fragment = document.createDocumentFragment(); + const children = $getChildNodes + ? $getChildNodes() + : $isElementNode(target) + ? target.getChildren() + : []; + + const fragmentAppend = fragment.append.bind(fragment); + for (const childNode of children) { + const shouldIncludeChild = $appendNodesToHTML( + editor, + childNode, + fragmentAppend, + selection, + domConfig, + ); + + if ( + !shouldInclude && + shouldIncludeChild && + domConfig.$extractWithChild( + currentNode, + childNode, + selection, + 'html', + editor, + ) + ) { + shouldInclude = true; + } + } + + if (shouldInclude && !shouldExclude) { + if (isHTMLElement(element) || isDocumentFragment(element)) { + if (append) { + append(fragment); + } else { + element.append(fragment); + } + } + parentElementAppend(element); + + if (after) { + const newElement = after.call(target, element); + if (newElement) { + if (isDocumentFragment(element)) { + element.replaceChildren(newElement); + } else { + element.replaceWith(newElement); + } + } + } + } else { + parentElementAppend(fragment); + } + + return shouldInclude; +} + +export function $generateHtmlFromNodes( + editor: LexicalEditor, + selection: BaseSelection | null = null, +): string { + if ( + typeof document === 'undefined' || + (typeof window === 'undefined' && typeof global.window === 'undefined') + ) { + invariant( + false, + 'To use $generateHtmlFromNodes in headless mode please initialize a headless browser implementation such as JSDom or use withDOM from @lexical/headless/dom before calling this function.', + ); + } + return $generateDOMFromNodes(document.createElement('div'), selection, editor) + .innerHTML; +} diff --git a/packages/lexical-html/src/$generateNodesFromDOM.ts b/packages/lexical-html/src/$generateNodesFromDOM.ts new file mode 100644 index 00000000000..e06bb1537dd --- /dev/null +++ b/packages/lexical-html/src/$generateNodesFromDOM.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import { + ArtificialNode__DO_NOT_USE, + isDOMDocumentNode, + type LexicalEditor, + type LexicalNode, +} from 'lexical'; + +import {$createNodesFromDOM} from './$createNodesFromDOM'; +import {$unwrapArtificialNodes} from './$unwrapArtificialNodes'; +import {IGNORE_TAGS} from './constants'; + +/** + * How you parse your html string to get a document is left up to you. In the browser you can use the native + * DOMParser API to generate a document (see clipboard.ts), but to use in a headless environment you can use JSDom + * or an equivalent library and pass in the document here. + */ + +export function $generateNodesFromDOM( + editor: LexicalEditor, + dom: Document | ParentNode, +): Array { + const elements = isDOMDocumentNode(dom) + ? dom.body.childNodes + : dom.childNodes; + let lexicalNodes: Array = []; + const allArtificialNodes: Array = []; + for (const element of elements) { + if (!IGNORE_TAGS.has(element.nodeName)) { + const lexicalNode = $createNodesFromDOM( + element, + editor, + allArtificialNodes, + false, + ); + if (lexicalNode !== null) { + lexicalNodes = lexicalNodes.concat(lexicalNode); + } + } + } + $unwrapArtificialNodes(allArtificialNodes); + + return lexicalNodes; +} diff --git a/packages/lexical-html/src/$unwrapArtificialNodes.ts b/packages/lexical-html/src/$unwrapArtificialNodes.ts new file mode 100644 index 00000000000..6c46e788290 --- /dev/null +++ b/packages/lexical-html/src/$unwrapArtificialNodes.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {$createLineBreakNode, ArtificialNode__DO_NOT_USE} from 'lexical'; + +export function $unwrapArtificialNodes( + allArtificialNodes: Array, +) { + for (const node of allArtificialNodes) { + if (node.getNextSibling() instanceof ArtificialNode__DO_NOT_USE) { + node.insertAfter($createLineBreakNode()); + } + } + // Replace artificial node with it's children + for (const node of allArtificialNodes) { + const children = node.getChildren(); + for (const child of children) { + node.insertBefore(child); + } + node.remove(); + } +} diff --git a/packages/lexical-html/src/ContextRecord.ts b/packages/lexical-html/src/ContextRecord.ts new file mode 100644 index 00000000000..6447cdde40b --- /dev/null +++ b/packages/lexical-html/src/ContextRecord.ts @@ -0,0 +1,156 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import { + getExtensionDependencyFromEditor, + LexicalBuilder, +} from '@lexical/extension'; +import { + $getEditor, + type AnyStateConfig, + ArtificialNode__DO_NOT_USE, + createState, + DOMChildConversion, + type LexicalEditor, + LexicalNode, + type StateConfig, +} from 'lexical'; + +import {DOMExtensionName} from './constants'; +import {DOMExtension} from './DOMExtension'; +import {DOMExtensionOutput} from './types'; + +let activeDOMContext: + | undefined + | {editor: LexicalEditor; context: ContextRecord}; + +export type ContextRecord = Map; +export function contextFromPairs( + pairs: Iterable, +): undefined | ContextRecord { + let rval: undefined | ContextRecord; + for (const [k, v] of pairs) { + rval = (rval || new Map()).set(k, v); + } + return rval; +} +function mergeContext( + defaults: ContextRecord, + overrides: ContextRecord | Iterable, +) { + let ctx: undefined | ContextRecord; + for (const [k, v] of overrides) { + if (!ctx) { + if (defaults.get(k) === v) { + continue; + } + ctx = new Map(defaults); + } + ctx.set(k, v); + } + return ctx || defaults; +} + +export function getContextValueFromRecord( + context: ContextRecord, + cfg: StateConfig, +): V { + const v = context.get(cfg); + return v !== undefined || context.has(cfg) ? (v as V) : cfg.defaultValue; +} + +export function $getDOMContextValue( + cfg: StateConfig, + editor: LexicalEditor = $getEditor(), +): V { + const context = + activeDOMContext && activeDOMContext.editor === editor + ? activeDOMContext.context + : getExtensionDependencyFromEditor(editor, DOMExtension).output.defaults; + return getContextValueFromRecord(context, cfg); +} + +export const $getDOMImportContextValue = $getDOMContextValue; + +export function $withDOMContext( + cfg: Iterable, + editor = $getEditor(), +): (f: () => T) => T { + const updates = contextFromPairs(cfg); + return (f) => { + if (!updates) { + return f(); + } + const prevDOMContext = activeDOMContext; + let context: ContextRecord; + if (prevDOMContext && prevDOMContext.editor === editor) { + context = mergeContext(prevDOMContext.context, updates); + } else { + const ext = getDOMExtensionOutputIfAvailable(editor); + context = ext ? mergeContext(ext.defaults, updates) : updates; + } + try { + activeDOMContext = {context, editor}; + return f(); + } finally { + activeDOMContext = prevDOMContext; + } + }; +} +export const $withDOMImportContext = $withDOMContext; + +/** true if this is a whole document export operation ($generateDOMFromRoot) */ +export const DOMContextRoot = createState('@lexical/html/root', { + parse: Boolean, +}); + +/** true if this is an export operation ($generateHtmlFromNodes) */ +export const DOMContextExport = createState('@lexical/html/export', { + parse: Boolean, +}); +/** true if the DOM is for or from the clipboard */ +export const DOMContextClipboard = createState('@lexical/html/clipboard', { + parse: Boolean, +}); +export const DOMContextForChildMap = createState('@lexical/htm/forChildMap', { + parse: (): null | Map => null, +}); +export const DOMContextParentLexicalNode = createState( + '@lexical/html/parentLexicalNode', + { + parse: (): null | LexicalNode => null, + }, +); +export const DOMContextHasBlockAncestorLexicalNode = createState( + '@lexical/html/hasBlockAncestorLexicalNode', + { + parse: Boolean, + }, +); +export const DOMContextArtificialNodes = createState( + '@lexical/html/ArtificialNodes', + { + parse: (): null | ArtificialNode__DO_NOT_USE[] => null, + }, +); + +export type StateConfigPair = readonly [ + StateConfig, + V, +]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyStateConfigPair = StateConfigPair; + +export function getDOMExtensionOutputIfAvailable( + editor: LexicalEditor, +): undefined | DOMExtensionOutput { + const builder = LexicalBuilder.maybeFromEditor(editor); + return builder && builder.hasExtensionByName(DOMExtensionName) + ? getExtensionDependencyFromEditor(editor, DOMExtension).output + : undefined; +} diff --git a/packages/lexical-html/src/DOMExtension.ts b/packages/lexical-html/src/DOMExtension.ts new file mode 100644 index 00000000000..4e0447249c1 --- /dev/null +++ b/packages/lexical-html/src/DOMExtension.ts @@ -0,0 +1,178 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type {AnyDOMConfigMatch, DOMConfig, DOMExtensionOutput} from './types'; + +import { + $isElementNode, + DEFAULT_EDITOR_DOM_CONFIG, + defineExtension, + EditorDOMConfig, + type LexicalNode, + RootNode, + shallowMergeConfig, +} from 'lexical'; + +import {DOMExtensionName} from './constants'; +import {contextFromPairs} from './ContextRecord'; + +export function compileDOMConfigOverrides( + {overrides}: DOMConfig, + defaults: EditorDOMConfig, +): EditorDOMConfig { + function mergeDOMConfigMatch( + acc: EditorDOMConfig, + match: AnyDOMConfigMatch, + ): EditorDOMConfig { + // TODO Consider using a node type map to make this more efficient when + // there are more overrides + const { + nodes, + $getDOMSlot, + $createDOM, + $updateDOM, + $exportDOM, + $shouldExclude, + $shouldInclude, + $extractWithChild, + } = match; + const matcher = (node: LexicalNode): boolean => { + for (const predicate of nodes) { + if (predicate === '*') { + return true; + } else if ('getType' in predicate || '$config' in predicate.prototype) { + if (node instanceof predicate) { + return true; + } + } else if (predicate(node)) { + return true; + } + } + return false; + }; + return { + $createDOM: $createDOM + ? (node, editor) => { + const $next = () => acc.$createDOM(node, editor); + return matcher(node) ? $createDOM(node, $next, editor) : $next(); + } + : acc.$createDOM, + $exportDOM: $exportDOM + ? (node, editor) => { + const $next = () => acc.$exportDOM(node, editor); + return matcher(node) ? $exportDOM(node, $next, editor) : $next(); + } + : acc.$exportDOM, + $extractWithChild: $extractWithChild + ? (node, childNode, selection, destination, editor) => { + const $next = () => + acc.$extractWithChild( + node, + childNode, + selection, + destination, + editor, + ); + return matcher(node) + ? $extractWithChild( + node, + childNode, + selection, + destination, + $next, + editor, + ) + : $next(); + } + : acc.$extractWithChild, + $getDOMSlot: $getDOMSlot + ? (node, dom, editor) => { + const $next = () => acc.$getDOMSlot(node, dom, editor); + return $isElementNode(node) && matcher(node) + ? $getDOMSlot(node, $next, editor) + : $next(); + } + : acc.$getDOMSlot, + $shouldExclude: $shouldExclude + ? (node, selection, editor) => { + const $next = () => acc.$shouldExclude(node, selection, editor); + return matcher(node) + ? $shouldExclude(node, selection, $next, editor) + : $next(); + } + : acc.$shouldExclude, + $shouldInclude: $shouldInclude + ? (node, selection, editor) => { + const $next = () => acc.$shouldInclude(node, selection, editor); + return matcher(node) + ? $shouldInclude(node, selection, $next, editor) + : $next(); + } + : acc.$shouldInclude, + $updateDOM: $updateDOM + ? (nextNode, prevNode, dom, editor) => { + const $next = () => acc.$updateDOM(nextNode, prevNode, dom, editor); + return matcher(nextNode) + ? $updateDOM(nextNode, prevNode, dom, $next, editor) + : $next(); + } + : acc.$updateDOM, + }; + } + // The beginning of the array will be the overrides towards the top + // of the tree so should be higher precedence, so we compose the functions + // from the right + return overrides.reduceRight(mergeDOMConfigMatch, defaults); +} + +/** @internal @experimental */ + +export const DOMExtension = defineExtension< + DOMConfig, + typeof DOMExtensionName, + DOMExtensionOutput, + void +>({ + build(editor, config, state) { + return { + defaults: contextFromPairs(config.contextDefaults) || new Map(), + }; + }, + config: { + contextDefaults: [], + overrides: [], + }, + html: { + // Define a RootNode export for $generateDOMFromRoot + export: new Map([ + [ + RootNode, + () => { + const element = document.createElement('div'); + element.role = 'textbox'; + return {element}; + }, + ], + ]), + }, + init(editorConfig, config) { + editorConfig.dom = compileDOMConfigOverrides(config, { + ...DEFAULT_EDITOR_DOM_CONFIG, + ...editorConfig.dom, + }); + }, + mergeConfig(config, partial) { + const merged = shallowMergeConfig(config, partial); + for (const k of ['overrides', 'contextDefaults'] as const) { + if (partial[k]) { + (merged[k] as unknown[]) = [...merged[k], ...partial[k]]; + } + } + return merged; + }, + name: DOMExtensionName, +}); diff --git a/packages/lexical-html/src/DOMImportExtension.ts b/packages/lexical-html/src/DOMImportExtension.ts new file mode 100644 index 00000000000..d5b21bc3be9 --- /dev/null +++ b/packages/lexical-html/src/DOMImportExtension.ts @@ -0,0 +1,395 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type { + DOMImportConfig, + DOMImportConfigMatch, + DOMImportExtensionOutput, + DOMImportNodeFunction, + DOMImportOutput, +} from './types'; + +import { + $createLineBreakNode, + $createParagraphNode, + $isBlockElementNode, + $isElementNode, + $isRootOrShadowRoot, + ArtificialNode__DO_NOT_USE, + defineExtension, + DOMConversionOutput, + isBlockDomNode, + isDOMDocumentNode, + isHTMLElement, + type LexicalEditor, + type LexicalNode, + shallowMergeConfig, +} from 'lexical'; +import invariant from 'shared/invariant'; + +import {$unwrapArtificialNodes} from './$unwrapArtificialNodes'; +import {DOMImportExtensionName, IGNORE_TAGS} from './constants'; +import { + $getDOMImportContextValue, + $withDOMImportContext, + AnyStateConfigPair, + DOMContextArtificialNodes, + DOMContextForChildMap, + DOMContextHasBlockAncestorLexicalNode, + DOMContextParentLexicalNode, +} from './ContextRecord'; +import {DOMExtension} from './DOMExtension'; +import {getConversionFunction} from './getConversionFunction'; +import {isDomNodeBetweenTwoInlineNodes} from './isDomNodeBetweenTwoInlineNodes'; +import {$wrapContinuousInlines} from './wrapContinuousInlines'; + +class MatchesImport { + tag: string; + matches: DOMImportConfigMatch[] = []; + constructor(tag: string) { + this.tag = tag; + } + push(match: DOMImportConfigMatch) { + invariant( + match.tag === this.tag, + 'MatchesImport requires all to use the same tag', + ); + this.matches.push(match); + } + compile( + $nextImport: (node: Node) => null | undefined | DOMImportOutput, + editor: LexicalEditor, + ): DOMImportExtensionOutput['$importNode'] { + const {matches, tag} = this; + return (node) => { + const el = isHTMLElement(node) ? node : null; + const $importAt = (start: number): null | undefined | DOMImportOutput => { + let rval: undefined | null | DOMImportOutput; + for (let i = start; !rval && i >= 0; i--) { + const match = matches[i]; + if (match) { + const {$import, selector} = matches[i]; + if (!selector || (el && el.matches(selector))) { + rval = $import(node, $importAt.bind(null, i - 1), editor); + } + } + } + return rval; + }; + return ( + ((tag === node.nodeName.toLowerCase() || (el && tag === '*')) && + $importAt(matches.length - 1)) || + $nextImport(node) + ); + }; + } +} + +class TagImport { + tags: Map = new Map(); + push(match: DOMImportConfigMatch) { + invariant(match.tag !== '*', 'TagImport can not handle wildcards'); + const matches = this.tags.get(match.tag) || new MatchesImport(match.tag); + this.tags.set(match.tag, matches); + matches.push(match); + } + compile( + $nextImport: (node: Node) => null | undefined | DOMImportOutput, + editor: LexicalEditor, + ): DOMImportExtensionOutput['$importNode'] { + const compiled = new Map(); + for (const [tag, matches] of this.tags.entries()) { + compiled.set(tag, matches.compile($nextImport, editor)); + } + return compiled.size === 0 + ? $nextImport + : (node: Node) => { + const $import = compiled.get(node.nodeName.toLowerCase()); + return $import ? $import(node) : $nextImport(node); + }; + } +} + +const EMPTY_ARRAY = [] as const; +const emptyGetChildren = () => EMPTY_ARRAY; + +function compileLegacyImportDOM( + editor: LexicalEditor, +): DOMImportExtensionOutput['$importNode'] { + return (node) => { + if (IGNORE_TAGS.has(node.nodeName)) { + return {getChildren: emptyGetChildren, node: null}; + } + // If the DOM node doesn't have a transformer, we don't know what + // to do with it but we still need to process any childNodes. + let childLexicalNodes: LexicalNode[] = []; + let postTransform: DOMConversionOutput['after']; + let hasBlockAncestorLexicalNodeForChildren = false; + const output: DOMImportOutput & {node: LexicalNode[]} = { + $appendChild: (childNode) => childLexicalNodes.push(childNode), + $finalize: (nodeOrNodes) => { + const finalLexicalNodes = Array.isArray(nodeOrNodes) + ? nodeOrNodes + : nodeOrNodes + ? [nodeOrNodes] + : []; + const finalLexicalNode: null | LexicalNode = + finalLexicalNodes[finalLexicalNodes.length - 1] || null; + if (postTransform) { + childLexicalNodes = postTransform(childLexicalNodes); + } + if (isBlockDomNode(node)) { + if (!hasBlockAncestorLexicalNodeForChildren) { + childLexicalNodes = $wrapContinuousInlines( + node, + childLexicalNodes, + $createParagraphNode, + ); + } else { + const allArtificialNodes = $getDOMImportContextValue( + DOMContextArtificialNodes, + ); + invariant( + allArtificialNodes !== null, + 'Missing DOMContextArtificialNodes', + ); + childLexicalNodes = $wrapContinuousInlines( + node, + childLexicalNodes, + () => { + const artificialNode = new ArtificialNode__DO_NOT_USE(); + allArtificialNodes.push(artificialNode); + return artificialNode; + }, + ); + } + } + + if (finalLexicalNode == null) { + if (childLexicalNodes.length > 0) { + // If it hasn't been converted to a LexicalNode, we hoist its children + // up to the same level as it. + finalLexicalNodes.push(...childLexicalNodes); + } else { + if (isBlockDomNode(node) && isDomNodeBetweenTwoInlineNodes(node)) { + // Empty block dom node that hasnt been converted, we replace it with a linebreak if its between inline nodes + finalLexicalNodes.push($createLineBreakNode()); + } + } + } else { + if ($isElementNode(finalLexicalNode)) { + // If the current node is a ElementNode after conversion, + // we can append all the children to it. + finalLexicalNode.splice( + finalLexicalNode.getChildrenSize(), + 0, + childLexicalNodes, + ); + } + } + + return finalLexicalNodes; + }, + node: [], + }; + let currentLexicalNode = null; + const transformFunction = getConversionFunction(node, editor); + const transformOutput = transformFunction + ? transformFunction(node as HTMLElement) + : null; + const addChildContext = (cfg: AnyStateConfigPair) => { + output.childContext = output.childContext || []; + output.childContext.push(cfg); + }; + + if (transformOutput !== null) { + const forChildMap = $getDOMImportContextValue( + DOMContextForChildMap, + editor, + ); + const parentLexicalNode = $getDOMImportContextValue( + DOMContextParentLexicalNode, + editor, + ); + postTransform = transformOutput.after; + const transformNodes = transformOutput.node; + currentLexicalNode = Array.isArray(transformNodes) + ? transformNodes[transformNodes.length - 1] + : transformNodes; + + if (currentLexicalNode !== null && forChildMap) { + for (const forChildFunction of forChildMap.values()) { + currentLexicalNode = forChildFunction( + currentLexicalNode, + parentLexicalNode, + ); + + if (!currentLexicalNode) { + break; + } + } + + if (currentLexicalNode) { + output.node.push( + ...(Array.isArray(transformNodes) + ? transformNodes + : [currentLexicalNode]), + ); + } + } + + if (transformOutput.forChild != null) { + addChildContext( + DOMContextForChildMap.pair( + new Map(forChildMap || []).set( + node.nodeName, + transformOutput.forChild, + ), + ), + ); + } + } + + const hasBlockAncestorLexicalNode = $getDOMImportContextValue( + DOMContextHasBlockAncestorLexicalNode, + ); + hasBlockAncestorLexicalNodeForChildren = + currentLexicalNode != null && $isRootOrShadowRoot(currentLexicalNode) + ? false + : (currentLexicalNode != null && + $isBlockElementNode(currentLexicalNode)) || + $getDOMImportContextValue(DOMContextHasBlockAncestorLexicalNode); + if ( + hasBlockAncestorLexicalNode !== hasBlockAncestorLexicalNodeForChildren + ) { + addChildContext( + DOMContextHasBlockAncestorLexicalNode.pair( + hasBlockAncestorLexicalNodeForChildren, + ), + ); + } + return output; + }; +} + +function importOverrideSort( + a: DOMImportConfigMatch, + b: DOMImportConfigMatch, +): number { + // Lowest priority and non-wildcards first + return ( + (a.priority || 0) - (b.priority || 0) || + Number(a.tag === '*') - Number(b.tag === '*') + ); +} + +export function $compileImportOverrides( + editor: LexicalEditor, + config: DOMImportConfig, +): DOMImportExtensionOutput { + let $importNode = compileLegacyImportDOM(editor); + let importer: TagImport | MatchesImport = new TagImport(); + const sortedOverrides = config.overrides.sort(importOverrideSort); + for (const match of sortedOverrides) { + if (match.tag === '*') { + if (!(importer instanceof MatchesImport && importer.tag === match.tag)) { + $importNode = importer.compile($importNode, editor); + importer = new MatchesImport(match.tag); + } + } else if (importer instanceof MatchesImport) { + $importNode = importer.compile($importNode, editor); + importer = new TagImport(); + } + importer.push(match); + } + $importNode = importer.compile($importNode, editor); + const $importNodes = ( + rootOrDocument: ParentNode | Document, + ): LexicalNode[] => { + const artificialNodes: ArtificialNode__DO_NOT_USE[] = []; + return $withDOMImportContext([ + DOMContextArtificialNodes.pair(artificialNodes), + ])(() => { + const nodes: LexicalNode[] = []; + const stack: [ + Node, + AnyStateConfigPair[], + DOMImportNodeFunction, + NonNullable, + ][] = [ + [ + isDOMDocumentNode(rootOrDocument) + ? rootOrDocument.body + : rootOrDocument, + [], + () => ({node: null}), + (node) => { + nodes.push(node); + }, + ], + ]; + for (let entry = stack.pop(); entry; ) { + const [node, ctx, fn, $parentAppendChild] = entry; + const output = $withDOMImportContext(ctx)(() => fn(node)); + const children = + output && output.getChildren + ? output.getChildren() + : isHTMLElement(node) + ? node.childNodes + : EMPTY_ARRAY; + const mergedContext = + output && output.childContext && output.childContext.length > 0 + ? [...ctx, ...output.childContext] + : ctx; + let $appendChild = $parentAppendChild; + if (output) { + const outputNode = output.node; + if (output.$appendChild) { + $appendChild = output.$appendChild; + } else if (Array.isArray(outputNode)) { + $appendChild = (childNode, _dom) => outputNode.push(childNode); + } else if ($isElementNode(outputNode)) { + $appendChild = (childNode, _dom) => outputNode.append(childNode); + } + } + for (let i = children.length - 1; i >= 0; i--) { + const childDom = children[i]; + stack.push([childDom, mergedContext, $importNode, $appendChild]); + } + } + $unwrapArtificialNodes(artificialNodes); + return nodes; + }); + }; + + return { + $importNode, + $importNodes, + }; +} + +/** @internal @experimental */ +export const DOMImportExtension = defineExtension< + DOMImportConfig, + typeof DOMImportExtensionName, + DOMImportExtensionOutput, + null +>({ + build: $compileImportOverrides, + config: {overrides: []}, + dependencies: [DOMExtension], + mergeConfig(config, partial) { + const merged = shallowMergeConfig(config, partial); + for (const k of ['overrides'] as const) { + if (partial[k]) { + (merged[k] as unknown[]) = [...merged[k], ...partial[k]]; + } + } + return merged; + }, + name: DOMImportExtensionName, +}); diff --git a/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts b/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts index 97a2c3ec86a..0877da2229d 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts @@ -7,13 +7,7 @@ */ import {buildEditorFromExtensions} from '@lexical/extension'; -import { - $generateDOMFromRoot, - $getDOMContextValue, - DOMContextRoot, - DOMExtension, - domOverride, -} from '@lexical/html'; +import {$generateDOMFromRoot} from '@lexical/html'; import { $createParagraphNode, $createTextNode, @@ -30,6 +24,10 @@ import { import {expectHtmlToBeEqual, html} from 'lexical/src/__tests__/utils'; import {describe, expect, test} from 'vitest'; +import {$getDOMContextValue, DOMContextRoot} from '../../ContextRecord'; +import {DOMExtension} from '../../DOMExtension'; +import {domOverride} from '../../domOverride'; + const idState = createState('id', { parse: (v) => (typeof v === 'string' ? v : null), }); diff --git a/packages/lexical-html/src/constants.ts b/packages/lexical-html/src/constants.ts new file mode 100644 index 00000000000..54c762e4d42 --- /dev/null +++ b/packages/lexical-html/src/constants.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +export const DOMExtensionName = '@lexical/html/DOM'; +export const DOMImportExtensionName = '@lexical/html/DOMImport'; +export const IGNORE_TAGS = new Set(['STYLE', 'SCRIPT']); diff --git a/packages/lexical-html/src/domOverride.ts b/packages/lexical-html/src/domOverride.ts new file mode 100644 index 00000000000..cf7451ef9c7 --- /dev/null +++ b/packages/lexical-html/src/domOverride.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type {AnyDOMConfigMatch, DOMConfigMatch, NodeMatch} from './types'; +import type {LexicalNode} from 'lexical'; + +/** + * A convenience function for type inference when constructing DOM overrides for + * use with {@link DOMExtension}. + * + * @__NO_SIDE_EFFECTS__ + */ + +export function domOverride( + nodes: '*', + config: Omit, 'nodes'>, +): DOMConfigMatch; +export function domOverride( + nodes: readonly NodeMatch[], + config: Omit, 'nodes'>, +): DOMConfigMatch; +export function domOverride( + nodes: AnyDOMConfigMatch['nodes'], + config: Omit, +): AnyDOMConfigMatch { + return {...config, nodes}; +} diff --git a/packages/lexical-html/src/getConversionFunction.ts b/packages/lexical-html/src/getConversionFunction.ts new file mode 100644 index 00000000000..ef889b416f8 --- /dev/null +++ b/packages/lexical-html/src/getConversionFunction.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type {DOMConversion, DOMConversionFn, LexicalEditor} from 'lexical'; + +export function getConversionFunction( + domNode: Node, + editor: LexicalEditor, +): DOMConversionFn | null { + const {nodeName} = domNode; + + const cachedConversions = editor._htmlConversions.get(nodeName.toLowerCase()); + + let currentConversion: DOMConversion | null = null; + + if (cachedConversions !== undefined) { + for (const cachedConversion of cachedConversions) { + const domConversion = cachedConversion(domNode); + if ( + domConversion !== null && + (currentConversion === null || + // Given equal priority, prefer the last registered importer + // which is typically an application custom node or HTMLConfig['import'] + (currentConversion.priority || 0) <= (domConversion.priority || 0)) + ) { + currentConversion = domConversion; + } + } + } + + return currentConversion !== null ? currentConversion.conversion : null; +} diff --git a/packages/lexical-html/src/importOverride.ts b/packages/lexical-html/src/importOverride.ts new file mode 100644 index 00000000000..f7bcc5d6379 --- /dev/null +++ b/packages/lexical-html/src/importOverride.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type { + DOMImportConfigMatch, + DOMImportFunction, + NodeNameToType, +} from './types'; + +/** + * A convenience function for type inference when constructing DOM overrides for + * use with {@link DOMImportExtension}. + * + * @__NO_SIDE_EFFECTS__ + */ + +export function importOverride( + tag: T, + $import: DOMImportFunction>, + options: Omit = {}, +): DOMImportConfigMatch { + return { + ...options, + $import: $import as DOMImportFunction, + tag: tag.toLowerCase(), + }; +} diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index cda0f67e6ba..d6f9e590db2 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -5,1159 +5,41 @@ * LICENSE file in the root directory of this source tree. * */ - -import type { - AnyStateConfig, - BaseSelection, - DOMChildConversion, - DOMConversion, - DOMConversionFn, - DOMConversionOutput, - DOMExportOutput, - EditorDOMConfig, - ElementDOMSlot, - ElementFormatType, - Klass, - LexicalEditor, - LexicalNode, - StateConfig, -} from 'lexical'; - -import { - getExtensionDependencyFromEditor, - LexicalBuilder, -} from '@lexical/extension'; -import {$sliceSelectedTextNodeContent} from '@lexical/selection'; -import {isBlockDomNode, isHTMLElement} from '@lexical/utils'; -import { - $createLineBreakNode, - $createParagraphNode, - $getEditor, - $getRoot, - $isBlockElementNode, - $isElementNode, - $isRootOrShadowRoot, - $isTextNode, - ArtificialNode__DO_NOT_USE, - createState, - DEFAULT_EDITOR_DOM_CONFIG, - defineExtension, - ElementNode, - isDocumentFragment, - isDOMDocumentNode, - isInlineDomNode, - RootNode, - shallowMergeConfig, -} from 'lexical'; -import invariant from 'shared/invariant'; - -/** - * How you parse your html string to get a document is left up to you. In the browser you can use the native - * DOMParser API to generate a document (see clipboard.ts), but to use in a headless environment you can use JSDom - * or an equivalent library and pass in the document here. - */ -export function $generateNodesFromDOM( - editor: LexicalEditor, - dom: Document | ParentNode, -): Array { - const elements = isDOMDocumentNode(dom) - ? dom.body.childNodes - : dom.childNodes; - let lexicalNodes: Array = []; - const allArtificialNodes: Array = []; - for (const element of elements) { - if (!IGNORE_TAGS.has(element.nodeName)) { - const lexicalNode = $createNodesFromDOM( - element, - editor, - allArtificialNodes, - false, - ); - if (lexicalNode !== null) { - lexicalNodes = lexicalNodes.concat(lexicalNode); - } - } - } - $unwrapArtificialNodes(allArtificialNodes); - - return lexicalNodes; -} - -function getEditorDOMConfig(editor: LexicalEditor): EditorDOMConfig { - return editor._config.dom || DEFAULT_EDITOR_DOM_CONFIG; -} - -export function $generateHtmlFromNodes( - editor: LexicalEditor, - selection: BaseSelection | null = null, -): string { - if ( - typeof document === 'undefined' || - (typeof window === 'undefined' && typeof global.window === 'undefined') - ) { - invariant( - false, - 'To use $generateHtmlFromNodes in headless mode please initialize a headless browser implementation such as JSDom or use withDOM from @lexical/headless/dom before calling this function.', - ); - } - return $generateDOMFromNodes(document.createElement('div'), selection, editor) - .innerHTML; -} - -function $appendNodesToHTML( - editor: LexicalEditor, - currentNode: LexicalNode, - parentElementAppend: (element: Node) => void, - selection: BaseSelection | null = null, - domConfig: EditorDOMConfig = getEditorDOMConfig(editor), -): boolean { - let shouldInclude = domConfig.$shouldInclude(currentNode, selection, editor); - const shouldExclude = domConfig.$shouldExclude( - currentNode, - selection, - editor, - ); - let target = currentNode; - - if (selection !== null && $isTextNode(currentNode)) { - target = $sliceSelectedTextNodeContent(selection, currentNode, 'clone'); - } - const exportProps = domConfig.$exportDOM(target, editor); - const {element, after, append, $getChildNodes} = exportProps; - - if (!element) { - return false; - } - - const fragment = document.createDocumentFragment(); - const children = $getChildNodes - ? $getChildNodes() - : $isElementNode(target) - ? target.getChildren() - : []; - - const fragmentAppend = fragment.append.bind(fragment); - for (const childNode of children) { - const shouldIncludeChild = $appendNodesToHTML( - editor, - childNode, - fragmentAppend, - selection, - domConfig, - ); - - if ( - !shouldInclude && - shouldIncludeChild && - domConfig.$extractWithChild( - currentNode, - childNode, - selection, - 'html', - editor, - ) - ) { - shouldInclude = true; - } - } - - if (shouldInclude && !shouldExclude) { - if (isHTMLElement(element) || isDocumentFragment(element)) { - if (append) { - append(fragment); - } else { - element.append(fragment); - } - } - parentElementAppend(element); - - if (after) { - const newElement = after.call(target, element); - if (newElement) { - if (isDocumentFragment(element)) { - element.replaceChildren(newElement); - } else { - element.replaceWith(newElement); - } - } - } - } else { - parentElementAppend(fragment); - } - - return shouldInclude; -} - -function getConversionFunction( - domNode: Node, - editor: LexicalEditor, -): DOMConversionFn | null { - const {nodeName} = domNode; - - const cachedConversions = editor._htmlConversions.get(nodeName.toLowerCase()); - - let currentConversion: DOMConversion | null = null; - - if (cachedConversions !== undefined) { - for (const cachedConversion of cachedConversions) { - const domConversion = cachedConversion(domNode); - if ( - domConversion !== null && - (currentConversion === null || - // Given equal priority, prefer the last registered importer - // which is typically an application custom node or HTMLConfig['import'] - (currentConversion.priority || 0) <= (domConversion.priority || 0)) - ) { - currentConversion = domConversion; - } - } - } - - return currentConversion !== null ? currentConversion.conversion : null; -} - -const IGNORE_TAGS = new Set(['STYLE', 'SCRIPT']); - -function $createNodesFromDOM( - node: Node, - editor: LexicalEditor, - allArtificialNodes: Array, - hasBlockAncestorLexicalNode: boolean, - forChildMap: Map = new Map(), - parentLexicalNode?: LexicalNode | null | undefined, -): Array { - let lexicalNodes: Array = []; - - if (IGNORE_TAGS.has(node.nodeName)) { - return lexicalNodes; - } - - let currentLexicalNode = null; - const transformFunction = getConversionFunction(node, editor); - const transformOutput = transformFunction - ? transformFunction(node as HTMLElement) - : null; - let postTransform = null; - - if (transformOutput !== null) { - postTransform = transformOutput.after; - const transformNodes = transformOutput.node; - currentLexicalNode = Array.isArray(transformNodes) - ? transformNodes[transformNodes.length - 1] - : transformNodes; - - if (currentLexicalNode !== null) { - for (const [, forChildFunction] of forChildMap) { - currentLexicalNode = forChildFunction( - currentLexicalNode, - parentLexicalNode, - ); - - if (!currentLexicalNode) { - break; - } - } - - if (currentLexicalNode) { - lexicalNodes.push( - ...(Array.isArray(transformNodes) - ? transformNodes - : [currentLexicalNode]), - ); - } - } - - if (transformOutput.forChild != null) { - forChildMap.set(node.nodeName, transformOutput.forChild); - } - } - - // If the DOM node doesn't have a transformer, we don't know what - // to do with it but we still need to process any childNodes. - const children = node.childNodes; - let childLexicalNodes = []; - - const hasBlockAncestorLexicalNodeForChildren = - currentLexicalNode != null && $isRootOrShadowRoot(currentLexicalNode) - ? false - : (currentLexicalNode != null && - $isBlockElementNode(currentLexicalNode)) || - hasBlockAncestorLexicalNode; - - for (let i = 0; i < children.length; i++) { - childLexicalNodes.push( - ...$createNodesFromDOM( - children[i], - editor, - allArtificialNodes, - hasBlockAncestorLexicalNodeForChildren, - new Map(forChildMap), - currentLexicalNode, - ), - ); - } - - if (postTransform != null) { - childLexicalNodes = postTransform(childLexicalNodes); - } - - if (isBlockDomNode(node)) { - if (!hasBlockAncestorLexicalNodeForChildren) { - childLexicalNodes = wrapContinuousInlines( - node, - childLexicalNodes, - $createParagraphNode, - ); - } else { - childLexicalNodes = wrapContinuousInlines(node, childLexicalNodes, () => { - const artificialNode = new ArtificialNode__DO_NOT_USE(); - allArtificialNodes.push(artificialNode); - return artificialNode; - }); - } - } - - if (currentLexicalNode == null) { - if (childLexicalNodes.length > 0) { - // If it hasn't been converted to a LexicalNode, we hoist its children - // up to the same level as it. - lexicalNodes = lexicalNodes.concat(childLexicalNodes); - } else { - if (isBlockDomNode(node) && isDomNodeBetweenTwoInlineNodes(node)) { - // Empty block dom node that hasnt been converted, we replace it with a linebreak if its between inline nodes - lexicalNodes = lexicalNodes.concat($createLineBreakNode()); - } - } - } else { - if ($isElementNode(currentLexicalNode)) { - // If the current node is a ElementNode after conversion, - // we can append all the children to it. - currentLexicalNode.append(...childLexicalNodes); - } - } - - return lexicalNodes; -} - -function wrapContinuousInlines( - domNode: Node, - nodes: Array, - createWrapperFn: () => ElementNode, -): Array { - const textAlign = (domNode as HTMLElement).style - .textAlign as ElementFormatType; - const out: Array = []; - let continuousInlines: Array = []; - // wrap contiguous inline child nodes in para - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - if ($isBlockElementNode(node)) { - if (textAlign && !node.getFormat()) { - node.setFormat(textAlign); - } - out.push(node); - } else { - continuousInlines.push(node); - if ( - i === nodes.length - 1 || - (i < nodes.length - 1 && $isBlockElementNode(nodes[i + 1])) - ) { - const wrapper = createWrapperFn(); - wrapper.setFormat(textAlign); - wrapper.append(...continuousInlines); - out.push(wrapper); - continuousInlines = []; - } - } - } - return out; -} - -function $unwrapArtificialNodes( - allArtificialNodes: Array, -) { - for (const node of allArtificialNodes) { - if (node.getNextSibling() instanceof ArtificialNode__DO_NOT_USE) { - node.insertAfter($createLineBreakNode()); - } - } - // Replace artificial node with it's children - for (const node of allArtificialNodes) { - const children = node.getChildren(); - for (const child of children) { - node.insertBefore(child); - } - node.remove(); - } -} - -function isDomNodeBetweenTwoInlineNodes(node: Node): boolean { - if (node.nextSibling == null || node.previousSibling == null) { - return false; - } - return ( - isInlineDomNode(node.nextSibling) && isInlineDomNode(node.previousSibling) - ); -} - -/** @internal @experimental */ -export interface DOMConfig { - overrides: AnyDOMConfigMatch[]; - contextDefaults: AnyStateConfigPair[]; -} - -/** @internal @experimental */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyDOMConfigMatch = DOMConfigMatch; - -type NodeMatch = - | Klass - | ((node: LexicalNode) => node is T); - -/** @internal @experimental */ -export interface DOMConfigMatch { - readonly nodes: '*' | readonly NodeMatch[]; - $getDOMSlot?: ( - node: N, - $next: () => ElementDOMSlot, - editor: LexicalEditor, - ) => ElementDOMSlot; - $createDOM?: ( - node: T, - $next: () => HTMLElement, - editor: LexicalEditor, - ) => HTMLElement; - $updateDOM?: ( - nextNode: T, - prevNode: T, - dom: HTMLElement, - $next: () => boolean, - editor: LexicalEditor, - ) => boolean; - $exportDOM?: ( - node: T, - $next: () => DOMExportOutput, - editor: LexicalEditor, - ) => DOMExportOutput; - $shouldExclude?: ( - node: T, - selection: null | BaseSelection, - $next: () => boolean, - editor: LexicalEditor, - ) => boolean; - $shouldInclude?: ( - node: T, - selection: null | BaseSelection, - $next: () => boolean, - editor: LexicalEditor, - ) => boolean; - $extractWithChild?: ( - node: T, - childNode: LexicalNode, - selection: null | BaseSelection, - destination: 'clone' | 'html', - $next: () => boolean, - editor: LexicalEditor, - ) => boolean; -} - -function compileDOMConfigOverrides( - {overrides}: DOMConfig, - defaults: EditorDOMConfig, -): EditorDOMConfig { - function mergeDOMConfigMatch( - acc: EditorDOMConfig, - match: AnyDOMConfigMatch, - ): EditorDOMConfig { - // TODO Consider using a node type map to make this more efficient when - // there are more overrides - const { - nodes, - $getDOMSlot, - $createDOM, - $updateDOM, - $exportDOM, - $shouldExclude, - $shouldInclude, - $extractWithChild, - } = match; - const matcher = (node: LexicalNode): boolean => { - for (const predicate of nodes) { - if (predicate === '*') { - return true; - } else if ('getType' in predicate || '$config' in predicate.prototype) { - if (node instanceof predicate) { - return true; - } - } else if (predicate(node)) { - return true; - } - } - return false; - }; - return { - $createDOM: $createDOM - ? (node, editor) => { - const $next = () => acc.$createDOM(node, editor); - return matcher(node) ? $createDOM(node, $next, editor) : $next(); - } - : acc.$createDOM, - $exportDOM: $exportDOM - ? (node, editor) => { - const $next = () => acc.$exportDOM(node, editor); - return matcher(node) ? $exportDOM(node, $next, editor) : $next(); - } - : acc.$exportDOM, - $extractWithChild: $extractWithChild - ? (node, childNode, selection, destination, editor) => { - const $next = () => - acc.$extractWithChild( - node, - childNode, - selection, - destination, - editor, - ); - return matcher(node) - ? $extractWithChild( - node, - childNode, - selection, - destination, - $next, - editor, - ) - : $next(); - } - : acc.$extractWithChild, - $getDOMSlot: $getDOMSlot - ? (node, dom, editor) => { - const $next = () => acc.$getDOMSlot(node, dom, editor); - return $isElementNode(node) && matcher(node) - ? $getDOMSlot(node, $next, editor) - : $next(); - } - : acc.$getDOMSlot, - $shouldExclude: $shouldExclude - ? (node, selection, editor) => { - const $next = () => acc.$shouldExclude(node, selection, editor); - return matcher(node) - ? $shouldExclude(node, selection, $next, editor) - : $next(); - } - : acc.$shouldExclude, - $shouldInclude: $shouldInclude - ? (node, selection, editor) => { - const $next = () => acc.$shouldInclude(node, selection, editor); - return matcher(node) - ? $shouldInclude(node, selection, $next, editor) - : $next(); - } - : acc.$shouldInclude, - $updateDOM: $updateDOM - ? (nextNode, prevNode, dom, editor) => { - const $next = () => acc.$updateDOM(nextNode, prevNode, dom, editor); - return matcher(nextNode) - ? $updateDOM(nextNode, prevNode, dom, $next, editor) - : $next(); - } - : acc.$updateDOM, - }; - } - // The beginning of the array will be the overrides towards the top - // of the tree so should be higher precedence, so we compose the functions - // from the right - return overrides.reduceRight(mergeDOMConfigMatch, defaults); -} - -/** true if this is a whole document export operation ($generateDOMFromRoot) */ -export const DOMContextRoot = createState('@lexical/html/root', { - parse: Boolean, -}); - -/** true if this is an export operation ($generateHtmlFromNodes) */ -export const DOMContextExport = createState('@lexical/html/export', { - parse: Boolean, -}); -/** true if the DOM is for or from the clipboard */ -export const DOMContextClipboard = createState('@lexical/html/clipboard', { - parse: Boolean, -}); - -const DOMContextForChildMap = createState('@lexical/htm/forChildMap', { - parse: (): null | Map => null, -}); -const DOMContextParentLexicalNode = createState( - '@lexical/html/parentLexicalNode', - { - parse: (): null | LexicalNode => null, - }, -); -const DOMContextHasBlockAncestorLexicalNode = createState( - '@lexical/html/hasBlockAncestorLexicalNode', - { - parse: Boolean, - }, -); -const DOMContextArtificialNodes = createState('@lexical/html/ArtificialNodes', { - parse: (): null | ArtificialNode__DO_NOT_USE[] => null, -}); - -export type StateConfigPair = readonly [ - StateConfig, - V, -]; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type AnyStateConfigPair = StateConfigPair; - -export interface DOMExtensionOutput { - defaults: ContextRecord; -} - -type ContextRecord = Map; - -function contextFromPairs(pairs: Iterable): ContextRecord { - return new Map(pairs); -} - -function mergeContext( - defaults: ContextRecord, - overrides: ContextRecord | Iterable, -) { - const ctx = new Map(defaults); - for (const [k, v] of overrides) { - ctx.set(k, v); - } - return ctx; -} - -function getDOMExtensionOutputIfAvailable( - editor: LexicalEditor, -): undefined | DOMExtensionOutput { - const builder = LexicalBuilder.maybeFromEditor(editor); - return builder && builder.hasExtensionByName(DOMExtensionName) - ? getExtensionDependencyFromEditor(editor, DOMExtension).output - : undefined; -} - -export function getContextValueFromRecord( - context: ContextRecord, - cfg: StateConfig, -): V { - const v = context.get(cfg); - return v !== undefined || context.has(cfg) ? (v as V) : cfg.defaultValue; -} - -export function $getDOMContextValue( - cfg: StateConfig, - editor: LexicalEditor = $getEditor(), -): V { - const context = - activeDOMContext && activeDOMContext.editor === editor - ? activeDOMContext.context - : getExtensionDependencyFromEditor(editor, DOMExtension).output.defaults; - return getContextValueFromRecord(context, cfg); -} - -export const $getDOMImportContextValue = $getDOMContextValue; - -export function $withDOMContext( - cfg: Iterable, - editor = $getEditor(), -): (f: () => T) => T { - const updates = contextFromPairs(cfg); - return (f) => { - const prevDOMContext = activeDOMContext; - let context: ContextRecord; - if (prevDOMContext && prevDOMContext.editor === editor) { - context = mergeContext(prevDOMContext.context, updates); - } else { - const ext = getDOMExtensionOutputIfAvailable(editor); - context = ext ? mergeContext(ext.defaults, updates) : updates; - } - try { - activeDOMContext = {context, editor}; - return f(); - } finally { - activeDOMContext = prevDOMContext; - } - }; -} - -export function $generateDOMFromNodes( - container: T, - selection: null | BaseSelection = null, - editor: LexicalEditor = $getEditor(), -): T { - return $withDOMContext( - [DOMContextExport.pair(true)], - editor, - )(() => { - const root = $getRoot(); - const domConfig = getEditorDOMConfig(editor); - - const parentElementAppend = container.append.bind(container); - for (const topLevelNode of root.getChildren()) { - $appendNodesToHTML( - editor, - topLevelNode, - parentElementAppend, - selection, - domConfig, - ); - } - return container; - }); -} - -export function $generateDOMFromRoot( - container: T, - root: LexicalNode = $getRoot(), -): T { - const editor = $getEditor(); - return $withDOMContext( - [DOMContextExport.pair(true), DOMContextRoot.pair(true)], - editor, - )(() => { - const selection = null; - const domConfig = getEditorDOMConfig(editor); - const parentElementAppend = container.append.bind(container); - $appendNodesToHTML(editor, root, parentElementAppend, selection, domConfig); - return container; - }); -} - -let activeDOMContext: - | undefined - | {editor: LexicalEditor; context: ContextRecord}; - -/** - * A convenience function for type inference when constructing DOM overrides for - * use with {@link DOMExtension}. - * - * @__NO_SIDE_EFFECTS__ - */ -export function domOverride( - nodes: '*', - config: Omit, 'nodes'>, -): DOMConfigMatch; -export function domOverride( - nodes: readonly NodeMatch[], - config: Omit, 'nodes'>, -): DOMConfigMatch; -export function domOverride( - nodes: AnyDOMConfigMatch['nodes'], - config: Omit, -): AnyDOMConfigMatch { - return {...config, nodes}; -} - -const DOMExtensionName = '@lexical/html/DOM'; -/** @internal @experimental */ -export const DOMExtension = defineExtension< +export { + $generateDOMFromNodes, + $generateDOMFromRoot, + $generateHtmlFromNodes, +} from './$generateDOMFromNodes'; +export {$generateNodesFromDOM} from './$generateNodesFromDOM'; +export { + $getDOMContextValue, + $getDOMImportContextValue, + $withDOMContext, + $withDOMImportContext, + // DOMContextArtificialNodes, + DOMContextClipboard, + DOMContextExport, + // DOMContextForChildMap, + DOMContextHasBlockAncestorLexicalNode, + DOMContextParentLexicalNode, + DOMContextRoot, +} from './ContextRecord'; +export {DOMExtension} from './DOMExtension'; +export {DOMImportExtension} from './DOMImportExtension'; +export {domOverride} from './domOverride'; +export {importOverride} from './importOverride'; +export type { + AnyDOMConfigMatch, DOMConfig, - typeof DOMExtensionName, + DOMConfigMatch, DOMExtensionOutput, - void ->({ - build(editor, config, state) { - return { - defaults: contextFromPairs(config.contextDefaults), - }; - }, - config: { - contextDefaults: [], - overrides: [], - }, - html: { - // Define a RootNode export for $generateDOMFromRoot - export: new Map([ - [ - RootNode, - () => { - const element = document.createElement('div'); - element.role = 'textbox'; - return {element}; - }, - ], - ]), - }, - init(editorConfig, config) { - editorConfig.dom = compileDOMConfigOverrides(config, { - ...DEFAULT_EDITOR_DOM_CONFIG, - ...editorConfig.dom, - }); - }, - mergeConfig(config, partial) { - const merged = shallowMergeConfig(config, partial); - for (const k of ['overrides', 'contextDefaults'] as const) { - if (partial[k]) { - (merged[k] as unknown[]) = [...merged[k], ...partial[k]]; - } - } - return merged; - }, - name: DOMExtensionName, -}); - -/** @internal @experimental */ -export interface DOMImportOutput { - node: null | LexicalNode | LexicalNode[]; - getChildren?: () => Iterable; - childContext?: AnyStateConfigPair[]; - $appendChild?: (node: LexicalNode, dom: ChildNode) => void; - $finalize?: ( - node: null | LexicalNode | LexicalNode[], - ) => null | LexicalNode | LexicalNode[]; -} - -export type DOMImportFunction = ( - node: T, - $next: () => null | undefined | DOMImportOutput, - editor: LexicalEditor, -) => null | undefined | DOMImportOutput; - -export interface NodeNameMap extends HTMLElementTagNameMap { - '*': Node; - '#text': Text; - '#document': Document; - '#comment': Comment; - '#cdata-section': CDATASection; -} - -export type NodeNameToType = T extends keyof NodeNameMap - ? NodeNameMap[T] - : Node; - -/** - * A convenience function for type inference when constructing DOM overrides for - * use with {@link DOMImportExtension}. - * - * @__NO_SIDE_EFFECTS__ - */ -export function importOverride( - tag: T, - $import: DOMImportFunction>, - options: Omit = {}, -): DOMImportConfigMatch { - return { - ...options, - $import: $import as DOMImportFunction, - tag: tag.toLowerCase(), - }; -} - -/** @internal @experimental */ -export interface DOMImportConfig { - overrides: DOMImportConfigMatch[]; -} -export interface DOMImportConfigMatch { - tag: '*' | '#text' | '#cdata-section' | '#comment' | (string & {}); - selector?: string; - priority?: 0 | 1 | 2 | 3 | 4; - $import: ( - node: Node, - $next: () => null | undefined | DOMImportOutput, - editor: LexicalEditor, - ) => null | undefined | DOMImportOutput; -} - -export type DOMImportNodeFunction = DOMImportExtensionOutput['$importNode']; -export interface DOMImportExtensionOutput { - $importNode: (node: Node) => null | undefined | DOMImportOutput; - $importNodes: (node: Node) => LexicalNode[]; -} - -const DOMImportExtensionName = '@lexical/html/DOMImport'; - -class MatchesImport { - tag: string; - matches: DOMImportConfigMatch[] = []; - constructor(tag: string) { - this.tag = tag; - } - push(match: DOMImportConfigMatch) { - invariant( - match.tag === this.tag, - 'MatchesImport requires all to use the same tag', - ); - this.matches.push(match); - } - compile( - $nextImport: (node: Node) => null | undefined | DOMImportOutput, - editor: LexicalEditor, - ): DOMImportExtensionOutput['$importNode'] { - const {matches, tag} = this; - return (node) => { - const el = isHTMLElement(node) ? node : null; - const $importAt = (start: number): null | undefined | DOMImportOutput => { - let rval: undefined | null | DOMImportOutput; - for (let i = start; !rval && i >= 0; i--) { - const match = matches[i]; - if (match) { - const {$import, selector} = matches[i]; - if (!selector || (el && el.matches(selector))) { - rval = $import(node, $importAt.bind(null, i - 1), editor); - } - } - } - return rval; - }; - return ( - ((tag === node.nodeName.toLowerCase() || (el && tag === '*')) && - $importAt(matches.length - 1)) || - $nextImport(node) - ); - }; - } -} - -class TagImport { - tags: Map = new Map(); - push(match: DOMImportConfigMatch) { - invariant(match.tag !== '*', 'TagImport can not handle wildcards'); - const matches = this.tags.get(match.tag) || new MatchesImport(match.tag); - this.tags.set(match.tag, matches); - matches.push(match); - } - compile( - $nextImport: (node: Node) => null | undefined | DOMImportOutput, - editor: LexicalEditor, - ): DOMImportExtensionOutput['$importNode'] { - const compiled = new Map(); - for (const [tag, matches] of this.tags.entries()) { - compiled.set(tag, matches.compile($nextImport, editor)); - } - return compiled.size === 0 - ? $nextImport - : (node: Node) => { - const $import = compiled.get(node.nodeName.toLowerCase()); - return $import ? $import(node) : $nextImport(node); - }; - } -} - -const EMPTY_ARRAY = [] as const; -const emptyGetChildren = () => EMPTY_ARRAY; - -function compileLegacyImportDOM( - editor: LexicalEditor, -): DOMImportExtensionOutput['$importNode'] { - return (node) => { - if (IGNORE_TAGS.has(node.nodeName)) { - return {getChildren: emptyGetChildren, node: null}; - } - // If the DOM node doesn't have a transformer, we don't know what - // to do with it but we still need to process any childNodes. - let childLexicalNodes: LexicalNode[] = []; - let postTransform: DOMConversionOutput['after']; - let hasBlockAncestorLexicalNodeForChildren = false; - const output: DOMImportOutput & {node: LexicalNode[]} = { - $appendChild: (childNode) => childLexicalNodes.push(childNode), - $finalize: (nodeOrNodes) => { - const finalLexicalNodes = Array.isArray(nodeOrNodes) - ? nodeOrNodes - : nodeOrNodes - ? [nodeOrNodes] - : []; - const finalLexicalNode: null | LexicalNode = - finalLexicalNodes[finalLexicalNodes.length - 1] || null; - if (postTransform) { - childLexicalNodes = postTransform(childLexicalNodes); - } - if (isBlockDomNode(node)) { - if (!hasBlockAncestorLexicalNodeForChildren) { - childLexicalNodes = wrapContinuousInlines( - node, - childLexicalNodes, - $createParagraphNode, - ); - } else { - const allArtificialNodes = $getDOMImportContextValue( - DOMContextArtificialNodes, - ); - invariant( - allArtificialNodes !== null, - 'Missing DOMContextArtificialNodes', - ); - childLexicalNodes = wrapContinuousInlines( - node, - childLexicalNodes, - () => { - const artificialNode = new ArtificialNode__DO_NOT_USE(); - allArtificialNodes.push(artificialNode); - return artificialNode; - }, - ); - } - } - - if (finalLexicalNode == null) { - if (childLexicalNodes.length > 0) { - // If it hasn't been converted to a LexicalNode, we hoist its children - // up to the same level as it. - finalLexicalNodes.push(...childLexicalNodes); - } else { - if (isBlockDomNode(node) && isDomNodeBetweenTwoInlineNodes(node)) { - // Empty block dom node that hasnt been converted, we replace it with a linebreak if its between inline nodes - finalLexicalNodes.push($createLineBreakNode()); - } - } - } else { - if ($isElementNode(finalLexicalNode)) { - // If the current node is a ElementNode after conversion, - // we can append all the children to it. - finalLexicalNode.splice( - finalLexicalNode.getChildrenSize(), - 0, - childLexicalNodes, - ); - } - } - - return finalLexicalNodes; - }, - node: [], - }; - let currentLexicalNode = null; - const transformFunction = getConversionFunction(node, editor); - const transformOutput = transformFunction - ? transformFunction(node as HTMLElement) - : null; - const addChildContext = (cfg: AnyStateConfigPair) => { - output.childContext = output.childContext || []; - output.childContext.push(cfg); - }; - - if (transformOutput !== null) { - const forChildMap = $getDOMImportContextValue( - DOMContextForChildMap, - editor, - ); - const parentLexicalNode = $getDOMImportContextValue( - DOMContextParentLexicalNode, - editor, - ); - postTransform = transformOutput.after; - const transformNodes = transformOutput.node; - currentLexicalNode = Array.isArray(transformNodes) - ? transformNodes[transformNodes.length - 1] - : transformNodes; - - if (currentLexicalNode !== null && forChildMap) { - for (const forChildFunction of forChildMap.values()) { - currentLexicalNode = forChildFunction( - currentLexicalNode, - parentLexicalNode, - ); - - if (!currentLexicalNode) { - break; - } - } - - if (currentLexicalNode) { - output.node.push( - ...(Array.isArray(transformNodes) - ? transformNodes - : [currentLexicalNode]), - ); - } - } - - if (transformOutput.forChild != null) { - addChildContext( - DOMContextForChildMap.pair( - new Map(forChildMap || []).set( - node.nodeName, - transformOutput.forChild, - ), - ), - ); - } - } - - const hasBlockAncestorLexicalNode = $getDOMImportContextValue( - DOMContextHasBlockAncestorLexicalNode, - ); - hasBlockAncestorLexicalNodeForChildren = - currentLexicalNode != null && $isRootOrShadowRoot(currentLexicalNode) - ? false - : (currentLexicalNode != null && - $isBlockElementNode(currentLexicalNode)) || - $getDOMImportContextValue(DOMContextHasBlockAncestorLexicalNode); - if ( - hasBlockAncestorLexicalNode !== hasBlockAncestorLexicalNodeForChildren - ) { - addChildContext( - DOMContextHasBlockAncestorLexicalNode.pair( - hasBlockAncestorLexicalNodeForChildren, - ), - ); - } - return output; - }; -} - -function importOverrideSort( - a: DOMImportConfigMatch, - b: DOMImportConfigMatch, -): number { - // Lowest priority and non-wildcards first - return ( - (a.priority || 0) - (b.priority || 0) || - Number(a.tag === '*') - Number(b.tag === '*') - ); -} - -function compileImportOverrides( - editor: LexicalEditor, - config: DOMImportConfig, -): DOMImportExtensionOutput { - function $importNodes(): LexicalNode[] { - return []; - } - let $importNode = compileLegacyImportDOM(editor); - let importer: TagImport | MatchesImport = new TagImport(); - const sortedOverrides = config.overrides.sort(importOverrideSort); - for (const match of sortedOverrides) { - if (match.tag === '*') { - if (!(importer instanceof MatchesImport && importer.tag === match.tag)) { - $importNode = importer.compile($importNode, editor); - importer = new MatchesImport(match.tag); - } - } else if (importer instanceof MatchesImport) { - $importNode = importer.compile($importNode, editor); - importer = new TagImport(); - } - importer.push(match); - } - $importNode = importer.compile($importNode, editor); - - return { - $importNode, - $importNodes, - }; -} - -/** @internal @experimental */ -export const DOMImportExtension = defineExtension< DOMImportConfig, - typeof DOMImportExtensionName, + DOMImportConfigMatch, DOMImportExtensionOutput, - null ->({ - build: compileImportOverrides, - config: {overrides: []}, - dependencies: [DOMExtension], - mergeConfig(config, partial) { - const merged = shallowMergeConfig(config, partial); - for (const k of ['overrides'] as const) { - if (partial[k]) { - (merged[k] as unknown[]) = [...merged[k], ...partial[k]]; - } - } - return merged; - }, - name: DOMImportExtensionName, -}); + DOMImportFunction, + DOMImportNodeFunction, + DOMImportOutput, + NodeMatch, + NodeNameMap, + NodeNameToType, +} from './types'; diff --git a/packages/lexical-html/src/isDomNodeBetweenTwoInlineNodes.ts b/packages/lexical-html/src/isDomNodeBetweenTwoInlineNodes.ts new file mode 100644 index 00000000000..1a0c8aec377 --- /dev/null +++ b/packages/lexical-html/src/isDomNodeBetweenTwoInlineNodes.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {isInlineDomNode} from 'lexical'; + +export function isDomNodeBetweenTwoInlineNodes(node: Node): boolean { + if (node.nextSibling == null || node.previousSibling == null) { + return false; + } + return ( + isInlineDomNode(node.nextSibling) && isInlineDomNode(node.previousSibling) + ); +} diff --git a/packages/lexical-html/src/types.ts b/packages/lexical-html/src/types.ts new file mode 100644 index 00000000000..89c9e09dc88 --- /dev/null +++ b/packages/lexical-html/src/types.ts @@ -0,0 +1,133 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {AnyStateConfigPair, ContextRecord} from './ContextRecord'; +import type { + BaseSelection, + DOMExportOutput, + ElementDOMSlot, + ElementNode, + Klass, + LexicalEditor, + LexicalNode, +} from 'lexical'; + +export interface DOMExtensionOutput { + defaults: ContextRecord; +} + +/** @internal @experimental */ +export interface DOMImportOutput { + node: null | LexicalNode | LexicalNode[]; + getChildren?: () => NodeListOf | readonly ChildNode[]; + childContext?: AnyStateConfigPair[]; + $appendChild?: (node: LexicalNode, dom: ChildNode) => void; + $finalize?: ( + node: null | LexicalNode | LexicalNode[], + ) => null | LexicalNode | LexicalNode[]; +} + +export type DOMImportFunction = ( + node: T, + $next: () => null | undefined | DOMImportOutput, + editor: LexicalEditor, +) => null | undefined | DOMImportOutput; + +export interface NodeNameMap extends HTMLElementTagNameMap { + '*': Node; + '#text': Text; + '#document': Document; + '#comment': Comment; + '#cdata-section': CDATASection; +} + +export type NodeNameToType = T extends keyof NodeNameMap + ? NodeNameMap[T] + : Node; + +/** @internal @experimental */ +export interface DOMConfig { + overrides: AnyDOMConfigMatch[]; + contextDefaults: AnyStateConfigPair[]; +} + +/** @internal @experimental */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyDOMConfigMatch = DOMConfigMatch; + +export type NodeMatch = + | Klass + | ((node: LexicalNode) => node is T); + +/** @internal @experimental */ +export interface DOMConfigMatch { + readonly nodes: '*' | readonly NodeMatch[]; + $getDOMSlot?: ( + node: N, + $next: () => ElementDOMSlot, + editor: LexicalEditor, + ) => ElementDOMSlot; + $createDOM?: ( + node: T, + $next: () => HTMLElement, + editor: LexicalEditor, + ) => HTMLElement; + $updateDOM?: ( + nextNode: T, + prevNode: T, + dom: HTMLElement, + $next: () => boolean, + editor: LexicalEditor, + ) => boolean; + $exportDOM?: ( + node: T, + $next: () => DOMExportOutput, + editor: LexicalEditor, + ) => DOMExportOutput; + $shouldExclude?: ( + node: T, + selection: null | BaseSelection, + $next: () => boolean, + editor: LexicalEditor, + ) => boolean; + $shouldInclude?: ( + node: T, + selection: null | BaseSelection, + $next: () => boolean, + editor: LexicalEditor, + ) => boolean; + $extractWithChild?: ( + node: T, + childNode: LexicalNode, + selection: null | BaseSelection, + destination: 'clone' | 'html', + $next: () => boolean, + editor: LexicalEditor, + ) => boolean; +} + +/** @internal @experimental */ +export interface DOMImportConfig { + overrides: DOMImportConfigMatch[]; +} +export interface DOMImportConfigMatch { + tag: '*' | '#text' | '#cdata-section' | '#comment' | (string & {}); + selector?: string; + priority?: 0 | 1 | 2 | 3 | 4; + $import: ( + node: Node, + $next: () => null | undefined | DOMImportOutput, + editor: LexicalEditor, + ) => null | undefined | DOMImportOutput; +} + +export type DOMImportNodeFunction = DOMImportExtensionOutput['$importNode']; +export interface DOMImportExtensionOutput { + $importNode: (node: Node) => null | undefined | DOMImportOutput; + $importNodes: (root: ParentNode | Document) => LexicalNode[]; +} diff --git a/packages/lexical-html/src/wrapContinuousInlines.ts b/packages/lexical-html/src/wrapContinuousInlines.ts new file mode 100644 index 00000000000..12048605593 --- /dev/null +++ b/packages/lexical-html/src/wrapContinuousInlines.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import { + $isBlockElementNode, + type ElementFormatType, + ElementNode, + type LexicalNode, +} from 'lexical'; + +export function $wrapContinuousInlines( + domNode: Node, + nodes: Array, + $createWrapperFn: () => ElementNode, +): Array { + const textAlign = (domNode as HTMLElement).style + .textAlign as ElementFormatType; + const out: Array = []; + let continuousInlines: Array = []; + // wrap contiguous inline child nodes in para + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if ($isBlockElementNode(node)) { + if (textAlign && !node.getFormat()) { + node.setFormat(textAlign); + } + out.push(node); + } else { + continuousInlines.push(node); + if ( + i === nodes.length - 1 || + (i < nodes.length - 1 && $isBlockElementNode(nodes[i + 1])) + ) { + const wrapper = $createWrapperFn(); + wrapper.setFormat(textAlign); + wrapper.append(...continuousInlines); + out.push(wrapper); + continuousInlines = []; + } + } + } + return out; +} From d48f946dedffa4103abea877a2ec7a59fa9ed73a Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 7 Oct 2025 18:35:42 -0700 Subject: [PATCH 21/47] more import madness --- .../lexical-html/src/DOMImportExtension.ts | 69 ++++++++------ .../src/__tests__/unit/DOMExtension.test.ts | 12 ++- .../__tests__/unit/DOMImportExtension.test.ts | 93 +++++++++++++++++++ 3 files changed, 143 insertions(+), 31 deletions(-) create mode 100644 packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts diff --git a/packages/lexical-html/src/DOMImportExtension.ts b/packages/lexical-html/src/DOMImportExtension.ts index d5b21bc3be9..3a8058be7a7 100644 --- a/packages/lexical-html/src/DOMImportExtension.ts +++ b/packages/lexical-html/src/DOMImportExtension.ts @@ -129,7 +129,7 @@ function compileLegacyImportDOM( let childLexicalNodes: LexicalNode[] = []; let postTransform: DOMConversionOutput['after']; let hasBlockAncestorLexicalNodeForChildren = false; - const output: DOMImportOutput & {node: LexicalNode[]} = { + const output: DOMImportOutput = { $appendChild: (childNode) => childLexicalNodes.push(childNode), $finalize: (nodeOrNodes) => { const finalLexicalNodes = Array.isArray(nodeOrNodes) @@ -194,9 +194,9 @@ function compileLegacyImportDOM( return finalLexicalNodes; }, - node: [], + node: null, }; - let currentLexicalNode = null; + let currentLexicalNode: null | LexicalNode = null; const transformFunction = getConversionFunction(node, editor); const transformOutput = transformFunction ? transformFunction(node as HTMLElement) @@ -216,31 +216,30 @@ function compileLegacyImportDOM( editor, ); postTransform = transformOutput.after; - const transformNodes = transformOutput.node; - currentLexicalNode = Array.isArray(transformNodes) - ? transformNodes[transformNodes.length - 1] - : transformNodes; + let transformNodeArray = Array.isArray(transformOutput.node) + ? transformOutput.node + : transformOutput.node + ? [transformOutput.node] + : []; - if (currentLexicalNode !== null && forChildMap) { - for (const forChildFunction of forChildMap.values()) { - currentLexicalNode = forChildFunction( - currentLexicalNode, - parentLexicalNode, - ); + if (transformNodeArray.length > 0 && forChildMap) { + const transformWithForChild = (initial: LexicalNode) => { + let current: null | undefined | LexicalNode = initial; + for (const forChildFunction of forChildMap.values()) { + current = forChildFunction(current, parentLexicalNode); - if (!currentLexicalNode) { - break; + if (!current) { + return []; + } } - } - - if (currentLexicalNode) { - output.node.push( - ...(Array.isArray(transformNodes) - ? transformNodes - : [currentLexicalNode]), - ); - } + return [current]; + }; + transformNodeArray = transformNodeArray.flatMap(transformWithForChild); } + currentLexicalNode = + transformNodeArray[transformNodeArray.length - 1] || null; + output.node = + transformNodeArray.length > 1 ? transformNodeArray : currentLexicalNode; if (transformOutput.forChild != null) { addChildContext( @@ -262,7 +261,7 @@ function compileLegacyImportDOM( ? false : (currentLexicalNode != null && $isBlockElementNode(currentLexicalNode)) || - $getDOMImportContextValue(DOMContextHasBlockAncestorLexicalNode); + hasBlockAncestorLexicalNode; if ( hasBlockAncestorLexicalNode !== hasBlockAncestorLexicalNodeForChildren ) { @@ -272,6 +271,9 @@ function compileLegacyImportDOM( ), ); } + if ($isElementNode(currentLexicalNode)) { + addChildContext(DOMContextParentLexicalNode.pair(currentLexicalNode)); + } return output; }; } @@ -332,7 +334,7 @@ export function $compileImportOverrides( }, ], ]; - for (let entry = stack.pop(); entry; ) { + for (let entry = stack.pop(); entry; entry = stack.pop()) { const [node, ctx, fn, $parentAppendChild] = entry; const output = $withDOMImportContext(ctx)(() => fn(node)); const children = @@ -355,6 +357,21 @@ export function $compileImportOverrides( } else if ($isElementNode(outputNode)) { $appendChild = (childNode, _dom) => outputNode.append(childNode); } + const {$finalize} = output; + if ($finalize) { + stack.push([ + node, + ctx, + () => ({node: $finalize(outputNode)}), + $parentAppendChild, + ]); + } else if (outputNode) { + for (const addNode of Array.isArray(outputNode) + ? outputNode + : [outputNode]) { + $parentAppendChild(addNode, node as ChildNode); + } + } } for (let i = children.length - 1; i >= 0; i--) { const childDom = children[i]; diff --git a/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts b/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts index 0877da2229d..97a2c3ec86a 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts @@ -7,7 +7,13 @@ */ import {buildEditorFromExtensions} from '@lexical/extension'; -import {$generateDOMFromRoot} from '@lexical/html'; +import { + $generateDOMFromRoot, + $getDOMContextValue, + DOMContextRoot, + DOMExtension, + domOverride, +} from '@lexical/html'; import { $createParagraphNode, $createTextNode, @@ -24,10 +30,6 @@ import { import {expectHtmlToBeEqual, html} from 'lexical/src/__tests__/utils'; import {describe, expect, test} from 'vitest'; -import {$getDOMContextValue, DOMContextRoot} from '../../ContextRecord'; -import {DOMExtension} from '../../DOMExtension'; -import {domOverride} from '../../domOverride'; - const idState = createState('id', { parse: (v) => (typeof v === 'string' ? v : null), }); diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts new file mode 100644 index 00000000000..0b5b0f637ec --- /dev/null +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts @@ -0,0 +1,93 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$insertGeneratedNodes} from '@lexical/clipboard'; +import { + buildEditorFromExtensions, + getExtensionDependencyFromEditor, +} from '@lexical/extension'; +import { + $generateHtmlFromNodes, + DOMConfig, + DOMExtension, + DOMImportConfig, + DOMImportExtension, +} from '@lexical/html'; +import { + $getEditor, + $selectAll, + $setSelection, + configExtension, + defineExtension, +} from 'lexical'; +import {expectHtmlToBeEqual, html} from 'lexical/src/__tests__/utils'; +import {describe, test} from 'vitest'; + +interface ImportTestCase { + name: string; + inputHtml: string; + exportHtml: string; + importConfig?: Partial; + exportConfig?: Partial; +} + +function importCase( + name: string, + inputHtml: string, + exportHtml: string, +): ImportTestCase { + return {exportHtml, inputHtml, name}; +} + +describe('DOMImportExtension', () => { + test.each([ + importCase( + 'center aligned', + html` +

Hello world!

+ `, + html` +

+ Hello world! +

+ `, + ), + ])( + '$name', + ({ + inputHtml, + exportHtml, + importConfig = {}, + exportConfig = {}, + }: ImportTestCase) => { + const builtEditor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: (editor) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(inputHtml, 'text/html'); + const nodes = getExtensionDependencyFromEditor( + $getEditor(), + DOMImportExtension, + ).output.$importNodes(doc); + $insertGeneratedNodes(editor, nodes, $selectAll()); + $setSelection(null); + }, + dependencies: [ + configExtension(DOMImportExtension, importConfig), + configExtension(DOMExtension, exportConfig), + ], + name: 'root', + }), + ); + expectHtmlToBeEqual( + builtEditor.read(() => $generateHtmlFromNodes(builtEditor)), + exportHtml, + ); + }, + ); +}); From 9bb054260bd4d0f4d80fe92f2794101fdd9a8748 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 7 Oct 2025 22:57:51 -0700 Subject: [PATCH 22/47] more tests (one failing) --- .../__tests__/unit/DOMImportExtension.test.ts | 384 +++++++++++++++++- 1 file changed, 368 insertions(+), 16 deletions(-) diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts index 0b5b0f637ec..f09825da434 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts @@ -12,36 +12,39 @@ import { getExtensionDependencyFromEditor, } from '@lexical/extension'; import { - $generateHtmlFromNodes, DOMConfig, DOMExtension, DOMImportConfig, DOMImportExtension, } from '@lexical/html'; +import {CheckListExtension, ListExtension} from '@lexical/list'; import { $getEditor, + $getSelection, + $isRangeSelection, $selectAll, $setSelection, configExtension, defineExtension, } from 'lexical'; import {expectHtmlToBeEqual, html} from 'lexical/src/__tests__/utils'; -import {describe, test} from 'vitest'; +import {assert, describe, test} from 'vitest'; interface ImportTestCase { name: string; - inputHtml: string; - exportHtml: string; + pastedHTML: string; + expectedHTML: string; + plainTextInsert?: string; importConfig?: Partial; exportConfig?: Partial; } function importCase( name: string, - inputHtml: string, - exportHtml: string, + pastedHTML: string, + expectedHTML: string, ): ImportTestCase { - return {exportHtml, inputHtml, name}; + return {expectedHTML, name, pastedHTML}; } describe('DOMImportExtension', () => { @@ -52,16 +55,349 @@ describe('DOMImportExtension', () => {

Hello world!

`, html` -

- Hello world! +

+ Hello world!

`, ), + + { + expectedHTML: html` +

Hello!

+ `, + name: 'plain DOM text node', + pastedHTML: html` + Hello! + `, + }, + { + expectedHTML: html` +

Hello!

+


+ `, + name: 'a paragraph element', + pastedHTML: html` +

Hello!

+

+ `, + }, + { + expectedHTML: html` +

123

+

456

+ `, + name: 'a single div', + pastedHTML: html` + 123 +
456
+ `, + }, + { + expectedHTML: html` +

a b c d e

+

f g h

+ `, + name: 'multiple nested spans and divs', + pastedHTML: html` +
+ a b + + c d + e + +
+ f + g h +
+
+ `, + }, + { + expectedHTML: html` +

123

+

456

+ `, + name: 'nested span in a div', + pastedHTML: html` +
+ + 123 +
456
+
+
+ `, + }, + { + expectedHTML: html` +

123

+

456

+ `, + name: 'nested div in a span', + pastedHTML: html` + + 123 +
456
+
+ `, + }, + { + expectedHTML: html` +
    +
  • + done +
  • +
  • + todo +
  • +
  • +
      +
    • + done +
    • +
    • + todo +
    • +
    +
  • +
  • + todo +
  • +
+ `, + name: 'google doc checklist', + pastedHTML: html` + + + +
    +
  • + checked +

    + + done + +

    +
  • +
  • + unchecked +

    + + todo + +

    +
  • +
      +
    • + checked +

      + + done + +

      +
    • +
    • + unchecked +

      + + todo + +

      +
    • +
    +
  • + unchecked +

    + + todo + +

    +
  • +
+
+ `, + }, + { + expectedHTML: html` +

+ checklist +

+
    +
  • + done +
  • +
  • + todo +
  • +
+ `, + name: 'github checklist', + pastedHTML: html` + +

+ checklist +

+
    +
  • + + + + + + done +
  • +
  • + + + + + + todo +
  • +
+ `, + }, + { + expectedHTML: html` +

+ + hello world + +

+ `, + name: 'pasting inheritance', + pastedHTML: html` + hello + `, + plainTextInsert: ' world', + }, ])( '$name', ({ - inputHtml, - exportHtml, + expectedHTML, + pastedHTML, + plainTextInsert, importConfig = {}, exportConfig = {}, }: ImportTestCase) => { @@ -69,25 +405,41 @@ describe('DOMImportExtension', () => { defineExtension({ $initialEditorState: (editor) => { const parser = new DOMParser(); - const doc = parser.parseFromString(inputHtml, 'text/html'); + const doc = parser.parseFromString(pastedHTML, 'text/html'); const nodes = getExtensionDependencyFromEditor( $getEditor(), DOMImportExtension, ).output.$importNodes(doc); $insertGeneratedNodes(editor, nodes, $selectAll()); + if (plainTextInsert) { + const newSelection = $getSelection(); + assert( + $isRangeSelection(newSelection), + 'isRangeSelection(newSelection) for plainTextInsert', + ); + newSelection.insertText(plainTextInsert); + } $setSelection(null); }, dependencies: [ configExtension(DOMImportExtension, importConfig), configExtension(DOMExtension, exportConfig), + ListExtension, + CheckListExtension, ], name: 'root', + theme: { + text: { + bold: 'editor-text-bold', + italic: 'editor-text-italic', + underline: 'editor-text-underline', + }, + }, }), ); - expectHtmlToBeEqual( - builtEditor.read(() => $generateHtmlFromNodes(builtEditor)), - exportHtml, - ); + const rootElement = document.createElement('div'); + builtEditor.setRootElement(rootElement); + expectHtmlToBeEqual(rootElement.innerHTML, expectedHTML); }, ); }); From 9ad6b0dd33f31e25d0c1753f7af1ff9f2ce5cdbe Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 7 Oct 2025 23:21:17 -0700 Subject: [PATCH 23/47] unify createNodesFromDOM --- .../lexical-html/src/$createNodesFromDOM.ts | 149 ------------------ .../lexical-html/src/$generateNodesFromDOM.ts | 136 +++++++++++++++- .../__tests__/unit/DOMImportExtension.test.ts | 13 +- 3 files changed, 146 insertions(+), 152 deletions(-) delete mode 100644 packages/lexical-html/src/$createNodesFromDOM.ts diff --git a/packages/lexical-html/src/$createNodesFromDOM.ts b/packages/lexical-html/src/$createNodesFromDOM.ts deleted file mode 100644 index d72668e2aa4..00000000000 --- a/packages/lexical-html/src/$createNodesFromDOM.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ -import { - $createLineBreakNode, - $createParagraphNode, - $isBlockElementNode, - $isElementNode, - $isRootOrShadowRoot, - ArtificialNode__DO_NOT_USE, - type DOMChildConversion, - isBlockDomNode, - type LexicalEditor, - type LexicalNode, -} from 'lexical'; - -import {IGNORE_TAGS} from './constants'; -import {getConversionFunction} from './getConversionFunction'; -import {isDomNodeBetweenTwoInlineNodes} from './isDomNodeBetweenTwoInlineNodes'; -import {$wrapContinuousInlines} from './wrapContinuousInlines'; - -export function $createNodesFromDOM( - node: Node, - editor: LexicalEditor, - allArtificialNodes: Array, - hasBlockAncestorLexicalNode: boolean, - forChildMap: Map = new Map(), - parentLexicalNode?: LexicalNode | null | undefined, -): Array { - let lexicalNodes: Array = []; - - if (IGNORE_TAGS.has(node.nodeName)) { - return lexicalNodes; - } - - let currentLexicalNode = null; - const transformFunction = getConversionFunction(node, editor); - const transformOutput = transformFunction - ? transformFunction(node as HTMLElement) - : null; - let postTransform = null; - - if (transformOutput !== null) { - postTransform = transformOutput.after; - const transformNodes = transformOutput.node; - currentLexicalNode = Array.isArray(transformNodes) - ? transformNodes[transformNodes.length - 1] - : transformNodes; - - if (currentLexicalNode !== null) { - for (const [, forChildFunction] of forChildMap) { - currentLexicalNode = forChildFunction( - currentLexicalNode, - parentLexicalNode, - ); - - if (!currentLexicalNode) { - break; - } - } - - if (currentLexicalNode) { - lexicalNodes.push( - ...(Array.isArray(transformNodes) - ? transformNodes - : [currentLexicalNode]), - ); - } - } - - if (transformOutput.forChild != null) { - forChildMap.set(node.nodeName, transformOutput.forChild); - } - } - - // If the DOM node doesn't have a transformer, we don't know what - // to do with it but we still need to process any childNodes. - const children = node.childNodes; - let childLexicalNodes = []; - - const hasBlockAncestorLexicalNodeForChildren = - currentLexicalNode != null && $isRootOrShadowRoot(currentLexicalNode) - ? false - : (currentLexicalNode != null && - $isBlockElementNode(currentLexicalNode)) || - hasBlockAncestorLexicalNode; - - for (let i = 0; i < children.length; i++) { - childLexicalNodes.push( - ...$createNodesFromDOM( - children[i], - editor, - allArtificialNodes, - hasBlockAncestorLexicalNodeForChildren, - new Map(forChildMap), - currentLexicalNode, - ), - ); - } - - if (postTransform != null) { - childLexicalNodes = postTransform(childLexicalNodes); - } - - if (isBlockDomNode(node)) { - if (!hasBlockAncestorLexicalNodeForChildren) { - childLexicalNodes = $wrapContinuousInlines( - node, - childLexicalNodes, - $createParagraphNode, - ); - } else { - childLexicalNodes = $wrapContinuousInlines( - node, - childLexicalNodes, - () => { - const artificialNode = new ArtificialNode__DO_NOT_USE(); - allArtificialNodes.push(artificialNode); - return artificialNode; - }, - ); - } - } - - if (currentLexicalNode == null) { - if (childLexicalNodes.length > 0) { - // If it hasn't been converted to a LexicalNode, we hoist its children - // up to the same level as it. - lexicalNodes = lexicalNodes.concat(childLexicalNodes); - } else { - if (isBlockDomNode(node) && isDomNodeBetweenTwoInlineNodes(node)) { - // Empty block dom node that hasnt been converted, we replace it with a linebreak if its between inline nodes - lexicalNodes = lexicalNodes.concat($createLineBreakNode()); - } - } - } else { - if ($isElementNode(currentLexicalNode)) { - // If the current node is a ElementNode after conversion, - // we can append all the children to it. - currentLexicalNode.append(...childLexicalNodes); - } - } - - return lexicalNodes; -} diff --git a/packages/lexical-html/src/$generateNodesFromDOM.ts b/packages/lexical-html/src/$generateNodesFromDOM.ts index e06bb1537dd..8f3a98162cc 100644 --- a/packages/lexical-html/src/$generateNodesFromDOM.ts +++ b/packages/lexical-html/src/$generateNodesFromDOM.ts @@ -6,15 +6,24 @@ * */ import { + $createLineBreakNode, + $createParagraphNode, + $isBlockElementNode, + $isElementNode, + $isRootOrShadowRoot, ArtificialNode__DO_NOT_USE, + type DOMChildConversion, + isBlockDomNode, isDOMDocumentNode, type LexicalEditor, type LexicalNode, } from 'lexical'; -import {$createNodesFromDOM} from './$createNodesFromDOM'; import {$unwrapArtificialNodes} from './$unwrapArtificialNodes'; import {IGNORE_TAGS} from './constants'; +import {getConversionFunction} from './getConversionFunction'; +import {isDomNodeBetweenTwoInlineNodes} from './isDomNodeBetweenTwoInlineNodes'; +import {$wrapContinuousInlines} from './wrapContinuousInlines'; /** * How you parse your html string to get a document is left up to you. In the browser you can use the native @@ -48,3 +57,128 @@ export function $generateNodesFromDOM( return lexicalNodes; } + +export function $createNodesFromDOM( + node: Node, + editor: LexicalEditor, + allArtificialNodes: Array, + hasBlockAncestorLexicalNode: boolean, + forChildMap: Map = new Map(), + parentLexicalNode?: LexicalNode | null | undefined, +): Array { + let lexicalNodes: Array = []; + + if (IGNORE_TAGS.has(node.nodeName)) { + return lexicalNodes; + } + + let currentLexicalNode = null; + const transformFunction = getConversionFunction(node, editor); + const transformOutput = transformFunction + ? transformFunction(node as HTMLElement) + : null; + let postTransform = null; + + if (transformOutput !== null) { + postTransform = transformOutput.after; + const transformNodes = transformOutput.node; + currentLexicalNode = Array.isArray(transformNodes) + ? transformNodes[transformNodes.length - 1] + : transformNodes; + + if (currentLexicalNode !== null) { + for (const [, forChildFunction] of forChildMap) { + currentLexicalNode = forChildFunction( + currentLexicalNode, + parentLexicalNode, + ); + + if (!currentLexicalNode) { + break; + } + } + + if (currentLexicalNode) { + lexicalNodes.push( + ...(Array.isArray(transformNodes) + ? transformNodes + : [currentLexicalNode]), + ); + } + } + + if (transformOutput.forChild != null) { + forChildMap.set(node.nodeName, transformOutput.forChild); + } + } + + // If the DOM node doesn't have a transformer, we don't know what + // to do with it but we still need to process any childNodes. + const children = node.childNodes; + let childLexicalNodes = []; + + const hasBlockAncestorLexicalNodeForChildren = + currentLexicalNode != null && $isRootOrShadowRoot(currentLexicalNode) + ? false + : (currentLexicalNode != null && + $isBlockElementNode(currentLexicalNode)) || + hasBlockAncestorLexicalNode; + + for (let i = 0; i < children.length; i++) { + childLexicalNodes.push( + ...$createNodesFromDOM( + children[i], + editor, + allArtificialNodes, + hasBlockAncestorLexicalNodeForChildren, + new Map(forChildMap), + currentLexicalNode, + ), + ); + } + + if (postTransform != null) { + childLexicalNodes = postTransform(childLexicalNodes); + } + + if (isBlockDomNode(node)) { + if (!hasBlockAncestorLexicalNodeForChildren) { + childLexicalNodes = $wrapContinuousInlines( + node, + childLexicalNodes, + $createParagraphNode, + ); + } else { + childLexicalNodes = $wrapContinuousInlines( + node, + childLexicalNodes, + () => { + const artificialNode = new ArtificialNode__DO_NOT_USE(); + allArtificialNodes.push(artificialNode); + return artificialNode; + }, + ); + } + } + + if (currentLexicalNode == null) { + if (childLexicalNodes.length > 0) { + // If it hasn't been converted to a LexicalNode, we hoist its children + // up to the same level as it. + lexicalNodes = lexicalNodes.concat(childLexicalNodes); + } else { + if (isBlockDomNode(node) && isDomNodeBetweenTwoInlineNodes(node)) { + // Empty block dom node that hasnt been converted, we replace it with a linebreak if its between inline nodes + lexicalNodes = lexicalNodes.concat($createLineBreakNode()); + } + } + } else { + if ($isElementNode(currentLexicalNode)) { + // If the current node is a ElementNode after conversion, + // we can append all the children to it. + currentLexicalNode.append(...childLexicalNodes); + } + } + + return lexicalNodes; +} diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts index f09825da434..8bfd1931288 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts @@ -27,7 +27,11 @@ import { configExtension, defineExtension, } from 'lexical'; -import {expectHtmlToBeEqual, html} from 'lexical/src/__tests__/utils'; +import { + expectHtmlToBeEqual, + html, + // prettifyHtml, +} from 'lexical/src/__tests__/utils'; import {assert, describe, test} from 'vitest'; interface ImportTestCase { @@ -166,7 +170,6 @@ describe('DOMImportExtension', () => { `, name: 'google doc checklist', pastedHTML: html` - { ); const rootElement = document.createElement('div'); builtEditor.setRootElement(rootElement); + // try { expectHtmlToBeEqual(rootElement.innerHTML, expectedHTML); + // } catch (err) { + // console.log(prettifyHtml(rootElement.innerHTML)); + // console.log(prettifyHtml(expectedHTML)); + // throw err; + // } }, ); }); From 51f4c87f8bcc397c2b53d397ec5df94aa1f4f1a9 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 8 Oct 2025 07:23:19 -0700 Subject: [PATCH 24/47] debug test case, there is still too much nesting --- .../__tests__/unit/DOMImportExtension.test.ts | 129 +----------------- 1 file changed, 2 insertions(+), 127 deletions(-) diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts index 8bfd1931288..d7cebcbd517 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts @@ -64,7 +64,6 @@ describe('DOMImportExtension', () => {

`, ), - { expectedHTML: html`

Hello!

@@ -169,132 +168,8 @@ describe('DOMImportExtension', () => { `, name: 'google doc checklist', - pastedHTML: html` - - -
    -
  • - checked -

    - - done - -

    -
  • -
  • - unchecked -

    - - todo - -

    -
  • -
      -
    • - checked -

      - - done - -

      -
    • -
    • - unchecked -

      - - todo - -

      -
    • -
    -
  • - unchecked -

    - - todo - -

    -
  • -
-
- `, + // We can't use the HTML template literal formatter here because it's white-space:pre + pastedHTML: `
  • checked

    done

  • unchecked

    todo

    • checked

      done

    • unchecked

      todo

  • unchecked

    todo

`, }, { expectedHTML: html` From 193eb715bdfa3f2614eee07f2e1aac0d689de973 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 8 Oct 2025 07:39:14 -0700 Subject: [PATCH 25/47] add a simpler failing case --- .../src/__tests__/unit/DOMImportExtension.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts index d7cebcbd517..35d849b0431 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts @@ -64,6 +64,16 @@ describe('DOMImportExtension', () => {

`, ), + importCase( + 'reduced ul>li>p', + `
  • first

  • second

`, + html` +
    +
  • first
  • +
  • second
  • +
+ `, + ), { expectedHTML: html`

Hello!

From 8b3558c3041121c47881b5199e6cf3ef67aac164 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 8 Oct 2025 12:20:26 -0700 Subject: [PATCH 26/47] $wrapContinuousInlinesInPlace --- .../lexical-html/src/$generateNodesFromDOM.ts | 37 ++++++++++++- .../lexical-html/src/DOMImportExtension.ts | 52 +++++++++++++------ .../__tests__/unit/DOMImportExtension.test.ts | 24 ++++++++- .../lexical-html/src/wrapContinuousInlines.ts | 47 ----------------- .../lexical/src/nodes/LexicalElementNode.ts | 2 +- 5 files changed, 96 insertions(+), 66 deletions(-) delete mode 100644 packages/lexical-html/src/wrapContinuousInlines.ts diff --git a/packages/lexical-html/src/$generateNodesFromDOM.ts b/packages/lexical-html/src/$generateNodesFromDOM.ts index 8f3a98162cc..cce30ed827c 100644 --- a/packages/lexical-html/src/$generateNodesFromDOM.ts +++ b/packages/lexical-html/src/$generateNodesFromDOM.ts @@ -13,6 +13,8 @@ import { $isRootOrShadowRoot, ArtificialNode__DO_NOT_USE, type DOMChildConversion, + type ElementFormatType, + type ElementNode, isBlockDomNode, isDOMDocumentNode, type LexicalEditor, @@ -23,7 +25,40 @@ import {$unwrapArtificialNodes} from './$unwrapArtificialNodes'; import {IGNORE_TAGS} from './constants'; import {getConversionFunction} from './getConversionFunction'; import {isDomNodeBetweenTwoInlineNodes} from './isDomNodeBetweenTwoInlineNodes'; -import {$wrapContinuousInlines} from './wrapContinuousInlines'; + +function $wrapContinuousInlines( + domNode: Node, + nodes: Array, + $createWrapperFn: () => ElementNode, +): Array { + const textAlign = (domNode as HTMLElement).style + .textAlign as ElementFormatType; + const out: Array = []; + let continuousInlines: Array = []; + // wrap contiguous inline child nodes in para + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if ($isBlockElementNode(node)) { + if (textAlign && !node.getFormat()) { + node.setFormat(textAlign); + } + out.push(node); + } else { + continuousInlines.push(node); + if ( + i === nodes.length - 1 || + (i < nodes.length - 1 && $isBlockElementNode(nodes[i + 1])) + ) { + const wrapper = $createWrapperFn(); + wrapper.setFormat(textAlign); + wrapper.append(...continuousInlines); + out.push(wrapper); + continuousInlines = []; + } + } + } + return out; +} /** * How you parse your html string to get a document is left up to you. In the browser you can use the native diff --git a/packages/lexical-html/src/DOMImportExtension.ts b/packages/lexical-html/src/DOMImportExtension.ts index 3a8058be7a7..0b5df5bce28 100644 --- a/packages/lexical-html/src/DOMImportExtension.ts +++ b/packages/lexical-html/src/DOMImportExtension.ts @@ -22,6 +22,8 @@ import { ArtificialNode__DO_NOT_USE, defineExtension, DOMConversionOutput, + type ElementFormatType, + type ElementNode, isBlockDomNode, isDOMDocumentNode, isHTMLElement, @@ -45,7 +47,33 @@ import { import {DOMExtension} from './DOMExtension'; import {getConversionFunction} from './getConversionFunction'; import {isDomNodeBetweenTwoInlineNodes} from './isDomNodeBetweenTwoInlineNodes'; -import {$wrapContinuousInlines} from './wrapContinuousInlines'; + +function $wrapContinuousInlinesInPlace( + domNode: Node, + nodes: LexicalNode[], + $createWrapperFn: () => ElementNode, +): void { + const textAlign = (domNode as HTMLElement).style + .textAlign as ElementFormatType; + // wrap contiguous inline child nodes in para + let j = 0; + for (let i = 0, wrapper: undefined | ElementNode; i < nodes.length; i++) { + const node = nodes[i]; + if ($isBlockElementNode(node)) { + if (textAlign && !node.getFormat()) { + node.setFormat(textAlign); + } + wrapper = undefined; + nodes[j++] = node; + } else { + if (!wrapper) { + nodes[j++] = wrapper = $createWrapperFn().setFormat(textAlign); + } + wrapper.append(node); + } + } + nodes.length = j; +} class MatchesImport { tag: string; @@ -144,7 +172,7 @@ function compileLegacyImportDOM( } if (isBlockDomNode(node)) { if (!hasBlockAncestorLexicalNodeForChildren) { - childLexicalNodes = $wrapContinuousInlines( + $wrapContinuousInlinesInPlace( node, childLexicalNodes, $createParagraphNode, @@ -157,15 +185,11 @@ function compileLegacyImportDOM( allArtificialNodes !== null, 'Missing DOMContextArtificialNodes', ); - childLexicalNodes = $wrapContinuousInlines( - node, - childLexicalNodes, - () => { - const artificialNode = new ArtificialNode__DO_NOT_USE(); - allArtificialNodes.push(artificialNode); - return artificialNode; - }, - ); + $wrapContinuousInlinesInPlace(node, childLexicalNodes, () => { + const artificialNode = new ArtificialNode__DO_NOT_USE(); + allArtificialNodes.push(artificialNode); + return artificialNode; + }); } } @@ -184,11 +208,7 @@ function compileLegacyImportDOM( if ($isElementNode(finalLexicalNode)) { // If the current node is a ElementNode after conversion, // we can append all the children to it. - finalLexicalNode.splice( - finalLexicalNode.getChildrenSize(), - 0, - childLexicalNodes, - ); + finalLexicalNode.append(...childLexicalNodes); } } diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts index 35d849b0431..49d5a4c907a 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts @@ -12,6 +12,7 @@ import { getExtensionDependencyFromEditor, } from '@lexical/extension'; import { + $generateNodesFromDOM, DOMConfig, DOMExtension, DOMImportConfig, @@ -21,18 +22,20 @@ import {CheckListExtension, ListExtension} from '@lexical/list'; import { $getEditor, $getSelection, + $isElementNode, $isRangeSelection, $selectAll, $setSelection, configExtension, defineExtension, + LexicalNode, } from 'lexical'; import { expectHtmlToBeEqual, html, // prettifyHtml, } from 'lexical/src/__tests__/utils'; -import {assert, describe, test} from 'vitest'; +import {assert, describe, expect, test} from 'vitest'; interface ImportTestCase { name: string; @@ -298,6 +301,25 @@ describe('DOMImportExtension', () => { $getEditor(), DOMImportExtension, ).output.$importNodes(doc); + + // Compare legacy $generateNodesFromDOM to $generateNodes + const legacyNodes = $generateNodesFromDOM(editor, doc); + expect(nodes.length).toEqual(legacyNodes.length); + function compareJSON(a: LexicalNode, b: LexicalNode) { + expect(a.exportJSON()).toEqual(b.exportJSON()); + if ($isElementNode(a) && $isElementNode(b)) { + const as = a.getChildren(); + const bs = b.getChildren(); + expect(as.length).toEqual(bs.length); + for (let i = 0; i < as.length; i++) { + compareJSON(as[i], bs[i]); + } + } + } + for (let i = 0; i < nodes.length; i++) { + compareJSON(nodes[i], legacyNodes[i]); + } + $insertGeneratedNodes(editor, nodes, $selectAll()); if (plainTextInsert) { const newSelection = $getSelection(); diff --git a/packages/lexical-html/src/wrapContinuousInlines.ts b/packages/lexical-html/src/wrapContinuousInlines.ts deleted file mode 100644 index 12048605593..00000000000 --- a/packages/lexical-html/src/wrapContinuousInlines.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ -import { - $isBlockElementNode, - type ElementFormatType, - ElementNode, - type LexicalNode, -} from 'lexical'; - -export function $wrapContinuousInlines( - domNode: Node, - nodes: Array, - $createWrapperFn: () => ElementNode, -): Array { - const textAlign = (domNode as HTMLElement).style - .textAlign as ElementFormatType; - const out: Array = []; - let continuousInlines: Array = []; - // wrap contiguous inline child nodes in para - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - if ($isBlockElementNode(node)) { - if (textAlign && !node.getFormat()) { - node.setFormat(textAlign); - } - out.push(node); - } else { - continuousInlines.push(node); - if ( - i === nodes.length - 1 || - (i < nodes.length - 1 && $isBlockElementNode(nodes[i + 1])) - ) { - const wrapper = $createWrapperFn(); - wrapper.setFormat(textAlign); - wrapper.append(...continuousInlines); - out.push(wrapper); - continuousInlines = []; - } - } - } - return out; -} diff --git a/packages/lexical/src/nodes/LexicalElementNode.ts b/packages/lexical/src/nodes/LexicalElementNode.ts index 676e178c7eb..bbfb99debe6 100644 --- a/packages/lexical/src/nodes/LexicalElementNode.ts +++ b/packages/lexical/src/nodes/LexicalElementNode.ts @@ -658,7 +658,7 @@ export class ElementNode extends LexicalNode { } setFormat(type: ElementFormatType): this { const self = this.getWritable(); - self.__format = type !== '' ? ELEMENT_TYPE_TO_FORMAT[type] : 0; + self.__format = type !== '' ? ELEMENT_TYPE_TO_FORMAT[type] || 0 : 0; return this; } setStyle(style: string): this { From 012f7a93910f83aa8879523c6495d3134fd7e1e5 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 8 Oct 2025 18:58:45 -0700 Subject: [PATCH 27/47] debug $unwrapArtificialNodes --- .../src/$unwrapArtificialNodes.ts | 16 +- .../lexical-html/src/DOMImportExtension.ts | 152 +++++++++++------- 2 files changed, 100 insertions(+), 68 deletions(-) diff --git a/packages/lexical-html/src/$unwrapArtificialNodes.ts b/packages/lexical-html/src/$unwrapArtificialNodes.ts index 6c46e788290..fd881efd1a2 100644 --- a/packages/lexical-html/src/$unwrapArtificialNodes.ts +++ b/packages/lexical-html/src/$unwrapArtificialNodes.ts @@ -11,16 +11,20 @@ export function $unwrapArtificialNodes( allArtificialNodes: Array, ) { for (const node of allArtificialNodes) { - if (node.getNextSibling() instanceof ArtificialNode__DO_NOT_USE) { - node.insertAfter($createLineBreakNode()); + if (node.isAttached()) { + if (node.getNextSibling() instanceof ArtificialNode__DO_NOT_USE) { + node.insertAfter($createLineBreakNode()); + } } } // Replace artificial node with it's children for (const node of allArtificialNodes) { - const children = node.getChildren(); - for (const child of children) { - node.insertBefore(child); + if (node.getParent()) { + const children = node.getChildren(); + for (const child of children) { + node.insertBefore(child); + } + node.remove(); } - node.remove(); } } diff --git a/packages/lexical-html/src/DOMImportExtension.ts b/packages/lexical-html/src/DOMImportExtension.ts index 0b5df5bce28..dbf809a1c22 100644 --- a/packages/lexical-html/src/DOMImportExtension.ts +++ b/packages/lexical-html/src/DOMImportExtension.ts @@ -156,7 +156,6 @@ function compileLegacyImportDOM( // to do with it but we still need to process any childNodes. let childLexicalNodes: LexicalNode[] = []; let postTransform: DOMConversionOutput['after']; - let hasBlockAncestorLexicalNodeForChildren = false; const output: DOMImportOutput = { $appendChild: (childNode) => childLexicalNodes.push(childNode), $finalize: (nodeOrNodes) => { @@ -171,6 +170,14 @@ function compileLegacyImportDOM( childLexicalNodes = postTransform(childLexicalNodes); } if (isBlockDomNode(node)) { + const hasBlockAncestorLexicalNodeForChildren = + finalLexicalNode && $isRootOrShadowRoot(finalLexicalNode) + ? false + : (finalLexicalNode && $isBlockElementNode(finalLexicalNode)) || + $getDOMImportContextValue( + DOMContextHasBlockAncestorLexicalNode, + ); + if (!hasBlockAncestorLexicalNodeForChildren) { $wrapContinuousInlinesInPlace( node, @@ -261,7 +268,7 @@ function compileLegacyImportDOM( output.node = transformNodeArray.length > 1 ? transformNodeArray : currentLexicalNode; - if (transformOutput.forChild != null) { + if (transformOutput.forChild) { addChildContext( DOMContextForChildMap.pair( new Map(forChildMap || []).set( @@ -273,27 +280,6 @@ function compileLegacyImportDOM( } } - const hasBlockAncestorLexicalNode = $getDOMImportContextValue( - DOMContextHasBlockAncestorLexicalNode, - ); - hasBlockAncestorLexicalNodeForChildren = - currentLexicalNode != null && $isRootOrShadowRoot(currentLexicalNode) - ? false - : (currentLexicalNode != null && - $isBlockElementNode(currentLexicalNode)) || - hasBlockAncestorLexicalNode; - if ( - hasBlockAncestorLexicalNode !== hasBlockAncestorLexicalNodeForChildren - ) { - addChildContext( - DOMContextHasBlockAncestorLexicalNode.pair( - hasBlockAncestorLexicalNodeForChildren, - ), - ); - } - if ($isElementNode(currentLexicalNode)) { - addChildContext(DOMContextParentLexicalNode.pair(currentLexicalNode)); - } return output; }; } @@ -356,47 +342,89 @@ export function $compileImportOverrides( ]; for (let entry = stack.pop(); entry; entry = stack.pop()) { const [node, ctx, fn, $parentAppendChild] = entry; - const output = $withDOMImportContext(ctx)(() => fn(node)); - const children = - output && output.getChildren - ? output.getChildren() - : isHTMLElement(node) - ? node.childNodes - : EMPTY_ARRAY; - const mergedContext = - output && output.childContext && output.childContext.length > 0 - ? [...ctx, ...output.childContext] - : ctx; - let $appendChild = $parentAppendChild; - if (output) { - const outputNode = output.node; - if (output.$appendChild) { - $appendChild = output.$appendChild; - } else if (Array.isArray(outputNode)) { - $appendChild = (childNode, _dom) => outputNode.push(childNode); - } else if ($isElementNode(outputNode)) { - $appendChild = (childNode, _dom) => outputNode.append(childNode); - } - const {$finalize} = output; - if ($finalize) { - stack.push([ - node, - ctx, - () => ({node: $finalize(outputNode)}), - $parentAppendChild, - ]); - } else if (outputNode) { - for (const addNode of Array.isArray(outputNode) - ? outputNode - : [outputNode]) { - $parentAppendChild(addNode, node as ChildNode); + $withDOMImportContext(ctx)(() => { + const output = fn(node); + const children = + output && output.getChildren + ? output.getChildren() + : isHTMLElement(node) + ? node.childNodes + : EMPTY_ARRAY; + + let mergedContext = ctx; + let $appendChild = $parentAppendChild; + if (output) { + const outputNode = output.node; + if (output.$appendChild) { + $appendChild = output.$appendChild; + } else if (Array.isArray(outputNode)) { + $appendChild = (childNode, _dom) => outputNode.push(childNode); + } else if ($isElementNode(outputNode)) { + $appendChild = (childNode, _dom) => outputNode.append(childNode); + } + const {$finalize} = output; + if ($finalize) { + stack.push([ + node, + ctx, + () => ({node: $finalize(outputNode)}), + $parentAppendChild, + ]); + } else if (outputNode) { + for (const addNode of Array.isArray(outputNode) + ? outputNode + : [outputNode]) { + $parentAppendChild(addNode, node as ChildNode); + } + } + + const addChildContext = (pair: AnyStateConfigPair) => { + if ($getDOMImportContextValue(pair[0]) === pair[1]) { + return; + } + if (mergedContext === ctx) { + mergedContext = [...ctx]; + } + mergedContext.push(pair); + }; + for (const pair of output.childContext || EMPTY_ARRAY) { + addChildContext(pair); + } + const currentLexicalNode = Array.isArray(outputNode) + ? outputNode[outputNode.length - 1] || null + : outputNode; + const hasBlockAncestorLexicalNode = $getDOMImportContextValue( + DOMContextHasBlockAncestorLexicalNode, + ); + const hasBlockAncestorLexicalNodeForChildren = + currentLexicalNode && $isRootOrShadowRoot(currentLexicalNode) + ? false + : (currentLexicalNode && + $isBlockElementNode(currentLexicalNode)) || + hasBlockAncestorLexicalNode; + + if ( + hasBlockAncestorLexicalNode !== + hasBlockAncestorLexicalNodeForChildren + ) { + addChildContext( + DOMContextHasBlockAncestorLexicalNode.pair( + hasBlockAncestorLexicalNodeForChildren, + ), + ); + } + if ($isElementNode(currentLexicalNode)) { + addChildContext( + DOMContextParentLexicalNode.pair(currentLexicalNode), + ); } } - } - for (let i = children.length - 1; i >= 0; i--) { - const childDom = children[i]; - stack.push([childDom, mergedContext, $importNode, $appendChild]); - } + // Push children in reverse so they are popped off the stack in-order + for (let i = children.length - 1; i >= 0; i--) { + const childDom = children[i]; + stack.push([childDom, mergedContext, $importNode, $appendChild]); + } + }); } $unwrapArtificialNodes(artificialNodes); return nodes; From 51527905b0a9821573901ecc1ce3770cfdfa3f30 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Thu, 9 Oct 2025 08:43:48 -0700 Subject: [PATCH 28/47] fix unwrap --- packages/lexical-html/src/$unwrapArtificialNodes.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/lexical-html/src/$unwrapArtificialNodes.ts b/packages/lexical-html/src/$unwrapArtificialNodes.ts index fd881efd1a2..4a54b3e6901 100644 --- a/packages/lexical-html/src/$unwrapArtificialNodes.ts +++ b/packages/lexical-html/src/$unwrapArtificialNodes.ts @@ -11,7 +11,7 @@ export function $unwrapArtificialNodes( allArtificialNodes: Array, ) { for (const node of allArtificialNodes) { - if (node.isAttached()) { + if (node.getParent()) { if (node.getNextSibling() instanceof ArtificialNode__DO_NOT_USE) { node.insertAfter($createLineBreakNode()); } @@ -20,6 +20,7 @@ export function $unwrapArtificialNodes( // Replace artificial node with it's children for (const node of allArtificialNodes) { if (node.getParent()) { + node.getIndexWithinParent(); const children = node.getChildren(); for (const child of children) { node.insertBefore(child); From cf5474d263e9b63425524168e134749ce671aa02 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Thu, 9 Oct 2025 09:07:11 -0700 Subject: [PATCH 29/47] make legacy support optional --- .../src/$unwrapArtificialNodes.ts | 20 +++++++------------ .../lexical-html/src/DOMImportExtension.ts | 9 ++++----- packages/lexical-html/src/index.ts | 1 - packages/lexical-html/src/types.ts | 4 +++- 4 files changed, 14 insertions(+), 20 deletions(-) diff --git a/packages/lexical-html/src/$unwrapArtificialNodes.ts b/packages/lexical-html/src/$unwrapArtificialNodes.ts index 4a54b3e6901..c68bc411c8a 100644 --- a/packages/lexical-html/src/$unwrapArtificialNodes.ts +++ b/packages/lexical-html/src/$unwrapArtificialNodes.ts @@ -10,22 +10,16 @@ import {$createLineBreakNode, ArtificialNode__DO_NOT_USE} from 'lexical'; export function $unwrapArtificialNodes( allArtificialNodes: Array, ) { + // Replace artificial node with its children, inserting a linebreak + // between adjacent artificial nodes for (const node of allArtificialNodes) { - if (node.getParent()) { - if (node.getNextSibling() instanceof ArtificialNode__DO_NOT_USE) { - node.insertAfter($createLineBreakNode()); - } - } - } - // Replace artificial node with it's children - for (const node of allArtificialNodes) { - if (node.getParent()) { - node.getIndexWithinParent(); + const parent = node.getParent(); + if (parent) { const children = node.getChildren(); - for (const child of children) { - node.insertBefore(child); + if (node.getNextSibling() instanceof ArtificialNode__DO_NOT_USE) { + children.push($createLineBreakNode()); } - node.remove(); + parent.splice(node.getIndexWithinParent(), 1, children); } } } diff --git a/packages/lexical-html/src/DOMImportExtension.ts b/packages/lexical-html/src/DOMImportExtension.ts index dbf809a1c22..a9885af1e27 100644 --- a/packages/lexical-html/src/DOMImportExtension.ts +++ b/packages/lexical-html/src/DOMImportExtension.ts @@ -9,7 +9,6 @@ import type { DOMImportConfig, DOMImportConfigMatch, DOMImportExtensionOutput, - DOMImportNodeFunction, DOMImportOutput, } from './types'; @@ -129,7 +128,7 @@ class TagImport { $nextImport: (node: Node) => null | undefined | DOMImportOutput, editor: LexicalEditor, ): DOMImportExtensionOutput['$importNode'] { - const compiled = new Map(); + const compiled = new Map(); for (const [tag, matches] of this.tags.entries()) { compiled.set(tag, matches.compile($nextImport, editor)); } @@ -299,7 +298,7 @@ export function $compileImportOverrides( editor: LexicalEditor, config: DOMImportConfig, ): DOMImportExtensionOutput { - let $importNode = compileLegacyImportDOM(editor); + let $importNode = config.compileLegacyImportNode(editor); let importer: TagImport | MatchesImport = new TagImport(); const sortedOverrides = config.overrides.sort(importOverrideSort); for (const match of sortedOverrides) { @@ -326,7 +325,7 @@ export function $compileImportOverrides( const stack: [ Node, AnyStateConfigPair[], - DOMImportNodeFunction, + DOMImportExtensionOutput['$importNode'], NonNullable, ][] = [ [ @@ -445,7 +444,7 @@ export const DOMImportExtension = defineExtension< null >({ build: $compileImportOverrides, - config: {overrides: []}, + config: {compileLegacyImportNode: compileLegacyImportDOM, overrides: []}, dependencies: [DOMExtension], mergeConfig(config, partial) { const merged = shallowMergeConfig(config, partial); diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index d6f9e590db2..2db662d1d37 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -37,7 +37,6 @@ export type { DOMImportConfigMatch, DOMImportExtensionOutput, DOMImportFunction, - DOMImportNodeFunction, DOMImportOutput, NodeMatch, NodeNameMap, diff --git a/packages/lexical-html/src/types.ts b/packages/lexical-html/src/types.ts index 89c9e09dc88..955c1b4201d 100644 --- a/packages/lexical-html/src/types.ts +++ b/packages/lexical-html/src/types.ts @@ -114,6 +114,9 @@ export interface DOMConfigMatch { /** @internal @experimental */ export interface DOMImportConfig { overrides: DOMImportConfigMatch[]; + compileLegacyImportNode: ( + editor: LexicalEditor, + ) => DOMImportExtensionOutput['$importNode']; } export interface DOMImportConfigMatch { tag: '*' | '#text' | '#cdata-section' | '#comment' | (string & {}); @@ -126,7 +129,6 @@ export interface DOMImportConfigMatch { ) => null | undefined | DOMImportOutput; } -export type DOMImportNodeFunction = DOMImportExtensionOutput['$importNode']; export interface DOMImportExtensionOutput { $importNode: (node: Node) => null | undefined | DOMImportOutput; $importNodes: (root: ParentNode | Document) => LexicalNode[]; From fa33e9ffb109c9cc28e407bc84933ec202878169 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 10 Oct 2025 15:34:08 -0700 Subject: [PATCH 30/47] prototype import without legacy --- packages/lexical-html/src/ContextRecord.ts | 48 +- .../lexical-html/src/DOMImportExtension.ts | 351 +++++++--- .../unit/DOMImportExtensionNoLegacy.test.ts | 605 ++++++++++++++++++ packages/lexical-html/src/constants.ts | 16 + packages/lexical-html/src/index.ts | 5 +- packages/lexical-html/src/parseStringEnum.ts | 13 + packages/lexical-html/src/types.ts | 34 +- packages/lexical/src/LexicalUtils.ts | 11 +- 8 files changed, 963 insertions(+), 120 deletions(-) create mode 100644 packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts create mode 100644 packages/lexical-html/src/parseStringEnum.ts diff --git a/packages/lexical-html/src/ContextRecord.ts b/packages/lexical-html/src/ContextRecord.ts index 6447cdde40b..aef685644fa 100644 --- a/packages/lexical-html/src/ContextRecord.ts +++ b/packages/lexical-html/src/ContextRecord.ts @@ -5,6 +5,12 @@ * LICENSE file in the root directory of this source tree. * */ +import type { + DOMExtensionOutput, + DOMTextWrapMode, + DOMWhiteSpaceCollapse, +} from './types'; + import { getExtensionDependencyFromEditor, LexicalBuilder, @@ -12,17 +18,20 @@ import { import { $getEditor, type AnyStateConfig, - ArtificialNode__DO_NOT_USE, createState, - DOMChildConversion, type LexicalEditor, LexicalNode, type StateConfig, + TextFormatType, } from 'lexical'; -import {DOMExtensionName} from './constants'; +import { + DOMExtensionName, + DOMTextWrapModeKeys, + DOMWhiteSpaceCollapseKeys, +} from './constants'; import {DOMExtension} from './DOMExtension'; -import {DOMExtensionOutput} from './types'; +import {parseStringEnum} from './parseStringEnum'; let activeDOMContext: | undefined @@ -116,9 +125,30 @@ export const DOMContextExport = createState('@lexical/html/export', { export const DOMContextClipboard = createState('@lexical/html/clipboard', { parse: Boolean, }); -export const DOMContextForChildMap = createState('@lexical/htm/forChildMap', { - parse: (): null | Map => null, + +export const DOMContextTextFormats = createState('@lexical/html/textFormats', { + parse: (s): null | {[K in TextFormatType]?: undefined | boolean} => null, }); + +export const DOMContextWhiteSpaceCollapse = createState( + '@lexical/html/whiteSpaceCollapse', + { + parse: (s): DOMWhiteSpaceCollapse => + (typeof s === 'string' && + parseStringEnum(DOMWhiteSpaceCollapseKeys, s)) || + 'collapse', + }, +); + +export const DOMContextTextWrapMode = createState( + '@lexical/html/textWrapMode', + { + parse: (s): DOMTextWrapMode => + (typeof s === 'string' && parseStringEnum(DOMTextWrapModeKeys, s)) || + 'wrap', + }, +); + export const DOMContextParentLexicalNode = createState( '@lexical/html/parentLexicalNode', { @@ -131,12 +161,6 @@ export const DOMContextHasBlockAncestorLexicalNode = createState( parse: Boolean, }, ); -export const DOMContextArtificialNodes = createState( - '@lexical/html/ArtificialNodes', - { - parse: (): null | ArtificialNode__DO_NOT_USE[] => null, - }, -); export type StateConfigPair = readonly [ StateConfig, diff --git a/packages/lexical-html/src/DOMImportExtension.ts b/packages/lexical-html/src/DOMImportExtension.ts index a9885af1e27..f25f28c0925 100644 --- a/packages/lexical-html/src/DOMImportExtension.ts +++ b/packages/lexical-html/src/DOMImportExtension.ts @@ -9,7 +9,11 @@ import type { DOMImportConfig, DOMImportConfigMatch, DOMImportExtensionOutput, + DOMImportNext, DOMImportOutput, + DOMImportOutputContinue, + DOMTextWrapMode, + DOMWhiteSpaceCollapse, } from './types'; import { @@ -19,7 +23,9 @@ import { $isElementNode, $isRootOrShadowRoot, ArtificialNode__DO_NOT_USE, + createState, defineExtension, + DOMChildConversion, DOMConversionOutput, type ElementFormatType, type ElementNode, @@ -33,20 +39,37 @@ import { import invariant from 'shared/invariant'; import {$unwrapArtificialNodes} from './$unwrapArtificialNodes'; -import {DOMImportExtensionName, IGNORE_TAGS} from './constants'; +import { + DOMImportExtensionName, + DOMImportNextSymbol, + DOMTextWrapModeKeys, + DOMWhiteSpaceCollapseKeys, + IGNORE_TAGS, +} from './constants'; import { $getDOMImportContextValue, $withDOMImportContext, AnyStateConfigPair, - DOMContextArtificialNodes, - DOMContextForChildMap, DOMContextHasBlockAncestorLexicalNode, DOMContextParentLexicalNode, + DOMContextTextWrapMode, + DOMContextWhiteSpaceCollapse, } from './ContextRecord'; import {DOMExtension} from './DOMExtension'; import {getConversionFunction} from './getConversionFunction'; import {isDomNodeBetweenTwoInlineNodes} from './isDomNodeBetweenTwoInlineNodes'; +export const DOMContextForChildMap = createState('@lexical/htm/forChildMap', { + parse: (): null | Map => null, +}); + +export const DOMContextArtificialNodes = createState( + '@lexical/html/ArtificialNodes', + { + parse: (): null | ArtificialNode__DO_NOT_USE[] => null, + }, +); + function $wrapContinuousInlinesInPlace( domNode: Node, nodes: LexicalNode[], @@ -101,7 +124,11 @@ class MatchesImport { if (match) { const {$import, selector} = matches[i]; if (!selector || (el && el.matches(selector))) { - rval = $import(node, $importAt.bind(null, i - 1), editor); + rval = $import( + node, + withImportNextSymbol($importAt.bind(null, i - 1)), + editor, + ); } } } @@ -109,13 +136,30 @@ class MatchesImport { }; return ( ((tag === node.nodeName.toLowerCase() || (el && tag === '*')) && - $importAt(matches.length - 1)) || - $nextImport(node) + $importAt(matches.length - 1)) || { + node: withImportNextSymbol($nextImport.bind(null, node)), + } ); }; } } +function $isImportOutputContinue( + rval: undefined | null | DOMImportOutput, +): rval is DOMImportOutputContinue { + return ( + !!rval && + typeof rval.node === 'function' && + DOMImportNextSymbol in rval.node + ); +} + +function withImportNextSymbol( + fn: () => null | undefined | DOMImportOutput, +): DOMImportNext { + return Object.assign(fn, {[DOMImportNextSymbol]: true} as const); +} + class TagImport { tags: Map = new Map(); push(match: DOMImportConfigMatch) { @@ -134,22 +178,19 @@ class TagImport { } return compiled.size === 0 ? $nextImport - : (node: Node) => { - const $import = compiled.get(node.nodeName.toLowerCase()); - return $import ? $import(node) : $nextImport(node); - }; + : (node: Node) => + (compiled.get(node.nodeName.toLowerCase()) || $nextImport)(node); } } const EMPTY_ARRAY = [] as const; -const emptyGetChildren = () => EMPTY_ARRAY; function compileLegacyImportDOM( editor: LexicalEditor, ): DOMImportExtensionOutput['$importNode'] { return (node) => { if (IGNORE_TAGS.has(node.nodeName)) { - return {getChildren: emptyGetChildren, node: null}; + return {childNodes: EMPTY_ARRAY, node: null}; } // If the DOM node doesn't have a transformer, we don't know what // to do with it but we still need to process any childNodes. @@ -287,14 +328,77 @@ function importOverrideSort( a: DOMImportConfigMatch, b: DOMImportConfigMatch, ): number { - // Lowest priority and non-wildcards first - return ( - (a.priority || 0) - (b.priority || 0) || - Number(a.tag === '*') - Number(b.tag === '*') - ); + // Lowest priority first + return (a.priority || 0) - (b.priority || 0); +} + +type ImportStackEntry = [ + dom: Node, + ctx: AnyStateConfigPair[], + $importNode: DOMImportExtensionOutput['$importNode'], + $appendChild: NonNullable, +]; + +function composeFinalizers( + outer: undefined | ((v: T) => T), + inner: undefined | ((v: T) => T), +): undefined | ((v: T) => T) { + return outer ? (inner ? (v) => outer(inner(v)) : outer) : inner; +} + +function parseDOMWhiteSpaceCollapseFromNode( + node: Node, +): undefined | AnyStateConfigPair[] { + let pairs: undefined | AnyStateConfigPair[]; + if (isHTMLElement(node)) { + const {style} = node; + let textWrapMode: undefined | DOMTextWrapMode; + let whiteSpaceCollapse: undefined | DOMWhiteSpaceCollapse; + switch (style.whiteSpace) { + case 'normal': + whiteSpaceCollapse = 'collapse'; + textWrapMode = 'wrap'; + break; + case 'pre': + whiteSpaceCollapse = 'preserve'; + textWrapMode = 'nowrap'; + break; + case 'pre-wrap': + whiteSpaceCollapse = 'preserve'; + textWrapMode = 'wrap'; + break; + case 'pre-line': + whiteSpaceCollapse = 'preserve-breaks'; + textWrapMode = 'nowrap'; + break; + default: + break; + } + whiteSpaceCollapse = + ( + DOMWhiteSpaceCollapseKeys as Record< + string, + undefined | DOMWhiteSpaceCollapse + > + )[style.whiteSpaceCollapse] || whiteSpaceCollapse; + textWrapMode = + (DOMTextWrapModeKeys as Record)[ + style.textWrapMode + ] || textWrapMode; + if (textWrapMode || whiteSpaceCollapse) { + pairs = []; + if (textWrapMode) { + pairs.push(DOMContextTextWrapMode.pair(textWrapMode)); + } + if (whiteSpaceCollapse) { + pairs.push(DOMContextWhiteSpaceCollapse.pair(whiteSpaceCollapse)); + } + } + } + return pairs; } -export function $compileImportOverrides( +export function compileDOMImportOverrides( editor: LexicalEditor, config: DOMImportConfig, ): DOMImportExtensionOutput { @@ -322,12 +426,7 @@ export function $compileImportOverrides( DOMContextArtificialNodes.pair(artificialNodes), ])(() => { const nodes: LexicalNode[] = []; - const stack: [ - Node, - AnyStateConfigPair[], - DOMImportExtensionOutput['$importNode'], - NonNullable, - ][] = [ + const stack: ImportStackEntry[] = [ [ isDOMDocumentNode(rootOrDocument) ? rootOrDocument.body @@ -341,89 +440,141 @@ export function $compileImportOverrides( ]; for (let entry = stack.pop(); entry; entry = stack.pop()) { const [node, ctx, fn, $parentAppendChild] = entry; - $withDOMImportContext(ctx)(() => { - const output = fn(node); - const children = - output && output.getChildren - ? output.getChildren() - : isHTMLElement(node) - ? node.childNodes - : EMPTY_ARRAY; - - let mergedContext = ctx; - let $appendChild = $parentAppendChild; - if (output) { - const outputNode = output.node; - if (output.$appendChild) { - $appendChild = output.$appendChild; - } else if (Array.isArray(outputNode)) { - $appendChild = (childNode, _dom) => outputNode.push(childNode); - } else if ($isElementNode(outputNode)) { - $appendChild = (childNode, _dom) => outputNode.append(childNode); + const outputContinue: DOMImportOutputContinue & { + nextContext: AnyStateConfigPair[]; + } = { + nextContext: ctx, + node: withImportNextSymbol(fn.bind(null, node)), + }; + const whiteSpaceState = parseDOMWhiteSpaceCollapseFromNode(node); + if (whiteSpaceState) { + outputContinue.nextContext = [...ctx, ...whiteSpaceState]; + } + let currentOutput: null | undefined | DOMImportOutput = outputContinue; + while ($isImportOutputContinue(currentOutput)) { + if (currentOutput.nextContext && outputContinue.nextContext !== ctx) { + if (outputContinue.nextContext === ctx) { + outputContinue.nextContext = [...ctx]; } - const {$finalize} = output; - if ($finalize) { - stack.push([ - node, - ctx, - () => ({node: $finalize(outputNode)}), - $parentAppendChild, - ]); - } else if (outputNode) { - for (const addNode of Array.isArray(outputNode) - ? outputNode - : [outputNode]) { - $parentAppendChild(addNode, node as ChildNode); - } + outputContinue.nextContext.push(...currentOutput.nextContext); + } + const $finalize = composeFinalizers( + outputContinue.$finalize, + currentOutput.$finalize, + ); + if ($finalize) { + outputContinue.$finalize = $finalize; + } + if (currentOutput.childContext) { + if (!outputContinue.childContext) { + outputContinue.childContext = [...currentOutput.childContext]; + } else { + outputContinue.childContext.push(...currentOutput.childContext); } - - const addChildContext = (pair: AnyStateConfigPair) => { - if ($getDOMImportContextValue(pair[0]) === pair[1]) { - return; - } - if (mergedContext === ctx) { - mergedContext = [...ctx]; - } - mergedContext.push(pair); - }; - for (const pair of output.childContext || EMPTY_ARRAY) { - addChildContext(pair); + } + currentOutput = $withDOMImportContext(outputContinue.nextContext)( + currentOutput.node, + ); + } + invariant( + !$isImportOutputContinue(currentOutput), + 'currentOutput can not be a continue', + ); + const output = currentOutput; + let children: NodeListOf | readonly ChildNode[] = + isHTMLElement(node) ? node.childNodes : EMPTY_ARRAY; + let $finalize = outputContinue.$finalize; + let mergedContext = outputContinue.childContext + ? [...outputContinue.nextContext, ...outputContinue.childContext] + : outputContinue.nextContext; + let $appendChild = $parentAppendChild; + const outputNode = output ? output.node : null; + invariant( + typeof outputNode !== 'function', + 'outputNode must not be a function', + ); + if (!output) { + if ($finalize) { + const $boundFinalize = $finalize.bind(null, null); + stack.push([ + node, + ctx, + () => ({node: $boundFinalize()}), + $parentAppendChild, + ]); + } + } else { + if (output.$appendChild) { + $appendChild = output.$appendChild; + } else if (Array.isArray(outputNode)) { + $appendChild = (childNode, _dom) => outputNode.push(childNode); + } else if ($isElementNode(outputNode)) { + $appendChild = (childNode, _dom) => outputNode.append(childNode); + } + children = output.childNodes || children; + $finalize = composeFinalizers($finalize, output.$finalize); + if ($finalize) { + const $boundFinalize = $finalize.bind(null, outputNode); + stack.push([ + node, + ctx, + () => ({node: $boundFinalize()}), + $parentAppendChild, + ]); + } else if (outputNode) { + for (const addNode of Array.isArray(outputNode) + ? outputNode + : [outputNode]) { + $parentAppendChild(addNode, node as ChildNode); } - const currentLexicalNode = Array.isArray(outputNode) - ? outputNode[outputNode.length - 1] || null - : outputNode; - const hasBlockAncestorLexicalNode = $getDOMImportContextValue( - DOMContextHasBlockAncestorLexicalNode, - ); - const hasBlockAncestorLexicalNodeForChildren = - currentLexicalNode && $isRootOrShadowRoot(currentLexicalNode) - ? false - : (currentLexicalNode && - $isBlockElementNode(currentLexicalNode)) || - hasBlockAncestorLexicalNode; + } - if ( - hasBlockAncestorLexicalNode !== - hasBlockAncestorLexicalNodeForChildren - ) { - addChildContext( - DOMContextHasBlockAncestorLexicalNode.pair( - hasBlockAncestorLexicalNodeForChildren, - ), - ); + const addChildContext = (pair: AnyStateConfigPair) => { + if ($getDOMImportContextValue(pair[0]) === pair[1]) { + return; } - if ($isElementNode(currentLexicalNode)) { - addChildContext( - DOMContextParentLexicalNode.pair(currentLexicalNode), - ); + if (mergedContext === ctx) { + mergedContext = [...ctx]; } + mergedContext.push(pair); + }; + for (const pair of output.childContext || EMPTY_ARRAY) { + addChildContext(pair); } - // Push children in reverse so they are popped off the stack in-order - for (let i = children.length - 1; i >= 0; i--) { - const childDom = children[i]; - stack.push([childDom, mergedContext, $importNode, $appendChild]); + const currentLexicalNode = Array.isArray(outputNode) + ? outputNode[outputNode.length - 1] || null + : outputNode; + const hasBlockAncestorLexicalNode = $getDOMImportContextValue( + DOMContextHasBlockAncestorLexicalNode, + ); + const hasBlockAncestorLexicalNodeForChildren = + currentLexicalNode && $isRootOrShadowRoot(currentLexicalNode) + ? false + : (currentLexicalNode && + $isBlockElementNode(currentLexicalNode)) || + hasBlockAncestorLexicalNode; + + if ( + hasBlockAncestorLexicalNode !== + hasBlockAncestorLexicalNodeForChildren + ) { + addChildContext( + DOMContextHasBlockAncestorLexicalNode.pair( + hasBlockAncestorLexicalNodeForChildren, + ), + ); + } + if ($isElementNode(currentLexicalNode)) { + addChildContext( + DOMContextParentLexicalNode.pair(currentLexicalNode), + ); } - }); + } + // Push children in reverse so they are popped off the stack in-order + for (let i = children.length - 1; i >= 0; i--) { + const childDom = children[i]; + stack.push([childDom, mergedContext, $importNode, $appendChild]); + } } $unwrapArtificialNodes(artificialNodes); return nodes; @@ -443,7 +594,7 @@ export const DOMImportExtension = defineExtension< DOMImportExtensionOutput, null >({ - build: $compileImportOverrides, + build: compileDOMImportOverrides, config: {compileLegacyImportNode: compileLegacyImportDOM, overrides: []}, dependencies: [DOMExtension], mergeConfig(config, partial) { diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts new file mode 100644 index 00000000000..1018375df77 --- /dev/null +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts @@ -0,0 +1,605 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$insertGeneratedNodes} from '@lexical/clipboard'; +import { + buildEditorFromExtensions, + getExtensionDependencyFromEditor, +} from '@lexical/extension'; +import { + $generateNodesFromDOM, + $getDOMImportContextValue, + DOMConfig, + DOMContextWhiteSpaceCollapse, + DOMExtension, + DOMImportConfig, + DOMImportExtension, + importOverride, +} from '@lexical/html'; +import { + $createListItemNode, + $createListNode, + CheckListExtension, + ListExtension, +} from '@lexical/list'; +import { + $createLineBreakNode, + $createParagraphNode, + $createTabNode, + $createTextNode, + $getEditor, + $getSelection, + $isElementNode, + $isRangeSelection, + $selectAll, + $setSelection, + CaretDirection, + configExtension, + defineExtension, + isBlockDomNode, + isDOMTextNode, + isHTMLElement, + isInlineDomNode, + LexicalNode, + TextFormatType, + TextNode, +} from 'lexical'; +import { + expectHtmlToBeEqual, + html, + // prettifyHtml, +} from 'lexical/src/__tests__/utils'; +import invariant from 'shared/invariant'; +import {assert, describe, expect, test} from 'vitest'; + +import {DOMContextTextFormats} from '../../ContextRecord'; +import { + DOMImportNext, + DOMImportOutputContinue, + DOMImportOutputNode, +} from '../../types'; + +interface ImportTestCase { + name: string; + pastedHTML: string; + expectedHTML: string; + plainTextInsert?: string; + importConfig?: Partial; + exportConfig?: Partial; +} + +function importCase( + name: string, + pastedHTML: string, + expectedHTML: string, +): ImportTestCase { + return {expectedHTML, name, pastedHTML}; +} + +function $importListNode( + dom: HTMLUListElement | HTMLOListElement, +): DOMImportOutputNode { + const listNode = $createListNode().setListType( + dom.tagName === 'OL' ? 'number' : 'bullet', + ); + return {childNodes: dom.querySelectorAll(':scope>li'), node: listNode}; +} + +const listOverrides = (['ul', 'ol'] as const).map((tag) => + importOverride(tag, $importListNode), +); + +// https://drafts.csswg.org/css-text-4/#line-break-transform +// const collapsePreserve = (s: string): string => s; +// const collapseFunctions: Record string> = +// { +// 'break-spaces': collapsePreserve, +// collapse: (s) => s.replace(/\s+/g, ' '), +// discard: (s) => s.replace(/\s+/g, ''), +// preserve: collapsePreserve, +// 'preserve-breaks': (s) => s.replace(/[ \t]+/g, ' '), +// 'preserve-spaces': (s) => s.replace(/(?:\r?\n|\t)/g, ' '), +// }; + +function $addTextFormatContinue( + format: TextFormatType, +): (node: HTMLElement, $next: DOMImportNext) => null | DOMImportOutputContinue { + return (_dom, $next) => { + const prev = $getDOMImportContextValue(DOMContextTextFormats); + const rval: null | DOMImportOutputContinue = + !prev || !prev[format] + ? { + childContext: [ + DOMContextTextFormats.pair({...prev, [format]: true}), + ], + node: $next, + } + : null; + return rval; + }; +} + +function findTextInLine(text: Text, direction: CaretDirection): null | Text { + let node: Node = text; + const siblingProp = `${direction}Sibling` as const; + const childProp = `${direction === 'next' ? 'first' : 'last'}Child` as const; + // eslint-disable-next-line no-constant-condition + while (true) { + let sibling: null | Node; + while ((sibling = node[siblingProp]) === null) { + const parentElement = node.parentElement; + if (parentElement === null) { + return null; + } + node = parentElement; + } + node = sibling; + if (isHTMLElement(node)) { + const display = node.style.display; + if (!(display ? display.startsWith('inline') : isInlineDomNode(node))) { + return null; + } + } + let descendant: null | Node = node; + while ((descendant = node[childProp]) !== null) { + node = descendant; + } + if (isDOMTextNode(node)) { + return node; + } else if (node.nodeName === 'BR') { + return null; + } + } +} + +function $createTextNodeWithCurrentFormat(text: string = ''): TextNode { + let node = $createTextNode(text); + const fmt = $getDOMImportContextValue(DOMContextTextFormats); + if (fmt) { + for (const k in fmt) { + const textFormat = k as keyof typeof fmt; + if (fmt[textFormat]) { + node = node.toggleFormat(textFormat); + } + } + } + return node; +} + +function $convertTextDOMNode(domNode: Text): DOMImportOutputNode { + const domNode_ = domNode as Text; + const parentDom = domNode.parentElement; + invariant( + parentDom !== null, + 'Expected parentElement of Text not to be null', + ); + let textContent = domNode_.textContent || ''; + // No collapse and preserve segment break for pre, pre-wrap and pre-line + if ( + $getDOMImportContextValue(DOMContextWhiteSpaceCollapse).startsWith('pre') + ) { + const parts = textContent.split(/(\r?\n|\t)/); + const nodes: Array = []; + const length = parts.length; + for (let i = 0; i < length; i++) { + const part = parts[i]; + if (part === '\n' || part === '\r\n') { + nodes.push($createLineBreakNode()); + } else if (part === '\t') { + nodes.push($createTabNode()); + } else if (part !== '') { + nodes.push($createTextNodeWithCurrentFormat(part)); + } + } + return {node: nodes}; + } + textContent = textContent.replace(/\r/g, '').replace(/[ \t\n]+/g, ' '); + if (textContent === '') { + return {node: null}; + } + if (textContent[0] === ' ') { + // Traverse backward while in the same line. If content contains new line or tab -> potential + // delete, other elements can borrow from this one. Deletion depends on whether it's also the + // last space (see next condition: textContent[textContent.length - 1] === ' ')) + let previousText: null | Text = domNode_; + let isStartOfLine = true; + while ( + previousText !== null && + (previousText = findTextInLine(previousText, 'previous')) !== null + ) { + const previousTextContent = previousText.textContent || ''; + if (previousTextContent.length > 0) { + if (/[ \t\n]$/.test(previousTextContent)) { + textContent = textContent.slice(1); + } + isStartOfLine = false; + break; + } + } + if (isStartOfLine) { + textContent = textContent.slice(1); + } + } + if (textContent[textContent.length - 1] === ' ') { + // Traverse forward while in the same line, preserve if next inline will require a space + let nextText: null | Text = domNode_; + let isEndOfLine = true; + while ( + nextText !== null && + (nextText = findTextInLine(nextText, 'next')) !== null + ) { + const nextTextContent = (nextText.textContent || '').replace( + /^( |\t|\r?\n)+/, + '', + ); + if (nextTextContent.length > 0) { + isEndOfLine = false; + break; + } + } + if (isEndOfLine) { + textContent = textContent.slice(0, textContent.length - 1); + } + } + if (textContent === '') { + return {node: null}; + } + return {node: $createTextNodeWithCurrentFormat(textContent)}; +} + +const TO_FORMAT = { + code: 'code', + em: 'italic', + i: 'italic', + mark: 'highlight', + s: 'strikethrough', + strong: 'bold', + sub: 'subscript', + sup: 'superscript', + u: 'underline', +} as const; +const formatOverrides = Object.entries(TO_FORMAT).map(([tag, format]) => + importOverride(tag as keyof typeof TO_FORMAT, $addTextFormatContinue(format)), +); + +const NO_LEGACY_CONFIG: Partial = { + compileLegacyImportNode: () => () => null, + overrides: [ + importOverride('#text', $convertTextDOMNode), + importOverride('*', (dom) => { + if (isBlockDomNode(dom)) { + const node = $createParagraphNode(); + const {textAlign} = dom.style; + switch (textAlign) { + case 'center': + case 'end': + case 'justify': + case 'left': + case 'right': + case 'start': + node.setFormat(textAlign); + break; + default: + break; + } + return {node}; + } + }), + ...formatOverrides, + ...listOverrides, + importOverride('li', (dom) => { + return {node: $createListItemNode()}; + }), + ], +}; + +describe('DOMImportExtension (no legacy)', () => { + test.each([ + importCase( + 'center aligned', + html` +

Hello world!

+ `, + html` +

+ Hello world! +

+ `, + ), + importCase( + 'reduced ul>li>p', + `
  • first

  • second

`, + html` +
    +
  • first
  • +
  • second
  • +
+ `, + ), + { + expectedHTML: html` +

Hello!

+ `, + name: 'plain DOM text node', + pastedHTML: html` + Hello! + `, + }, + { + expectedHTML: html` +

Hello!

+


+ `, + name: 'a paragraph element', + pastedHTML: html` +

Hello!

+

+ `, + }, + { + expectedHTML: html` +

123

+

456

+ `, + name: 'a single div', + pastedHTML: html` + 123 +
456
+ `, + }, + { + expectedHTML: html` +

a b c d e

+

f g h

+ `, + name: 'multiple nested spans and divs', + pastedHTML: html` +
+ a b + + c d + e + +
+ f + g h +
+
+ `, + }, + { + expectedHTML: html` +

123

+

456

+ `, + name: 'nested span in a div', + pastedHTML: html` +
+ + 123 +
456
+
+
+ `, + }, + { + expectedHTML: html` +

123

+

456

+ `, + name: 'nested div in a span', + pastedHTML: html` + + 123 +
456
+
+ `, + }, + { + expectedHTML: html` +
    +
  • + done +
  • +
  • + todo +
  • +
  • +
      +
    • + done +
    • +
    • + todo +
    • +
    +
  • +
  • + todo +
  • +
+ `, + name: 'google doc checklist', + // We can't use the HTML template literal formatter here because it's white-space:pre + pastedHTML: `
  • checked

    done

  • unchecked

    todo

    • checked

      done

    • unchecked

      todo

  • unchecked

    todo

`, + }, + { + expectedHTML: html` +

+ checklist +

+
    +
  • + done +
  • +
  • + todo +
  • +
+ `, + name: 'github checklist', + pastedHTML: html` + +

+ checklist +

+
    +
  • + + + + + + done +
  • +
  • + + + + + + todo +
  • +
+ `, + }, + { + expectedHTML: html` +

+ + hello world + +

+ `, + name: 'pasting inheritance', + pastedHTML: html` + hello + `, + plainTextInsert: ' world', + }, + ])( + '$name', + ({ + expectedHTML, + pastedHTML, + plainTextInsert, + importConfig = {}, + exportConfig = {}, + }: ImportTestCase) => { + const builtEditor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: (editor) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(pastedHTML, 'text/html'); + const nodes = getExtensionDependencyFromEditor( + $getEditor(), + DOMImportExtension, + ).output.$importNodes(doc); + + // Compare legacy $generateNodesFromDOM to $generateNodes + const legacyNodes = $generateNodesFromDOM(editor, doc); + expect(nodes.length).toEqual(legacyNodes.length); + function compareJSON(a: LexicalNode, b: LexicalNode) { + expect(a.exportJSON()).toEqual(b.exportJSON()); + if ($isElementNode(a) && $isElementNode(b)) { + const as = a.getChildren(); + const bs = b.getChildren(); + expect(as.length).toEqual(bs.length); + for (let i = 0; i < as.length; i++) { + compareJSON(as[i], bs[i]); + } + } + } + for (let i = 0; i < nodes.length; i++) { + compareJSON(nodes[i], legacyNodes[i]); + } + + $insertGeneratedNodes(editor, nodes, $selectAll()); + if (plainTextInsert) { + const newSelection = $getSelection(); + assert( + $isRangeSelection(newSelection), + 'isRangeSelection(newSelection) for plainTextInsert', + ); + newSelection.insertText(plainTextInsert); + } + $setSelection(null); + }, + dependencies: [ + configExtension(DOMImportExtension, NO_LEGACY_CONFIG, importConfig), + configExtension(DOMExtension, exportConfig), + ListExtension, + CheckListExtension, + ], + name: 'root', + theme: { + text: { + bold: 'editor-text-bold', + italic: 'editor-text-italic', + underline: 'editor-text-underline', + }, + }, + }), + ); + const rootElement = document.createElement('div'); + builtEditor.setRootElement(rootElement); + // try { + expectHtmlToBeEqual(rootElement.innerHTML, expectedHTML); + // } catch (err) { + // console.log(prettifyHtml(rootElement.innerHTML)); + // console.log(prettifyHtml(expectedHTML)); + // throw err; + // } + }, + ); +}); diff --git a/packages/lexical-html/src/constants.ts b/packages/lexical-html/src/constants.ts index 54c762e4d42..edca0c8b2b8 100644 --- a/packages/lexical-html/src/constants.ts +++ b/packages/lexical-html/src/constants.ts @@ -8,3 +8,19 @@ export const DOMExtensionName = '@lexical/html/DOM'; export const DOMImportExtensionName = '@lexical/html/DOMImport'; export const IGNORE_TAGS = new Set(['STYLE', 'SCRIPT']); +export const DOMImportNextSymbol = Symbol.for('@lexical/html/DOMImportNext'); + +// https://drafts.csswg.org/css-text-4/#white-space-collapsing +export const DOMWhiteSpaceCollapseKeys = { + 'break-spaces': 'break-spaces', + collapse: 'collapse', + discard: 'discard', + preserve: 'preserve', + 'preserve-breaks': 'preserve-breaks', + 'preserve-spaces': 'preserve-spaces', +} as const; + +export const DOMTextWrapModeKeys = { + nowrap: 'nowrap', + wrap: 'wrap', +} as const; diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 2db662d1d37..038714e2470 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -16,13 +16,12 @@ export { $getDOMImportContextValue, $withDOMContext, $withDOMImportContext, - // DOMContextArtificialNodes, DOMContextClipboard, DOMContextExport, - // DOMContextForChildMap, DOMContextHasBlockAncestorLexicalNode, DOMContextParentLexicalNode, DOMContextRoot, + DOMContextWhiteSpaceCollapse, } from './ContextRecord'; export {DOMExtension} from './DOMExtension'; export {DOMImportExtension} from './DOMImportExtension'; @@ -38,6 +37,8 @@ export type { DOMImportExtensionOutput, DOMImportFunction, DOMImportOutput, + DOMTextWrapMode, + DOMWhiteSpaceCollapse, NodeMatch, NodeNameMap, NodeNameToType, diff --git a/packages/lexical-html/src/parseStringEnum.ts b/packages/lexical-html/src/parseStringEnum.ts new file mode 100644 index 00000000000..9c6db7bd9c9 --- /dev/null +++ b/packages/lexical-html/src/parseStringEnum.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +export function parseStringEnum( + stringEnum: {[K in T]: K}, + value: string, +): T | undefined { + return (stringEnum as Record)[value]; +} diff --git a/packages/lexical-html/src/types.ts b/packages/lexical-html/src/types.ts index 955c1b4201d..2a14d8df11b 100644 --- a/packages/lexical-html/src/types.ts +++ b/packages/lexical-html/src/types.ts @@ -6,6 +6,11 @@ * */ +import type { + DOMImportNextSymbol, + DOMTextWrapModeKeys, + DOMWhiteSpaceCollapseKeys, +} from './constants'; import type {AnyStateConfigPair, ContextRecord} from './ContextRecord'; import type { BaseSelection, @@ -22,9 +27,11 @@ export interface DOMExtensionOutput { } /** @internal @experimental */ -export interface DOMImportOutput { +export type DOMImportOutput = DOMImportOutputNode | DOMImportOutputContinue; + +export interface DOMImportOutputNode { node: null | LexicalNode | LexicalNode[]; - getChildren?: () => NodeListOf | readonly ChildNode[]; + childNodes?: NodeListOf | readonly ChildNode[]; childContext?: AnyStateConfigPair[]; $appendChild?: (node: LexicalNode, dom: ChildNode) => void; $finalize?: ( @@ -32,9 +39,20 @@ export interface DOMImportOutput { ) => null | LexicalNode | LexicalNode[]; } +export interface DOMImportOutputContinue { + node: DOMImportNext; + childContext?: AnyStateConfigPair[]; + nextContext?: AnyStateConfigPair[]; + $appendChild?: never; + childNodes?: never; + $finalize?: ( + node: null | LexicalNode | LexicalNode[], + ) => null | LexicalNode | LexicalNode[]; +} + export type DOMImportFunction = ( node: T, - $next: () => null | undefined | DOMImportOutput, + $next: DOMImportNext, editor: LexicalEditor, ) => null | undefined | DOMImportOutput; @@ -124,12 +142,20 @@ export interface DOMImportConfigMatch { priority?: 0 | 1 | 2 | 3 | 4; $import: ( node: Node, - $next: () => null | undefined | DOMImportOutput, + $next: DOMImportNext, editor: LexicalEditor, ) => null | undefined | DOMImportOutput; } +export interface DOMImportNext { + (): null | undefined | DOMImportOutput; + readonly [DOMImportNextSymbol]: true; +} + export interface DOMImportExtensionOutput { $importNode: (node: Node) => null | undefined | DOMImportOutput; $importNodes: (root: ParentNode | Document) => LexicalNode[]; } + +export type DOMWhiteSpaceCollapse = keyof typeof DOMWhiteSpaceCollapseKeys; +export type DOMTextWrapMode = keyof typeof DOMTextWrapModeKeys; diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index 9e5e661c5b7..77a5a701c10 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -1817,7 +1817,9 @@ export function isDocumentFragment(x: unknown): x is DocumentFragment { * @param node - the Dom Node to check * @returns if the Dom Node is an inline node */ -export function isInlineDomNode(node: Node) { +export function isInlineDomNode( + node: Node, +): node is (HTMLElement | Text) & {[InlineDOMBrand]: never} { const inlineNodes = new RegExp( /^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|mark|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var|#text)$/, 'i', @@ -1825,12 +1827,17 @@ export function isInlineDomNode(node: Node) { return node.nodeName.match(inlineNodes) !== null; } +const BlockDOMBrand = Symbol.for('@lexical/BlockDOMBrand'); +const InlineDOMBrand = Symbol.for('@lexical/InlineDOMBrand'); + /** * * @param node - the Dom Node to check * @returns if the Dom Node is a block node */ -export function isBlockDomNode(node: Node) { +export function isBlockDomNode( + node: Node, +): node is HTMLElement & {[BlockDOMBrand]: never} { const blockNodes = new RegExp( /^(address|article|aside|blockquote|canvas|dd|div|dl|dt|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hr|li|main|nav|noscript|ol|p|pre|section|table|td|tfoot|ul|video)$/, 'i', From 2df0682b3b7b23e72a66122c6bc99d83f31a2b7a Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 12 Oct 2025 22:19:29 -0700 Subject: [PATCH 31/47] ContextRecord refactor --- examples/dev-node-state-style/README.md | 2 +- .../dev-node-state-style/src/styleState.ts | 4 +- .../lexical-html/src/$generateDOMFromNodes.ts | 26 +- packages/lexical-html/src/ContextRecord.ts | 277 ++++++-------- .../lexical-html/src/DOMImportExtension.ts | 359 ++++++++---------- ...{DOMExtension.ts => DOMRenderExtension.ts} | 42 +- packages/lexical-html/src/ImportContext.ts | 97 +++++ packages/lexical-html/src/RenderContext.ts | 78 ++++ .../src/__tests__/unit/DOMExtension.test.ts | 14 +- .../__tests__/unit/DOMImportExtension.test.ts | 8 +- .../unit/DOMImportExtensionNoLegacy.test.ts | 33 +- packages/lexical-html/src/constants.ts | 8 +- packages/lexical-html/src/domOverride.ts | 18 +- packages/lexical-html/src/index.ts | 41 +- packages/lexical-html/src/types.ts | 65 +++- .../src/LexicalTableSelectionHelpers.ts | 5 +- packages/lexical-utils/src/markSelection.ts | 8 +- packages/lexical/flow/Lexical.js.flow | 4 +- packages/lexical/src/LexicalEditor.ts | 8 +- packages/lexical/src/LexicalNodeState.ts | 8 +- packages/lexical/src/LexicalReconciler.ts | 25 +- packages/lexical/src/LexicalSelection.ts | 4 +- packages/lexical/src/LexicalUtils.ts | 6 +- packages/lexical/src/index.ts | 4 +- .../lexical/src/nodes/LexicalElementNode.ts | 8 +- 25 files changed, 674 insertions(+), 478 deletions(-) rename packages/lexical-html/src/{DOMExtension.ts => DOMRenderExtension.ts} (85%) create mode 100644 packages/lexical-html/src/ImportContext.ts create mode 100644 packages/lexical-html/src/RenderContext.ts diff --git a/examples/dev-node-state-style/README.md b/examples/dev-node-state-style/README.md index a4a9daeb17b..5e639ed3aca 100644 --- a/examples/dev-node-state-style/README.md +++ b/examples/dev-node-state-style/README.md @@ -1,7 +1,7 @@ # DEV Node State Style example Here we have an example that demonstrates how NodeState can be used with a -DOMExtension to override create and export behavior of any node. +DOMRenderExtension to override create and export behavior of any node. This example currently depends on unreleased features (v0.37+) and will not work outside of the monorepo. diff --git a/examples/dev-node-state-style/src/styleState.ts b/examples/dev-node-state-style/src/styleState.ts index 8efcf1d7610..69d5b66dda8 100644 --- a/examples/dev-node-state-style/src/styleState.ts +++ b/examples/dev-node-state-style/src/styleState.ts @@ -8,7 +8,7 @@ import type {PropertiesHyphenFallback} from 'csstype'; -import {DOMExtension, domOverride} from '@lexical/html'; +import {domOverride, DOMRenderExtension} from '@lexical/html'; import {$forEachSelectedTextNode} from '@lexical/selection'; import InlineStyleParser from 'inline-style-parser'; import { @@ -379,7 +379,7 @@ export function constructStyleImportMap( export const StyleStateExtension = defineExtension({ dependencies: [ - configExtension(DOMExtension, { + configExtension(DOMRenderExtension, { overrides: [ domOverride('*', { $createDOM(node, $next) { diff --git a/packages/lexical-html/src/$generateDOMFromNodes.ts b/packages/lexical-html/src/$generateDOMFromNodes.ts index b530794ddc0..27fa024f703 100644 --- a/packages/lexical-html/src/$generateDOMFromNodes.ts +++ b/packages/lexical-html/src/$generateDOMFromNodes.ts @@ -8,12 +8,12 @@ import {$sliceSelectedTextNodeContent} from '@lexical/selection'; import { $getEditor, - $getEditorDOMConfig, + $getEditorDOMRenderConfig, $getRoot, $isElementNode, $isTextNode, type BaseSelection, - type EditorDOMConfig, + type EditorDOMRenderConfig, isDocumentFragment, isHTMLElement, type LexicalEditor, @@ -22,22 +22,22 @@ import { import invariant from 'shared/invariant'; import { - $withDOMContext, - DOMContextExport, - DOMContextRoot, -} from './ContextRecord'; + $withRenderContext, + RenderContextExport, + RenderContextRoot, +} from './RenderContext'; export function $generateDOMFromNodes( container: T, selection: null | BaseSelection = null, editor: LexicalEditor = $getEditor(), ): T { - return $withDOMContext( - [DOMContextExport.pair(true)], + return $withRenderContext( + [RenderContextExport.pair(true)], editor, )(() => { const root = $getRoot(); - const domConfig = $getEditorDOMConfig(editor); + const domConfig = $getEditorDOMRenderConfig(editor); const parentElementAppend = container.append.bind(container); for (const topLevelNode of root.getChildren()) { @@ -58,12 +58,12 @@ export function $generateDOMFromRoot( root: LexicalNode = $getRoot(), ): T { const editor = $getEditor(); - return $withDOMContext( - [DOMContextExport.pair(true), DOMContextRoot.pair(true)], + return $withRenderContext( + [RenderContextExport.pair(true), RenderContextRoot.pair(true)], editor, )(() => { const selection = null; - const domConfig = $getEditorDOMConfig(editor); + const domConfig = $getEditorDOMRenderConfig(editor); const parentElementAppend = container.append.bind(container); $appendNodesToHTML(editor, root, parentElementAppend, selection, domConfig); return container; @@ -74,7 +74,7 @@ function $appendNodesToHTML( currentNode: LexicalNode, parentElementAppend: (element: Node) => void, selection: BaseSelection | null = null, - domConfig: EditorDOMConfig = $getEditorDOMConfig(editor), + domConfig: EditorDOMRenderConfig = $getEditorDOMRenderConfig(editor), ): boolean { let shouldInclude = domConfig.$shouldInclude(currentNode, selection, editor); const shouldExclude = domConfig.$shouldExclude( diff --git a/packages/lexical-html/src/ContextRecord.ts b/packages/lexical-html/src/ContextRecord.ts index aef685644fa..9e4e709e28d 100644 --- a/packages/lexical-html/src/ContextRecord.ts +++ b/packages/lexical-html/src/ContextRecord.ts @@ -6,175 +6,150 @@ * */ import type { - DOMExtensionOutput, - DOMTextWrapMode, - DOMWhiteSpaceCollapse, + AnyContextConfigPair, + AnyContextSymbol, + ContextConfig, + ContextRecord, } from './types'; -import { - getExtensionDependencyFromEditor, - LexicalBuilder, -} from '@lexical/extension'; -import { - $getEditor, - type AnyStateConfig, - createState, - type LexicalEditor, - LexicalNode, - type StateConfig, - TextFormatType, -} from 'lexical'; +import {$getEditor, createState, type LexicalEditor} from 'lexical'; -import { - DOMExtensionName, - DOMTextWrapModeKeys, - DOMWhiteSpaceCollapseKeys, -} from './constants'; -import {DOMExtension} from './DOMExtension'; -import {parseStringEnum} from './parseStringEnum'; +let activeContext: undefined | EditorContext; -let activeDOMContext: - | undefined - | {editor: LexicalEditor; context: ContextRecord}; +type WithContext = { + [K in Ctx]?: undefined | ContextRecord; +}; -export type ContextRecord = Map; -export function contextFromPairs( - pairs: Iterable, -): undefined | ContextRecord { - let rval: undefined | ContextRecord; - for (const [k, v] of pairs) { - rval = (rval || new Map()).set(k, v); - } - return rval; -} -function mergeContext( - defaults: ContextRecord, - overrides: ContextRecord | Iterable, +export type EditorContext = { + editor: LexicalEditor; +} & WithContext; + +export function getContextValue( + contextRecord: undefined | ContextRecord, + cfg: ContextConfig, ) { - let ctx: undefined | ContextRecord; - for (const [k, v] of overrides) { - if (!ctx) { - if (defaults.get(k) === v) { - continue; - } - ctx = new Map(defaults); - } - ctx.set(k, v); - } - return ctx || defaults; + const {key} = cfg; + return contextRecord && key in contextRecord + ? (contextRecord[key] as V) + : cfg.defaultValue; } -export function getContextValueFromRecord( - context: ContextRecord, - cfg: StateConfig, -): V { - const v = context.get(cfg); - return v !== undefined || context.has(cfg) ? (v as V) : cfg.defaultValue; +export function getEditorContext( + editor: LexicalEditor, +): undefined | EditorContext { + return activeContext && activeContext.editor === editor + ? activeContext + : undefined; } -export function $getDOMContextValue( - cfg: StateConfig, - editor: LexicalEditor = $getEditor(), +export function getEditorContextValue( + sym: Ctx, + context: undefined | EditorContext, + cfg: ContextConfig, ): V { - const context = - activeDOMContext && activeDOMContext.editor === editor - ? activeDOMContext.context - : getExtensionDependencyFromEditor(editor, DOMExtension).output.defaults; - return getContextValueFromRecord(context, cfg); + return getContextValue(context && context[sym], cfg); } -export const $getDOMImportContextValue = $getDOMContextValue; - -export function $withDOMContext( - cfg: Iterable, - editor = $getEditor(), -): (f: () => T) => T { - const updates = contextFromPairs(cfg); - return (f) => { - if (!updates) { - return f(); - } - const prevDOMContext = activeDOMContext; - let context: ContextRecord; - if (prevDOMContext && prevDOMContext.editor === editor) { - context = mergeContext(prevDOMContext.context, updates); - } else { - const ext = getDOMExtensionOutputIfAvailable(editor); - context = ext ? mergeContext(ext.defaults, updates) : updates; - } - try { - activeDOMContext = {context, editor}; - return f(); - } finally { - activeDOMContext = prevDOMContext; +export function contextFromPairs( + pairs: readonly AnyContextConfigPair[], + parent: undefined | ContextRecord, +): undefined | ContextRecord { + let rval = parent; + for (const [k, v] of pairs) { + const key = k.key; + if (rval === parent && getContextValue(rval, k) === v) { + continue; } - }; + const ctx = rval || createChildContext(parent); + ctx[key] = v; + rval = ctx; + } + return rval; } -export const $withDOMImportContext = $withDOMContext; - -/** true if this is a whole document export operation ($generateDOMFromRoot) */ -export const DOMContextRoot = createState('@lexical/html/root', { - parse: Boolean, -}); - -/** true if this is an export operation ($generateHtmlFromNodes) */ -export const DOMContextExport = createState('@lexical/html/export', { - parse: Boolean, -}); -/** true if the DOM is for or from the clipboard */ -export const DOMContextClipboard = createState('@lexical/html/clipboard', { - parse: Boolean, -}); - -export const DOMContextTextFormats = createState('@lexical/html/textFormats', { - parse: (s): null | {[K in TextFormatType]?: undefined | boolean} => null, -}); -export const DOMContextWhiteSpaceCollapse = createState( - '@lexical/html/whiteSpaceCollapse', - { - parse: (s): DOMWhiteSpaceCollapse => - (typeof s === 'string' && - parseStringEnum(DOMWhiteSpaceCollapseKeys, s)) || - 'collapse', - }, -); - -export const DOMContextTextWrapMode = createState( - '@lexical/html/textWrapMode', - { - parse: (s): DOMTextWrapMode => - (typeof s === 'string' && parseStringEnum(DOMTextWrapModeKeys, s)) || - 'wrap', - }, -); +export function createChildContext( + parent: undefined | ContextRecord, +): ContextRecord { + return Object.create(parent || null); +} -export const DOMContextParentLexicalNode = createState( - '@lexical/html/parentLexicalNode', - { - parse: (): null | LexicalNode => null, - }, -); -export const DOMContextHasBlockAncestorLexicalNode = createState( - '@lexical/html/hasBlockAncestorLexicalNode', - { - parse: Boolean, - }, -); +export function updateContextFromPairs( + contextRecord: ContextRecord, + pairs: undefined | readonly AnyContextConfigPair[], +): ContextRecord { + if (pairs) { + for (const [k, v] of pairs) { + contextRecord[k.key] = v; + } + } + return contextRecord; +} -export type StateConfigPair = readonly [ - StateConfig, - V, -]; +/** + * @__NO_SIDE_EFFECTS__ + */ +export function $withFullContext( + sym: Ctx, + contextRecord: ContextRecord, + f: () => T, + editor: LexicalEditor = $getEditor(), +): T { + const prevDOMContext = activeContext; + const parentEditorContext = getEditorContext(editor); + try { + activeContext = {...parentEditorContext, editor, [sym]: contextRecord}; + return f(); + } finally { + activeContext = prevDOMContext; + } +} -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type AnyStateConfigPair = StateConfigPair; +/** + * @__NO_SIDE_EFFECTS__ + */ +export function $withContext( + sym: Ctx, + $defaults: (editor: LexicalEditor) => undefined | ContextRecord = () => + undefined, +) { + return ( + cfg: readonly AnyContextConfigPair[], + editor = $getEditor(), + ): ((f: () => T) => T) => { + return (f) => { + const prevDOMContext = activeContext; + const parentEditorContext = getEditorContext(editor); + const parentContextRecord = + parentEditorContext && parentEditorContext[sym]; + const contextRecord = contextFromPairs( + cfg, + parentContextRecord || $defaults(editor), + ); + if (!contextRecord || contextRecord === parentContextRecord) { + return f(); + } + try { + activeContext = {...parentEditorContext, editor, [sym]: contextRecord}; + return f(); + } finally { + activeContext = prevDOMContext; + } + }; + }; +} -export function getDOMExtensionOutputIfAvailable( - editor: LexicalEditor, -): undefined | DOMExtensionOutput { - const builder = LexicalBuilder.maybeFromEditor(editor); - return builder && builder.hasExtensionByName(DOMExtensionName) - ? getExtensionDependencyFromEditor(editor, DOMExtension).output - : undefined; +/** + * @__NO_SIDE_EFFECTS__ + */ +export function createContextStateFactory(tag: Tag) { + const contextTag: {readonly [k in Tag]: true} = {[tag]: true} as const; + return ( + name: string, + getDefaultValue: () => V, + isEqual?: (a: V, b: V) => boolean, + ) => + Object.assign( + createState(Symbol(name), {isEqual, parse: getDefaultValue}), + contextTag, + ); } diff --git a/packages/lexical-html/src/DOMImportExtension.ts b/packages/lexical-html/src/DOMImportExtension.ts index f25f28c0925..950a16caac5 100644 --- a/packages/lexical-html/src/DOMImportExtension.ts +++ b/packages/lexical-html/src/DOMImportExtension.ts @@ -6,6 +6,8 @@ * */ import type { + AnyImportStateConfigPair, + ContextRecord, DOMImportConfig, DOMImportConfigMatch, DOMImportExtensionOutput, @@ -23,10 +25,8 @@ import { $isElementNode, $isRootOrShadowRoot, ArtificialNode__DO_NOT_USE, - createState, defineExtension, - DOMChildConversion, - DOMConversionOutput, + type DOMConversionOutput, type ElementFormatType, type ElementNode, isBlockDomNode, @@ -40,6 +40,7 @@ import invariant from 'shared/invariant'; import {$unwrapArtificialNodes} from './$unwrapArtificialNodes'; import { + DOMImportContextSymbol, DOMImportExtensionName, DOMImportNextSymbol, DOMTextWrapModeKeys, @@ -47,29 +48,23 @@ import { IGNORE_TAGS, } from './constants'; import { - $getDOMImportContextValue, - $withDOMImportContext, - AnyStateConfigPair, - DOMContextHasBlockAncestorLexicalNode, - DOMContextParentLexicalNode, - DOMContextTextWrapMode, - DOMContextWhiteSpaceCollapse, + $withFullContext, + createChildContext, + updateContextFromPairs, } from './ContextRecord'; -import {DOMExtension} from './DOMExtension'; import {getConversionFunction} from './getConversionFunction'; +import { + $getImportContextValue, + ImportContextArtificialNodes, + ImportContextDOMNode, + ImportContextForChildMap, + ImportContextHasBlockAncestorLexicalNode, + ImportContextParentLexicalNode, + ImportContextTextWrapMode, + ImportContextWhiteSpaceCollapse, +} from './ImportContext'; import {isDomNodeBetweenTwoInlineNodes} from './isDomNodeBetweenTwoInlineNodes'; -export const DOMContextForChildMap = createState('@lexical/htm/forChildMap', { - parse: (): null | Map => null, -}); - -export const DOMContextArtificialNodes = createState( - '@lexical/html/ArtificialNodes', - { - parse: (): null | ArtificialNode__DO_NOT_USE[] => null, - }, -); - function $wrapContinuousInlinesInPlace( domNode: Node, nodes: LexicalNode[], @@ -214,8 +209,8 @@ function compileLegacyImportDOM( finalLexicalNode && $isRootOrShadowRoot(finalLexicalNode) ? false : (finalLexicalNode && $isBlockElementNode(finalLexicalNode)) || - $getDOMImportContextValue( - DOMContextHasBlockAncestorLexicalNode, + $getImportContextValue( + ImportContextHasBlockAncestorLexicalNode, ); if (!hasBlockAncestorLexicalNodeForChildren) { @@ -225,12 +220,12 @@ function compileLegacyImportDOM( $createParagraphNode, ); } else { - const allArtificialNodes = $getDOMImportContextValue( - DOMContextArtificialNodes, + const allArtificialNodes = $getImportContextValue( + ImportContextArtificialNodes, ); invariant( allArtificialNodes !== null, - 'Missing DOMContextArtificialNodes', + 'Missing ImportContextArtificialNodes', ); $wrapContinuousInlinesInPlace(node, childLexicalNodes, () => { const artificialNode = new ArtificialNode__DO_NOT_USE(); @@ -268,18 +263,18 @@ function compileLegacyImportDOM( const transformOutput = transformFunction ? transformFunction(node as HTMLElement) : null; - const addChildContext = (cfg: AnyStateConfigPair) => { + const addChildContext = (cfg: AnyImportStateConfigPair) => { output.childContext = output.childContext || []; output.childContext.push(cfg); }; if (transformOutput !== null) { - const forChildMap = $getDOMImportContextValue( - DOMContextForChildMap, + const forChildMap = $getImportContextValue( + ImportContextForChildMap, editor, ); - const parentLexicalNode = $getDOMImportContextValue( - DOMContextParentLexicalNode, + const parentLexicalNode = $getImportContextValue( + ImportContextParentLexicalNode, editor, ); postTransform = transformOutput.after; @@ -310,7 +305,7 @@ function compileLegacyImportDOM( if (transformOutput.forChild) { addChildContext( - DOMContextForChildMap.pair( + ImportContextForChildMap.pair( new Map(forChildMap || []).set( node.nodeName, transformOutput.forChild, @@ -334,7 +329,7 @@ function importOverrideSort( type ImportStackEntry = [ dom: Node, - ctx: AnyStateConfigPair[], + ctx: ContextRecord, $importNode: DOMImportExtensionOutput['$importNode'], $appendChild: NonNullable, ]; @@ -347,9 +342,9 @@ function composeFinalizers( } function parseDOMWhiteSpaceCollapseFromNode( + ctx: ContextRecord, node: Node, -): undefined | AnyStateConfigPair[] { - let pairs: undefined | AnyStateConfigPair[]; +): ContextRecord { if (isHTMLElement(node)) { const {style} = node; let textWrapMode: undefined | DOMTextWrapMode; @@ -385,17 +380,14 @@ function parseDOMWhiteSpaceCollapseFromNode( (DOMTextWrapModeKeys as Record)[ style.textWrapMode ] || textWrapMode; - if (textWrapMode || whiteSpaceCollapse) { - pairs = []; - if (textWrapMode) { - pairs.push(DOMContextTextWrapMode.pair(textWrapMode)); - } - if (whiteSpaceCollapse) { - pairs.push(DOMContextWhiteSpaceCollapse.pair(whiteSpaceCollapse)); - } + if (textWrapMode) { + ctx[ImportContextTextWrapMode.key] = textWrapMode; + } + if (whiteSpaceCollapse) { + ctx[ImportContextWhiteSpaceCollapse.key] = whiteSpaceCollapse; } } - return pairs; + return ctx; } export function compileDOMImportOverrides( @@ -422,163 +414,145 @@ export function compileDOMImportOverrides( rootOrDocument: ParentNode | Document, ): LexicalNode[] => { const artificialNodes: ArtificialNode__DO_NOT_USE[] = []; - return $withDOMImportContext([ - DOMContextArtificialNodes.pair(artificialNodes), - ])(() => { - const nodes: LexicalNode[] = []; - const stack: ImportStackEntry[] = [ - [ - isDOMDocumentNode(rootOrDocument) - ? rootOrDocument.body - : rootOrDocument, - [], - () => ({node: null}), - (node) => { - nodes.push(node); - }, - ], - ]; - for (let entry = stack.pop(); entry; entry = stack.pop()) { - const [node, ctx, fn, $parentAppendChild] = entry; - const outputContinue: DOMImportOutputContinue & { - nextContext: AnyStateConfigPair[]; - } = { - nextContext: ctx, - node: withImportNextSymbol(fn.bind(null, node)), - }; - const whiteSpaceState = parseDOMWhiteSpaceCollapseFromNode(node); - if (whiteSpaceState) { - outputContinue.nextContext = [...ctx, ...whiteSpaceState]; - } - let currentOutput: null | undefined | DOMImportOutput = outputContinue; - while ($isImportOutputContinue(currentOutput)) { - if (currentOutput.nextContext && outputContinue.nextContext !== ctx) { - if (outputContinue.nextContext === ctx) { - outputContinue.nextContext = [...ctx]; - } - outputContinue.nextContext.push(...currentOutput.nextContext); - } - const $finalize = composeFinalizers( - outputContinue.$finalize, - currentOutput.$finalize, - ); - if ($finalize) { - outputContinue.$finalize = $finalize; - } - if (currentOutput.childContext) { - if (!outputContinue.childContext) { - outputContinue.childContext = [...currentOutput.childContext]; - } else { - outputContinue.childContext.push(...currentOutput.childContext); - } - } - currentOutput = $withDOMImportContext(outputContinue.nextContext)( - currentOutput.node, + const nodes: LexicalNode[] = []; + const rootNode = isDOMDocumentNode(rootOrDocument) + ? rootOrDocument.body + : rootOrDocument; + const stack: ImportStackEntry[] = [ + [ + rootNode, + updateContextFromPairs(createChildContext(undefined), [ + ImportContextArtificialNodes.pair(artificialNodes), + ]), + () => ({node: null}), + (node) => { + nodes.push(node); + }, + ], + ]; + for (let entry = stack.pop(); entry; entry = stack.pop()) { + const [node, ctx, fn, $parentAppendChild] = entry; + ctx[ImportContextDOMNode.key] = node; + const outputContinue: DOMImportOutputContinue = { + node: withImportNextSymbol(fn.bind(null, node)), + }; + parseDOMWhiteSpaceCollapseFromNode(ctx, node); + let currentOutput: null | undefined | DOMImportOutput = outputContinue; + let childContext: + | undefined + | ContextRecord; + const updateChildContext = ( + pairs: undefined | readonly AnyImportStateConfigPair[], + ) => { + if (pairs) { + childContext = updateContextFromPairs( + childContext || createChildContext(ctx), + pairs, ); } - invariant( - !$isImportOutputContinue(currentOutput), - 'currentOutput can not be a continue', + }; + while ($isImportOutputContinue(currentOutput)) { + updateContextFromPairs(ctx, currentOutput.nextContext); + updateChildContext(currentOutput.childContext); + outputContinue.$finalize = composeFinalizers( + outputContinue.$finalize, + currentOutput.$finalize, ); - const output = currentOutput; - let children: NodeListOf | readonly ChildNode[] = - isHTMLElement(node) ? node.childNodes : EMPTY_ARRAY; - let $finalize = outputContinue.$finalize; - let mergedContext = outputContinue.childContext - ? [...outputContinue.nextContext, ...outputContinue.childContext] - : outputContinue.nextContext; - let $appendChild = $parentAppendChild; - const outputNode = output ? output.node : null; - invariant( - typeof outputNode !== 'function', - 'outputNode must not be a function', + currentOutput = $withFullContext( + DOMImportContextSymbol, + ctx, + currentOutput.node, + editor, ); - if (!output) { - if ($finalize) { - const $boundFinalize = $finalize.bind(null, null); - stack.push([ - node, - ctx, - () => ({node: $boundFinalize()}), - $parentAppendChild, - ]); - } - } else { - if (output.$appendChild) { - $appendChild = output.$appendChild; - } else if (Array.isArray(outputNode)) { - $appendChild = (childNode, _dom) => outputNode.push(childNode); - } else if ($isElementNode(outputNode)) { - $appendChild = (childNode, _dom) => outputNode.append(childNode); - } - children = output.childNodes || children; - $finalize = composeFinalizers($finalize, output.$finalize); - if ($finalize) { - const $boundFinalize = $finalize.bind(null, outputNode); - stack.push([ - node, - ctx, - () => ({node: $boundFinalize()}), - $parentAppendChild, - ]); - } else if (outputNode) { - for (const addNode of Array.isArray(outputNode) - ? outputNode - : [outputNode]) { - $parentAppendChild(addNode, node as ChildNode); - } + } + invariant( + !$isImportOutputContinue(currentOutput), + 'currentOutput can not be a continue', + ); + const output = currentOutput; + let children: NodeListOf | readonly ChildNode[] = + isHTMLElement(node) ? node.childNodes : EMPTY_ARRAY; + let $finalize = outputContinue.$finalize; + let $appendChild = $parentAppendChild; + const outputNode = output ? output.node : null; + invariant( + typeof outputNode !== 'function', + 'outputNode must not be a function', + ); + const pushFinalize = () => { + if ($finalize) { + const $boundFinalize = $finalize.bind(null, outputNode); + stack.push([ + node, + ctx, + () => ({node: $boundFinalize()}), + $parentAppendChild, + ]); + } + }; + if (!output) { + pushFinalize(); + } else { + if (output.$appendChild) { + $appendChild = output.$appendChild; + } else if (Array.isArray(outputNode)) { + $appendChild = (childNode, _dom) => outputNode.push(childNode); + } else if ($isElementNode(outputNode)) { + $appendChild = (childNode, _dom) => outputNode.append(childNode); + } + children = output.childNodes || children; + $finalize = composeFinalizers($finalize, output.$finalize); + if ($finalize) { + pushFinalize(); + } else if (outputNode) { + for (const addNode of Array.isArray(outputNode) + ? outputNode + : [outputNode]) { + $parentAppendChild(addNode, node as ChildNode); } + } - const addChildContext = (pair: AnyStateConfigPair) => { - if ($getDOMImportContextValue(pair[0]) === pair[1]) { - return; - } - if (mergedContext === ctx) { - mergedContext = [...ctx]; - } - mergedContext.push(pair); - }; - for (const pair of output.childContext || EMPTY_ARRAY) { - addChildContext(pair); - } - const currentLexicalNode = Array.isArray(outputNode) - ? outputNode[outputNode.length - 1] || null - : outputNode; - const hasBlockAncestorLexicalNode = $getDOMImportContextValue( - DOMContextHasBlockAncestorLexicalNode, - ); - const hasBlockAncestorLexicalNodeForChildren = - currentLexicalNode && $isRootOrShadowRoot(currentLexicalNode) - ? false - : (currentLexicalNode && - $isBlockElementNode(currentLexicalNode)) || - hasBlockAncestorLexicalNode; + updateChildContext(output.childContext); + const currentLexicalNode = Array.isArray(outputNode) + ? outputNode[outputNode.length - 1] || null + : outputNode; + const hasBlockAncestorLexicalNode = $getImportContextValue( + ImportContextHasBlockAncestorLexicalNode, + ); + const hasBlockAncestorLexicalNodeForChildren = + currentLexicalNode && $isRootOrShadowRoot(currentLexicalNode) + ? false + : (currentLexicalNode && $isBlockElementNode(currentLexicalNode)) || + hasBlockAncestorLexicalNode; - if ( - hasBlockAncestorLexicalNode !== - hasBlockAncestorLexicalNodeForChildren - ) { - addChildContext( - DOMContextHasBlockAncestorLexicalNode.pair( - hasBlockAncestorLexicalNodeForChildren, - ), - ); - } - if ($isElementNode(currentLexicalNode)) { - addChildContext( - DOMContextParentLexicalNode.pair(currentLexicalNode), - ); - } + if ( + hasBlockAncestorLexicalNode !== hasBlockAncestorLexicalNodeForChildren + ) { + updateChildContext([ + ImportContextHasBlockAncestorLexicalNode.pair( + hasBlockAncestorLexicalNodeForChildren, + ), + ]); } - // Push children in reverse so they are popped off the stack in-order - for (let i = children.length - 1; i >= 0; i--) { - const childDom = children[i]; - stack.push([childDom, mergedContext, $importNode, $appendChild]); + if ($isElementNode(currentLexicalNode)) { + updateChildContext([ + ImportContextParentLexicalNode.pair(currentLexicalNode), + ]); } } - $unwrapArtificialNodes(artificialNodes); - return nodes; - }); + // Push children in reverse so they are popped off the stack in-order + for (let i = children.length - 1; i >= 0; i--) { + const childDom = children[i]; + stack.push([ + childDom, + createChildContext(childContext || ctx), + $importNode, + $appendChild, + ]); + } + } + $unwrapArtificialNodes(artificialNodes); + return nodes; }; return { @@ -596,7 +570,6 @@ export const DOMImportExtension = defineExtension< >({ build: compileDOMImportOverrides, config: {compileLegacyImportNode: compileLegacyImportDOM, overrides: []}, - dependencies: [DOMExtension], mergeConfig(config, partial) { const merged = shallowMergeConfig(config, partial); for (const k of ['overrides'] as const) { diff --git a/packages/lexical-html/src/DOMExtension.ts b/packages/lexical-html/src/DOMRenderExtension.ts similarity index 85% rename from packages/lexical-html/src/DOMExtension.ts rename to packages/lexical-html/src/DOMRenderExtension.ts index 4e0447249c1..99104688e42 100644 --- a/packages/lexical-html/src/DOMExtension.ts +++ b/packages/lexical-html/src/DOMRenderExtension.ts @@ -5,29 +5,33 @@ * LICENSE file in the root directory of this source tree. * */ -import type {AnyDOMConfigMatch, DOMConfig, DOMExtensionOutput} from './types'; +import type { + AnyDOMRenderMatch, + DOMRenderConfig, + DOMRenderExtensionOutput, +} from './types'; import { $isElementNode, DEFAULT_EDITOR_DOM_CONFIG, defineExtension, - EditorDOMConfig, + EditorDOMRenderConfig, type LexicalNode, RootNode, shallowMergeConfig, } from 'lexical'; -import {DOMExtensionName} from './constants'; +import {DOMRenderExtensionName} from './constants'; import {contextFromPairs} from './ContextRecord'; -export function compileDOMConfigOverrides( - {overrides}: DOMConfig, - defaults: EditorDOMConfig, -): EditorDOMConfig { - function mergeDOMConfigMatch( - acc: EditorDOMConfig, - match: AnyDOMConfigMatch, - ): EditorDOMConfig { +function compileDOMRenderConfigOverrides( + {overrides}: DOMRenderConfig, + defaults: EditorDOMRenderConfig, +): EditorDOMRenderConfig { + function mergeDOMRenderMatch( + acc: EditorDOMRenderConfig, + match: AnyDOMRenderMatch, + ): EditorDOMRenderConfig { // TODO Consider using a node type map to make this more efficient when // there are more overrides const { @@ -126,20 +130,20 @@ export function compileDOMConfigOverrides( // The beginning of the array will be the overrides towards the top // of the tree so should be higher precedence, so we compose the functions // from the right - return overrides.reduceRight(mergeDOMConfigMatch, defaults); + return overrides.reduceRight(mergeDOMRenderMatch, defaults); } /** @internal @experimental */ -export const DOMExtension = defineExtension< - DOMConfig, - typeof DOMExtensionName, - DOMExtensionOutput, +export const DOMRenderExtension = defineExtension< + DOMRenderConfig, + typeof DOMRenderExtensionName, + DOMRenderExtensionOutput, void >({ build(editor, config, state) { return { - defaults: contextFromPairs(config.contextDefaults) || new Map(), + defaults: contextFromPairs(config.contextDefaults, undefined), }; }, config: { @@ -160,7 +164,7 @@ export const DOMExtension = defineExtension< ]), }, init(editorConfig, config) { - editorConfig.dom = compileDOMConfigOverrides(config, { + editorConfig.dom = compileDOMRenderConfigOverrides(config, { ...DEFAULT_EDITOR_DOM_CONFIG, ...editorConfig.dom, }); @@ -174,5 +178,5 @@ export const DOMExtension = defineExtension< } return merged; }, - name: DOMExtensionName, + name: DOMRenderExtensionName, }); diff --git a/packages/lexical-html/src/ImportContext.ts b/packages/lexical-html/src/ImportContext.ts new file mode 100644 index 00000000000..bc6d36fb4a1 --- /dev/null +++ b/packages/lexical-html/src/ImportContext.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + AnyContextConfigPair, + DOMTextWrapMode, + DOMWhiteSpaceCollapse, + ImportStateConfig, +} from './types'; + +import { + $getEditor, + type ArtificialNode__DO_NOT_USE, + type DOMChildConversion, + type LexicalEditor, + type LexicalNode, + type TextFormatType, +} from 'lexical'; + +import {DOMImportContextSymbol} from './constants'; +import { + $withContext, + createContextStateFactory, + getEditorContext, + getEditorContextValue, +} from './ContextRecord'; + +/** + * @__NO_SIDE_EFFECTS__ + */ +export const createImportState: ( + name: string, + getDefaultValue: () => V, + isEqual?: (a: V, b: V) => boolean, +) => ImportStateConfig = /*@__PURE__*/ createContextStateFactory( + DOMImportContextSymbol, +); + +export const $withImportContext: ( + cfg: readonly AnyContextConfigPair[], + editor?: LexicalEditor, +) => (f: () => T) => T = /*@__PURE__*/ $withContext(DOMImportContextSymbol); + +export function $getImportContextValue( + cfg: ImportStateConfig, + editor: LexicalEditor = $getEditor(), +): V { + return getEditorContextValue( + DOMImportContextSymbol, + getEditorContext(editor), + cfg, + ); +} + +export const ImportContextDOMNode = createImportState( + 'domNode', + (): null | Node => null, +); + +export const ImportContextTextFormats = createImportState( + 'textFormats', + (): null | {[K in TextFormatType]?: undefined | boolean} => null, +); + +export const ImportContextWhiteSpaceCollapse = createImportState( + 'whiteSpaceCollapse', + (): DOMWhiteSpaceCollapse => 'collapse', +); + +export const ImportContextTextWrapMode = createImportState( + 'textWrapMode', + (): DOMTextWrapMode => 'wrap', +); + +export const ImportContextParentLexicalNode = createImportState( + 'parentLexicalNode', + (): null | LexicalNode => null, +); +export const ImportContextHasBlockAncestorLexicalNode = createImportState( + 'hasBlockAncestorLexicalNode', + Boolean, +); + +export const ImportContextForChildMap = createImportState( + 'forChildMap', + (): null | Map => null, +); + +export const ImportContextArtificialNodes = createImportState( + 'ArtificialNodes', + (): null | ArtificialNode__DO_NOT_USE[] => null, +); diff --git a/packages/lexical-html/src/RenderContext.ts b/packages/lexical-html/src/RenderContext.ts new file mode 100644 index 00000000000..c7d6314e880 --- /dev/null +++ b/packages/lexical-html/src/RenderContext.ts @@ -0,0 +1,78 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import { + getExtensionDependencyFromEditor, + LexicalBuilder, +} from '@lexical/extension'; +import {$getEditor, LexicalEditor} from 'lexical'; + +import {DOMRenderContextSymbol, DOMRenderExtensionName} from './constants'; +import { + $withContext, + createContextStateFactory, + getContextValue, + getEditorContext, +} from './ContextRecord'; +import {DOMRenderExtension} from './DOMRenderExtension'; +import {AnyContextConfigPair, ContextRecord, RenderStateConfig} from './types'; + +/** + * @__NO_SIDE_EFFECTS__ + */ +export const createRenderState: ( + name: string, + getDefaultValue: () => V, + isEqual?: (a: V, b: V) => boolean, +) => RenderStateConfig = /*@__PURE__*/ createContextStateFactory( + DOMRenderContextSymbol, +); + +/** + * true if the export was initiated from the root of the document + */ +export const RenderContextRoot = createRenderState('root', Boolean); + +/** + * true if this is an export operation ($generateHtmlFromNodes) + */ +export const RenderContextExport = createRenderState('isExport', Boolean); + +function getDefaultRenderContext( + editor: LexicalEditor, +): undefined | ContextRecord { + const builder = LexicalBuilder.maybeFromEditor(editor); + return builder && builder.hasExtensionByName(DOMRenderExtensionName) + ? getExtensionDependencyFromEditor(editor, DOMRenderExtension).output + .defaults + : undefined; +} + +function getRenderContext( + editor: LexicalEditor, +): undefined | ContextRecord { + const editorContext = getEditorContext(editor); + return ( + (editorContext && editorContext[DOMRenderContextSymbol]) || + getDefaultRenderContext(editor) + ); +} + +export function $getRenderContextValue( + cfg: RenderStateConfig, + editor: LexicalEditor = $getEditor(), +): V { + return getContextValue(getRenderContext(editor), cfg); +} + +export const $withRenderContext: ( + cfg: readonly AnyContextConfigPair[], + editor?: LexicalEditor, +) => (f: () => T) => T = $withContext( + DOMRenderContextSymbol, + getDefaultRenderContext, +); diff --git a/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts b/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts index 97a2c3ec86a..31274e4cae7 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts @@ -9,10 +9,10 @@ import {buildEditorFromExtensions} from '@lexical/extension'; import { $generateDOMFromRoot, - $getDOMContextValue, - DOMContextRoot, - DOMExtension, + $getRenderContextValue, domOverride, + DOMRenderExtension, + RenderContextRoot, } from '@lexical/html'; import { $createParagraphNode, @@ -34,7 +34,7 @@ const idState = createState('id', { parse: (v) => (typeof v === 'string' ? v : null), }); -describe('DOMExtension', () => { +describe('DOMRenderExtension', () => { test('can override DOM create + update', () => { const editor = buildEditorFromExtensions( defineExtension({ @@ -47,7 +47,7 @@ describe('DOMExtension', () => { ); }, dependencies: [ - configExtension(DOMExtension, { + configExtension(DOMRenderExtension, { overrides: [ domOverride('*', { $createDOM(node, $next) { @@ -148,7 +148,7 @@ describe('DOMExtension', () => { ); }, dependencies: [ - configExtension(DOMExtension, { + configExtension(DOMRenderExtension, { overrides: [ domOverride('*', { $exportDOM(node, $next) { @@ -164,7 +164,7 @@ describe('DOMExtension', () => { $exportDOM(node, $next) { const result = $next(); if ( - $getDOMContextValue(DOMContextRoot) && + $getRenderContextValue(RenderContextRoot) && isHTMLElement(result.element) && result.element.style.getPropertyValue('white-space') ) { diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts index 49d5a4c907a..e167c5058b7 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts @@ -13,10 +13,10 @@ import { } from '@lexical/extension'; import { $generateNodesFromDOM, - DOMConfig, - DOMExtension, DOMImportConfig, DOMImportExtension, + DOMRenderConfig, + DOMRenderExtension, } from '@lexical/html'; import {CheckListExtension, ListExtension} from '@lexical/list'; import { @@ -43,7 +43,7 @@ interface ImportTestCase { expectedHTML: string; plainTextInsert?: string; importConfig?: Partial; - exportConfig?: Partial; + exportConfig?: Partial; } function importCase( @@ -333,7 +333,7 @@ describe('DOMImportExtension', () => { }, dependencies: [ configExtension(DOMImportExtension, importConfig), - configExtension(DOMExtension, exportConfig), + configExtension(DOMRenderExtension, exportConfig), ListExtension, CheckListExtension, ], diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts index 1018375df77..57f3f2a08ef 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts @@ -13,12 +13,16 @@ import { } from '@lexical/extension'; import { $generateNodesFromDOM, - $getDOMImportContextValue, - DOMConfig, - DOMContextWhiteSpaceCollapse, - DOMExtension, - DOMImportConfig, + $getImportContextValue, + type DOMImportConfig, DOMImportExtension, + type DOMImportNext, + type DOMImportOutputContinue, + type DOMImportOutputNode, + type DOMRenderConfig, + DOMRenderExtension, + ImportContextTextFormats, + ImportContextWhiteSpaceCollapse, importOverride, } from '@lexical/html'; import { @@ -57,20 +61,13 @@ import { import invariant from 'shared/invariant'; import {assert, describe, expect, test} from 'vitest'; -import {DOMContextTextFormats} from '../../ContextRecord'; -import { - DOMImportNext, - DOMImportOutputContinue, - DOMImportOutputNode, -} from '../../types'; - interface ImportTestCase { name: string; pastedHTML: string; expectedHTML: string; plainTextInsert?: string; importConfig?: Partial; - exportConfig?: Partial; + exportConfig?: Partial; } function importCase( @@ -110,12 +107,12 @@ function $addTextFormatContinue( format: TextFormatType, ): (node: HTMLElement, $next: DOMImportNext) => null | DOMImportOutputContinue { return (_dom, $next) => { - const prev = $getDOMImportContextValue(DOMContextTextFormats); + const prev = $getImportContextValue(ImportContextTextFormats); const rval: null | DOMImportOutputContinue = !prev || !prev[format] ? { childContext: [ - DOMContextTextFormats.pair({...prev, [format]: true}), + ImportContextTextFormats.pair({...prev, [format]: true}), ], node: $next, } @@ -159,7 +156,7 @@ function findTextInLine(text: Text, direction: CaretDirection): null | Text { function $createTextNodeWithCurrentFormat(text: string = ''): TextNode { let node = $createTextNode(text); - const fmt = $getDOMImportContextValue(DOMContextTextFormats); + const fmt = $getImportContextValue(ImportContextTextFormats); if (fmt) { for (const k in fmt) { const textFormat = k as keyof typeof fmt; @@ -181,7 +178,7 @@ function $convertTextDOMNode(domNode: Text): DOMImportOutputNode { let textContent = domNode_.textContent || ''; // No collapse and preserve segment break for pre, pre-wrap and pre-line if ( - $getDOMImportContextValue(DOMContextWhiteSpaceCollapse).startsWith('pre') + $getImportContextValue(ImportContextWhiteSpaceCollapse).startsWith('pre') ) { const parts = textContent.split(/(\r?\n|\t)/); const nodes: Array = []; @@ -577,7 +574,7 @@ describe('DOMImportExtension (no legacy)', () => { }, dependencies: [ configExtension(DOMImportExtension, NO_LEGACY_CONFIG, importConfig), - configExtension(DOMExtension, exportConfig), + configExtension(DOMRenderExtension, exportConfig), ListExtension, CheckListExtension, ], diff --git a/packages/lexical-html/src/constants.ts b/packages/lexical-html/src/constants.ts index edca0c8b2b8..d0ef7766ba3 100644 --- a/packages/lexical-html/src/constants.ts +++ b/packages/lexical-html/src/constants.ts @@ -5,10 +5,16 @@ * LICENSE file in the root directory of this source tree. * */ -export const DOMExtensionName = '@lexical/html/DOM'; +export const DOMRenderExtensionName = '@lexical/html/DOM'; export const DOMImportExtensionName = '@lexical/html/DOMImport'; export const IGNORE_TAGS = new Set(['STYLE', 'SCRIPT']); export const DOMImportNextSymbol = Symbol.for('@lexical/html/DOMImportNext'); +export const DOMImportContextSymbol = Symbol.for( + '@lexical/html/DOMImportContext', +); +export const DOMRenderContextSymbol = Symbol.for( + '@lexical/html/DOMExportContext', +); // https://drafts.csswg.org/css-text-4/#white-space-collapsing export const DOMWhiteSpaceCollapseKeys = { diff --git a/packages/lexical-html/src/domOverride.ts b/packages/lexical-html/src/domOverride.ts index cf7451ef9c7..d73a599f951 100644 --- a/packages/lexical-html/src/domOverride.ts +++ b/packages/lexical-html/src/domOverride.ts @@ -5,27 +5,27 @@ * LICENSE file in the root directory of this source tree. * */ -import type {AnyDOMConfigMatch, DOMConfigMatch, NodeMatch} from './types'; +import type {AnyDOMRenderMatch, DOMRenderMatch, NodeMatch} from './types'; import type {LexicalNode} from 'lexical'; /** * A convenience function for type inference when constructing DOM overrides for - * use with {@link DOMExtension}. + * use with {@link DOMRenderExtension}. * * @__NO_SIDE_EFFECTS__ */ export function domOverride( nodes: '*', - config: Omit, 'nodes'>, -): DOMConfigMatch; + config: Omit, 'nodes'>, +): DOMRenderMatch; export function domOverride( nodes: readonly NodeMatch[], - config: Omit, 'nodes'>, -): DOMConfigMatch; + config: Omit, 'nodes'>, +): DOMRenderMatch; export function domOverride( - nodes: AnyDOMConfigMatch['nodes'], - config: Omit, -): AnyDOMConfigMatch { + nodes: AnyDOMRenderMatch['nodes'], + config: Omit, +): AnyDOMRenderMatch { return {...config, nodes}; } diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 038714e2470..f19a05ef308 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -11,32 +11,39 @@ export { $generateHtmlFromNodes, } from './$generateDOMFromNodes'; export {$generateNodesFromDOM} from './$generateNodesFromDOM'; -export { - $getDOMContextValue, - $getDOMImportContextValue, - $withDOMContext, - $withDOMImportContext, - DOMContextClipboard, - DOMContextExport, - DOMContextHasBlockAncestorLexicalNode, - DOMContextParentLexicalNode, - DOMContextRoot, - DOMContextWhiteSpaceCollapse, -} from './ContextRecord'; -export {DOMExtension} from './DOMExtension'; export {DOMImportExtension} from './DOMImportExtension'; export {domOverride} from './domOverride'; +export {DOMRenderExtension} from './DOMRenderExtension'; +export { + $getImportContextValue, + $withImportContext, + ImportContextHasBlockAncestorLexicalNode, + ImportContextParentLexicalNode, + ImportContextTextFormats, + ImportContextWhiteSpaceCollapse, +} from './ImportContext'; export {importOverride} from './importOverride'; +export { + $getRenderContextValue, + $withRenderContext, + RenderContextExport, + RenderContextRoot, +} from './RenderContext'; export type { - AnyDOMConfigMatch, - DOMConfig, - DOMConfigMatch, - DOMExtensionOutput, + AnyDOMRenderMatch, + AnyImportStateConfig, + AnyRenderStateConfig, DOMImportConfig, DOMImportConfigMatch, DOMImportExtensionOutput, DOMImportFunction, + DOMImportNext, DOMImportOutput, + DOMImportOutputContinue, + DOMImportOutputNode, + DOMRenderConfig, + DOMRenderExtensionOutput, + DOMRenderMatch, DOMTextWrapMode, DOMWhiteSpaceCollapse, NodeMatch, diff --git a/packages/lexical-html/src/types.ts b/packages/lexical-html/src/types.ts index 2a14d8df11b..51dff181e0a 100644 --- a/packages/lexical-html/src/types.ts +++ b/packages/lexical-html/src/types.ts @@ -7,11 +7,12 @@ */ import type { + DOMImportContextSymbol, DOMImportNextSymbol, + DOMRenderContextSymbol, DOMTextWrapModeKeys, DOMWhiteSpaceCollapseKeys, } from './constants'; -import type {AnyStateConfigPair, ContextRecord} from './ContextRecord'; import type { BaseSelection, DOMExportOutput, @@ -20,19 +21,61 @@ import type { Klass, LexicalEditor, LexicalNode, + StateConfig, } from 'lexical'; -export interface DOMExtensionOutput { - defaults: ContextRecord; +export type AnyContextSymbol = + | typeof DOMImportContextSymbol + | typeof DOMRenderContextSymbol; + +export type ContextRecord<_K extends symbol> = Record; + +export type ContextConfig = StateConfig & { + readonly [K in Sym]?: true; +}; + +export type ContextConfigPair = readonly [ + ContextConfig, + V, +]; + +export type AnyContextConfigPair = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ContextConfigPair; + +export interface DOMRenderExtensionOutput { + defaults: undefined | ContextRecord; } +export type ImportStateConfig = ContextConfig< + typeof DOMImportContextSymbol, + V +>; + +export type RenderStateConfig = ContextConfig< + typeof DOMRenderContextSymbol, + V +>; + +export type AnyImportStateConfigPair = AnyContextConfigPair< + typeof DOMImportContextSymbol +>; +export type AnyRenderStateConfigPair = AnyContextConfigPair< + typeof DOMRenderContextSymbol +>; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyRenderStateConfig = RenderStateConfig; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyImportStateConfig = ImportStateConfig; + /** @internal @experimental */ export type DOMImportOutput = DOMImportOutputNode | DOMImportOutputContinue; export interface DOMImportOutputNode { node: null | LexicalNode | LexicalNode[]; childNodes?: NodeListOf | readonly ChildNode[]; - childContext?: AnyStateConfigPair[]; + childContext?: AnyImportStateConfigPair[]; $appendChild?: (node: LexicalNode, dom: ChildNode) => void; $finalize?: ( node: null | LexicalNode | LexicalNode[], @@ -41,8 +84,8 @@ export interface DOMImportOutputNode { export interface DOMImportOutputContinue { node: DOMImportNext; - childContext?: AnyStateConfigPair[]; - nextContext?: AnyStateConfigPair[]; + childContext?: AnyImportStateConfigPair[]; + nextContext?: AnyImportStateConfigPair[]; $appendChild?: never; childNodes?: never; $finalize?: ( @@ -69,21 +112,21 @@ export type NodeNameToType = T extends keyof NodeNameMap : Node; /** @internal @experimental */ -export interface DOMConfig { - overrides: AnyDOMConfigMatch[]; - contextDefaults: AnyStateConfigPair[]; +export interface DOMRenderConfig { + overrides: AnyDOMRenderMatch[]; + contextDefaults: AnyRenderStateConfigPair[]; } /** @internal @experimental */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type AnyDOMConfigMatch = DOMConfigMatch; +export type AnyDOMRenderMatch = DOMRenderMatch; export type NodeMatch = | Klass | ((node: LexicalNode) => node is T); /** @internal @experimental */ -export interface DOMConfigMatch { +export interface DOMRenderMatch { readonly nodes: '*' | readonly NodeMatch[]; $getDOMSlot?: ( node: N, diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index e60a29a2fc4..1d60516de7f 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -47,7 +47,7 @@ import { $getAdjacentChildCaret, $getChildCaret, $getEditor, - $getEditorDOMConfig, + $getEditorDOMRenderConfig, $getNearestNodeFromDOMNode, $getPreviousSelection, $getSelection, @@ -134,7 +134,8 @@ export function $getTableElement( const element = ( isHTMLTableElement(dom) ? dom - : $getEditorDOMConfig(editor).$getDOMSlot(tableNode, dom, editor).element + : $getEditorDOMRenderConfig(editor).$getDOMSlot(tableNode, dom, editor) + .element ) as HTMLTableElementWithWithTableSelectionState; invariant( element.nodeName === 'TABLE', diff --git a/packages/lexical-utils/src/markSelection.ts b/packages/lexical-utils/src/markSelection.ts index 0a4640d9196..3959f84c838 100644 --- a/packages/lexical-utils/src/markSelection.ts +++ b/packages/lexical-utils/src/markSelection.ts @@ -8,7 +8,7 @@ import { $getEditor, - $getEditorDOMConfig, + $getEditorDOMRenderConfig, $getSelection, $isElementNode, $isRangeSelection, @@ -40,7 +40,11 @@ function $rangeTargetFromPoint( return [textDOM, point.offset]; } else { const editor = $getEditor(); - const slot = $getEditorDOMConfig(editor).$getDOMSlot(node, dom, editor); + const slot = $getEditorDOMRenderConfig(editor).$getDOMSlot( + node, + dom, + editor, + ); return [slot.element, slot.getFirstChildOffset() + point.offset]; } } diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index a230383da2e..b2a86418f8e 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -146,7 +146,7 @@ type DOMConversionCache = Map< Array<(node: Node) => DOMConversion | null>, >; -export type EditorDOMConfig = { +export type EditorDOMRenderConfig = { /** @internal @experimental */ createDOM: ( node: T, @@ -168,7 +168,7 @@ export type EditorDOMConfig = { export type CreateEditorArgs = { /** @internal @experimental */ - dom?: Partial; + dom?: Partial; disableEvents?: boolean; editorState?: EditorState; namespace?: string; diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index e7626cbee52..742d5340d42 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -197,7 +197,7 @@ export type EditorThemeClasses = { }; export interface EditorConfig { - dom?: EditorDOMConfig; + dom?: EditorDOMRenderConfig; disableEvents?: boolean; namespace: string; theme: EditorThemeClasses; @@ -223,7 +223,7 @@ export type HTMLConfig = { export type LexicalNodeConfig = Klass | LexicalNodeReplacement; /** @internal @experimental */ -export interface EditorDOMConfig { +export interface EditorDOMRenderConfig { /** @internal @experimental */ $createDOM: ( node: T, @@ -279,7 +279,7 @@ export interface CreateEditorArgs { editable?: boolean; theme?: EditorThemeClasses; html?: HTMLConfig; - dom?: Partial; + dom?: Partial; } export type RegisteredNodes = Map; @@ -552,7 +552,7 @@ function initializeConversionCache( } /** @internal */ -export const DEFAULT_EDITOR_DOM_CONFIG: EditorDOMConfig = { +export const DEFAULT_EDITOR_DOM_CONFIG: EditorDOMRenderConfig = { $createDOM: (node, editor) => node.createDOM(editor._config, editor), $exportDOM: (node, editor) => { const registeredNode = getRegisteredNode(editor, node.getType()); diff --git a/packages/lexical/src/LexicalNodeState.ts b/packages/lexical/src/LexicalNodeState.ts index cc09bca0f04..0e68b998267 100644 --- a/packages/lexical/src/LexicalNodeState.ts +++ b/packages/lexical/src/LexicalNodeState.ts @@ -227,9 +227,9 @@ export interface StateValueConfig { /** * A tuple of a StateConfig and a value, used for contexts outside of - * NodeState such as DOMExtension. + * NodeState such as DOMRenderExtension. */ -export type StateConfigPair = readonly [ +export type StateConfigPair = readonly [ StateConfig, V, ]; @@ -238,7 +238,7 @@ export type StateConfigPair = readonly [ * The return value of {@link createState}, for use with * {@link $getState} and {@link $setState}. */ -export class StateConfig { +export class StateConfig { /** The string key used when serializing this state to JSON */ readonly key: K; /** The parse function from the StateValueConfig passed to createState */ @@ -313,7 +313,7 @@ export type AnyStateConfig = StateConfig; * * @__NO_SIDE_EFFECTS__ */ -export function createState( +export function createState( key: K, valueConfig: StateValueConfig, ): StateConfig { diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index 9002f4a33be..1348dbdf173 100644 --- a/packages/lexical/src/LexicalReconciler.ts +++ b/packages/lexical/src/LexicalReconciler.ts @@ -8,7 +8,7 @@ import type { EditorConfig, - EditorDOMConfig, + EditorDOMRenderConfig, LexicalEditor, MutatedNodes, MutationListeners, @@ -64,7 +64,7 @@ let activePrevNodeMap: NodeMap; let activeNextNodeMap: NodeMap; let activePrevKeyToDOMMap: Map; let mutatedNodes: MutatedNodes; -let activeEditorDOMConfig: EditorDOMConfig; +let activeEditorDOMRenderConfig: EditorDOMRenderConfig; function destroyNode(key: NodeKey, parentDOM: null | HTMLElement): void { const node = activePrevNodeMap.get(key); @@ -196,7 +196,7 @@ function $createNode(key: NodeKey, slot: ElementDOMSlot | null): HTMLElement { if (node === undefined) { invariant(false, 'createNode: node does not exist in nodeMap'); } - const dom = activeEditorDOMConfig.$createDOM(node, activeEditor); + const dom = activeEditorDOMRenderConfig.$createDOM(node, activeEditor); storeDOMWithKey(key, dom, activeEditor); // This helps preserve the text, and stops spell check tools from @@ -223,7 +223,7 @@ function $createNode(key: NodeKey, slot: ElementDOMSlot | null): HTMLElement { node, 0, endIndex, - activeEditorDOMConfig.$getDOMSlot(node, dom, activeEditor), + activeEditorDOMRenderConfig.$getDOMSlot(node, dom, activeEditor), ); } const format = node.__format; @@ -341,7 +341,7 @@ function $reconcileElementTerminatingLineBreak( activeNextNodeMap, ); if (prevLineBreak !== nextLineBreak) { - activeEditorDOMConfig + activeEditorDOMRenderConfig .$getDOMSlot(nextElement, dom, activeEditor) .setManagedLineBreak(nextLineBreak); } @@ -377,7 +377,7 @@ function $reconcileChildrenWithDirection( $reconcileChildren( prevElement, nextElement, - activeEditorDOMConfig.$getDOMSlot(nextElement, dom, activeEditor), + activeEditorDOMRenderConfig.$getDOMSlot(nextElement, dom, activeEditor), ); reconcileTextFormat(nextElement); reconcileTextStyle(nextElement); @@ -562,7 +562,14 @@ function $reconcileNode( } // Update node. If it returns true, we need to unmount and re-create the node - if (activeEditorDOMConfig.$updateDOM(nextNode, prevNode, dom, activeEditor)) { + if ( + activeEditorDOMRenderConfig.$updateDOM( + nextNode, + prevNode, + dom, + activeEditor, + ) + ) { const replacementDOM = $createNode(key, null); if (parentDOM === null) { @@ -788,7 +795,7 @@ export function $reconcileRoot( treatAllNodesAsDirty = dirtyType === FULL_RECONCILE; activeEditor = editor; activeEditorConfig = editor._config; - activeEditorDOMConfig = editor._config.dom || DEFAULT_EDITOR_DOM_CONFIG; + activeEditorDOMRenderConfig = editor._config.dom || DEFAULT_EDITOR_DOM_CONFIG; activeEditorNodes = editor._nodes; activeMutationListeners = activeEditor._listeners.mutation; activeDirtyElements = dirtyElements; @@ -824,7 +831,7 @@ export function $reconcileRoot( activePrevKeyToDOMMap = undefined; // @ts-ignore mutatedNodes = undefined; - activeEditorDOMConfig = DEFAULT_EDITOR_DOM_CONFIG; + activeEditorDOMRenderConfig = DEFAULT_EDITOR_DOM_CONFIG; return currentMutatedNodes; } diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 185cc91ce66..4a52c5812d0 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -70,7 +70,7 @@ import {SKIP_SELECTION_FOCUS_TAG} from './LexicalUpdateTags'; import { $findMatchingParent, $getCompositionKey, - $getEditorDOMConfig, + $getEditorDOMRenderConfig, $getNearestRootOrShadowRoot, $getNodeByKey, $getNodeFromDOM, @@ -2302,7 +2302,7 @@ function $internalResolveSelectionPoint( elementDOM !== null, '$internalResolveSelectionPoint: node in DOM but not keyToDOMMap', ); - const slot = $getEditorDOMConfig(editor).$getDOMSlot( + const slot = $getEditorDOMRenderConfig(editor).$getDOMSlot( resolvedElement, elementDOM, editor, diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index 77a5a701c10..5c28db7db76 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -9,7 +9,7 @@ import type { CommandPayloadType, EditorConfig, - EditorDOMConfig, + EditorDOMRenderConfig, EditorThemeClasses, Klass, LexicalCommand, @@ -1889,9 +1889,9 @@ export function $getEditor(): LexicalEditor { /** * @internal @experimental */ -export function $getEditorDOMConfig( +export function $getEditorDOMRenderConfig( editor: LexicalEditor = $getEditor(), -): EditorDOMConfig { +): EditorDOMRenderConfig { return editor._config.dom || DEFAULT_EDITOR_DOM_CONFIG; } diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 5a4fca0e03e..71cbe9e9da9 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -135,7 +135,7 @@ export type { CreateEditorArgs, EditableListener, EditorConfig, - EditorDOMConfig, + EditorDOMRenderConfig, EditorSetOptions, EditorThemeClasses, EditorThemeClassName, @@ -247,7 +247,7 @@ export { $findMatchingParent, $getAdjacentNode, $getEditor, - $getEditorDOMConfig, + $getEditorDOMRenderConfig, $getNearestNodeFromDOMNode, $getNearestRootOrShadowRoot, $getNodeByKey, diff --git a/packages/lexical/src/nodes/LexicalElementNode.ts b/packages/lexical/src/nodes/LexicalElementNode.ts index bbfb99debe6..a29edb55ee7 100644 --- a/packages/lexical/src/nodes/LexicalElementNode.ts +++ b/packages/lexical/src/nodes/LexicalElementNode.ts @@ -44,7 +44,7 @@ import { } from '../LexicalSelection'; import {errorOnReadOnly, getActiveEditor} from '../LexicalUpdates'; import { - $getEditorDOMConfig, + $getEditorDOMRenderConfig, $getNodeByKey, $isRootOrShadowRoot, isHTMLElement, @@ -973,7 +973,11 @@ export class ElementNode extends LexicalNode { /** @internal */ reconcileObservedMutation(dom: HTMLElement, editor: LexicalEditor): void { - const slot = $getEditorDOMConfig(editor).$getDOMSlot(this, dom, editor); + const slot = $getEditorDOMRenderConfig(editor).$getDOMSlot( + this, + dom, + editor, + ); let currentDOM = slot.getFirstChild(); for ( let currentNode = this.getFirstChild(); From 0124dd7f6b8a7fa023e23537d74e52d55f8ca691 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 12 Oct 2025 23:53:11 -0700 Subject: [PATCH 32/47] WIP --- packages/lexical-html/src/ContextRecord.ts | 7 +- packages/lexical-html/src/ImportContext.ts | 11 +- .../unit/DOMImportExtensionNoLegacy.test.ts | 151 +++++++++++++----- packages/lexical-html/src/index.ts | 3 + packages/lexical/src/LexicalNodeState.ts | 4 +- 5 files changed, 135 insertions(+), 41 deletions(-) diff --git a/packages/lexical-html/src/ContextRecord.ts b/packages/lexical-html/src/ContextRecord.ts index 9e4e709e28d..4f3870beb7d 100644 --- a/packages/lexical-html/src/ContextRecord.ts +++ b/packages/lexical-html/src/ContextRecord.ts @@ -78,8 +78,11 @@ export function updateContextFromPairs( pairs: undefined | readonly AnyContextConfigPair[], ): ContextRecord { if (pairs) { - for (const [k, v] of pairs) { - contextRecord[k.key] = v; + for (const [{key}, valueOrUpdater] of pairs) { + contextRecord[key] = + typeof valueOrUpdater === 'function' + ? valueOrUpdater(contextRecord[key]) + : valueOrUpdater; } } return contextRecord; diff --git a/packages/lexical-html/src/ImportContext.ts b/packages/lexical-html/src/ImportContext.ts index bc6d36fb4a1..a0a856a4f75 100644 --- a/packages/lexical-html/src/ImportContext.ts +++ b/packages/lexical-html/src/ImportContext.ts @@ -17,6 +17,7 @@ import { $getEditor, type ArtificialNode__DO_NOT_USE, type DOMChildConversion, + ElementFormatType, type LexicalEditor, type LexicalNode, type TextFormatType, @@ -62,9 +63,17 @@ export const ImportContextDOMNode = createImportState( (): null | Node => null, ); +const NO_FORMATS: {readonly [K in TextFormatType]?: undefined | boolean} = + Object.create(null); + +export const ImportContextTextAlign = createImportState( + 'textAlign', + (): undefined | ElementFormatType => undefined, +); + export const ImportContextTextFormats = createImportState( 'textFormats', - (): null | {[K in TextFormatType]?: undefined | boolean} => null, + () => NO_FORMATS, ); export const ImportContextWhiteSpaceCollapse = createImportState( diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts index 57f3f2a08ef..f525c067b4d 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts @@ -14,6 +14,7 @@ import { import { $generateNodesFromDOM, $getImportContextValue, + AnyImportStateConfigPair, type DOMImportConfig, DOMImportExtension, type DOMImportNext, @@ -21,6 +22,7 @@ import { type DOMImportOutputNode, type DOMRenderConfig, DOMRenderExtension, + ImportContextTextAlign, ImportContextTextFormats, ImportContextWhiteSpaceCollapse, importOverride, @@ -30,6 +32,7 @@ import { $createListNode, CheckListExtension, ListExtension, + ListType, } from '@lexical/list'; import { $createLineBreakNode, @@ -45,11 +48,13 @@ import { CaretDirection, configExtension, defineExtension, + ElementNode, isBlockDomNode, isDOMTextNode, isHTMLElement, isInlineDomNode, LexicalNode, + StateConfigValue, TextFormatType, TextNode, } from 'lexical'; @@ -78,12 +83,24 @@ function importCase( return {expectedHTML, name, pastedHTML}; } +function listTypeFromDOM(dom: HTMLUListElement | HTMLOListElement): ListType { + if ( + // lexical html + dom.getAttribute('__lexicallisttype') === 'check' || + // is github checklist + dom.classList.contains('contains-task-list') || + // if children are checklist items, the node is a checklist ul. Applicable for googledoc checklist pasting. + dom.querySelector(':scope > li[aria-checked]') + ) { + return 'check'; + } + return dom.tagName === 'OL' ? 'number' : 'bullet'; +} + function $importListNode( dom: HTMLUListElement | HTMLOListElement, ): DOMImportOutputNode { - const listNode = $createListNode().setListType( - dom.tagName === 'OL' ? 'number' : 'bullet', - ); + const listNode = $createListNode().setListType(listTypeFromDOM(dom)); return {childNodes: dom.querySelectorAll(':scope>li'), node: listNode}; } @@ -107,17 +124,12 @@ function $addTextFormatContinue( format: TextFormatType, ): (node: HTMLElement, $next: DOMImportNext) => null | DOMImportOutputContinue { return (_dom, $next) => { - const prev = $getImportContextValue(ImportContextTextFormats); - const rval: null | DOMImportOutputContinue = - !prev || !prev[format] - ? { - childContext: [ - ImportContextTextFormats.pair({...prev, [format]: true}), - ], - node: $next, - } - : null; - return rval; + return { + childContext: [ + ImportContextTextFormats.pair((prev) => ({...prev, [format]: true})), + ], + node: $next, + }; }; } @@ -157,12 +169,10 @@ function findTextInLine(text: Text, direction: CaretDirection): null | Text { function $createTextNodeWithCurrentFormat(text: string = ''): TextNode { let node = $createTextNode(text); const fmt = $getImportContextValue(ImportContextTextFormats); - if (fmt) { - for (const k in fmt) { - const textFormat = k as keyof typeof fmt; - if (fmt[textFormat]) { - node = node.toggleFormat(textFormat); - } + for (const k in fmt) { + const textFormat = k as keyof typeof fmt; + if (fmt[textFormat]) { + node = node.toggleFormat(textFormat); } } return node; @@ -264,33 +274,102 @@ const formatOverrides = Object.entries(TO_FORMAT).map(([tag, format]) => importOverride(tag as keyof typeof TO_FORMAT, $addTextFormatContinue(format)), ); +function $applyTextAlignToElement(node: T): T { + const align = $getImportContextValue(ImportContextTextAlign); + return align ? node.setFormat(align) : node; +} + const NO_LEGACY_CONFIG: Partial = { compileLegacyImportNode: () => () => null, overrides: [ importOverride('#text', $convertTextDOMNode), importOverride('*', (dom) => { if (isBlockDomNode(dom)) { - const node = $createParagraphNode(); - const {textAlign} = dom.style; - switch (textAlign) { - case 'center': - case 'end': - case 'justify': - case 'left': - case 'right': - case 'start': - node.setFormat(textAlign); - break; - default: - break; - } - return {node}; + return {node: $applyTextAlignToElement($createParagraphNode())}; } }), + importOverride( + '*', + (dom, $next): undefined | DOMImportOutputContinue => { + const childContext: AnyImportStateConfigPair[] = []; + if (isBlockDomNode(dom)) { + const {textAlign} = dom.style; + switch (textAlign) { + case 'center': + case 'end': + case 'justify': + case 'left': + case 'right': + case 'start': + childContext.push(ImportContextTextAlign.pair(textAlign)); + break; + default: + break; + } + } + if (isHTMLElement(dom)) { + const {fontWeight, fontStyle, textDecoration, verticalAlign} = + dom.style; + let formats: + | undefined + | StateConfigValue; + const setFormat = (k: TextFormatType, v: boolean) => { + const fmt = formats || Object.create(null); + fmt[k] = v; + formats = fmt; + }; + switch (fontWeight) { + case '400': + case 'normal': + setFormat('bold', false); + break; + case '700': + case 'bold': + setFormat('bold', true); + break; + default: + break; + } + const italic = 'italic'; + if (fontStyle === 'normal') { + setFormat(italic, false); + } else if (fontStyle === italic) { + setFormat(italic, true); + } + const underline = 'underline'; + const strikethrough = 'strikethrough'; + for (const dec of textDecoration.split(' ')) { + if (dec === 'none') { + setFormat(underline, false); + setFormat(strikethrough, false); + } else if (dec === underline) { + setFormat(underline, true); + } else if (dec === 'line-through') { + setFormat('strikethrough', true); + } + } + if (verticalAlign === 'sub') { + setFormat('subscript', true); + } else if (verticalAlign === 'super') { + setFormat('superscript', true); + } + if (formats) { + const boundFormats = formats; + childContext.push( + ImportContextTextFormats.pair((v) => ({...v, ...boundFormats})), + ); + } + } + if (childContext.length > 0) { + return {childContext, node: $next}; + } + }, + {priority: 1}, + ), ...formatOverrides, ...listOverrides, importOverride('li', (dom) => { - return {node: $createListItemNode()}; + return {node: $applyTextAlignToElement($createListItemNode())}; }), ], }; diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index f19a05ef308..d6423f6fdbf 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -19,6 +19,7 @@ export { $withImportContext, ImportContextHasBlockAncestorLexicalNode, ImportContextParentLexicalNode, + ImportContextTextAlign, ImportContextTextFormats, ImportContextWhiteSpaceCollapse, } from './ImportContext'; @@ -32,7 +33,9 @@ export { export type { AnyDOMRenderMatch, AnyImportStateConfig, + AnyImportStateConfigPair, AnyRenderStateConfig, + AnyRenderStateConfigPair, DOMImportConfig, DOMImportConfigMatch, DOMImportExtensionOutput, diff --git a/packages/lexical/src/LexicalNodeState.ts b/packages/lexical/src/LexicalNodeState.ts index 0e68b998267..9f437513216 100644 --- a/packages/lexical/src/LexicalNodeState.ts +++ b/packages/lexical/src/LexicalNodeState.ts @@ -231,7 +231,7 @@ export interface StateValueConfig { */ export type StateConfigPair = readonly [ StateConfig, - V, + ValueOrUpdater, ]; /** @@ -277,7 +277,7 @@ export class StateConfig { * Convenience method to produce a tuple of a StateConfig and a value * of that StateConfig (skipping the parse step). */ - pair(value: V): StateConfigPair { + pair(value: ValueOrUpdater): StateConfigPair { return [this, value]; } } From 315cc2abe63362d7c3ea973a70c850f9924d263d Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 14 Oct 2025 19:09:33 -0700 Subject: [PATCH 33/47] refine export example --- packages/lexical-html/src/DOMRenderExtension.ts | 17 ++++++++++++++--- .../src/__tests__/unit/DOMExtension.test.ts | 7 ++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/lexical-html/src/DOMRenderExtension.ts b/packages/lexical-html/src/DOMRenderExtension.ts index 99104688e42..057fbc91ce4 100644 --- a/packages/lexical-html/src/DOMRenderExtension.ts +++ b/packages/lexical-html/src/DOMRenderExtension.ts @@ -20,6 +20,7 @@ import { RootNode, shallowMergeConfig, } from 'lexical'; +import devInvariant from 'shared/devInvariant'; import {DOMRenderExtensionName} from './constants'; import {contextFromPairs} from './ContextRecord'; @@ -48,12 +49,22 @@ function compileDOMRenderConfigOverrides( for (const predicate of nodes) { if (predicate === '*') { return true; - } else if ('getType' in predicate || '$config' in predicate.prototype) { + } + if ('getType' in predicate || '$config' in predicate.prototype) { if (node instanceof predicate) { return true; } - } else if (predicate(node)) { - return true; + } else { + const rval = predicate(node); + devInvariant( + typeof rval === 'boolean', + 'domOverride predicate %s returned %s, expecting boolean', + predicate.name, + typeof rval, + ); + if (rval) { + return true; + } } } return false; diff --git a/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts b/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts index 31274e4cae7..9406005b40a 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts @@ -166,7 +166,12 @@ describe('DOMRenderExtension', () => { if ( $getRenderContextValue(RenderContextRoot) && isHTMLElement(result.element) && - result.element.style.getPropertyValue('white-space') + result.element.style.getPropertyValue('white-space') === + 'pre-wrap' && + // we know there aren't tabs or newlines but if there are + // leading, trailing, or adjacent spaces then we need the + // pre-wrap to preserve the content + !/^\s|\s$|\s\s/.test(result.element.textContent) ) { result.element.style.setProperty('white-space', null); if (result.element.style.cssText === '') { From 4ef40d156d88b665f1db5ea928db8797c2d4da3a Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 14 Oct 2025 19:21:14 -0700 Subject: [PATCH 34/47] fix annotations --- packages/lexical-html/src/ContextRecord.ts | 21 ++++++++++----------- packages/lexical-html/src/ImportContext.ts | 15 ++++++++++----- packages/lexical-html/src/RenderContext.ts | 15 ++++++++++----- 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/packages/lexical-html/src/ContextRecord.ts b/packages/lexical-html/src/ContextRecord.ts index 4f3870beb7d..ce81fa78561 100644 --- a/packages/lexical-html/src/ContextRecord.ts +++ b/packages/lexical-html/src/ContextRecord.ts @@ -144,15 +144,14 @@ export function $withContext( /** * @__NO_SIDE_EFFECTS__ */ -export function createContextStateFactory(tag: Tag) { - const contextTag: {readonly [k in Tag]: true} = {[tag]: true} as const; - return ( - name: string, - getDefaultValue: () => V, - isEqual?: (a: V, b: V) => boolean, - ) => - Object.assign( - createState(Symbol(name), {isEqual, parse: getDefaultValue}), - contextTag, - ); +export function createContextState( + tag: Tag, + name: string, + getDefaultValue: () => V, + isEqual?: (a: V, b: V) => boolean, +): ContextConfig { + return Object.assign( + createState(Symbol(name), {isEqual, parse: getDefaultValue}), + {[tag]: true} as const, + ); } diff --git a/packages/lexical-html/src/ImportContext.ts b/packages/lexical-html/src/ImportContext.ts index a0a856a4f75..075d731609e 100644 --- a/packages/lexical-html/src/ImportContext.ts +++ b/packages/lexical-html/src/ImportContext.ts @@ -26,7 +26,7 @@ import { import {DOMImportContextSymbol} from './constants'; import { $withContext, - createContextStateFactory, + createContextState, getEditorContext, getEditorContextValue, } from './ContextRecord'; @@ -34,13 +34,18 @@ import { /** * @__NO_SIDE_EFFECTS__ */ -export const createImportState: ( +export function createImportState( name: string, getDefaultValue: () => V, isEqual?: (a: V, b: V) => boolean, -) => ImportStateConfig = /*@__PURE__*/ createContextStateFactory( - DOMImportContextSymbol, -); +): ImportStateConfig { + return createContextState( + DOMImportContextSymbol, + name, + getDefaultValue, + isEqual, + ); +} export const $withImportContext: ( cfg: readonly AnyContextConfigPair[], diff --git a/packages/lexical-html/src/RenderContext.ts b/packages/lexical-html/src/RenderContext.ts index c7d6314e880..7beabc190d0 100644 --- a/packages/lexical-html/src/RenderContext.ts +++ b/packages/lexical-html/src/RenderContext.ts @@ -14,7 +14,7 @@ import {$getEditor, LexicalEditor} from 'lexical'; import {DOMRenderContextSymbol, DOMRenderExtensionName} from './constants'; import { $withContext, - createContextStateFactory, + createContextState, getContextValue, getEditorContext, } from './ContextRecord'; @@ -24,13 +24,18 @@ import {AnyContextConfigPair, ContextRecord, RenderStateConfig} from './types'; /** * @__NO_SIDE_EFFECTS__ */ -export const createRenderState: ( +export function createRenderState( name: string, getDefaultValue: () => V, isEqual?: (a: V, b: V) => boolean, -) => RenderStateConfig = /*@__PURE__*/ createContextStateFactory( - DOMRenderContextSymbol, -); +): RenderStateConfig { + return createContextState( + DOMRenderContextSymbol, + name, + getDefaultValue, + isEqual, + ); +} /** * true if the export was initiated from the root of the document From 6b382c8316ca5987773eb5f546a9b97711425462 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 15 Oct 2025 00:15:42 -0700 Subject: [PATCH 35/47] fix merge --- .../lexical-html/src/DOMImportExtension.ts | 18 ++++++---- .../lexical-html/src/DOMRenderExtension.ts | 2 +- .../unit/DOMImportExtensionNoLegacy.test.ts | 36 ++++++++++++++----- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/packages/lexical-html/src/DOMImportExtension.ts b/packages/lexical-html/src/DOMImportExtension.ts index 950a16caac5..fd03762289c 100644 --- a/packages/lexical-html/src/DOMImportExtension.ts +++ b/packages/lexical-html/src/DOMImportExtension.ts @@ -127,13 +127,17 @@ class MatchesImport { } } } - return rval; + return ( + rval || { + node: withImportNextSymbol($nextImport.bind(null, node)), + } + ); }; - return ( - ((tag === node.nodeName.toLowerCase() || (el && tag === '*')) && - $importAt(matches.length - 1)) || { - node: withImportNextSymbol($nextImport.bind(null, node)), - } + + return $importAt( + (tag === node.nodeName.toLowerCase() || (el && tag === '*') + ? matches.length + : 0) - 1, ); }; } @@ -574,7 +578,7 @@ export const DOMImportExtension = defineExtension< const merged = shallowMergeConfig(config, partial); for (const k of ['overrides'] as const) { if (partial[k]) { - (merged[k] as unknown[]) = [...merged[k], ...partial[k]]; + (merged[k] as unknown[]) = [...config[k], ...partial[k]]; } } return merged; diff --git a/packages/lexical-html/src/DOMRenderExtension.ts b/packages/lexical-html/src/DOMRenderExtension.ts index 057fbc91ce4..147d7491fb1 100644 --- a/packages/lexical-html/src/DOMRenderExtension.ts +++ b/packages/lexical-html/src/DOMRenderExtension.ts @@ -184,7 +184,7 @@ export const DOMRenderExtension = defineExtension< const merged = shallowMergeConfig(config, partial); for (const k of ['overrides', 'contextDefaults'] as const) { if (partial[k]) { - (merged[k] as unknown[]) = [...merged[k], ...partial[k]]; + (merged[k] as unknown[]) = [...config[k], ...partial[k]]; } } return merged; diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts index f525c067b4d..9558ae8d3cc 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts @@ -283,15 +283,18 @@ const NO_LEGACY_CONFIG: Partial = { compileLegacyImportNode: () => () => null, overrides: [ importOverride('#text', $convertTextDOMNode), - importOverride('*', (dom) => { + importOverride('*', function $overrideCreateParagraphFromBlock(dom) { if (isBlockDomNode(dom)) { return {node: $applyTextAlignToElement($createParagraphNode())}; } }), importOverride( '*', - (dom, $next): undefined | DOMImportOutputContinue => { - const childContext: AnyImportStateConfigPair[] = []; + function $overrideBlockFormatAndAlignment( + dom, + $next, + ): undefined | DOMImportOutputContinue { + const nextContext: AnyImportStateConfigPair[] = []; if (isBlockDomNode(dom)) { const {textAlign} = dom.style; switch (textAlign) { @@ -301,7 +304,7 @@ const NO_LEGACY_CONFIG: Partial = { case 'left': case 'right': case 'start': - childContext.push(ImportContextTextAlign.pair(textAlign)); + nextContext.push(ImportContextTextAlign.pair(textAlign)); break; default: break; @@ -355,13 +358,13 @@ const NO_LEGACY_CONFIG: Partial = { } if (formats) { const boundFormats = formats; - childContext.push( + nextContext.push( ImportContextTextFormats.pair((v) => ({...v, ...boundFormats})), ); } } - if (childContext.length > 0) { - return {childContext, node: $next}; + if (nextContext.length > 0) { + return {nextContext, node: $next}; } }, {priority: 1}, @@ -369,7 +372,24 @@ const NO_LEGACY_CONFIG: Partial = { ...formatOverrides, ...listOverrides, importOverride('li', (dom) => { - return {node: $applyTextAlignToElement($createListItemNode())}; + const node = $applyTextAlignToElement($createListItemNode()); + let ariaChecked: boolean | undefined; + if (dom.ariaChecked === 'true') { + ariaChecked = true; + } else if (dom.ariaChecked === 'false') { + ariaChecked = false; + } else { + const input: null | HTMLInputElement = dom.querySelector( + 'input[type=checkbox]', + ); + if (input) { + ariaChecked = input.checked; + } + } + if (ariaChecked !== undefined) { + node.setChecked(ariaChecked); + } + return {node}; }), ], }; From 620779f17473d15a7a7fd33781f98da3e789d418 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 15 Oct 2025 10:17:42 -0700 Subject: [PATCH 36/47] list importers --- .../lexical-html/src/DOMImportExtension.ts | 2 +- .../unit/DOMImportExtensionNoLegacy.test.ts | 81 ++++++++++++++++++- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/packages/lexical-html/src/DOMImportExtension.ts b/packages/lexical-html/src/DOMImportExtension.ts index fd03762289c..34bdc809bc7 100644 --- a/packages/lexical-html/src/DOMImportExtension.ts +++ b/packages/lexical-html/src/DOMImportExtension.ts @@ -489,7 +489,7 @@ export function compileDOMImportOverrides( stack.push([ node, ctx, - () => ({node: $boundFinalize()}), + () => ({childNodes: EMPTY_ARRAY, node: $boundFinalize()}), $parentAppendChild, ]); } diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts index 9558ae8d3cc..35c44f05b2d 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts @@ -30,15 +30,19 @@ import { import { $createListItemNode, $createListNode, + $isListItemNode, + $isListNode, CheckListExtension, ListExtension, ListType, } from '@lexical/list'; import { + $copyNode, $createLineBreakNode, $createParagraphNode, $createTabNode, $createTextNode, + $getChildCaret, $getEditor, $getSelection, $isElementNode, @@ -97,11 +101,47 @@ function listTypeFromDOM(dom: HTMLUListElement | HTMLOListElement): ListType { return dom.tagName === 'OL' ? 'number' : 'bullet'; } +/* + * This function normalizes the children of a ListNode after the conversion from HTML, + * ensuring that they are all ListItemNodes + */ +function $normalizeListNode( + node: null | LexicalNode | LexicalNode[], +): null | LexicalNode | LexicalNode[] { + if (Array.isArray(node) || !$isListNode(node)) { + return node; + } + for (const child of node.getChildren()) { + // Wrap all children in li elements + if (!$isListItemNode(child)) { + const li = $createListItemNode(); + child.replace(li).append(child); + } + } + return node; +} + +function $isOnlyChild(node: LexicalNode): boolean { + return !!(node.getPreviousSibling() || node.getNextSibling()); +} + +function $normalizeListItemNode( + node: null | LexicalNode | LexicalNode[], +): null | LexicalNode | LexicalNode[] { + if (Array.isArray(node) || !$isListItemNode(node)) { + return node; + } + return $unwrapBlockDOM( + node, + (el) => !el.isInline() || ($isListNode(el) && !$isOnlyChild(el)), + ); +} + function $importListNode( dom: HTMLUListElement | HTMLOListElement, ): DOMImportOutputNode { const listNode = $createListNode().setListType(listTypeFromDOM(dom)); - return {childNodes: dom.querySelectorAll(':scope>li'), node: listNode}; + return {$finalize: $normalizeListNode, node: listNode}; } const listOverrides = (['ul', 'ol'] as const).map((tag) => @@ -279,13 +319,48 @@ function $applyTextAlignToElement(node: T): T { return align ? node.setFormat(align) : node; } +function $unwrapBlockDOM( + node: LexicalNode | LexicalNode[] | null, + $splitPredicate: (el: ElementNode) => boolean = (el) => !el.isInline(), + $createNextElement: (el: ElementNode) => ElementNode = $copyNode, +): null | LexicalNode | LexicalNode[] { + if (Array.isArray(node) || !$isElementNode(node)) { + return node; + } + let adjacentNodes: undefined | ElementNode[]; + let lastParent: undefined | ElementNode; + for (const {origin} of $getChildCaret(node, 'next')) { + if ($isElementNode(origin) && $splitPredicate(origin)) { + lastParent = undefined; + adjacentNodes = adjacentNodes || []; + origin.remove(); + adjacentNodes.push(origin); + } else if (adjacentNodes) { + lastParent = lastParent || $createNextElement(node); + origin.remove(); + lastParent.append(origin); + } + } + if (adjacentNodes) { + if (node.isEmpty()) { + node.remove(); + } else { + adjacentNodes.unshift(node); + } + } + return adjacentNodes || node; +} + const NO_LEGACY_CONFIG: Partial = { compileLegacyImportNode: () => () => null, overrides: [ importOverride('#text', $convertTextDOMNode), importOverride('*', function $overrideCreateParagraphFromBlock(dom) { if (isBlockDomNode(dom)) { - return {node: $applyTextAlignToElement($createParagraphNode())}; + return { + $finalize: $unwrapBlockDOM, + node: $applyTextAlignToElement($createParagraphNode()), + }; } }), importOverride( @@ -389,7 +464,7 @@ const NO_LEGACY_CONFIG: Partial = { if (ariaChecked !== undefined) { node.setChecked(ariaChecked); } - return {node}; + return {$finalize: $normalizeListItemNode, node}; }), ], }; From 66932091be26214c67cd5590bebff0f04ce6a226 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 15 Oct 2025 10:33:56 -0700 Subject: [PATCH 37/47] Proactive wrapping --- .../src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts index 35c44f05b2d..ac06558fe0c 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts @@ -22,6 +22,7 @@ import { type DOMImportOutputNode, type DOMRenderConfig, DOMRenderExtension, + ImportContextParentLexicalNode, ImportContextTextAlign, ImportContextTextFormats, ImportContextWhiteSpaceCollapse, @@ -118,7 +119,10 @@ function $normalizeListNode( child.replace(li).append(child); } } - return node; + // Wrap self in a ListItem if it's directly in a ListNode + return $isListNode($getImportContextValue(ImportContextParentLexicalNode)) + ? $createListItemNode().append(node) + : node; } function $isOnlyChild(node: LexicalNode): boolean { From 779b52a1408c350671e37455d8ae27a300a31358 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 15 Oct 2025 18:17:35 -0700 Subject: [PATCH 38/47] fix $unwrapArtificialNodes --- .../lexical-html/src/$unwrapArtificialNodes.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/lexical-html/src/$unwrapArtificialNodes.ts b/packages/lexical-html/src/$unwrapArtificialNodes.ts index c68bc411c8a..08c9d1f1488 100644 --- a/packages/lexical-html/src/$unwrapArtificialNodes.ts +++ b/packages/lexical-html/src/$unwrapArtificialNodes.ts @@ -12,14 +12,18 @@ export function $unwrapArtificialNodes( ) { // Replace artificial node with its children, inserting a linebreak // between adjacent artificial nodes + for (const node of allArtificialNodes) { + if ( + node.getParent() && + node.getNextSibling() instanceof ArtificialNode__DO_NOT_USE + ) { + node.insertAfter($createLineBreakNode()); + } + } for (const node of allArtificialNodes) { const parent = node.getParent(); if (parent) { - const children = node.getChildren(); - if (node.getNextSibling() instanceof ArtificialNode__DO_NOT_USE) { - children.push($createLineBreakNode()); - } - parent.splice(node.getIndexWithinParent(), 1, children); + parent.splice(node.getIndexWithinParent(), 1, node.getChildren()); } } } From 951e30447890c54e9f4695622b68987be55c43b0 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Thu, 23 Oct 2025 18:33:54 -0700 Subject: [PATCH 39/47] render compilation --- packages/lexical-extension/src/config.ts | 17 +- .../lexical-html/src/DOMRenderExtension.ts | 142 +------- .../unit/DOMImportExtensionNoLegacy.test.ts | 6 +- .../src/compileDOMRenderConfigOverrides.ts | 309 ++++++++++++++++++ packages/lexical-html/src/index.ts | 2 +- packages/lexical-html/src/types.ts | 8 +- 6 files changed, 332 insertions(+), 152 deletions(-) create mode 100644 packages/lexical-html/src/compileDOMRenderConfigOverrides.ts diff --git a/packages/lexical-extension/src/config.ts b/packages/lexical-extension/src/config.ts index 28e45e684ea..92034bf0edb 100644 --- a/packages/lexical-extension/src/config.ts +++ b/packages/lexical-extension/src/config.ts @@ -5,11 +5,12 @@ * LICENSE file in the root directory of this source tree. * */ -import type { - CreateEditorArgs, - InitialEditorConfig, - KlassConstructor, - LexicalNode, +import { + type CreateEditorArgs, + getStaticNodeConfig, + type InitialEditorConfig, + type KlassConstructor, + type LexicalNode, } from 'lexical'; export interface KnownTypesAndNodes { @@ -25,7 +26,9 @@ export interface KnownTypesAndNodes { * @param config The InitialEditorConfig (accessible from an extension's init) * @returns The known types and nodes as Sets */ -export function getKnownTypesAndNodes(config: InitialEditorConfig) { +export function getKnownTypesAndNodes( + config: InitialEditorConfig, +): KnownTypesAndNodes { const types: KnownTypesAndNodes['types'] = new Set(); const nodes: KnownTypesAndNodes['nodes'] = new Set(); for (const klassOrReplacement of getNodeConfig(config)) { @@ -33,6 +36,8 @@ export function getKnownTypesAndNodes(config: InitialEditorConfig) { typeof klassOrReplacement === 'function' ? klassOrReplacement : klassOrReplacement.replace; + // For the side-effect of filling in the static methods + void getStaticNodeConfig(klass); types.add(klass.getType()); nodes.add(klass); } diff --git a/packages/lexical-html/src/DOMRenderExtension.ts b/packages/lexical-html/src/DOMRenderExtension.ts index 147d7491fb1..6e845b2f1cb 100644 --- a/packages/lexical-html/src/DOMRenderExtension.ts +++ b/packages/lexical-html/src/DOMRenderExtension.ts @@ -5,145 +5,14 @@ * LICENSE file in the root directory of this source tree. * */ -import type { - AnyDOMRenderMatch, - DOMRenderConfig, - DOMRenderExtensionOutput, -} from './types'; +import type {DOMRenderConfig, DOMRenderExtensionOutput} from './types'; -import { - $isElementNode, - DEFAULT_EDITOR_DOM_CONFIG, - defineExtension, - EditorDOMRenderConfig, - type LexicalNode, - RootNode, - shallowMergeConfig, -} from 'lexical'; -import devInvariant from 'shared/devInvariant'; +import {defineExtension, RootNode, shallowMergeConfig} from 'lexical'; +import {compileDOMRenderConfigOverrides} from './compileDOMRenderConfigOverrides'; import {DOMRenderExtensionName} from './constants'; import {contextFromPairs} from './ContextRecord'; -function compileDOMRenderConfigOverrides( - {overrides}: DOMRenderConfig, - defaults: EditorDOMRenderConfig, -): EditorDOMRenderConfig { - function mergeDOMRenderMatch( - acc: EditorDOMRenderConfig, - match: AnyDOMRenderMatch, - ): EditorDOMRenderConfig { - // TODO Consider using a node type map to make this more efficient when - // there are more overrides - const { - nodes, - $getDOMSlot, - $createDOM, - $updateDOM, - $exportDOM, - $shouldExclude, - $shouldInclude, - $extractWithChild, - } = match; - const matcher = (node: LexicalNode): boolean => { - for (const predicate of nodes) { - if (predicate === '*') { - return true; - } - if ('getType' in predicate || '$config' in predicate.prototype) { - if (node instanceof predicate) { - return true; - } - } else { - const rval = predicate(node); - devInvariant( - typeof rval === 'boolean', - 'domOverride predicate %s returned %s, expecting boolean', - predicate.name, - typeof rval, - ); - if (rval) { - return true; - } - } - } - return false; - }; - return { - $createDOM: $createDOM - ? (node, editor) => { - const $next = () => acc.$createDOM(node, editor); - return matcher(node) ? $createDOM(node, $next, editor) : $next(); - } - : acc.$createDOM, - $exportDOM: $exportDOM - ? (node, editor) => { - const $next = () => acc.$exportDOM(node, editor); - return matcher(node) ? $exportDOM(node, $next, editor) : $next(); - } - : acc.$exportDOM, - $extractWithChild: $extractWithChild - ? (node, childNode, selection, destination, editor) => { - const $next = () => - acc.$extractWithChild( - node, - childNode, - selection, - destination, - editor, - ); - return matcher(node) - ? $extractWithChild( - node, - childNode, - selection, - destination, - $next, - editor, - ) - : $next(); - } - : acc.$extractWithChild, - $getDOMSlot: $getDOMSlot - ? (node, dom, editor) => { - const $next = () => acc.$getDOMSlot(node, dom, editor); - return $isElementNode(node) && matcher(node) - ? $getDOMSlot(node, $next, editor) - : $next(); - } - : acc.$getDOMSlot, - $shouldExclude: $shouldExclude - ? (node, selection, editor) => { - const $next = () => acc.$shouldExclude(node, selection, editor); - return matcher(node) - ? $shouldExclude(node, selection, $next, editor) - : $next(); - } - : acc.$shouldExclude, - $shouldInclude: $shouldInclude - ? (node, selection, editor) => { - const $next = () => acc.$shouldInclude(node, selection, editor); - return matcher(node) - ? $shouldInclude(node, selection, $next, editor) - : $next(); - } - : acc.$shouldInclude, - $updateDOM: $updateDOM - ? (nextNode, prevNode, dom, editor) => { - const $next = () => acc.$updateDOM(nextNode, prevNode, dom, editor); - return matcher(nextNode) - ? $updateDOM(nextNode, prevNode, dom, $next, editor) - : $next(); - } - : acc.$updateDOM, - }; - } - // The beginning of the array will be the overrides towards the top - // of the tree so should be higher precedence, so we compose the functions - // from the right - return overrides.reduceRight(mergeDOMRenderMatch, defaults); -} - /** @internal @experimental */ export const DOMRenderExtension = defineExtension< @@ -175,10 +44,7 @@ export const DOMRenderExtension = defineExtension< ]), }, init(editorConfig, config) { - editorConfig.dom = compileDOMRenderConfigOverrides(config, { - ...DEFAULT_EDITOR_DOM_CONFIG, - ...editorConfig.dom, - }); + editorConfig.dom = compileDOMRenderConfigOverrides(editorConfig, config); }, mergeConfig(config, partial) { const merged = shallowMergeConfig(config, partial); diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts index ac06558fe0c..85e0992a167 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts @@ -19,7 +19,7 @@ import { DOMImportExtension, type DOMImportNext, type DOMImportOutputContinue, - type DOMImportOutputNode, + type DOMImportOutputNodes, type DOMRenderConfig, DOMRenderExtension, ImportContextParentLexicalNode, @@ -143,7 +143,7 @@ function $normalizeListItemNode( function $importListNode( dom: HTMLUListElement | HTMLOListElement, -): DOMImportOutputNode { +): DOMImportOutputNodes { const listNode = $createListNode().setListType(listTypeFromDOM(dom)); return {$finalize: $normalizeListNode, node: listNode}; } @@ -222,7 +222,7 @@ function $createTextNodeWithCurrentFormat(text: string = ''): TextNode { return node; } -function $convertTextDOMNode(domNode: Text): DOMImportOutputNode { +function $convertTextDOMNode(domNode: Text): DOMImportOutputNodes { const domNode_ = domNode as Text; const parentDom = domNode.parentElement; invariant( diff --git a/packages/lexical-html/src/compileDOMRenderConfigOverrides.ts b/packages/lexical-html/src/compileDOMRenderConfigOverrides.ts new file mode 100644 index 00000000000..908d70d6f59 --- /dev/null +++ b/packages/lexical-html/src/compileDOMRenderConfigOverrides.ts @@ -0,0 +1,309 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {getKnownTypesAndNodes} from '@lexical/extension'; +import { + $isLexicalNode, + DEFAULT_EDITOR_DOM_CONFIG, + type EditorDOMRenderConfig, + getStaticNodeConfig, + InitialEditorConfig, + Klass, + LexicalEditor, + type LexicalNode, +} from 'lexical'; +import invariant from 'shared/invariant'; + +import {AnyDOMRenderMatch, DOMRenderConfig, DOMRenderMatch} from './types'; + +interface TypeRecord { + readonly klass: Klass; + readonly types: {[NodeAndSubclasses in string]?: boolean}; +} + +type TypeTree = { + [NodeType in string]?: TypeRecord; +}; + +function buildTypeTree(editorConfig: InitialEditorConfig): TypeTree { + const t: TypeTree = {}; + const {nodes} = getKnownTypesAndNodes(editorConfig); + for (const klass of nodes) { + const type = klass.getType(); + t[type] = {klass, types: {}}; + } + for (const baseRec of Object.values(t)) { + if (baseRec) { + const baseType = baseRec.klass.getType(); + for ( + let {klass} = baseRec; + $isLexicalNode(klass.prototype); + klass = Object.getPrototypeOf(klass) + ) { + const {ownNodeType} = getStaticNodeConfig(klass); + const superRec = ownNodeType && t[ownNodeType]; + if (superRec) { + superRec.types[baseType] = true; + } + } + } + } + return t; +} + +type PredicateOrTypes = + | ((node: LexicalNode) => boolean) + | {[NodeType in string]?: true}; +type TypeRender = {[NodeType in string]?: T[]}; +type AnyRender = + | readonly [(node: LexicalNode) => boolean, T] + | readonly ['types', TypeRender]; + +type PreEditorDOMRenderConfig = { + [K in keyof EditorDOMRenderConfig]: AnyRender[]; +}; + +const ALWAYS_TRUE = () => true as const; + +function buildNodePredicate(klass: Klass) { + return (node: LexicalNode): node is T => node instanceof klass; +} + +function getPredicate( + typeTree: TypeTree, + {nodes}: DOMRenderMatch, +): {[NodeType in string]?: true} | ((node: LexicalNode) => boolean) { + if (nodes === '*') { + return ALWAYS_TRUE; + } + let types: undefined | {[NodeType in string]?: true} = {}; + const predicates: ((node: LexicalNode) => boolean)[] = []; + for (const klassOrPredicate of nodes) { + if ('getType' in klassOrPredicate) { + const type = klassOrPredicate.getType(); + if (types) { + const tree = typeTree[type]; + invariant( + tree !== undefined, + 'Node class %s with type %s not registered in editor', + klassOrPredicate.name, + type, + ); + types = Object.assign(types, tree.types); + } + predicates.push(buildNodePredicate(klassOrPredicate)); + } else { + types = undefined; + predicates.push(klassOrPredicate); + } + } + if (types) { + return types; + } else if (predicates.length === 1) { + return predicates[0]; + } + return (node: LexicalNode): boolean => { + for (const predicate of predicates) { + if (predicate(node)) { + return true; + } + } + return false; + }; +} + +function makePrerender(): PreEditorDOMRenderConfig { + return { + $createDOM: [], + $exportDOM: [], + $extractWithChild: [], + $getDOMSlot: [], + $shouldExclude: [], + $shouldInclude: [], + $updateDOM: [], + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AccFn = ( + node: N, + ...rest: [...Args, editor: LexicalEditor] +) => T; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type GetOverrideFn = ( + n: N, +) => undefined | OverrideFn; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type OverrideFn = ( + node: N, + ...rest: [...Args, $next: () => T, editor: LexicalEditor] +) => T; + +function ignoreNext2( + acc: AccFn, +): OverrideFn { + return (node: N, _$next: () => T, editor: LexicalEditor) => acc(node, editor); +} +function ignoreNext3( + acc: AccFn, +): OverrideFn { + return (node: N, a: A, _$next: () => T, editor: LexicalEditor) => + acc(node, a, editor); +} +function ignoreNext4( + acc: AccFn, +): OverrideFn { + return (node: N, a: A, b: B, _$next: () => T, editor: LexicalEditor) => + acc(node, a, b, editor); +} +function ignoreNext5( + acc: AccFn, +): OverrideFn { + return (node: N, a: A, b: B, c: C, _$next: () => T, editor: LexicalEditor) => + acc(node, a, b, c, editor); +} + +function merge2( + $acc: AccFn, + $getOverride: GetOverrideFn, +): typeof $acc { + return (node, editor) => { + const $next = () => $acc(node, editor); + const $override = $getOverride(node); + return $override ? $override(node, $next, editor) : $next(); + }; +} + +function merge3( + acc: AccFn, + $getOverride: GetOverrideFn, +): typeof acc { + return (node, a, editor) => { + const $next = () => acc(node, a, editor); + const $override = $getOverride(node); + return $override ? $override(node, a, $next, editor) : $next(); + }; +} + +function merge4( + $acc: AccFn, + $getOverride: GetOverrideFn, +): typeof $acc { + return (node, a, b, editor) => { + const $next = () => $acc(node, a, b, editor); + const $override = $getOverride(node); + return $override ? $override(node, a, b, $next, editor) : $next(); + }; +} + +function merge5( + acc: AccFn, + $getOverride: GetOverrideFn, +): typeof acc { + return (node, a, b, c, editor) => { + const $next = () => acc(node, a, b, c, editor); + const $override = $getOverride(node); + return $override ? $override(node, a, b, c, $next, editor) : $next(); + }; +} + +function compilePrerenderKey( + prerender: PreEditorDOMRenderConfig, + k: K, + defaults: EditorDOMRenderConfig, + mergeFunction: ( + $acc: EditorDOMRenderConfig[K], + $getOverride: (node: LexicalNode) => AnyDOMRenderMatch[K], + ) => typeof $acc, + ignoreNextFunction: (fn: EditorDOMRenderConfig[K]) => AnyDOMRenderMatch[K], +): void { + let acc = defaults[k]; + for (const pair of prerender[k]) { + if (typeof pair[0] === 'function') { + const [$predicate, $override] = pair; + acc = mergeFunction( + acc, + (node) => ($predicate(node) && $override) || undefined, + ); + } else { + const typeOverrides = pair[1]; + const compiled: Record = {}; + for (const type in typeOverrides) { + const arr = typeOverrides[type]; + if (arr) { + compiled[type] = arr.reduce( + ($acc, $override) => mergeFunction($acc, () => $override), + acc, + ); + } + } + acc = mergeFunction(acc, (node) => { + const f = compiled[node.getType()]; + return f && ignoreNextFunction(f); + }); + } + } + defaults[k] = acc; +} + +function addOverride( + prerender: PreEditorDOMRenderConfig, + k: K, + predicateOrTypes: PredicateOrTypes, + override: AnyDOMRenderMatch[K], +): void { + if (!override) { + return; + } + const arr = prerender[k]; + if (typeof predicateOrTypes === 'function') { + arr.push([predicateOrTypes, override]); + } else { + const last = arr[arr.length - 1]; + let types: TypeRender; + if (last && last[0] === 'types') { + types = last[1]; + } else { + types = {}; + arr.push(['types', types]); + } + for (const type in predicateOrTypes) { + const typeArr = types[type] || []; + types[type] = typeArr; + typeArr.push(override); + } + } +} + +export function compileDOMRenderConfigOverrides( + editorConfig: InitialEditorConfig, + {overrides}: DOMRenderConfig, +): EditorDOMRenderConfig { + const typeTree = buildTypeTree(editorConfig); + const prerender = makePrerender(); + for (const override of overrides) { + const predicateOrTypes = getPredicate(typeTree, override); + for (const k_ in prerender) { + const k = k_ as keyof typeof prerender; + addOverride(prerender, k, predicateOrTypes, override[k]); + } + } + const dom = { + ...DEFAULT_EDITOR_DOM_CONFIG, + ...editorConfig.dom, + }; + compilePrerenderKey(prerender, '$createDOM', dom, merge2, ignoreNext2); + compilePrerenderKey(prerender, '$exportDOM', dom, merge2, ignoreNext2); + compilePrerenderKey(prerender, '$extractWithChild', dom, merge5, ignoreNext5); + compilePrerenderKey(prerender, '$getDOMSlot', dom, merge3, ignoreNext3); + compilePrerenderKey(prerender, '$shouldExclude', dom, merge3, ignoreNext3); + compilePrerenderKey(prerender, '$shouldInclude', dom, merge3, ignoreNext3); + compilePrerenderKey(prerender, '$updateDOM', dom, merge4, ignoreNext4); + return dom; +} diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index d6423f6fdbf..c6635033d81 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -43,7 +43,7 @@ export type { DOMImportNext, DOMImportOutput, DOMImportOutputContinue, - DOMImportOutputNode, + DOMImportOutputNodes, DOMRenderConfig, DOMRenderExtensionOutput, DOMRenderMatch, diff --git a/packages/lexical-html/src/types.ts b/packages/lexical-html/src/types.ts index 51dff181e0a..3a16061ab82 100644 --- a/packages/lexical-html/src/types.ts +++ b/packages/lexical-html/src/types.ts @@ -17,7 +17,6 @@ import type { BaseSelection, DOMExportOutput, ElementDOMSlot, - ElementNode, Klass, LexicalEditor, LexicalNode, @@ -70,9 +69,9 @@ export type AnyRenderStateConfig = RenderStateConfig; export type AnyImportStateConfig = ImportStateConfig; /** @internal @experimental */ -export type DOMImportOutput = DOMImportOutputNode | DOMImportOutputContinue; +export type DOMImportOutput = DOMImportOutputNodes | DOMImportOutputContinue; -export interface DOMImportOutputNode { +export interface DOMImportOutputNodes { node: null | LexicalNode | LexicalNode[]; childNodes?: NodeListOf | readonly ChildNode[]; childContext?: AnyImportStateConfigPair[]; @@ -128,8 +127,9 @@ export type NodeMatch = /** @internal @experimental */ export interface DOMRenderMatch { readonly nodes: '*' | readonly NodeMatch[]; - $getDOMSlot?: ( + $getDOMSlot?: ( node: N, + dom: HTMLElement, $next: () => ElementDOMSlot, editor: LexicalEditor, ) => ElementDOMSlot; From e48b19063e91d9ee4a60659b9dd176b20e5f4e35 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Thu, 23 Oct 2025 21:39:07 -0700 Subject: [PATCH 40/47] more render testing --- packages/lexical-extension/src/config.ts | 4 +- ...ion.test.ts => DOMRenderExtension.test.ts} | 72 +++++++ .../compileDOMRenderConfigOverrides.test.ts | 179 ++++++++++++++++++ .../src/compileDOMRenderConfigOverrides.ts | 22 ++- 4 files changed, 269 insertions(+), 8 deletions(-) rename packages/lexical-html/src/__tests__/unit/{DOMExtension.test.ts => DOMRenderExtension.test.ts} (73%) create mode 100644 packages/lexical-html/src/__tests__/unit/compileDOMRenderConfigOverrides.test.ts diff --git a/packages/lexical-extension/src/config.ts b/packages/lexical-extension/src/config.ts index 92034bf0edb..e8df5c7e6b4 100644 --- a/packages/lexical-extension/src/config.ts +++ b/packages/lexical-extension/src/config.ts @@ -27,7 +27,7 @@ export interface KnownTypesAndNodes { * @returns The known types and nodes as Sets */ export function getKnownTypesAndNodes( - config: InitialEditorConfig, + config: Pick, ): KnownTypesAndNodes { const types: KnownTypesAndNodes['types'] = new Set(); const nodes: KnownTypesAndNodes['nodes'] = new Set(); @@ -45,7 +45,7 @@ export function getKnownTypesAndNodes( } export function getNodeConfig( - config: InitialEditorConfig, + config: Pick, ): NonNullable { return ( (typeof config.nodes === 'function' ? config.nodes() : config.nodes) || [] diff --git a/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts b/packages/lexical-html/src/__tests__/unit/DOMRenderExtension.test.ts similarity index 73% rename from packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts rename to packages/lexical-html/src/__tests__/unit/DOMRenderExtension.test.ts index 9406005b40a..d4ce0c3a5ab 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMExtension.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMRenderExtension.test.ts @@ -15,6 +15,8 @@ import { RenderContextRoot, } from '@lexical/html'; import { + $create, + $createLineBreakNode, $createParagraphNode, $createTextNode, $getRoot, @@ -202,4 +204,74 @@ describe('DOMRenderExtension', () => { }), ); }); + test('type merge', () => { + class TextNodeA extends TextNode { + $config() { + return this.config('text-a', {extends: TextNode}); + } + } + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: () => { + $getRoot().append( + $createParagraphNode().append( + $create(TextNodeA).setTextContent('text a'), + $createLineBreakNode(), + $createTextNode().setTextContent('plain text'), + ), + ); + }, + dependencies: [ + configExtension(DOMRenderExtension, { + overrides: [ + domOverride([TextNode], { + $exportDOM(node) { + const span = document.createElement('span'); + span.append(node.getTextContent()); + return {element: span}; + }, + }), + domOverride([TextNodeA], { + $exportDOM(node) { + const span = document.createElement('span'); + span.append(node.getTextContent()); + span.dataset.lexicalType = node.getType(); + return {element: span}; + }, + }), + domOverride([TextNode], { + $exportDOM(node, $next) { + const r = $next(); + if (isHTMLElement(r.element)) { + r.element.dataset.didOverride = 'true'; + } + return r; + }, + }), + ], + }), + ], + name: 'root', + nodes: [TextNodeA], + }), + ); + expect( + editor.read(() => { + expectHtmlToBeEqual( + $generateDOMFromRoot(document.createElement('div')).innerHTML, + html` +
+

+ + text a + +
+ plain text +

+
+ `, + ); + }), + ); + }); }); diff --git a/packages/lexical-html/src/__tests__/unit/compileDOMRenderConfigOverrides.test.ts b/packages/lexical-html/src/__tests__/unit/compileDOMRenderConfigOverrides.test.ts new file mode 100644 index 00000000000..66bc3ac37d4 --- /dev/null +++ b/packages/lexical-html/src/__tests__/unit/compileDOMRenderConfigOverrides.test.ts @@ -0,0 +1,179 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {buildEditorFromExtensions} from '@lexical/extension'; +import {domOverride} from '@lexical/html'; +import { + $isLineBreakNode, + isHTMLElement, + LineBreakNode, + ParagraphNode, + TabNode, + TextNode, +} from 'lexical'; +import {describe, expect, test} from 'vitest'; + +import { + ALWAYS_TRUE, + buildTypeTree, + precompileDOMRenderConfigOverrides, +} from '../../compileDOMRenderConfigOverrides'; + +describe('buildTypeTree', () => { + test('includes basic types', () => { + const editor = buildEditorFromExtensions(); + expect(buildTypeTree(editor._createEditorArgs!)).toMatchObject({ + linebreak: { + klass: LineBreakNode, + types: { + linebreak: true, + }, + }, + paragraph: { + klass: ParagraphNode, + types: {paragraph: true}, + }, + tab: {klass: TabNode, types: {tab: true}}, + text: {klass: TextNode, types: {tab: true, text: true}}, + }); + }); +}); + +describe('precompileDOMRenderConfigOverrides', () => { + test('precompiles with only type overrides', () => { + class TextNodeA extends TextNode { + $config() { + return this.config('text-a', {extends: TextNode}); + } + } + const overrides = [ + domOverride([TextNode], { + $exportDOM(node) { + const span = document.createElement('span'); + span.append(node.getTextContent()); + return {element: span}; + }, + }), + domOverride([TextNodeA], { + $exportDOM(node) { + const span = document.createElement('span'); + span.append(node.getTextContent()); + span.dataset.lexicalType = node.getType(); + return {element: span}; + }, + }), + domOverride([TextNode], { + $exportDOM(node, $next) { + const r = $next(); + if (isHTMLElement(r.element)) { + r.element.dataset.didOverride = 'true'; + } + return r; + }, + }), + ]; + const prerender = precompileDOMRenderConfigOverrides( + {nodes: [TextNode, TextNodeA]}, + overrides, + ); + expect(prerender).toEqual({ + $createDOM: [], + $exportDOM: [ + [ + 'types', + { + text: [overrides[0].$exportDOM, overrides[2].$exportDOM], + 'text-a': [ + overrides[0].$exportDOM, + overrides[1].$exportDOM, + overrides[2].$exportDOM, + ], + }, + ], + ], + $extractWithChild: [], + $getDOMSlot: [], + $shouldExclude: [], + $shouldInclude: [], + $updateDOM: [], + }); + }); + test('precompiles with wildcards, predicates, and type overrides', () => { + class TextNodeA extends TextNode { + $config() { + return this.config('text-a', {extends: TextNode}); + } + } + const overrides = [ + domOverride([TextNode], { + $exportDOM(node) { + const span = document.createElement('span'); + span.append(node.getTextContent()); + return {element: span}; + }, + }), + domOverride('*', { + $exportDOM(node, $next) { + return $next(); + }, + }), + domOverride([TextNodeA], { + $exportDOM(node) { + const span = document.createElement('span'); + span.append(node.getTextContent()); + span.dataset.lexicalType = node.getType(); + return {element: span}; + }, + }), + domOverride([TextNode], { + $exportDOM(node, $next) { + const r = $next(); + if (isHTMLElement(r.element)) { + r.element.dataset.didOverride = 'true'; + } + return r; + }, + }), + domOverride([$isLineBreakNode], { + $exportDOM(node, $next) { + return $next(); + }, + }), + ]; + const prerender = precompileDOMRenderConfigOverrides( + {nodes: [TextNode, TextNodeA]}, + overrides, + ); + expect(prerender).toEqual({ + $createDOM: [], + $exportDOM: [ + [ + 'types', + { + text: [overrides[0].$exportDOM], + 'text-a': [overrides[0].$exportDOM], + }, + ], + [ALWAYS_TRUE, overrides[1].$exportDOM], + [ + 'types', + { + text: [overrides[3].$exportDOM], + 'text-a': [overrides[2].$exportDOM, overrides[3].$exportDOM], + }, + ], + [$isLineBreakNode, overrides[4].$exportDOM], + ], + $extractWithChild: [], + $getDOMSlot: [], + $shouldExclude: [], + $shouldInclude: [], + $updateDOM: [], + }); + }); +}); diff --git a/packages/lexical-html/src/compileDOMRenderConfigOverrides.ts b/packages/lexical-html/src/compileDOMRenderConfigOverrides.ts index 908d70d6f59..e11adce2d1b 100644 --- a/packages/lexical-html/src/compileDOMRenderConfigOverrides.ts +++ b/packages/lexical-html/src/compileDOMRenderConfigOverrides.ts @@ -29,7 +29,9 @@ type TypeTree = { [NodeType in string]?: TypeRecord; }; -function buildTypeTree(editorConfig: InitialEditorConfig): TypeTree { +export function buildTypeTree( + editorConfig: Pick, +): TypeTree { const t: TypeTree = {}; const {nodes} = getKnownTypesAndNodes(editorConfig); for (const klass of nodes) { @@ -67,7 +69,7 @@ type PreEditorDOMRenderConfig = { [K in keyof EditorDOMRenderConfig]: AnyRender[]; }; -const ALWAYS_TRUE = () => true as const; +export const ALWAYS_TRUE = () => true as const; function buildNodePredicate(klass: Klass) { return (node: LexicalNode): node is T => node instanceof klass; @@ -281,10 +283,10 @@ function addOverride( } } -export function compileDOMRenderConfigOverrides( - editorConfig: InitialEditorConfig, - {overrides}: DOMRenderConfig, -): EditorDOMRenderConfig { +export function precompileDOMRenderConfigOverrides( + editorConfig: Pick, + overrides: DOMRenderConfig['overrides'], +): PreEditorDOMRenderConfig { const typeTree = buildTypeTree(editorConfig); const prerender = makePrerender(); for (const override of overrides) { @@ -294,6 +296,14 @@ export function compileDOMRenderConfigOverrides( addOverride(prerender, k, predicateOrTypes, override[k]); } } + return prerender; +} + +export function compileDOMRenderConfigOverrides( + editorConfig: InitialEditorConfig, + {overrides}: DOMRenderConfig, +): EditorDOMRenderConfig { + const prerender = precompileDOMRenderConfigOverrides(editorConfig, overrides); const dom = { ...DEFAULT_EDITOR_DOM_CONFIG, ...editorConfig.dom, From e1db3c7d21c99edbf8910cb07e032ff6dd57e148 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 24 Oct 2025 13:03:25 -0700 Subject: [PATCH 41/47] ContextRecord refactoring --- .../src/$wrapContinuousInlinesInPlace.ts | 40 ++++ packages/lexical-html/src/ContextRecord.ts | 44 +++-- .../lexical-html/src/DOMImportExtension.ts | 180 +----------------- packages/lexical-html/src/ImportContext.ts | 36 ++-- packages/lexical-html/src/RenderContext.ts | 10 +- .../compileDOMRenderConfigOverrides.test.ts | 7 +- .../src/compileDOMRenderConfigOverrides.ts | 3 +- .../src/compileLegacyImportDOM.ts | 177 +++++++++++++++++ packages/lexical-html/src/constants.ts | 4 + packages/lexical-html/src/index.ts | 1 - 10 files changed, 289 insertions(+), 213 deletions(-) create mode 100644 packages/lexical-html/src/$wrapContinuousInlinesInPlace.ts create mode 100644 packages/lexical-html/src/compileLegacyImportDOM.ts diff --git a/packages/lexical-html/src/$wrapContinuousInlinesInPlace.ts b/packages/lexical-html/src/$wrapContinuousInlinesInPlace.ts new file mode 100644 index 00000000000..4315ae44c8e --- /dev/null +++ b/packages/lexical-html/src/$wrapContinuousInlinesInPlace.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import { + $isBlockElementNode, + type ElementFormatType, + type ElementNode, + type LexicalNode, +} from 'lexical'; + +export function $wrapContinuousInlinesInPlace( + domNode: Node, + nodes: LexicalNode[], + $createWrapperFn: () => ElementNode, +): void { + const textAlign = (domNode as HTMLElement).style + .textAlign as ElementFormatType; + // wrap contiguous inline child nodes in para + let j = 0; + for (let i = 0, wrapper: undefined | ElementNode; i < nodes.length; i++) { + const node = nodes[i]; + if ($isBlockElementNode(node)) { + if (textAlign && !node.getFormat()) { + node.setFormat(textAlign); + } + wrapper = undefined; + nodes[j++] = node; + } else { + if (!wrapper) { + nodes[j++] = wrapper = $createWrapperFn().setFormat(textAlign); + } + wrapper.append(node); + } + } + nodes.length = j; +} diff --git a/packages/lexical-html/src/ContextRecord.ts b/packages/lexical-html/src/ContextRecord.ts index ce81fa78561..1350ee93f83 100644 --- a/packages/lexical-html/src/ContextRecord.ts +++ b/packages/lexical-html/src/ContextRecord.ts @@ -12,7 +12,12 @@ import type { ContextRecord, } from './types'; -import {$getEditor, createState, type LexicalEditor} from 'lexical'; +import { + $getEditor, + createState, + type LexicalEditor, + ValueOrUpdater, +} from 'lexical'; let activeContext: undefined | EditorContext; @@ -34,20 +39,18 @@ export function getContextValue( : cfg.defaultValue; } -export function getEditorContext( - editor: LexicalEditor, -): undefined | EditorContext { +function getEditorContext(editor: LexicalEditor): undefined | EditorContext { return activeContext && activeContext.editor === editor ? activeContext : undefined; } -export function getEditorContextValue( +export function getContextRecord( sym: Ctx, - context: undefined | EditorContext, - cfg: ContextConfig, -): V { - return getContextValue(context && context[sym], cfg); + editor: LexicalEditor, +): undefined | ContextRecord { + const editorContext = getEditorContext(editor); + return editorContext && editorContext[sym]; } export function contextFromPairs( @@ -73,16 +76,29 @@ export function createChildContext( return Object.create(parent || null); } +export function setContextValue( + contextRecord: ContextRecord, + cfg: ContextConfig, + valueOrUpdater: ValueOrUpdater, +): V { + const {key, defaultValue} = cfg; + const value = + typeof valueOrUpdater !== 'function' + ? valueOrUpdater + : (valueOrUpdater as (prev: V) => V)( + key in contextRecord ? (contextRecord[key] as V) : defaultValue, + ); + contextRecord[key] = value; + return value; +} + export function updateContextFromPairs( contextRecord: ContextRecord, pairs: undefined | readonly AnyContextConfigPair[], ): ContextRecord { if (pairs) { - for (const [{key}, valueOrUpdater] of pairs) { - contextRecord[key] = - typeof valueOrUpdater === 'function' - ? valueOrUpdater(contextRecord[key]) - : valueOrUpdater; + for (const [cfg, valueOrUpdater] of pairs) { + setContextValue(contextRecord, cfg, valueOrUpdater); } } return contextRecord; diff --git a/packages/lexical-html/src/DOMImportExtension.ts b/packages/lexical-html/src/DOMImportExtension.ts index 34bdc809bc7..2e2daf96d8c 100644 --- a/packages/lexical-html/src/DOMImportExtension.ts +++ b/packages/lexical-html/src/DOMImportExtension.ts @@ -19,17 +19,11 @@ import type { } from './types'; import { - $createLineBreakNode, - $createParagraphNode, $isBlockElementNode, $isElementNode, $isRootOrShadowRoot, ArtificialNode__DO_NOT_USE, defineExtension, - type DOMConversionOutput, - type ElementFormatType, - type ElementNode, - isBlockDomNode, isDOMDocumentNode, isHTMLElement, type LexicalEditor, @@ -39,58 +33,29 @@ import { import invariant from 'shared/invariant'; import {$unwrapArtificialNodes} from './$unwrapArtificialNodes'; +import {compileLegacyImportDOM} from './compileLegacyImportDOM'; import { DOMImportContextSymbol, DOMImportExtensionName, DOMImportNextSymbol, DOMTextWrapModeKeys, DOMWhiteSpaceCollapseKeys, - IGNORE_TAGS, + EMPTY_ARRAY, } from './constants'; import { $withFullContext, createChildContext, updateContextFromPairs, } from './ContextRecord'; -import {getConversionFunction} from './getConversionFunction'; import { $getImportContextValue, ImportContextArtificialNodes, ImportContextDOMNode, - ImportContextForChildMap, ImportContextHasBlockAncestorLexicalNode, ImportContextParentLexicalNode, ImportContextTextWrapMode, ImportContextWhiteSpaceCollapse, } from './ImportContext'; -import {isDomNodeBetweenTwoInlineNodes} from './isDomNodeBetweenTwoInlineNodes'; - -function $wrapContinuousInlinesInPlace( - domNode: Node, - nodes: LexicalNode[], - $createWrapperFn: () => ElementNode, -): void { - const textAlign = (domNode as HTMLElement).style - .textAlign as ElementFormatType; - // wrap contiguous inline child nodes in para - let j = 0; - for (let i = 0, wrapper: undefined | ElementNode; i < nodes.length; i++) { - const node = nodes[i]; - if ($isBlockElementNode(node)) { - if (textAlign && !node.getFormat()) { - node.setFormat(textAlign); - } - wrapper = undefined; - nodes[j++] = node; - } else { - if (!wrapper) { - nodes[j++] = wrapper = $createWrapperFn().setFormat(textAlign); - } - wrapper.append(node); - } - } - nodes.length = j; -} class MatchesImport { tag: string; @@ -182,147 +147,6 @@ class TagImport { } } -const EMPTY_ARRAY = [] as const; - -function compileLegacyImportDOM( - editor: LexicalEditor, -): DOMImportExtensionOutput['$importNode'] { - return (node) => { - if (IGNORE_TAGS.has(node.nodeName)) { - return {childNodes: EMPTY_ARRAY, node: null}; - } - // If the DOM node doesn't have a transformer, we don't know what - // to do with it but we still need to process any childNodes. - let childLexicalNodes: LexicalNode[] = []; - let postTransform: DOMConversionOutput['after']; - const output: DOMImportOutput = { - $appendChild: (childNode) => childLexicalNodes.push(childNode), - $finalize: (nodeOrNodes) => { - const finalLexicalNodes = Array.isArray(nodeOrNodes) - ? nodeOrNodes - : nodeOrNodes - ? [nodeOrNodes] - : []; - const finalLexicalNode: null | LexicalNode = - finalLexicalNodes[finalLexicalNodes.length - 1] || null; - if (postTransform) { - childLexicalNodes = postTransform(childLexicalNodes); - } - if (isBlockDomNode(node)) { - const hasBlockAncestorLexicalNodeForChildren = - finalLexicalNode && $isRootOrShadowRoot(finalLexicalNode) - ? false - : (finalLexicalNode && $isBlockElementNode(finalLexicalNode)) || - $getImportContextValue( - ImportContextHasBlockAncestorLexicalNode, - ); - - if (!hasBlockAncestorLexicalNodeForChildren) { - $wrapContinuousInlinesInPlace( - node, - childLexicalNodes, - $createParagraphNode, - ); - } else { - const allArtificialNodes = $getImportContextValue( - ImportContextArtificialNodes, - ); - invariant( - allArtificialNodes !== null, - 'Missing ImportContextArtificialNodes', - ); - $wrapContinuousInlinesInPlace(node, childLexicalNodes, () => { - const artificialNode = new ArtificialNode__DO_NOT_USE(); - allArtificialNodes.push(artificialNode); - return artificialNode; - }); - } - } - - if (finalLexicalNode == null) { - if (childLexicalNodes.length > 0) { - // If it hasn't been converted to a LexicalNode, we hoist its children - // up to the same level as it. - finalLexicalNodes.push(...childLexicalNodes); - } else { - if (isBlockDomNode(node) && isDomNodeBetweenTwoInlineNodes(node)) { - // Empty block dom node that hasnt been converted, we replace it with a linebreak if its between inline nodes - finalLexicalNodes.push($createLineBreakNode()); - } - } - } else { - if ($isElementNode(finalLexicalNode)) { - // If the current node is a ElementNode after conversion, - // we can append all the children to it. - finalLexicalNode.append(...childLexicalNodes); - } - } - - return finalLexicalNodes; - }, - node: null, - }; - let currentLexicalNode: null | LexicalNode = null; - const transformFunction = getConversionFunction(node, editor); - const transformOutput = transformFunction - ? transformFunction(node as HTMLElement) - : null; - const addChildContext = (cfg: AnyImportStateConfigPair) => { - output.childContext = output.childContext || []; - output.childContext.push(cfg); - }; - - if (transformOutput !== null) { - const forChildMap = $getImportContextValue( - ImportContextForChildMap, - editor, - ); - const parentLexicalNode = $getImportContextValue( - ImportContextParentLexicalNode, - editor, - ); - postTransform = transformOutput.after; - let transformNodeArray = Array.isArray(transformOutput.node) - ? transformOutput.node - : transformOutput.node - ? [transformOutput.node] - : []; - - if (transformNodeArray.length > 0 && forChildMap) { - const transformWithForChild = (initial: LexicalNode) => { - let current: null | undefined | LexicalNode = initial; - for (const forChildFunction of forChildMap.values()) { - current = forChildFunction(current, parentLexicalNode); - - if (!current) { - return []; - } - } - return [current]; - }; - transformNodeArray = transformNodeArray.flatMap(transformWithForChild); - } - currentLexicalNode = - transformNodeArray[transformNodeArray.length - 1] || null; - output.node = - transformNodeArray.length > 1 ? transformNodeArray : currentLexicalNode; - - if (transformOutput.forChild) { - addChildContext( - ImportContextForChildMap.pair( - new Map(forChildMap || []).set( - node.nodeName, - transformOutput.forChild, - ), - ), - ); - } - } - - return output; - }; -} - function importOverrideSort( a: DOMImportConfigMatch, b: DOMImportConfigMatch, diff --git a/packages/lexical-html/src/ImportContext.ts b/packages/lexical-html/src/ImportContext.ts index 075d731609e..eb130da64d4 100644 --- a/packages/lexical-html/src/ImportContext.ts +++ b/packages/lexical-html/src/ImportContext.ts @@ -7,7 +7,6 @@ */ import type { - AnyContextConfigPair, DOMTextWrapMode, DOMWhiteSpaceCollapse, ImportStateConfig, @@ -21,17 +20,24 @@ import { type LexicalEditor, type LexicalNode, type TextFormatType, + ValueOrUpdater, } from 'lexical'; +import invariant from 'shared/invariant'; import {DOMImportContextSymbol} from './constants'; import { - $withContext, createContextState, - getEditorContext, - getEditorContextValue, + getContextRecord, + getContextValue, + setContextValue, } from './ContextRecord'; /** + * Create a context state to be used during import. + * + * Note that to support the ValueOrUpdater pattern you can not use a + * function for V (but you may wrap it in an array or object). + * * @__NO_SIDE_EFFECTS__ */ export function createImportState( @@ -47,20 +53,24 @@ export function createImportState( ); } -export const $withImportContext: ( - cfg: readonly AnyContextConfigPair[], - editor?: LexicalEditor, -) => (f: () => T) => T = /*@__PURE__*/ $withContext(DOMImportContextSymbol); - export function $getImportContextValue( cfg: ImportStateConfig, editor: LexicalEditor = $getEditor(), ): V { - return getEditorContextValue( - DOMImportContextSymbol, - getEditorContext(editor), - cfg, + return getContextValue(getContextRecord(DOMImportContextSymbol, editor), cfg); +} + +export function $setImportContextValue( + cfg: ImportStateConfig, + valueOrUpdater: ValueOrUpdater, + editor: LexicalEditor = $getEditor(), +): V { + const ctx = getContextRecord(DOMImportContextSymbol, editor); + invariant( + ctx !== undefined, + '$setImportContextValue used outside of DOM import', ); + return setContextValue(ctx, cfg, valueOrUpdater); } export const ImportContextDOMNode = createImportState( diff --git a/packages/lexical-html/src/RenderContext.ts b/packages/lexical-html/src/RenderContext.ts index 7beabc190d0..15af455e298 100644 --- a/packages/lexical-html/src/RenderContext.ts +++ b/packages/lexical-html/src/RenderContext.ts @@ -15,13 +15,18 @@ import {DOMRenderContextSymbol, DOMRenderExtensionName} from './constants'; import { $withContext, createContextState, + getContextRecord, getContextValue, - getEditorContext, } from './ContextRecord'; import {DOMRenderExtension} from './DOMRenderExtension'; import {AnyContextConfigPair, ContextRecord, RenderStateConfig} from './types'; /** + * Create a context state to be used during render. + * + * Note that to support the ValueOrUpdater pattern you can not use a + * function for V (but you may wrap it in an array or object). + * * @__NO_SIDE_EFFECTS__ */ export function createRenderState( @@ -60,9 +65,8 @@ function getDefaultRenderContext( function getRenderContext( editor: LexicalEditor, ): undefined | ContextRecord { - const editorContext = getEditorContext(editor); return ( - (editorContext && editorContext[DOMRenderContextSymbol]) || + getContextRecord(DOMRenderContextSymbol, editor) || getDefaultRenderContext(editor) ); } diff --git a/packages/lexical-html/src/__tests__/unit/compileDOMRenderConfigOverrides.test.ts b/packages/lexical-html/src/__tests__/unit/compileDOMRenderConfigOverrides.test.ts index 66bc3ac37d4..ebceebf0768 100644 --- a/packages/lexical-html/src/__tests__/unit/compileDOMRenderConfigOverrides.test.ts +++ b/packages/lexical-html/src/__tests__/unit/compileDOMRenderConfigOverrides.test.ts @@ -19,10 +19,10 @@ import { import {describe, expect, test} from 'vitest'; import { - ALWAYS_TRUE, buildTypeTree, precompileDOMRenderConfigOverrides, } from '../../compileDOMRenderConfigOverrides'; +import {ALWAYS_TRUE} from '../../constants'; describe('buildTypeTree', () => { test('includes basic types', () => { @@ -118,6 +118,9 @@ describe('precompileDOMRenderConfigOverrides', () => { }, }), domOverride('*', { + $createDOM(node, $next) { + return $next(); + }, $exportDOM(node, $next) { return $next(); }, @@ -150,7 +153,7 @@ describe('precompileDOMRenderConfigOverrides', () => { overrides, ); expect(prerender).toEqual({ - $createDOM: [], + $createDOM: [[ALWAYS_TRUE, overrides[1].$createDOM]], $exportDOM: [ [ 'types', diff --git a/packages/lexical-html/src/compileDOMRenderConfigOverrides.ts b/packages/lexical-html/src/compileDOMRenderConfigOverrides.ts index e11adce2d1b..9417cb24021 100644 --- a/packages/lexical-html/src/compileDOMRenderConfigOverrides.ts +++ b/packages/lexical-html/src/compileDOMRenderConfigOverrides.ts @@ -18,6 +18,7 @@ import { } from 'lexical'; import invariant from 'shared/invariant'; +import {ALWAYS_TRUE} from './constants'; import {AnyDOMRenderMatch, DOMRenderConfig, DOMRenderMatch} from './types'; interface TypeRecord { @@ -69,8 +70,6 @@ type PreEditorDOMRenderConfig = { [K in keyof EditorDOMRenderConfig]: AnyRender[]; }; -export const ALWAYS_TRUE = () => true as const; - function buildNodePredicate(klass: Klass) { return (node: LexicalNode): node is T => node instanceof klass; } diff --git a/packages/lexical-html/src/compileLegacyImportDOM.ts b/packages/lexical-html/src/compileLegacyImportDOM.ts new file mode 100644 index 00000000000..16088b327ef --- /dev/null +++ b/packages/lexical-html/src/compileLegacyImportDOM.ts @@ -0,0 +1,177 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type { + AnyImportStateConfigPair, + DOMImportExtensionOutput, + DOMImportOutput, +} from './types'; + +import { + $createLineBreakNode, + $createParagraphNode, + $isBlockElementNode, + $isElementNode, + $isRootOrShadowRoot, + ArtificialNode__DO_NOT_USE, + type DOMConversionOutput, + isBlockDomNode, + type LexicalEditor, + type LexicalNode, +} from 'lexical'; +import invariant from 'shared/invariant'; + +import {$wrapContinuousInlinesInPlace} from './$wrapContinuousInlinesInPlace'; +import {EMPTY_ARRAY, IGNORE_TAGS} from './constants'; +import {getConversionFunction} from './getConversionFunction'; +import { + $getImportContextValue, + ImportContextArtificialNodes, + ImportContextForChildMap, + ImportContextHasBlockAncestorLexicalNode, + ImportContextParentLexicalNode, +} from './ImportContext'; +import {isDomNodeBetweenTwoInlineNodes} from './isDomNodeBetweenTwoInlineNodes'; + +export function compileLegacyImportDOM( + editor: LexicalEditor, +): DOMImportExtensionOutput['$importNode'] { + return (node) => { + if (IGNORE_TAGS.has(node.nodeName)) { + return {childNodes: EMPTY_ARRAY, node: null}; + } + // If the DOM node doesn't have a transformer, we don't know what + // to do with it but we still need to process any childNodes. + let childLexicalNodes: LexicalNode[] = []; + let postTransform: DOMConversionOutput['after']; + const output: DOMImportOutput = { + $appendChild: (childNode) => childLexicalNodes.push(childNode), + $finalize: (nodeOrNodes) => { + const finalLexicalNodes = Array.isArray(nodeOrNodes) + ? nodeOrNodes + : nodeOrNodes + ? [nodeOrNodes] + : []; + const finalLexicalNode: null | LexicalNode = + finalLexicalNodes[finalLexicalNodes.length - 1] || null; + if (postTransform) { + childLexicalNodes = postTransform(childLexicalNodes); + } + if (isBlockDomNode(node)) { + const hasBlockAncestorLexicalNodeForChildren = + finalLexicalNode && $isRootOrShadowRoot(finalLexicalNode) + ? false + : (finalLexicalNode && $isBlockElementNode(finalLexicalNode)) || + $getImportContextValue( + ImportContextHasBlockAncestorLexicalNode, + ); + + if (!hasBlockAncestorLexicalNodeForChildren) { + $wrapContinuousInlinesInPlace( + node, + childLexicalNodes, + $createParagraphNode, + ); + } else { + const allArtificialNodes = $getImportContextValue( + ImportContextArtificialNodes, + ); + invariant( + allArtificialNodes !== null, + 'Missing ImportContextArtificialNodes', + ); + $wrapContinuousInlinesInPlace(node, childLexicalNodes, () => { + const artificialNode = new ArtificialNode__DO_NOT_USE(); + allArtificialNodes.push(artificialNode); + return artificialNode; + }); + } + } + + if (finalLexicalNode == null) { + if (childLexicalNodes.length > 0) { + // If it hasn't been converted to a LexicalNode, we hoist its children + // up to the same level as it. + finalLexicalNodes.push(...childLexicalNodes); + } else { + if (isBlockDomNode(node) && isDomNodeBetweenTwoInlineNodes(node)) { + // Empty block dom node that hasnt been converted, we replace it with a linebreak if its between inline nodes + finalLexicalNodes.push($createLineBreakNode()); + } + } + } else { + if ($isElementNode(finalLexicalNode)) { + // If the current node is a ElementNode after conversion, + // we can append all the children to it. + finalLexicalNode.append(...childLexicalNodes); + } + } + + return finalLexicalNodes; + }, + node: null, + }; + let currentLexicalNode: null | LexicalNode = null; + const transformFunction = getConversionFunction(node, editor); + const transformOutput = transformFunction + ? transformFunction(node as HTMLElement) + : null; + const addChildContext = (cfg: AnyImportStateConfigPair) => { + output.childContext = output.childContext || []; + output.childContext.push(cfg); + }; + + if (transformOutput !== null) { + const forChildMap = $getImportContextValue( + ImportContextForChildMap, + editor, + ); + const parentLexicalNode = $getImportContextValue( + ImportContextParentLexicalNode, + editor, + ); + postTransform = transformOutput.after; + let transformNodeArray = Array.isArray(transformOutput.node) + ? transformOutput.node + : transformOutput.node + ? [transformOutput.node] + : []; + + if (transformNodeArray.length > 0 && forChildMap) { + const transformWithForChild = (initial: LexicalNode) => { + let current: null | undefined | LexicalNode = initial; + for (const forChildFunction of forChildMap.values()) { + current = forChildFunction(current, parentLexicalNode); + + if (!current) { + return []; + } + } + return [current]; + }; + transformNodeArray = transformNodeArray.flatMap(transformWithForChild); + } + currentLexicalNode = + transformNodeArray[transformNodeArray.length - 1] || null; + output.node = + transformNodeArray.length > 1 ? transformNodeArray : currentLexicalNode; + + if (transformOutput.forChild) { + addChildContext( + ImportContextForChildMap.pair( + new Map(forChildMap || []).set( + node.nodeName, + transformOutput.forChild, + ), + ), + ); + } + } + + return output; + }; +} diff --git a/packages/lexical-html/src/constants.ts b/packages/lexical-html/src/constants.ts index d0ef7766ba3..1618a41045b 100644 --- a/packages/lexical-html/src/constants.ts +++ b/packages/lexical-html/src/constants.ts @@ -30,3 +30,7 @@ export const DOMTextWrapModeKeys = { nowrap: 'nowrap', wrap: 'wrap', } as const; + +export const EMPTY_ARRAY = [] as const; + +export const ALWAYS_TRUE = () => true as const; diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index c6635033d81..53493479403 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -16,7 +16,6 @@ export {domOverride} from './domOverride'; export {DOMRenderExtension} from './DOMRenderExtension'; export { $getImportContextValue, - $withImportContext, ImportContextHasBlockAncestorLexicalNode, ImportContextParentLexicalNode, ImportContextTextAlign, From e81b6cae5dda607a9b60571a6e7d6047561eade8 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sat, 25 Oct 2025 09:21:57 -0700 Subject: [PATCH 42/47] more refactoring --- packages/lexical-html/src/ContextRecord.ts | 63 ++- .../lexical-html/src/DOMImportExtension.ts | 384 +--------------- packages/lexical-html/src/ImportContext.ts | 5 +- packages/lexical-html/src/RenderContext.ts | 8 +- .../unit/DOMImportExtensionNoLegacy.test.ts | 15 +- .../src/compileDOMImportOverrides.ts | 417 ++++++++++++++++++ .../src/compileLegacyImportDOM.ts | 4 +- packages/lexical-html/src/index.ts | 5 +- packages/lexical-html/src/types.ts | 30 +- packages/lexical/src/LexicalNodeState.ts | 4 +- 10 files changed, 506 insertions(+), 429 deletions(-) create mode 100644 packages/lexical-html/src/compileDOMImportOverrides.ts diff --git a/packages/lexical-html/src/ContextRecord.ts b/packages/lexical-html/src/ContextRecord.ts index 1350ee93f83..e0be70481b2 100644 --- a/packages/lexical-html/src/ContextRecord.ts +++ b/packages/lexical-html/src/ContextRecord.ts @@ -6,18 +6,15 @@ * */ import type { - AnyContextConfigPair, + AnyContextConfigPairOrUpdater, AnyContextSymbol, ContextConfig, + ContextConfigPair, + ContextConfigUpdater, ContextRecord, } from './types'; -import { - $getEditor, - createState, - type LexicalEditor, - ValueOrUpdater, -} from 'lexical'; +import {$getEditor, createState, type LexicalEditor} from 'lexical'; let activeContext: undefined | EditorContext; @@ -53,12 +50,24 @@ export function getContextRecord( return editorContext && editorContext[sym]; } +function toPair( + contextRecord: undefined | ContextRecord, + pairOrUpdater: ContextConfigPair | ContextConfigUpdater, +): ContextConfigPair { + if ('cfg' in pairOrUpdater) { + const {cfg, updater} = pairOrUpdater; + return [cfg, updater(getContextValue(contextRecord, cfg))]; + } + return pairOrUpdater; +} + export function contextFromPairs( - pairs: readonly AnyContextConfigPair[], + pairs: readonly AnyContextConfigPairOrUpdater[], parent: undefined | ContextRecord, ): undefined | ContextRecord { let rval = parent; - for (const [k, v] of pairs) { + for (const pairOrUpdater of pairs) { + const [k, v] = toPair(rval, pairOrUpdater); const key = k.key; if (rval === parent && getContextValue(rval, k) === v) { continue; @@ -79,26 +88,36 @@ export function createChildContext( export function setContextValue( contextRecord: ContextRecord, cfg: ContextConfig, - valueOrUpdater: ValueOrUpdater, + value: V, ): V { - const {key, defaultValue} = cfg; - const value = - typeof valueOrUpdater !== 'function' - ? valueOrUpdater - : (valueOrUpdater as (prev: V) => V)( - key in contextRecord ? (contextRecord[key] as V) : defaultValue, - ); - contextRecord[key] = value; + contextRecord[cfg.key] = value; return value; } +export function contextUpdater( + cfg: ContextConfig, + updater: (prev: V) => V, +): ContextConfigUpdater { + return {cfg, updater}; +} + +export function updateContextValue( + contextRecord: ContextRecord, + cfg: ContextConfig, + updater: (prev: V) => V, +): V { + const value = updater(getContextValue(contextRecord, cfg)); + return setContextValue(contextRecord, cfg, value); +} + export function updateContextFromPairs( contextRecord: ContextRecord, - pairs: undefined | readonly AnyContextConfigPair[], + pairs: undefined | readonly AnyContextConfigPairOrUpdater[], ): ContextRecord { if (pairs) { - for (const [cfg, valueOrUpdater] of pairs) { - setContextValue(contextRecord, cfg, valueOrUpdater); + for (const pairOrUpdater of pairs) { + const [cfg, value] = toPair(contextRecord, pairOrUpdater); + setContextValue(contextRecord, cfg, value); } } return contextRecord; @@ -132,7 +151,7 @@ export function $withContext( undefined, ) { return ( - cfg: readonly AnyContextConfigPair[], + cfg: readonly AnyContextConfigPairOrUpdater[], editor = $getEditor(), ): ((f: () => T) => T) => { return (f) => { diff --git a/packages/lexical-html/src/DOMImportExtension.ts b/packages/lexical-html/src/DOMImportExtension.ts index 2e2daf96d8c..e2d19a6b7cb 100644 --- a/packages/lexical-html/src/DOMImportExtension.ts +++ b/packages/lexical-html/src/DOMImportExtension.ts @@ -5,389 +5,13 @@ * LICENSE file in the root directory of this source tree. * */ -import type { - AnyImportStateConfigPair, - ContextRecord, - DOMImportConfig, - DOMImportConfigMatch, - DOMImportExtensionOutput, - DOMImportNext, - DOMImportOutput, - DOMImportOutputContinue, - DOMTextWrapMode, - DOMWhiteSpaceCollapse, -} from './types'; +import type {DOMImportConfig, DOMImportExtensionOutput} from './types'; -import { - $isBlockElementNode, - $isElementNode, - $isRootOrShadowRoot, - ArtificialNode__DO_NOT_USE, - defineExtension, - isDOMDocumentNode, - isHTMLElement, - type LexicalEditor, - type LexicalNode, - shallowMergeConfig, -} from 'lexical'; -import invariant from 'shared/invariant'; +import {defineExtension, shallowMergeConfig} from 'lexical'; -import {$unwrapArtificialNodes} from './$unwrapArtificialNodes'; +import {compileDOMImportOverrides} from './compileDOMImportOverrides'; import {compileLegacyImportDOM} from './compileLegacyImportDOM'; -import { - DOMImportContextSymbol, - DOMImportExtensionName, - DOMImportNextSymbol, - DOMTextWrapModeKeys, - DOMWhiteSpaceCollapseKeys, - EMPTY_ARRAY, -} from './constants'; -import { - $withFullContext, - createChildContext, - updateContextFromPairs, -} from './ContextRecord'; -import { - $getImportContextValue, - ImportContextArtificialNodes, - ImportContextDOMNode, - ImportContextHasBlockAncestorLexicalNode, - ImportContextParentLexicalNode, - ImportContextTextWrapMode, - ImportContextWhiteSpaceCollapse, -} from './ImportContext'; - -class MatchesImport { - tag: string; - matches: DOMImportConfigMatch[] = []; - constructor(tag: string) { - this.tag = tag; - } - push(match: DOMImportConfigMatch) { - invariant( - match.tag === this.tag, - 'MatchesImport requires all to use the same tag', - ); - this.matches.push(match); - } - compile( - $nextImport: (node: Node) => null | undefined | DOMImportOutput, - editor: LexicalEditor, - ): DOMImportExtensionOutput['$importNode'] { - const {matches, tag} = this; - return (node) => { - const el = isHTMLElement(node) ? node : null; - const $importAt = (start: number): null | undefined | DOMImportOutput => { - let rval: undefined | null | DOMImportOutput; - for (let i = start; !rval && i >= 0; i--) { - const match = matches[i]; - if (match) { - const {$import, selector} = matches[i]; - if (!selector || (el && el.matches(selector))) { - rval = $import( - node, - withImportNextSymbol($importAt.bind(null, i - 1)), - editor, - ); - } - } - } - return ( - rval || { - node: withImportNextSymbol($nextImport.bind(null, node)), - } - ); - }; - - return $importAt( - (tag === node.nodeName.toLowerCase() || (el && tag === '*') - ? matches.length - : 0) - 1, - ); - }; - } -} - -function $isImportOutputContinue( - rval: undefined | null | DOMImportOutput, -): rval is DOMImportOutputContinue { - return ( - !!rval && - typeof rval.node === 'function' && - DOMImportNextSymbol in rval.node - ); -} - -function withImportNextSymbol( - fn: () => null | undefined | DOMImportOutput, -): DOMImportNext { - return Object.assign(fn, {[DOMImportNextSymbol]: true} as const); -} - -class TagImport { - tags: Map = new Map(); - push(match: DOMImportConfigMatch) { - invariant(match.tag !== '*', 'TagImport can not handle wildcards'); - const matches = this.tags.get(match.tag) || new MatchesImport(match.tag); - this.tags.set(match.tag, matches); - matches.push(match); - } - compile( - $nextImport: (node: Node) => null | undefined | DOMImportOutput, - editor: LexicalEditor, - ): DOMImportExtensionOutput['$importNode'] { - const compiled = new Map(); - for (const [tag, matches] of this.tags.entries()) { - compiled.set(tag, matches.compile($nextImport, editor)); - } - return compiled.size === 0 - ? $nextImport - : (node: Node) => - (compiled.get(node.nodeName.toLowerCase()) || $nextImport)(node); - } -} - -function importOverrideSort( - a: DOMImportConfigMatch, - b: DOMImportConfigMatch, -): number { - // Lowest priority first - return (a.priority || 0) - (b.priority || 0); -} - -type ImportStackEntry = [ - dom: Node, - ctx: ContextRecord, - $importNode: DOMImportExtensionOutput['$importNode'], - $appendChild: NonNullable, -]; - -function composeFinalizers( - outer: undefined | ((v: T) => T), - inner: undefined | ((v: T) => T), -): undefined | ((v: T) => T) { - return outer ? (inner ? (v) => outer(inner(v)) : outer) : inner; -} - -function parseDOMWhiteSpaceCollapseFromNode( - ctx: ContextRecord, - node: Node, -): ContextRecord { - if (isHTMLElement(node)) { - const {style} = node; - let textWrapMode: undefined | DOMTextWrapMode; - let whiteSpaceCollapse: undefined | DOMWhiteSpaceCollapse; - switch (style.whiteSpace) { - case 'normal': - whiteSpaceCollapse = 'collapse'; - textWrapMode = 'wrap'; - break; - case 'pre': - whiteSpaceCollapse = 'preserve'; - textWrapMode = 'nowrap'; - break; - case 'pre-wrap': - whiteSpaceCollapse = 'preserve'; - textWrapMode = 'wrap'; - break; - case 'pre-line': - whiteSpaceCollapse = 'preserve-breaks'; - textWrapMode = 'nowrap'; - break; - default: - break; - } - whiteSpaceCollapse = - ( - DOMWhiteSpaceCollapseKeys as Record< - string, - undefined | DOMWhiteSpaceCollapse - > - )[style.whiteSpaceCollapse] || whiteSpaceCollapse; - textWrapMode = - (DOMTextWrapModeKeys as Record)[ - style.textWrapMode - ] || textWrapMode; - if (textWrapMode) { - ctx[ImportContextTextWrapMode.key] = textWrapMode; - } - if (whiteSpaceCollapse) { - ctx[ImportContextWhiteSpaceCollapse.key] = whiteSpaceCollapse; - } - } - return ctx; -} - -export function compileDOMImportOverrides( - editor: LexicalEditor, - config: DOMImportConfig, -): DOMImportExtensionOutput { - let $importNode = config.compileLegacyImportNode(editor); - let importer: TagImport | MatchesImport = new TagImport(); - const sortedOverrides = config.overrides.sort(importOverrideSort); - for (const match of sortedOverrides) { - if (match.tag === '*') { - if (!(importer instanceof MatchesImport && importer.tag === match.tag)) { - $importNode = importer.compile($importNode, editor); - importer = new MatchesImport(match.tag); - } - } else if (importer instanceof MatchesImport) { - $importNode = importer.compile($importNode, editor); - importer = new TagImport(); - } - importer.push(match); - } - $importNode = importer.compile($importNode, editor); - const $importNodes = ( - rootOrDocument: ParentNode | Document, - ): LexicalNode[] => { - const artificialNodes: ArtificialNode__DO_NOT_USE[] = []; - const nodes: LexicalNode[] = []; - const rootNode = isDOMDocumentNode(rootOrDocument) - ? rootOrDocument.body - : rootOrDocument; - const stack: ImportStackEntry[] = [ - [ - rootNode, - updateContextFromPairs(createChildContext(undefined), [ - ImportContextArtificialNodes.pair(artificialNodes), - ]), - () => ({node: null}), - (node) => { - nodes.push(node); - }, - ], - ]; - for (let entry = stack.pop(); entry; entry = stack.pop()) { - const [node, ctx, fn, $parentAppendChild] = entry; - ctx[ImportContextDOMNode.key] = node; - const outputContinue: DOMImportOutputContinue = { - node: withImportNextSymbol(fn.bind(null, node)), - }; - parseDOMWhiteSpaceCollapseFromNode(ctx, node); - let currentOutput: null | undefined | DOMImportOutput = outputContinue; - let childContext: - | undefined - | ContextRecord; - const updateChildContext = ( - pairs: undefined | readonly AnyImportStateConfigPair[], - ) => { - if (pairs) { - childContext = updateContextFromPairs( - childContext || createChildContext(ctx), - pairs, - ); - } - }; - while ($isImportOutputContinue(currentOutput)) { - updateContextFromPairs(ctx, currentOutput.nextContext); - updateChildContext(currentOutput.childContext); - outputContinue.$finalize = composeFinalizers( - outputContinue.$finalize, - currentOutput.$finalize, - ); - currentOutput = $withFullContext( - DOMImportContextSymbol, - ctx, - currentOutput.node, - editor, - ); - } - invariant( - !$isImportOutputContinue(currentOutput), - 'currentOutput can not be a continue', - ); - const output = currentOutput; - let children: NodeListOf | readonly ChildNode[] = - isHTMLElement(node) ? node.childNodes : EMPTY_ARRAY; - let $finalize = outputContinue.$finalize; - let $appendChild = $parentAppendChild; - const outputNode = output ? output.node : null; - invariant( - typeof outputNode !== 'function', - 'outputNode must not be a function', - ); - const pushFinalize = () => { - if ($finalize) { - const $boundFinalize = $finalize.bind(null, outputNode); - stack.push([ - node, - ctx, - () => ({childNodes: EMPTY_ARRAY, node: $boundFinalize()}), - $parentAppendChild, - ]); - } - }; - if (!output) { - pushFinalize(); - } else { - if (output.$appendChild) { - $appendChild = output.$appendChild; - } else if (Array.isArray(outputNode)) { - $appendChild = (childNode, _dom) => outputNode.push(childNode); - } else if ($isElementNode(outputNode)) { - $appendChild = (childNode, _dom) => outputNode.append(childNode); - } - children = output.childNodes || children; - $finalize = composeFinalizers($finalize, output.$finalize); - if ($finalize) { - pushFinalize(); - } else if (outputNode) { - for (const addNode of Array.isArray(outputNode) - ? outputNode - : [outputNode]) { - $parentAppendChild(addNode, node as ChildNode); - } - } - - updateChildContext(output.childContext); - const currentLexicalNode = Array.isArray(outputNode) - ? outputNode[outputNode.length - 1] || null - : outputNode; - const hasBlockAncestorLexicalNode = $getImportContextValue( - ImportContextHasBlockAncestorLexicalNode, - ); - const hasBlockAncestorLexicalNodeForChildren = - currentLexicalNode && $isRootOrShadowRoot(currentLexicalNode) - ? false - : (currentLexicalNode && $isBlockElementNode(currentLexicalNode)) || - hasBlockAncestorLexicalNode; - - if ( - hasBlockAncestorLexicalNode !== hasBlockAncestorLexicalNodeForChildren - ) { - updateChildContext([ - ImportContextHasBlockAncestorLexicalNode.pair( - hasBlockAncestorLexicalNodeForChildren, - ), - ]); - } - if ($isElementNode(currentLexicalNode)) { - updateChildContext([ - ImportContextParentLexicalNode.pair(currentLexicalNode), - ]); - } - } - // Push children in reverse so they are popped off the stack in-order - for (let i = children.length - 1; i >= 0; i--) { - const childDom = children[i]; - stack.push([ - childDom, - createChildContext(childContext || ctx), - $importNode, - $appendChild, - ]); - } - } - $unwrapArtificialNodes(artificialNodes); - return nodes; - }; - - return { - $importNode, - $importNodes, - }; -} +import {DOMImportExtensionName} from './constants'; /** @internal @experimental */ export const DOMImportExtension = defineExtension< diff --git a/packages/lexical-html/src/ImportContext.ts b/packages/lexical-html/src/ImportContext.ts index eb130da64d4..70907fac42f 100644 --- a/packages/lexical-html/src/ImportContext.ts +++ b/packages/lexical-html/src/ImportContext.ts @@ -20,7 +20,6 @@ import { type LexicalEditor, type LexicalNode, type TextFormatType, - ValueOrUpdater, } from 'lexical'; import invariant from 'shared/invariant'; @@ -62,7 +61,7 @@ export function $getImportContextValue( export function $setImportContextValue( cfg: ImportStateConfig, - valueOrUpdater: ValueOrUpdater, + value: V, editor: LexicalEditor = $getEditor(), ): V { const ctx = getContextRecord(DOMImportContextSymbol, editor); @@ -70,7 +69,7 @@ export function $setImportContextValue( ctx !== undefined, '$setImportContextValue used outside of DOM import', ); - return setContextValue(ctx, cfg, valueOrUpdater); + return setContextValue(ctx, cfg, value); } export const ImportContextDOMNode = createImportState( diff --git a/packages/lexical-html/src/RenderContext.ts b/packages/lexical-html/src/RenderContext.ts index 15af455e298..bdf7f9a65d7 100644 --- a/packages/lexical-html/src/RenderContext.ts +++ b/packages/lexical-html/src/RenderContext.ts @@ -19,7 +19,11 @@ import { getContextValue, } from './ContextRecord'; import {DOMRenderExtension} from './DOMRenderExtension'; -import {AnyContextConfigPair, ContextRecord, RenderStateConfig} from './types'; +import { + AnyRenderStateConfigPairOrUpdater, + ContextRecord, + RenderStateConfig, +} from './types'; /** * Create a context state to be used during render. @@ -79,7 +83,7 @@ export function $getRenderContextValue( } export const $withRenderContext: ( - cfg: readonly AnyContextConfigPair[], + cfg: readonly AnyRenderStateConfigPairOrUpdater[], editor?: LexicalEditor, ) => (f: () => T) => T = $withContext( DOMRenderContextSymbol, diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts index 85e0992a167..6211ae32688 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts @@ -14,7 +14,8 @@ import { import { $generateNodesFromDOM, $getImportContextValue, - AnyImportStateConfigPair, + AnyImportStateConfigPairOrUpdater, + contextUpdater, type DOMImportConfig, DOMImportExtension, type DOMImportNext, @@ -170,7 +171,10 @@ function $addTextFormatContinue( return (_dom, $next) => { return { childContext: [ - ImportContextTextFormats.pair((prev) => ({...prev, [format]: true})), + contextUpdater(ImportContextTextFormats, (prev) => ({ + ...prev, + [format]: true, + })), ], node: $next, }; @@ -373,7 +377,7 @@ const NO_LEGACY_CONFIG: Partial = { dom, $next, ): undefined | DOMImportOutputContinue { - const nextContext: AnyImportStateConfigPair[] = []; + const nextContext: AnyImportStateConfigPairOrUpdater[] = []; if (isBlockDomNode(dom)) { const {textAlign} = dom.style; switch (textAlign) { @@ -438,7 +442,10 @@ const NO_LEGACY_CONFIG: Partial = { if (formats) { const boundFormats = formats; nextContext.push( - ImportContextTextFormats.pair((v) => ({...v, ...boundFormats})), + contextUpdater(ImportContextTextFormats, (v) => ({ + ...v, + ...boundFormats, + })), ); } } diff --git a/packages/lexical-html/src/compileDOMImportOverrides.ts b/packages/lexical-html/src/compileDOMImportOverrides.ts new file mode 100644 index 00000000000..f6e4854f3a7 --- /dev/null +++ b/packages/lexical-html/src/compileDOMImportOverrides.ts @@ -0,0 +1,417 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type { + AnyImportStateConfigPairOrUpdater, + ContextRecord, + DOMImportConfig, + DOMImportConfigMatch, + DOMImportExtensionOutput, + DOMImportNext, + DOMImportOutput, + DOMImportOutputContinue, + DOMTextWrapMode, + DOMWhiteSpaceCollapse, +} from './types'; + +import { + $isBlockElementNode, + $isElementNode, + $isRootOrShadowRoot, + ArtificialNode__DO_NOT_USE, + isDOMDocumentNode, + isHTMLElement, + type LexicalEditor, + type LexicalNode, +} from 'lexical'; +import invariant from 'shared/invariant'; + +import {$unwrapArtificialNodes} from './$unwrapArtificialNodes'; +import { + DOMImportContextSymbol, + DOMImportNextSymbol, + DOMTextWrapModeKeys, + DOMWhiteSpaceCollapseKeys, + EMPTY_ARRAY, +} from './constants'; +import { + $withFullContext, + createChildContext, + updateContextFromPairs, +} from './ContextRecord'; +import { + $getImportContextValue, + ImportContextArtificialNodes, + ImportContextDOMNode, + ImportContextHasBlockAncestorLexicalNode, + ImportContextParentLexicalNode, + ImportContextTextWrapMode, + ImportContextWhiteSpaceCollapse, +} from './ImportContext'; + +class MatchesImport { + tag: Tag; + matches: DOMImportConfigMatch[] = []; + constructor(tag: Tag) { + this.tag = tag; + } + push(match: DOMImportConfigMatch) { + invariant( + match.tag === this.tag, + 'MatchesImport.push: match tag %s !== this tag %s', + match.tag, + this.tag, + ); + this.matches.push(match); + } + compile( + $nextImport: (node: Node) => null | undefined | DOMImportOutput, + editor: LexicalEditor, + ): DOMImportExtensionOutput['$importNode'] { + const {matches, tag} = this; + return (node) => { + const el = isHTMLElement(node) ? node : null; + const $importAt = (start: number): null | undefined | DOMImportOutput => { + let rval: undefined | null | DOMImportOutput; + for (let i = start; !rval && i >= 0; i--) { + const match = matches[i]; + if (match) { + const {$import, selector} = matches[i]; + if (!selector || (el && el.matches(selector))) { + rval = $import( + node, + withImportNextSymbol($importAt.bind(null, i - 1)), + editor, + ); + } + } + } + return ( + rval || { + node: withImportNextSymbol($nextImport.bind(null, node)), + } + ); + }; + + return $importAt( + (tag === node.nodeName.toLowerCase() || (el && tag === '*') + ? matches.length + : 0) - 1, + ); + }; + } +} + +function $isImportOutputContinue( + rval: undefined | null | DOMImportOutput, +): rval is DOMImportOutputContinue { + return ( + !!rval && + typeof rval.node === 'function' && + DOMImportNextSymbol in rval.node + ); +} + +function withImportNextSymbol( + fn: () => null | undefined | DOMImportOutput, +): DOMImportNext { + return Object.assign(fn, {[DOMImportNextSymbol]: true} as const); +} + +class TagImport { + tags: Map> = new Map(); + push(match: DOMImportConfigMatch) { + invariant( + match.tag !== '*', + 'TagImport can not handle wildcard tag %s', + match.tag, + ); + const matches = this.tags.get(match.tag) || new MatchesImport(match.tag); + this.tags.set(match.tag, matches); + matches.push(match); + } + compile( + $nextImport: (node: Node) => null | undefined | DOMImportOutput, + editor: LexicalEditor, + ): DOMImportExtensionOutput['$importNode'] { + const compiled = new Map(); + for (const [tag, matches] of this.tags.entries()) { + compiled.set(tag, matches.compile($nextImport, editor)); + } + return compiled.size === 0 + ? $nextImport + : (node: Node) => + (compiled.get(node.nodeName.toLowerCase()) || $nextImport)(node); + } +} + +/** + * Sort matches by lowest priority first. This is to preserve the invariant + * that overrides added "later" (closer to the root of the extension tree, + * or later in a given array) should run at a higher priority. + * + * For example given the overrides `[a,b,c]` it is expected that the execution + * order is `c -> b -> a` assuming equal priorities. This is because the + * "least specific" behavior is going to be naturally "earlier" in the array + * (e.g. the initial implementation). + */ +function importOverrideSort( + a: DOMImportConfigMatch, + b: DOMImportConfigMatch, +): number { + return (a.priority || 0) - (b.priority || 0); +} + +type ImportStackEntry = [ + dom: Node, + ctx: ContextRecord, + $importNode: DOMImportExtensionOutput['$importNode'], + $appendChild: NonNullable, +]; + +function composeFinalizers( + outer: undefined | ((v: T) => T), + inner: undefined | ((v: T) => T), +): undefined | ((v: T) => T) { + return outer ? (inner ? (v) => outer(inner(v)) : outer) : inner; +} + +function parseDOMWhiteSpaceCollapseFromNode( + ctx: ContextRecord, + node: Node, +): ContextRecord { + if (isHTMLElement(node)) { + const {style} = node; + let textWrapMode: undefined | DOMTextWrapMode; + let whiteSpaceCollapse: undefined | DOMWhiteSpaceCollapse; + switch (style.whiteSpace) { + case 'normal': + whiteSpaceCollapse = 'collapse'; + textWrapMode = 'wrap'; + break; + case 'pre': + whiteSpaceCollapse = 'preserve'; + textWrapMode = 'nowrap'; + break; + case 'pre-wrap': + whiteSpaceCollapse = 'preserve'; + textWrapMode = 'wrap'; + break; + case 'pre-line': + whiteSpaceCollapse = 'preserve-breaks'; + textWrapMode = 'nowrap'; + break; + default: + break; + } + whiteSpaceCollapse = + ( + DOMWhiteSpaceCollapseKeys as Record< + string, + undefined | DOMWhiteSpaceCollapse + > + )[style.whiteSpaceCollapse] || whiteSpaceCollapse; + textWrapMode = + (DOMTextWrapModeKeys as Record)[ + style.textWrapMode + ] || textWrapMode; + if (textWrapMode) { + ctx[ImportContextTextWrapMode.key] = textWrapMode; + } + if (whiteSpaceCollapse) { + ctx[ImportContextWhiteSpaceCollapse.key] = whiteSpaceCollapse; + } + } + return ctx; +} + +function compileImportNodes( + editor: LexicalEditor, + $importNode: DOMImportExtensionOutput['$importNode'], +) { + return function $importNodes( + rootOrDocument: ParentNode | Document, + ): LexicalNode[] { + const artificialNodes: ArtificialNode__DO_NOT_USE[] = []; + const nodes: LexicalNode[] = []; + const rootNode = isDOMDocumentNode(rootOrDocument) + ? rootOrDocument.body + : rootOrDocument; + const stack: ImportStackEntry[] = [ + [ + rootNode, + updateContextFromPairs(createChildContext(undefined), [ + ImportContextArtificialNodes.pair(artificialNodes), + ]), + () => ({node: null}), + (node) => { + nodes.push(node); + }, + ], + ]; + for (let entry = stack.pop(); entry; entry = stack.pop()) { + const [node, ctx, fn, $parentAppendChild] = entry; + ctx[ImportContextDOMNode.key] = node; + const outputContinue: DOMImportOutputContinue = { + node: withImportNextSymbol(fn.bind(null, node)), + }; + parseDOMWhiteSpaceCollapseFromNode(ctx, node); + let currentOutput: null | undefined | DOMImportOutput = outputContinue; + let childContext: + | undefined + | ContextRecord; + const updateChildContext = ( + pairs: undefined | readonly AnyImportStateConfigPairOrUpdater[], + ) => { + if (pairs) { + childContext = updateContextFromPairs( + childContext || createChildContext(ctx), + pairs, + ); + } + }; + while ($isImportOutputContinue(currentOutput)) { + updateContextFromPairs(ctx, currentOutput.nextContext); + updateChildContext(currentOutput.childContext); + outputContinue.$finalize = composeFinalizers( + outputContinue.$finalize, + currentOutput.$finalize, + ); + currentOutput = $withFullContext( + DOMImportContextSymbol, + ctx, + currentOutput.node, + editor, + ); + } + invariant( + !$isImportOutputContinue(currentOutput), + 'currentOutput can not be a continue', + ); + const output = currentOutput; + let children: NodeListOf | readonly ChildNode[] = + isHTMLElement(node) ? node.childNodes : EMPTY_ARRAY; + let $finalize = outputContinue.$finalize; + let $appendChild = $parentAppendChild; + const outputNode = output ? output.node : null; + invariant( + typeof outputNode !== 'function', + 'outputNode must not be a function', + ); + const pushFinalize = () => { + if ($finalize) { + const $boundFinalize = $finalize.bind(null, outputNode); + stack.push([ + node, + ctx, + () => ({childNodes: EMPTY_ARRAY, node: $boundFinalize()}), + $parentAppendChild, + ]); + } + }; + if (!output) { + pushFinalize(); + } else { + if (output.$appendChild) { + $appendChild = output.$appendChild; + } else if (Array.isArray(outputNode)) { + $appendChild = (childNode, _dom) => outputNode.push(childNode); + } else if ($isElementNode(outputNode)) { + $appendChild = (childNode, _dom) => outputNode.append(childNode); + } + children = output.childNodes || children; + $finalize = composeFinalizers($finalize, output.$finalize); + if ($finalize) { + pushFinalize(); + } else if (outputNode) { + for (const addNode of Array.isArray(outputNode) + ? outputNode + : [outputNode]) { + $parentAppendChild(addNode, node as ChildNode); + } + } + + updateChildContext(output.childContext); + const currentLexicalNode = Array.isArray(outputNode) + ? outputNode[outputNode.length - 1] || null + : outputNode; + const hasBlockAncestorLexicalNode = $getImportContextValue( + ImportContextHasBlockAncestorLexicalNode, + ); + const hasBlockAncestorLexicalNodeForChildren = + currentLexicalNode && $isRootOrShadowRoot(currentLexicalNode) + ? false + : (currentLexicalNode && $isBlockElementNode(currentLexicalNode)) || + hasBlockAncestorLexicalNode; + + if ( + hasBlockAncestorLexicalNode !== hasBlockAncestorLexicalNodeForChildren + ) { + updateChildContext([ + ImportContextHasBlockAncestorLexicalNode.pair( + hasBlockAncestorLexicalNodeForChildren, + ), + ]); + } + if ($isElementNode(currentLexicalNode)) { + updateChildContext([ + ImportContextParentLexicalNode.pair(currentLexicalNode), + ]); + } + } + // Push children in reverse so they are popped off the stack in-order + for (let i = children.length - 1; i >= 0; i--) { + const childDom = children[i]; + stack.push([ + childDom, + createChildContext(childContext || ctx), + $importNode, + $appendChild, + ]); + } + } + $unwrapArtificialNodes(artificialNodes); + return nodes; + }; +} + +function matchHasTag( + match: DOMImportConfigMatch, + tag: T, +): match is DOMImportConfigMatch & {tag: T} { + return match.tag === tag; +} + +function compileImportNode(editor: LexicalEditor, config: DOMImportConfig) { + let $importNode = config.compileLegacyImportNode(editor); + let importer: TagImport | MatchesImport<'*'> = new TagImport(); + const sortedOverrides = config.overrides.sort(importOverrideSort); + for (const match of sortedOverrides) { + if (matchHasTag(match, '*')) { + if (importer instanceof TagImport) { + $importNode = importer.compile($importNode, editor); + importer = new MatchesImport(match.tag); + } + } else if (importer instanceof MatchesImport) { + $importNode = importer.compile($importNode, editor); + importer = new TagImport(); + } + importer.push(match); + } + return importer.compile($importNode, editor); +} + +export function compileDOMImportOverrides( + editor: LexicalEditor, + config: DOMImportConfig, +): DOMImportExtensionOutput { + const $importNode = compileImportNode(editor, config); + return { + $importNode, + $importNodes: compileImportNodes(editor, $importNode), + }; +} diff --git a/packages/lexical-html/src/compileLegacyImportDOM.ts b/packages/lexical-html/src/compileLegacyImportDOM.ts index 16088b327ef..a819422dc5c 100644 --- a/packages/lexical-html/src/compileLegacyImportDOM.ts +++ b/packages/lexical-html/src/compileLegacyImportDOM.ts @@ -6,7 +6,7 @@ * */ import type { - AnyImportStateConfigPair, + AnyImportStateConfigPairOrUpdater, DOMImportExtensionOutput, DOMImportOutput, } from './types'; @@ -120,7 +120,7 @@ export function compileLegacyImportDOM( const transformOutput = transformFunction ? transformFunction(node as HTMLElement) : null; - const addChildContext = (cfg: AnyImportStateConfigPair) => { + const addChildContext = (cfg: AnyImportStateConfigPairOrUpdater) => { output.childContext = output.childContext || []; output.childContext.push(cfg); }; diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 53493479403..0ef2fd6ea17 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -11,6 +11,7 @@ export { $generateHtmlFromNodes, } from './$generateDOMFromNodes'; export {$generateNodesFromDOM} from './$generateNodesFromDOM'; +export {contextUpdater} from './ContextRecord'; export {DOMImportExtension} from './DOMImportExtension'; export {domOverride} from './domOverride'; export {DOMRenderExtension} from './DOMRenderExtension'; @@ -32,9 +33,9 @@ export { export type { AnyDOMRenderMatch, AnyImportStateConfig, - AnyImportStateConfigPair, + AnyImportStateConfigPairOrUpdater, AnyRenderStateConfig, - AnyRenderStateConfigPair, + AnyRenderStateConfigPairOrUpdater, DOMImportConfig, DOMImportConfigMatch, DOMImportExtensionOutput, diff --git a/packages/lexical-html/src/types.ts b/packages/lexical-html/src/types.ts index 3a16061ab82..50624f87037 100644 --- a/packages/lexical-html/src/types.ts +++ b/packages/lexical-html/src/types.ts @@ -33,14 +33,20 @@ export type ContextConfig = StateConfig & { readonly [K in Sym]?: true; }; +export type ContextConfigUpdater = { + readonly cfg: ContextConfig; + readonly updater: (prev: V) => V; +}; export type ContextConfigPair = readonly [ ContextConfig, V, ]; -export type AnyContextConfigPair = +export type AnyContextConfigPairOrUpdater = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | ContextConfigPair // eslint-disable-next-line @typescript-eslint/no-explicit-any - ContextConfigPair; + | ContextConfigUpdater; export interface DOMRenderExtensionOutput { defaults: undefined | ContextRecord; @@ -56,10 +62,10 @@ export type RenderStateConfig = ContextConfig< V >; -export type AnyImportStateConfigPair = AnyContextConfigPair< +export type AnyImportStateConfigPairOrUpdater = AnyContextConfigPairOrUpdater< typeof DOMImportContextSymbol >; -export type AnyRenderStateConfigPair = AnyContextConfigPair< +export type AnyRenderStateConfigPairOrUpdater = AnyContextConfigPairOrUpdater< typeof DOMRenderContextSymbol >; @@ -74,7 +80,7 @@ export type DOMImportOutput = DOMImportOutputNodes | DOMImportOutputContinue; export interface DOMImportOutputNodes { node: null | LexicalNode | LexicalNode[]; childNodes?: NodeListOf | readonly ChildNode[]; - childContext?: AnyImportStateConfigPair[]; + childContext?: AnyImportStateConfigPairOrUpdater[]; $appendChild?: (node: LexicalNode, dom: ChildNode) => void; $finalize?: ( node: null | LexicalNode | LexicalNode[], @@ -83,8 +89,8 @@ export interface DOMImportOutputNodes { export interface DOMImportOutputContinue { node: DOMImportNext; - childContext?: AnyImportStateConfigPair[]; - nextContext?: AnyImportStateConfigPair[]; + childContext?: AnyImportStateConfigPairOrUpdater[]; + nextContext?: AnyImportStateConfigPairOrUpdater[]; $appendChild?: never; childNodes?: never; $finalize?: ( @@ -113,7 +119,7 @@ export type NodeNameToType = T extends keyof NodeNameMap /** @internal @experimental */ export interface DOMRenderConfig { overrides: AnyDOMRenderMatch[]; - contextDefaults: AnyRenderStateConfigPair[]; + contextDefaults: AnyRenderStateConfigPairOrUpdater[]; } /** @internal @experimental */ @@ -180,10 +186,10 @@ export interface DOMImportConfig { ) => DOMImportExtensionOutput['$importNode']; } export interface DOMImportConfigMatch { - tag: '*' | '#text' | '#cdata-section' | '#comment' | (string & {}); - selector?: string; - priority?: 0 | 1 | 2 | 3 | 4; - $import: ( + readonly tag: '*' | '#text' | '#cdata-section' | '#comment' | (string & {}); + readonly selector?: string; + readonly priority?: 0 | 1 | 2 | 3 | 4; + readonly $import: ( node: Node, $next: DOMImportNext, editor: LexicalEditor, diff --git a/packages/lexical/src/LexicalNodeState.ts b/packages/lexical/src/LexicalNodeState.ts index 9f437513216..0e68b998267 100644 --- a/packages/lexical/src/LexicalNodeState.ts +++ b/packages/lexical/src/LexicalNodeState.ts @@ -231,7 +231,7 @@ export interface StateValueConfig { */ export type StateConfigPair = readonly [ StateConfig, - ValueOrUpdater, + V, ]; /** @@ -277,7 +277,7 @@ export class StateConfig { * Convenience method to produce a tuple of a StateConfig and a value * of that StateConfig (skipping the parse step). */ - pair(value: ValueOrUpdater): StateConfigPair { + pair(value: V): StateConfigPair { return [this, value]; } } From a9d48af52d8f1faed277bec3eee786f686ca6cea Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sat, 25 Oct 2025 10:14:32 -0700 Subject: [PATCH 43/47] Change pair API to contextValue --- .../lexical-html/src/$generateDOMFromNodes.ts | 8 ++++++-- packages/lexical-html/src/ContextRecord.ts | 7 +++++++ .../unit/DOMImportExtensionNoLegacy.test.ts | 3 ++- .../src/compileDOMImportOverrides.ts | 8 +++++--- .../lexical-html/src/compileLegacyImportDOM.ts | 4 +++- packages/lexical-html/src/index.ts | 2 +- packages/lexical/src/LexicalNodeState.ts | 17 ----------------- packages/lexical/src/index.ts | 1 - 8 files changed, 24 insertions(+), 26 deletions(-) diff --git a/packages/lexical-html/src/$generateDOMFromNodes.ts b/packages/lexical-html/src/$generateDOMFromNodes.ts index 27fa024f703..df7a4a1c3ce 100644 --- a/packages/lexical-html/src/$generateDOMFromNodes.ts +++ b/packages/lexical-html/src/$generateDOMFromNodes.ts @@ -21,6 +21,7 @@ import { } from 'lexical'; import invariant from 'shared/invariant'; +import {contextValue} from './ContextRecord'; import { $withRenderContext, RenderContextExport, @@ -33,7 +34,7 @@ export function $generateDOMFromNodes( editor: LexicalEditor = $getEditor(), ): T { return $withRenderContext( - [RenderContextExport.pair(true)], + [contextValue(RenderContextExport, true)], editor, )(() => { const root = $getRoot(); @@ -59,7 +60,10 @@ export function $generateDOMFromRoot( ): T { const editor = $getEditor(); return $withRenderContext( - [RenderContextExport.pair(true), RenderContextRoot.pair(true)], + [ + contextValue(RenderContextExport, true), + contextValue(RenderContextRoot, true), + ], editor, )(() => { const selection = null; diff --git a/packages/lexical-html/src/ContextRecord.ts b/packages/lexical-html/src/ContextRecord.ts index e0be70481b2..fed587c0605 100644 --- a/packages/lexical-html/src/ContextRecord.ts +++ b/packages/lexical-html/src/ContextRecord.ts @@ -94,6 +94,13 @@ export function setContextValue( return value; } +export function contextValue( + cfg: ContextConfig, + value: V, +): ContextConfigPair { + return [cfg, value]; +} + export function contextUpdater( cfg: ContextConfig, updater: (prev: V) => V, diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts index 6211ae32688..a3c451e08e9 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts @@ -16,6 +16,7 @@ import { $getImportContextValue, AnyImportStateConfigPairOrUpdater, contextUpdater, + contextValue, type DOMImportConfig, DOMImportExtension, type DOMImportNext, @@ -387,7 +388,7 @@ const NO_LEGACY_CONFIG: Partial = { case 'left': case 'right': case 'start': - nextContext.push(ImportContextTextAlign.pair(textAlign)); + nextContext.push(contextValue(ImportContextTextAlign, textAlign)); break; default: break; diff --git a/packages/lexical-html/src/compileDOMImportOverrides.ts b/packages/lexical-html/src/compileDOMImportOverrides.ts index f6e4854f3a7..9fd7c569dfa 100644 --- a/packages/lexical-html/src/compileDOMImportOverrides.ts +++ b/packages/lexical-html/src/compileDOMImportOverrides.ts @@ -40,6 +40,7 @@ import { } from './constants'; import { $withFullContext, + contextValue, createChildContext, updateContextFromPairs, } from './ContextRecord'; @@ -245,7 +246,7 @@ function compileImportNodes( [ rootNode, updateContextFromPairs(createChildContext(undefined), [ - ImportContextArtificialNodes.pair(artificialNodes), + contextValue(ImportContextArtificialNodes, artificialNodes), ]), () => ({node: null}), (node) => { @@ -352,14 +353,15 @@ function compileImportNodes( hasBlockAncestorLexicalNode !== hasBlockAncestorLexicalNodeForChildren ) { updateChildContext([ - ImportContextHasBlockAncestorLexicalNode.pair( + contextValue( + ImportContextHasBlockAncestorLexicalNode, hasBlockAncestorLexicalNodeForChildren, ), ]); } if ($isElementNode(currentLexicalNode)) { updateChildContext([ - ImportContextParentLexicalNode.pair(currentLexicalNode), + contextValue(ImportContextParentLexicalNode, currentLexicalNode), ]); } } diff --git a/packages/lexical-html/src/compileLegacyImportDOM.ts b/packages/lexical-html/src/compileLegacyImportDOM.ts index a819422dc5c..416fb6184a9 100644 --- a/packages/lexical-html/src/compileLegacyImportDOM.ts +++ b/packages/lexical-html/src/compileLegacyImportDOM.ts @@ -27,6 +27,7 @@ import invariant from 'shared/invariant'; import {$wrapContinuousInlinesInPlace} from './$wrapContinuousInlinesInPlace'; import {EMPTY_ARRAY, IGNORE_TAGS} from './constants'; +import {contextValue} from './ContextRecord'; import {getConversionFunction} from './getConversionFunction'; import { $getImportContextValue, @@ -162,7 +163,8 @@ export function compileLegacyImportDOM( if (transformOutput.forChild) { addChildContext( - ImportContextForChildMap.pair( + contextValue( + ImportContextForChildMap, new Map(forChildMap || []).set( node.nodeName, transformOutput.forChild, diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 0ef2fd6ea17..3c0af8bf5be 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -11,7 +11,7 @@ export { $generateHtmlFromNodes, } from './$generateDOMFromNodes'; export {$generateNodesFromDOM} from './$generateNodesFromDOM'; -export {contextUpdater} from './ContextRecord'; +export {contextUpdater, contextValue} from './ContextRecord'; export {DOMImportExtension} from './DOMImportExtension'; export {domOverride} from './domOverride'; export {DOMRenderExtension} from './DOMRenderExtension'; diff --git a/packages/lexical/src/LexicalNodeState.ts b/packages/lexical/src/LexicalNodeState.ts index 0e68b998267..57466088884 100644 --- a/packages/lexical/src/LexicalNodeState.ts +++ b/packages/lexical/src/LexicalNodeState.ts @@ -225,15 +225,6 @@ export interface StateValueConfig { isEqual?: (a: V, b: V) => boolean; } -/** - * A tuple of a StateConfig and a value, used for contexts outside of - * NodeState such as DOMRenderExtension. - */ -export type StateConfigPair = readonly [ - StateConfig, - V, -]; - /** * The return value of {@link createState}, for use with * {@link $getState} and {@link $setState}. @@ -272,14 +263,6 @@ export class StateConfig { ); this.defaultValue = this.parse(undefined); } - - /** - * Convenience method to produce a tuple of a StateConfig and a value - * of that StateConfig (skipping the parse step). - */ - pair(value: V): StateConfigPair { - return [this, value]; - } } /** diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index fd63d78906a..b8308fb8875 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -207,7 +207,6 @@ export { type NodeStateJSON, type StateConfig, type StateConfigKey, - type StateConfigPair, type StateConfigValue, type StateValueConfig, type StateValueOrUpdater, From 7dd4c4b90a02b9308a2945e88954db3d28748263 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sat, 25 Oct 2025 10:35:06 -0700 Subject: [PATCH 44/47] $applyTextFormatsFromContext --- packages/lexical-html/src/ImportContext.ts | 12 ++++++++++++ .../unit/DOMImportExtensionNoLegacy.test.ts | 11 ++--------- packages/lexical-html/src/index.ts | 1 + 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/lexical-html/src/ImportContext.ts b/packages/lexical-html/src/ImportContext.ts index 70907fac42f..8ef7d737326 100644 --- a/packages/lexical-html/src/ImportContext.ts +++ b/packages/lexical-html/src/ImportContext.ts @@ -20,6 +20,7 @@ import { type LexicalEditor, type LexicalNode, type TextFormatType, + TextNode, } from 'lexical'; import invariant from 'shared/invariant'; @@ -90,6 +91,17 @@ export const ImportContextTextFormats = createImportState( () => NO_FORMATS, ); +export function $applyTextFormatsFromContext(node: T): T { + const fmt = $getImportContextValue(ImportContextTextFormats); + for (const k in fmt) { + const textFormat = k as keyof typeof fmt; + if (fmt[textFormat]) { + node = node.toggleFormat(textFormat); + } + } + return node; +} + export const ImportContextWhiteSpaceCollapse = createImportState( 'whiteSpaceCollapse', (): DOMWhiteSpaceCollapse => 'collapse', diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts index a3c451e08e9..b4c7048214a 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts @@ -12,6 +12,7 @@ import { getExtensionDependencyFromEditor, } from '@lexical/extension'; import { + $applyTextFormatsFromContext, $generateNodesFromDOM, $getImportContextValue, AnyImportStateConfigPairOrUpdater, @@ -216,15 +217,7 @@ function findTextInLine(text: Text, direction: CaretDirection): null | Text { } function $createTextNodeWithCurrentFormat(text: string = ''): TextNode { - let node = $createTextNode(text); - const fmt = $getImportContextValue(ImportContextTextFormats); - for (const k in fmt) { - const textFormat = k as keyof typeof fmt; - if (fmt[textFormat]) { - node = node.toggleFormat(textFormat); - } - } - return node; + return $applyTextFormatsFromContext($createTextNode(text)); } function $convertTextDOMNode(domNode: Text): DOMImportOutputNodes { diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 3c0af8bf5be..5a247e6eb89 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -16,6 +16,7 @@ export {DOMImportExtension} from './DOMImportExtension'; export {domOverride} from './domOverride'; export {DOMRenderExtension} from './DOMRenderExtension'; export { + $applyTextFormatsFromContext, $getImportContextValue, ImportContextHasBlockAncestorLexicalNode, ImportContextParentLexicalNode, From f7cc54307dde311a9fb53c96adec01895010aa13 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 26 Oct 2025 09:27:30 -0700 Subject: [PATCH 45/47] more import refactor --- packages/lexical-html/src/ImportContext.ts | 24 ++- .../unit/DOMImportExtensionNoLegacy.test.ts | 200 ++++++++---------- packages/lexical-html/src/index.ts | 3 + 3 files changed, 118 insertions(+), 109 deletions(-) diff --git a/packages/lexical-html/src/ImportContext.ts b/packages/lexical-html/src/ImportContext.ts index 8ef7d737326..33c76544254 100644 --- a/packages/lexical-html/src/ImportContext.ts +++ b/packages/lexical-html/src/ImportContext.ts @@ -16,11 +16,12 @@ import { $getEditor, type ArtificialNode__DO_NOT_USE, type DOMChildConversion, - ElementFormatType, + type ElementFormatType, + type ElementNode, type LexicalEditor, type LexicalNode, type TextFormatType, - TextNode, + type TextNode, } from 'lexical'; import invariant from 'shared/invariant'; @@ -30,6 +31,7 @@ import { getContextRecord, getContextValue, setContextValue, + updateContextValue, } from './ContextRecord'; /** @@ -73,6 +75,19 @@ export function $setImportContextValue( return setContextValue(ctx, cfg, value); } +export function $updateImportContextValue( + cfg: ImportStateConfig, + updater: (prev: V) => V, + editor: LexicalEditor = $getEditor(), +): V { + const ctx = getContextRecord(DOMImportContextSymbol, editor); + invariant( + ctx !== undefined, + '$updateImportContextValue used outside of DOM import', + ); + return updateContextValue(ctx, cfg, updater); +} + export const ImportContextDOMNode = createImportState( 'domNode', (): null | Node => null, @@ -86,6 +101,11 @@ export const ImportContextTextAlign = createImportState( (): undefined | ElementFormatType => undefined, ); +export function $applyTextAlignToElement(node: T): T { + const align = $getImportContextValue(ImportContextTextAlign); + return align ? node.setFormat(align) : node; +} + export const ImportContextTextFormats = createImportState( 'textFormats', () => NO_FORMATS, diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts index b4c7048214a..f7639d2b381 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts @@ -12,16 +12,14 @@ import { getExtensionDependencyFromEditor, } from '@lexical/extension'; import { + $applyTextAlignToElement, $applyTextFormatsFromContext, $generateNodesFromDOM, $getImportContextValue, - AnyImportStateConfigPairOrUpdater, - contextUpdater, - contextValue, + $setImportContextValue, + $updateImportContextValue, type DOMImportConfig, DOMImportExtension, - type DOMImportNext, - type DOMImportOutputContinue, type DOMImportOutputNodes, type DOMRenderConfig, DOMRenderExtension, @@ -167,19 +165,12 @@ const listOverrides = (['ul', 'ol'] as const).map((tag) => // 'preserve-spaces': (s) => s.replace(/(?:\r?\n|\t)/g, ' '), // }; -function $addTextFormatContinue( - format: TextFormatType, -): (node: HTMLElement, $next: DOMImportNext) => null | DOMImportOutputContinue { - return (_dom, $next) => { - return { - childContext: [ - contextUpdater(ImportContextTextFormats, (prev) => ({ - ...prev, - [format]: true, - })), - ], - node: $next, - }; +function $addTextFormatContinue(format: TextFormatType): () => undefined { + return () => { + $updateImportContextValue(ImportContextTextFormats, (prev) => ({ + ...prev, + [format]: true, + })); }; } @@ -316,11 +307,6 @@ const formatOverrides = Object.entries(TO_FORMAT).map(([tag, format]) => importOverride(tag as keyof typeof TO_FORMAT, $addTextFormatContinue(format)), ); -function $applyTextAlignToElement(node: T): T { - const align = $getImportContextValue(ImportContextTextAlign); - return align ? node.setFormat(align) : node; -} - function $unwrapBlockDOM( node: LexicalNode | LexicalNode[] | null, $splitPredicate: (el: ElementNode) => boolean = (el) => !el.isInline(), @@ -353,10 +339,82 @@ function $unwrapBlockDOM( return adjacentNodes || node; } +type Writable = {-readonly [K in keyof T]: T[K]}; + +function $updateFormatContextFromDOM(dom: HTMLElement) { + const {fontWeight, fontStyle, textDecoration, verticalAlign} = dom.style; + let formats: + | undefined + | Writable>; + const setFormat = (k: TextFormatType, v: boolean) => { + const fmt = formats || {}; + fmt[k] = v; + formats = fmt; + }; + switch (fontWeight) { + case '400': + case 'normal': + setFormat('bold', false); + break; + case '700': + case 'bold': + setFormat('bold', true); + break; + default: + break; + } + const italic = 'italic'; + if (fontStyle === 'normal') { + setFormat(italic, false); + } else if (fontStyle === italic) { + setFormat(italic, true); + } + const underline = 'underline'; + const strikethrough = 'strikethrough'; + for (const dec of textDecoration.split(' ')) { + if (dec === 'none') { + setFormat(underline, false); + setFormat(strikethrough, false); + } else if (dec === underline) { + setFormat(underline, true); + } else if (dec === 'line-through') { + setFormat('strikethrough', true); + } + } + if (verticalAlign === 'sub') { + setFormat('subscript', true); + } else if (verticalAlign === 'super') { + setFormat('superscript', true); + } + if (formats) { + $updateImportContextValue(ImportContextTextFormats, (v) => ({ + ...v, + ...formats, + })); + } +} + +function $updateTextAlignmentContextFromDOM(dom: HTMLElement): void { + const {textAlign} = dom.style; + switch (textAlign) { + case 'center': + case 'end': + case 'justify': + case 'left': + case 'right': + case 'start': + $setImportContextValue(ImportContextTextAlign, textAlign); + break; + default: + break; + } +} + const NO_LEGACY_CONFIG: Partial = { compileLegacyImportNode: () => () => null, overrides: [ importOverride('#text', $convertTextDOMNode), + importOverride('br', () => ({node: $createLineBreakNode()})), importOverride('*', function $overrideCreateParagraphFromBlock(dom) { if (isBlockDomNode(dom)) { return { @@ -365,90 +423,6 @@ const NO_LEGACY_CONFIG: Partial = { }; } }), - importOverride( - '*', - function $overrideBlockFormatAndAlignment( - dom, - $next, - ): undefined | DOMImportOutputContinue { - const nextContext: AnyImportStateConfigPairOrUpdater[] = []; - if (isBlockDomNode(dom)) { - const {textAlign} = dom.style; - switch (textAlign) { - case 'center': - case 'end': - case 'justify': - case 'left': - case 'right': - case 'start': - nextContext.push(contextValue(ImportContextTextAlign, textAlign)); - break; - default: - break; - } - } - if (isHTMLElement(dom)) { - const {fontWeight, fontStyle, textDecoration, verticalAlign} = - dom.style; - let formats: - | undefined - | StateConfigValue; - const setFormat = (k: TextFormatType, v: boolean) => { - const fmt = formats || Object.create(null); - fmt[k] = v; - formats = fmt; - }; - switch (fontWeight) { - case '400': - case 'normal': - setFormat('bold', false); - break; - case '700': - case 'bold': - setFormat('bold', true); - break; - default: - break; - } - const italic = 'italic'; - if (fontStyle === 'normal') { - setFormat(italic, false); - } else if (fontStyle === italic) { - setFormat(italic, true); - } - const underline = 'underline'; - const strikethrough = 'strikethrough'; - for (const dec of textDecoration.split(' ')) { - if (dec === 'none') { - setFormat(underline, false); - setFormat(strikethrough, false); - } else if (dec === underline) { - setFormat(underline, true); - } else if (dec === 'line-through') { - setFormat('strikethrough', true); - } - } - if (verticalAlign === 'sub') { - setFormat('subscript', true); - } else if (verticalAlign === 'super') { - setFormat('superscript', true); - } - if (formats) { - const boundFormats = formats; - nextContext.push( - contextUpdater(ImportContextTextFormats, (v) => ({ - ...v, - ...boundFormats, - })), - ); - } - } - if (nextContext.length > 0) { - return {nextContext, node: $next}; - } - }, - {priority: 1}, - ), ...formatOverrides, ...listOverrides, importOverride('li', (dom) => { @@ -471,6 +445,18 @@ const NO_LEGACY_CONFIG: Partial = { } return {$finalize: $normalizeListItemNode, node}; }), + importOverride( + '*', + function $overrideBlockFormatAndAlignment(dom): undefined { + if (isHTMLElement(dom)) { + if (isBlockDomNode(dom)) { + $updateTextAlignmentContextFromDOM(dom); + } + $updateFormatContextFromDOM(dom); + } + }, + {priority: 1}, + ), ], }; diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 5a247e6eb89..7952be9272c 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -16,8 +16,11 @@ export {DOMImportExtension} from './DOMImportExtension'; export {domOverride} from './domOverride'; export {DOMRenderExtension} from './DOMRenderExtension'; export { + $applyTextAlignToElement, $applyTextFormatsFromContext, $getImportContextValue, + $setImportContextValue, + $updateImportContextValue, ImportContextHasBlockAncestorLexicalNode, ImportContextParentLexicalNode, ImportContextTextAlign, From 6ce7320a682a53341b97d3ab4ce7e1077a9b3b7a Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 26 Oct 2025 15:49:29 -0700 Subject: [PATCH 46/47] move finalizers to context --- packages/lexical-html/src/ContextRecord.ts | 19 ++- packages/lexical-html/src/ImportContext.ts | 60 ++++++-- .../unit/DOMImportExtensionNoLegacy.test.ts | 16 +- .../src/compileDOMImportOverrides.ts | 123 +++++++-------- .../src/compileLegacyImportDOM.ts | 142 +++++++++--------- packages/lexical-html/src/index.ts | 4 +- packages/lexical-html/src/types.ts | 32 ++-- 7 files changed, 207 insertions(+), 189 deletions(-) diff --git a/packages/lexical-html/src/ContextRecord.ts b/packages/lexical-html/src/ContextRecord.ts index fed587c0605..e553ddf5826 100644 --- a/packages/lexical-html/src/ContextRecord.ts +++ b/packages/lexical-html/src/ContextRecord.ts @@ -29,13 +29,30 @@ export type EditorContext = { export function getContextValue( contextRecord: undefined | ContextRecord, cfg: ContextConfig, -) { +): V { const {key} = cfg; return contextRecord && key in contextRecord ? (contextRecord[key] as V) : cfg.defaultValue; } +export function popOwnContextValue( + contextRecord: ContextRecord, + cfg: ContextConfig, +): undefined | V { + const rval = getOwnContextValue(contextRecord, cfg); + delete contextRecord[cfg.key]; + return rval; +} + +export function getOwnContextValue( + contextRecord: ContextRecord, + cfg: ContextConfig, +): undefined | V { + const {key} = cfg; + return key in contextRecord ? (contextRecord[key] as V) : undefined; +} + function getEditorContext(editor: LexicalEditor): undefined | EditorContext { return activeContext && activeContext.editor === editor ? activeContext diff --git a/packages/lexical-html/src/ImportContext.ts b/packages/lexical-html/src/ImportContext.ts index 33c76544254..f4f1678ce33 100644 --- a/packages/lexical-html/src/ImportContext.ts +++ b/packages/lexical-html/src/ImportContext.ts @@ -7,6 +7,10 @@ */ import type { + AnyImportStateConfigPairOrUpdater, + ContextPairOrUpdater, + ContextRecord, + DOMImportContextFinalizer, DOMTextWrapMode, DOMWhiteSpaceCollapse, ImportStateConfig, @@ -30,6 +34,7 @@ import { createContextState, getContextRecord, getContextValue, + getOwnContextValue, setContextValue, updateContextValue, } from './ContextRecord'; @@ -62,17 +67,23 @@ export function $getImportContextValue( return getContextValue(getContextRecord(DOMImportContextSymbol, editor), cfg); } +function getImportContextOrThrow( + editor: LexicalEditor, +): ContextRecord { + const ctx = getContextRecord(DOMImportContextSymbol, editor); + invariant( + ctx !== undefined, + 'getImportContextOrThrow: Import context used outside of DOM import', + ); + return ctx; +} + export function $setImportContextValue( cfg: ImportStateConfig, value: V, editor: LexicalEditor = $getEditor(), ): V { - const ctx = getContextRecord(DOMImportContextSymbol, editor); - invariant( - ctx !== undefined, - '$setImportContextValue used outside of DOM import', - ); - return setContextValue(ctx, cfg, value); + return setContextValue(getImportContextOrThrow(editor), cfg, value); } export function $updateImportContextValue( @@ -80,12 +91,7 @@ export function $updateImportContextValue( updater: (prev: V) => V, editor: LexicalEditor = $getEditor(), ): V { - const ctx = getContextRecord(DOMImportContextSymbol, editor); - invariant( - ctx !== undefined, - '$updateImportContextValue used outside of DOM import', - ); - return updateContextValue(ctx, cfg, updater); + return updateContextValue(getImportContextOrThrow(editor), cfg, updater); } export const ImportContextDOMNode = createImportState( @@ -122,6 +128,36 @@ export function $applyTextFormatsFromContext(node: T): T { return node; } +export const ImportChildContext = createImportState( + 'childContext', + (): undefined | AnyImportStateConfigPairOrUpdater[] => undefined, +); + +export function $addImportChildContext( + pairOrUpdater: ContextPairOrUpdater, + editor: LexicalEditor = $getEditor(), +): void { + const ctx = getImportContextOrThrow(editor); + const childPairs = getOwnContextValue(ctx, ImportChildContext) || []; + childPairs.push(pairOrUpdater); + setContextValue(ctx, ImportChildContext, childPairs); +} + +export const ImportContextFinalizers = createImportState( + 'finalizers', + (): undefined | DOMImportContextFinalizer[] => undefined, +); + +export function $addImportContextFinalizer( + finalizer: DOMImportContextFinalizer, + editor: LexicalEditor = $getEditor(), +): void { + const ctx = getImportContextOrThrow(editor); + const finalizers = getOwnContextValue(ctx, ImportContextFinalizers) || []; + finalizers.push(finalizer); + setContextValue(ctx, ImportContextFinalizers, finalizers); +} + export const ImportContextWhiteSpaceCollapse = createImportState( 'whiteSpaceCollapse', (): DOMWhiteSpaceCollapse => 'collapse', diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts index f7639d2b381..58d065c24e7 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtensionNoLegacy.test.ts @@ -20,7 +20,7 @@ import { $updateImportContextValue, type DOMImportConfig, DOMImportExtension, - type DOMImportOutputNodes, + type DOMImportOutput, type DOMRenderConfig, DOMRenderExtension, ImportContextParentLexicalNode, @@ -72,6 +72,8 @@ import { import invariant from 'shared/invariant'; import {assert, describe, expect, test} from 'vitest'; +import {$addImportContextFinalizer} from '../../ImportContext'; + interface ImportTestCase { name: string; pastedHTML: string; @@ -144,9 +146,10 @@ function $normalizeListItemNode( function $importListNode( dom: HTMLUListElement | HTMLOListElement, -): DOMImportOutputNodes { +): DOMImportOutput { const listNode = $createListNode().setListType(listTypeFromDOM(dom)); - return {$finalize: $normalizeListNode, node: listNode}; + $addImportContextFinalizer($normalizeListNode); + return {node: listNode}; } const listOverrides = (['ul', 'ol'] as const).map((tag) => @@ -211,7 +214,7 @@ function $createTextNodeWithCurrentFormat(text: string = ''): TextNode { return $applyTextFormatsFromContext($createTextNode(text)); } -function $convertTextDOMNode(domNode: Text): DOMImportOutputNodes { +function $convertTextDOMNode(domNode: Text): DOMImportOutput { const domNode_ = domNode as Text; const parentDom = domNode.parentElement; invariant( @@ -417,8 +420,8 @@ const NO_LEGACY_CONFIG: Partial = { importOverride('br', () => ({node: $createLineBreakNode()})), importOverride('*', function $overrideCreateParagraphFromBlock(dom) { if (isBlockDomNode(dom)) { + $addImportContextFinalizer($unwrapBlockDOM); return { - $finalize: $unwrapBlockDOM, node: $applyTextAlignToElement($createParagraphNode()), }; } @@ -443,7 +446,8 @@ const NO_LEGACY_CONFIG: Partial = { if (ariaChecked !== undefined) { node.setChecked(ariaChecked); } - return {$finalize: $normalizeListItemNode, node}; + $addImportContextFinalizer($normalizeListItemNode); + return {node}; }), importOverride( '*', diff --git a/packages/lexical-html/src/compileDOMImportOverrides.ts b/packages/lexical-html/src/compileDOMImportOverrides.ts index 9fd7c569dfa..ffc914aa21b 100644 --- a/packages/lexical-html/src/compileDOMImportOverrides.ts +++ b/packages/lexical-html/src/compileDOMImportOverrides.ts @@ -10,10 +10,10 @@ import type { ContextRecord, DOMImportConfig, DOMImportConfigMatch, + DOMImportContextFinalizer, DOMImportExtensionOutput, DOMImportNext, DOMImportOutput, - DOMImportOutputContinue, DOMTextWrapMode, DOMWhiteSpaceCollapse, } from './types'; @@ -42,12 +42,15 @@ import { $withFullContext, contextValue, createChildContext, + popOwnContextValue, updateContextFromPairs, } from './ContextRecord'; import { $getImportContextValue, + ImportChildContext, ImportContextArtificialNodes, ImportContextDOMNode, + ImportContextFinalizers, ImportContextHasBlockAncestorLexicalNode, ImportContextParentLexicalNode, ImportContextTextWrapMode, @@ -72,30 +75,30 @@ class MatchesImport { compile( $nextImport: (node: Node) => null | undefined | DOMImportOutput, editor: LexicalEditor, - ): DOMImportExtensionOutput['$importNode'] { + ): (node: Node) => null | undefined | DOMImportOutput { const {matches, tag} = this; return (node) => { const el = isHTMLElement(node) ? node : null; const $importAt = (start: number): null | undefined | DOMImportOutput => { let rval: undefined | null | DOMImportOutput; - for (let i = start; !rval && i >= 0; i--) { + let nextCalled = false; + for (let i = start; !rval && i >= 0 && !nextCalled; i--) { const match = matches[i]; if (match) { const {$import, selector} = matches[i]; if (!selector || (el && el.matches(selector))) { rval = $import( node, - withImportNextSymbol($importAt.bind(null, i - 1)), + withImportNextSymbol(() => { + nextCalled = true; + return $importAt(i - 1); + }), editor, ); } } } - return ( - rval || { - node: withImportNextSymbol($nextImport.bind(null, node)), - } - ); + return rval || (nextCalled ? undefined : $nextImport(node)); }; return $importAt( @@ -107,16 +110,6 @@ class MatchesImport { } } -function $isImportOutputContinue( - rval: undefined | null | DOMImportOutput, -): rval is DOMImportOutputContinue { - return ( - !!rval && - typeof rval.node === 'function' && - DOMImportNextSymbol in rval.node - ); -} - function withImportNextSymbol( fn: () => null | undefined | DOMImportOutput, ): DOMImportNext { @@ -174,12 +167,12 @@ type ImportStackEntry = [ $appendChild: NonNullable, ]; -function composeFinalizers( - outer: undefined | ((v: T) => T), - inner: undefined | ((v: T) => T), -): undefined | ((v: T) => T) { - return outer ? (inner ? (v) => outer(inner(v)) : outer) : inner; -} +// function composeFinalizers( +// outer: undefined | ((v: T) => T), +// inner: undefined | ((v: T) => T), +// ): undefined | ((v: T) => T) { +// return outer ? (inner ? (v) => outer(inner(v)) : outer) : inner; +// } function parseDOMWhiteSpaceCollapseFromNode( ctx: ContextRecord, @@ -230,6 +223,23 @@ function parseDOMWhiteSpaceCollapseFromNode( return ctx; } +function makeFinalizer( + outputNode: null | LexicalNode | LexicalNode[], + finalizers: DOMImportContextFinalizer[], +): () => DOMImportOutput { + return () => { + let node = outputNode; + for ( + let finalizer = finalizers.pop(); + finalizer; + finalizer = finalizers.pop() + ) { + node = finalizer(node); + } + return {childNodes: EMPTY_ARRAY, node}; + }; +} + function compileImportNodes( editor: LexicalEditor, $importNode: DOMImportExtensionOutput['$importNode'], @@ -257,11 +267,7 @@ function compileImportNodes( for (let entry = stack.pop(); entry; entry = stack.pop()) { const [node, ctx, fn, $parentAppendChild] = entry; ctx[ImportContextDOMNode.key] = node; - const outputContinue: DOMImportOutputContinue = { - node: withImportNextSymbol(fn.bind(null, node)), - }; parseDOMWhiteSpaceCollapseFromNode(ctx, node); - let currentOutput: null | undefined | DOMImportOutput = outputContinue; let childContext: | undefined | ContextRecord; @@ -275,48 +281,19 @@ function compileImportNodes( ); } }; - while ($isImportOutputContinue(currentOutput)) { - updateContextFromPairs(ctx, currentOutput.nextContext); - updateChildContext(currentOutput.childContext); - outputContinue.$finalize = composeFinalizers( - outputContinue.$finalize, - currentOutput.$finalize, - ); - currentOutput = $withFullContext( - DOMImportContextSymbol, - ctx, - currentOutput.node, - editor, - ); - } - invariant( - !$isImportOutputContinue(currentOutput), - 'currentOutput can not be a continue', + const output = $withFullContext( + DOMImportContextSymbol, + ctx, + fn.bind(null, node), + editor, ); - const output = currentOutput; let children: NodeListOf | readonly ChildNode[] = isHTMLElement(node) ? node.childNodes : EMPTY_ARRAY; - let $finalize = outputContinue.$finalize; let $appendChild = $parentAppendChild; - const outputNode = output ? output.node : null; - invariant( - typeof outputNode !== 'function', - 'outputNode must not be a function', - ); - const pushFinalize = () => { - if ($finalize) { - const $boundFinalize = $finalize.bind(null, outputNode); - stack.push([ - node, - ctx, - () => ({childNodes: EMPTY_ARRAY, node: $boundFinalize()}), - $parentAppendChild, - ]); - } - }; - if (!output) { - pushFinalize(); - } else { + updateChildContext(popOwnContextValue(ctx, ImportChildContext)); + delete ctx[ImportChildContext.key]; + if (output) { + const outputNode = output.node; if (output.$appendChild) { $appendChild = output.$appendChild; } else if (Array.isArray(outputNode)) { @@ -325,9 +302,14 @@ function compileImportNodes( $appendChild = (childNode, _dom) => outputNode.append(childNode); } children = output.childNodes || children; - $finalize = composeFinalizers($finalize, output.$finalize); - if ($finalize) { - pushFinalize(); + const finalizers = popOwnContextValue(ctx, ImportContextFinalizers); + if (finalizers && finalizers.length > 0) { + stack.push([ + node, + ctx, + makeFinalizer(outputNode, finalizers), + $parentAppendChild, + ]); } else if (outputNode) { for (const addNode of Array.isArray(outputNode) ? outputNode @@ -336,7 +318,6 @@ function compileImportNodes( } } - updateChildContext(output.childContext); const currentLexicalNode = Array.isArray(outputNode) ? outputNode[outputNode.length - 1] || null : outputNode; diff --git a/packages/lexical-html/src/compileLegacyImportDOM.ts b/packages/lexical-html/src/compileLegacyImportDOM.ts index 416fb6184a9..7cd63f7fd47 100644 --- a/packages/lexical-html/src/compileLegacyImportDOM.ts +++ b/packages/lexical-html/src/compileLegacyImportDOM.ts @@ -5,11 +5,7 @@ * LICENSE file in the root directory of this source tree. * */ -import type { - AnyImportStateConfigPairOrUpdater, - DOMImportExtensionOutput, - DOMImportOutput, -} from './types'; +import type {DOMImportExtensionOutput, DOMImportOutput} from './types'; import { $createLineBreakNode, @@ -27,9 +23,11 @@ import invariant from 'shared/invariant'; import {$wrapContinuousInlinesInPlace} from './$wrapContinuousInlinesInPlace'; import {EMPTY_ARRAY, IGNORE_TAGS} from './constants'; -import {contextValue} from './ContextRecord'; +import {contextUpdater} from './ContextRecord'; import {getConversionFunction} from './getConversionFunction'; import { + $addImportChildContext, + $addImportContextFinalizer, $getImportContextValue, ImportContextArtificialNodes, ImportContextForChildMap, @@ -51,80 +49,74 @@ export function compileLegacyImportDOM( let postTransform: DOMConversionOutput['after']; const output: DOMImportOutput = { $appendChild: (childNode) => childLexicalNodes.push(childNode), - $finalize: (nodeOrNodes) => { - const finalLexicalNodes = Array.isArray(nodeOrNodes) - ? nodeOrNodes - : nodeOrNodes - ? [nodeOrNodes] - : []; - const finalLexicalNode: null | LexicalNode = - finalLexicalNodes[finalLexicalNodes.length - 1] || null; - if (postTransform) { - childLexicalNodes = postTransform(childLexicalNodes); - } - if (isBlockDomNode(node)) { - const hasBlockAncestorLexicalNodeForChildren = - finalLexicalNode && $isRootOrShadowRoot(finalLexicalNode) - ? false - : (finalLexicalNode && $isBlockElementNode(finalLexicalNode)) || - $getImportContextValue( - ImportContextHasBlockAncestorLexicalNode, - ); + node: null, + }; + $addImportContextFinalizer((nodeOrNodes) => { + const finalLexicalNodes = Array.isArray(nodeOrNodes) + ? nodeOrNodes + : nodeOrNodes + ? [nodeOrNodes] + : []; + const finalLexicalNode: null | LexicalNode = + finalLexicalNodes[finalLexicalNodes.length - 1] || null; + if (postTransform) { + childLexicalNodes = postTransform(childLexicalNodes); + } + if (isBlockDomNode(node)) { + const hasBlockAncestorLexicalNodeForChildren = + finalLexicalNode && $isRootOrShadowRoot(finalLexicalNode) + ? false + : (finalLexicalNode && $isBlockElementNode(finalLexicalNode)) || + $getImportContextValue(ImportContextHasBlockAncestorLexicalNode); - if (!hasBlockAncestorLexicalNodeForChildren) { - $wrapContinuousInlinesInPlace( - node, - childLexicalNodes, - $createParagraphNode, - ); - } else { - const allArtificialNodes = $getImportContextValue( - ImportContextArtificialNodes, - ); - invariant( - allArtificialNodes !== null, - 'Missing ImportContextArtificialNodes', - ); - $wrapContinuousInlinesInPlace(node, childLexicalNodes, () => { - const artificialNode = new ArtificialNode__DO_NOT_USE(); - allArtificialNodes.push(artificialNode); - return artificialNode; - }); - } + if (!hasBlockAncestorLexicalNodeForChildren) { + $wrapContinuousInlinesInPlace( + node, + childLexicalNodes, + $createParagraphNode, + ); + } else { + const allArtificialNodes = $getImportContextValue( + ImportContextArtificialNodes, + ); + invariant( + allArtificialNodes !== null, + 'Missing ImportContextArtificialNodes', + ); + $wrapContinuousInlinesInPlace(node, childLexicalNodes, () => { + const artificialNode = new ArtificialNode__DO_NOT_USE(); + allArtificialNodes.push(artificialNode); + return artificialNode; + }); } + } - if (finalLexicalNode == null) { - if (childLexicalNodes.length > 0) { - // If it hasn't been converted to a LexicalNode, we hoist its children - // up to the same level as it. - finalLexicalNodes.push(...childLexicalNodes); - } else { - if (isBlockDomNode(node) && isDomNodeBetweenTwoInlineNodes(node)) { - // Empty block dom node that hasnt been converted, we replace it with a linebreak if its between inline nodes - finalLexicalNodes.push($createLineBreakNode()); - } - } + if (finalLexicalNode == null) { + if (childLexicalNodes.length > 0) { + // If it hasn't been converted to a LexicalNode, we hoist its children + // up to the same level as it. + finalLexicalNodes.push(...childLexicalNodes); } else { - if ($isElementNode(finalLexicalNode)) { - // If the current node is a ElementNode after conversion, - // we can append all the children to it. - finalLexicalNode.append(...childLexicalNodes); + if (isBlockDomNode(node) && isDomNodeBetweenTwoInlineNodes(node)) { + // Empty block dom node that hasnt been converted, we replace it with a linebreak if its between inline nodes + finalLexicalNodes.push($createLineBreakNode()); } } + } else { + if ($isElementNode(finalLexicalNode)) { + // If the current node is a ElementNode after conversion, + // we can append all the children to it. + finalLexicalNode.append(...childLexicalNodes); + } + } - return finalLexicalNodes; - }, - node: null, - }; + return finalLexicalNodes; + }); let currentLexicalNode: null | LexicalNode = null; const transformFunction = getConversionFunction(node, editor); const transformOutput = transformFunction ? transformFunction(node as HTMLElement) : null; - const addChildContext = (cfg: AnyImportStateConfigPairOrUpdater) => { - output.childContext = output.childContext || []; - output.childContext.push(cfg); - }; if (transformOutput !== null) { const forChildMap = $getImportContextValue( @@ -162,14 +154,14 @@ export function compileLegacyImportDOM( transformNodeArray.length > 1 ? transformNodeArray : currentLexicalNode; if (transformOutput.forChild) { - addChildContext( - contextValue( - ImportContextForChildMap, - new Map(forChildMap || []).set( + const {forChild} = transformOutput; + $addImportChildContext( + contextUpdater(ImportContextForChildMap, (prev) => { + return new Map(prev || forChildMap || []).set( node.nodeName, - transformOutput.forChild, - ), - ), + forChild, + ); + }), ); } } diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 7952be9272c..f71d2316097 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -40,14 +40,14 @@ export type { AnyImportStateConfigPairOrUpdater, AnyRenderStateConfig, AnyRenderStateConfigPairOrUpdater, + ContextPairOrUpdater, DOMImportConfig, DOMImportConfigMatch, + DOMImportContextFinalizer, DOMImportExtensionOutput, DOMImportFunction, DOMImportNext, DOMImportOutput, - DOMImportOutputContinue, - DOMImportOutputNodes, DOMRenderConfig, DOMRenderExtensionOutput, DOMRenderMatch, diff --git a/packages/lexical-html/src/types.ts b/packages/lexical-html/src/types.ts index 50624f87037..08cbe1b35e0 100644 --- a/packages/lexical-html/src/types.ts +++ b/packages/lexical-html/src/types.ts @@ -42,11 +42,13 @@ export type ContextConfigPair = readonly [ V, ]; +export type ContextPairOrUpdater = + | ContextConfigPair + | ContextConfigUpdater; + export type AnyContextConfigPairOrUpdater = // eslint-disable-next-line @typescript-eslint/no-explicit-any - | ContextConfigPair - // eslint-disable-next-line @typescript-eslint/no-explicit-any - | ContextConfigUpdater; + ContextPairOrUpdater; export interface DOMRenderExtensionOutput { defaults: undefined | ContextRecord; @@ -74,28 +76,10 @@ export type AnyRenderStateConfig = RenderStateConfig; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnyImportStateConfig = ImportStateConfig; -/** @internal @experimental */ -export type DOMImportOutput = DOMImportOutputNodes | DOMImportOutputContinue; - -export interface DOMImportOutputNodes { +export interface DOMImportOutput { node: null | LexicalNode | LexicalNode[]; childNodes?: NodeListOf | readonly ChildNode[]; - childContext?: AnyImportStateConfigPairOrUpdater[]; $appendChild?: (node: LexicalNode, dom: ChildNode) => void; - $finalize?: ( - node: null | LexicalNode | LexicalNode[], - ) => null | LexicalNode | LexicalNode[]; -} - -export interface DOMImportOutputContinue { - node: DOMImportNext; - childContext?: AnyImportStateConfigPairOrUpdater[]; - nextContext?: AnyImportStateConfigPairOrUpdater[]; - $appendChild?: never; - childNodes?: never; - $finalize?: ( - node: null | LexicalNode | LexicalNode[], - ) => null | LexicalNode | LexicalNode[]; } export type DOMImportFunction = ( @@ -208,3 +192,7 @@ export interface DOMImportExtensionOutput { export type DOMWhiteSpaceCollapse = keyof typeof DOMWhiteSpaceCollapseKeys; export type DOMTextWrapMode = keyof typeof DOMTextWrapModeKeys; + +export type DOMImportContextFinalizer = ( + node: null | LexicalNode | LexicalNode[], +) => null | LexicalNode | LexicalNode[]; From 88645e33ca5a45815f9ec9a3541f0e4899e2f5bb Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 26 Oct 2025 18:11:14 -0700 Subject: [PATCH 47/47] cleanup DOMImportNext --- .../src/compileDOMImportOverrides.ts | 32 +++++++------------ packages/lexical-html/src/constants.ts | 3 +- packages/lexical-html/src/index.ts | 1 - packages/lexical-html/src/types.ts | 14 ++------ 4 files changed, 15 insertions(+), 35 deletions(-) diff --git a/packages/lexical-html/src/compileDOMImportOverrides.ts b/packages/lexical-html/src/compileDOMImportOverrides.ts index ffc914aa21b..c61a4496726 100644 --- a/packages/lexical-html/src/compileDOMImportOverrides.ts +++ b/packages/lexical-html/src/compileDOMImportOverrides.ts @@ -12,7 +12,6 @@ import type { DOMImportConfigMatch, DOMImportContextFinalizer, DOMImportExtensionOutput, - DOMImportNext, DOMImportOutput, DOMTextWrapMode, DOMWhiteSpaceCollapse, @@ -32,8 +31,8 @@ import invariant from 'shared/invariant'; import {$unwrapArtificialNodes} from './$unwrapArtificialNodes'; import { + ALWAYS_NULL, DOMImportContextSymbol, - DOMImportNextSymbol, DOMTextWrapModeKeys, DOMWhiteSpaceCollapseKeys, EMPTY_ARRAY, @@ -81,24 +80,28 @@ class MatchesImport { const el = isHTMLElement(node) ? node : null; const $importAt = (start: number): null | undefined | DOMImportOutput => { let rval: undefined | null | DOMImportOutput; - let nextCalled = false; - for (let i = start; !rval && i >= 0 && !nextCalled; i--) { + let $importFallback = $nextImport; + for ( + let i = start; + i >= 0 && !rval && $importFallback !== ALWAYS_NULL; + i-- + ) { const match = matches[i]; if (match) { const {$import, selector} = matches[i]; if (!selector || (el && el.matches(selector))) { rval = $import( node, - withImportNextSymbol(() => { - nextCalled = true; + () => { + $importFallback = ALWAYS_NULL; return $importAt(i - 1); - }), + }, editor, ); } } } - return rval || (nextCalled ? undefined : $nextImport(node)); + return rval || $importFallback(node); }; return $importAt( @@ -110,12 +113,6 @@ class MatchesImport { } } -function withImportNextSymbol( - fn: () => null | undefined | DOMImportOutput, -): DOMImportNext { - return Object.assign(fn, {[DOMImportNextSymbol]: true} as const); -} - class TagImport { tags: Map> = new Map(); push(match: DOMImportConfigMatch) { @@ -167,13 +164,6 @@ type ImportStackEntry = [ $appendChild: NonNullable, ]; -// function composeFinalizers( -// outer: undefined | ((v: T) => T), -// inner: undefined | ((v: T) => T), -// ): undefined | ((v: T) => T) { -// return outer ? (inner ? (v) => outer(inner(v)) : outer) : inner; -// } - function parseDOMWhiteSpaceCollapseFromNode( ctx: ContextRecord, node: Node, diff --git a/packages/lexical-html/src/constants.ts b/packages/lexical-html/src/constants.ts index 1618a41045b..954c990a064 100644 --- a/packages/lexical-html/src/constants.ts +++ b/packages/lexical-html/src/constants.ts @@ -8,7 +8,6 @@ export const DOMRenderExtensionName = '@lexical/html/DOM'; export const DOMImportExtensionName = '@lexical/html/DOMImport'; export const IGNORE_TAGS = new Set(['STYLE', 'SCRIPT']); -export const DOMImportNextSymbol = Symbol.for('@lexical/html/DOMImportNext'); export const DOMImportContextSymbol = Symbol.for( '@lexical/html/DOMImportContext', ); @@ -34,3 +33,5 @@ export const DOMTextWrapModeKeys = { export const EMPTY_ARRAY = [] as const; export const ALWAYS_TRUE = () => true as const; + +export const ALWAYS_NULL = () => null; diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index f71d2316097..4857502c12d 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -46,7 +46,6 @@ export type { DOMImportContextFinalizer, DOMImportExtensionOutput, DOMImportFunction, - DOMImportNext, DOMImportOutput, DOMRenderConfig, DOMRenderExtensionOutput, diff --git a/packages/lexical-html/src/types.ts b/packages/lexical-html/src/types.ts index 08cbe1b35e0..28093b63edd 100644 --- a/packages/lexical-html/src/types.ts +++ b/packages/lexical-html/src/types.ts @@ -8,7 +8,6 @@ import type { DOMImportContextSymbol, - DOMImportNextSymbol, DOMRenderContextSymbol, DOMTextWrapModeKeys, DOMWhiteSpaceCollapseKeys, @@ -84,7 +83,7 @@ export interface DOMImportOutput { export type DOMImportFunction = ( node: T, - $next: DOMImportNext, + $next: () => null | undefined | DOMImportOutput, editor: LexicalEditor, ) => null | undefined | DOMImportOutput; @@ -173,16 +172,7 @@ export interface DOMImportConfigMatch { readonly tag: '*' | '#text' | '#cdata-section' | '#comment' | (string & {}); readonly selector?: string; readonly priority?: 0 | 1 | 2 | 3 | 4; - readonly $import: ( - node: Node, - $next: DOMImportNext, - editor: LexicalEditor, - ) => null | undefined | DOMImportOutput; -} - -export interface DOMImportNext { - (): null | undefined | DOMImportOutput; - readonly [DOMImportNextSymbol]: true; + readonly $import: DOMImportFunction; } export interface DOMImportExtensionOutput {