diff --git a/.github/workflows/call-e2e-all-tests.yml b/.github/workflows/call-e2e-all-tests.yml index e32ddb03ec1..4260ffea4bb 100644 --- a/.github/workflows/call-e2e-all-tests.yml +++ b/.github/workflows/call-e2e-all-tests.yml @@ -199,6 +199,8 @@ jobs: browser: ${{ matrix.browser }} editor-mode: ${{ matrix.editor-mode }} flaky: + # There aren't currently any flaky tests + if: false needs: [build-ubuntu] strategy: matrix: diff --git a/.gitignore b/.gitignore index 8c4f427327d..679e563c6d8 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ e2e-screenshots test-results playwright-report /SOURCE_CODE_REVIEW.md +/playwright.local.config.mjs npm-debug.log* diff --git a/examples/agent-example/package.json b/examples/agent-example/package.json index 018404f581e..a5c16f9c8cc 100644 --- a/examples/agent-example/package.json +++ b/examples/agent-example/package.json @@ -31,6 +31,8 @@ "@vitejs/plugin-react": "^5.0.2", "cross-env": "^7.0.3", "jsdom": "^26.1.0", + "onnxruntime-node": "file:./stubs/empty", + "sharp": "file:./stubs/empty", "tailwindcss": "^4.2.1", "typescript": "^5.9.2", "vite": "^7.3.2", @@ -42,6 +44,10 @@ "@rollup/rollup-win32-x64-msvc": "4.52.0", "@rollup/wasm-node": "4.52.0" }, + "overrides": { + "onnxruntime-node": "$onnxruntime-node", + "sharp": "$sharp" + }, "pnpm": { "overrides": { "onnxruntime-node": "link:./stubs/empty", diff --git a/examples/extension-sveltekit-ssr-hydration/src/lib/buildEditor.ts b/examples/extension-sveltekit-ssr-hydration/src/lib/buildEditor.ts index fd68c8cb2df..07c1cfae275 100644 --- a/examples/extension-sveltekit-ssr-hydration/src/lib/buildEditor.ts +++ b/examples/extension-sveltekit-ssr-hydration/src/lib/buildEditor.ts @@ -16,7 +16,7 @@ import { buildEditorFromExtensions, EditorStateExtension, effect, - watchedSignal + WatchEditableExtension } from '@lexical/extension'; import { RichTextExtension } from '@lexical/rich-text'; import { TailwindExtension } from '@lexical/tailwind'; @@ -94,19 +94,6 @@ interface LexicalHMRState { } const HMR_KEY = 'lexicalHMR'; -export const WatchEditableExtension = defineExtension({ - name: '@lexical/extension/WatchEditable', - build(editor) { - return watchedSignal( - () => editor.isEditable(), - (signal) => - editor.registerEditableListener((editable) => { - signal.value = editable; - }) - ); - } -}); - const HMRExtension = defineExtension({ name: '@lexical/examples/hmr', config: safeCast<{ hot: null | ViteHotContext }>({ hot: null }), diff --git a/examples/extension-sveltekit-ssr-hydration/src/routes/+page.svelte b/examples/extension-sveltekit-ssr-hydration/src/routes/+page.svelte index 7cb3cdcfe71..1c1e66416c2 100644 --- a/examples/extension-sveltekit-ssr-hydration/src/routes/+page.svelte +++ b/examples/extension-sveltekit-ssr-hydration/src/routes/+page.svelte @@ -16,7 +16,7 @@ ).output; const editableSignal = extension.getExtensionDependencyFromEditor( editor, - buildEditor.WatchEditableExtension + extension.WatchEditableExtension ).output; $effect(() => { if (browser && editorRef) { diff --git a/packages/lexical-extension/src/WatchEditableExtension.ts b/packages/lexical-extension/src/WatchEditableExtension.ts new file mode 100644 index 00000000000..a7ae6eee5c5 --- /dev/null +++ b/packages/lexical-extension/src/WatchEditableExtension.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 {defineExtension} from 'lexical'; + +import {watchedSignal} from './watchedSignal'; + +/** + * Exposes the editor's editable state as a reactive `Signal` that + * mirrors `editor.isEditable()` via an editable listener. + * + * Depend on this extension and read its output `Signal` from a signals + * `effect`/`computed` to react to editability changes without subscribing + * through React (or any other framework). + */ +export const WatchEditableExtension = defineExtension({ + build(editor) { + return watchedSignal( + () => editor.isEditable(), + signal => + editor.registerEditableListener(editable => { + signal.value = editable; + }), + ); + }, + name: '@lexical/extension/WatchEditable', +}); diff --git a/packages/lexical-extension/src/index.ts b/packages/lexical-extension/src/index.ts index e409418525e..09aec7c527c 100644 --- a/packages/lexical-extension/src/index.ts +++ b/packages/lexical-extension/src/index.ts @@ -84,6 +84,7 @@ export { type TabIndentationConfig, TabIndentationExtension, } from './TabIndentationExtension'; +export {WatchEditableExtension} from './WatchEditableExtension'; export {watchedSignal} from './watchedSignal'; export { type AnyLexicalExtension, diff --git a/packages/lexical-html/src/__tests__/unit/CoreImportExtension.test.ts b/packages/lexical-html/src/__tests__/unit/CoreImportExtension.test.ts index bc853e4803b..ccecd053b1e 100644 --- a/packages/lexical-html/src/__tests__/unit/CoreImportExtension.test.ts +++ b/packages/lexical-html/src/__tests__/unit/CoreImportExtension.test.ts @@ -371,4 +371,26 @@ describe('CoreImportExtension', () => { expect(para.getTextContent()).toBe('x'); }); }); + + test('unconverted block elements (not just
) preserve block boundaries', () => { + using editor = buildEditor(); + // The legacy $generateNodesFromDOM treated every isBlockDomNode the + // same; sibling block containers must not collapse into one paragraph. + importInto(editor, '
a
b
'); + editor.read(() => { + const children = $getRoot().getChildren(); + expect(children.map(n => n.getTextContent())).toEqual(['a', 'b']); + expect(children.every($isParagraphNode)).toBe(true); + }); + }); + + test('text-align on a non-
block element is propagated to its paragraph', () => { + using editor = buildEditor(); + importInto(editor, '
x
'); + editor.read(() => { + const para = $getRoot().getFirstChild(); + assert($isParagraphNode(para), 'expected paragraph'); + expect(para.getFormatType()).toBe('right'); + }); + }); }); diff --git a/packages/lexical-html/src/__tests__/unit/HorizontalRuleImportExtension.test.ts b/packages/lexical-html/src/__tests__/unit/HorizontalRuleImportExtension.test.ts index 2f7e5d6d647..c9ef983e3b3 100644 --- a/packages/lexical-html/src/__tests__/unit/HorizontalRuleImportExtension.test.ts +++ b/packages/lexical-html/src/__tests__/unit/HorizontalRuleImportExtension.test.ts @@ -11,7 +11,11 @@ import { buildEditorFromExtensions, getExtensionDependencyFromEditor, } from '@lexical/extension'; -import {DOMImportExtension, HorizontalRuleImportExtension} from '@lexical/html'; +import { + CoreImportExtension, + DOMImportExtension, + HorizontalRuleImportExtension, +} from '@lexical/html'; import {JSDOM} from 'jsdom'; import { $getEditor, @@ -26,7 +30,9 @@ import {assert, describe, expect, test} from 'vitest'; function buildEditor() { return buildEditorFromExtensions( defineExtension({ - dependencies: [HorizontalRuleImportExtension], + // Leaf importer extensions no longer pull `CoreImportExtension` + // in by themselves — the application is expected to add it once. + dependencies: [CoreImportExtension, HorizontalRuleImportExtension], name: 'hr-host', }), ); diff --git a/packages/lexical-html/src/import/HorizontalRuleImportExtension.ts b/packages/lexical-html/src/import/HorizontalRuleImportExtension.ts index adc949c8454..737e8d56b4e 100644 --- a/packages/lexical-html/src/import/HorizontalRuleImportExtension.ts +++ b/packages/lexical-html/src/import/HorizontalRuleImportExtension.ts @@ -12,7 +12,6 @@ import { } from '@lexical/extension'; import {configExtension, defineExtension} from 'lexical'; -import {CoreImportExtension} from './CoreImportExtension'; import {defineImportRule} from './defineImportRule'; import {DOMImportExtension} from './DOMImportExtension'; import {selBase} from './sel'; @@ -31,21 +30,21 @@ const HorizontalRuleRule = defineImportRule({ export const HorizontalRuleImportRules = [HorizontalRuleRule]; /** - * Bundles {@link HorizontalRuleImportRules} (plus - * {@link CoreImportExtension}) into a single dependency. The legacy - * {@link HorizontalRuleExtension.importDOM} continues to work in parallel; - * depend on this extension to opt into the new pipeline. + * Bundles {@link HorizontalRuleImportRules} together with the runtime + * {@link HorizontalRuleExtension}. The application is expected to + * already have `CoreImportExtension` (or some equivalent) in its + * dependency graph — the core/text/paragraph/inline-format rules are a + * shared baseline, not something this leaf importer should re-declare. * * Lives in `@lexical/html` (not `@lexical/extension`) because * {@link DOMImportExtension} itself is in `@lexical/html`, and * `@lexical/extension` is upstream of `@lexical/html` in the dependency - * graph — same arrangement as {@link CoreImportExtension}. + * graph. * * @experimental */ export const HorizontalRuleImportExtension = defineExtension({ dependencies: [ - CoreImportExtension, HorizontalRuleExtension, configExtension(DOMImportExtension, {rules: HorizontalRuleImportRules}), ], diff --git a/packages/lexical-html/src/import/coreImportRules.ts b/packages/lexical-html/src/import/coreImportRules.ts index c22e8b70607..d4f9d2611e6 100644 --- a/packages/lexical-html/src/import/coreImportRules.ts +++ b/packages/lexical-html/src/import/coreImportRules.ts @@ -22,7 +22,10 @@ import { IS_SUBSCRIPT, IS_SUPERSCRIPT, IS_UNDERLINE, + isBlockDomNode, isDOMTextNode, + isLastChildInBlockNode, + isOnlyChildInBlockNode, type LexicalNode, setNodeIndentFromDOM, } from 'lexical'; @@ -35,6 +38,7 @@ import { ImportWhitespaceConfig, type WhitespaceImportConfig, } from './ImportContext'; +import {$propagateTextAlignToBlockChildren, BlockSchema} from './schemas'; import {selBase} from './sel'; const sel = selBase; @@ -437,7 +441,16 @@ const IgnoreScriptStyleRule = defineImportRule({ }); const LineBreakRule = defineImportRule({ - $import: () => [$createLineBreakNode()], + // Mirror the legacy LineBreakNode.importDOM filter: stray `
` that + // are the sole or trailing child of a block parent (e.g. Apple's + // `
` clipboard sentinel, or the + // trailing `
` browsers insert after the last text in a `
`) + // would otherwise survive as a LineBreakNode and tack an extra blank + // line onto the imported content. + $import: (_ctx, el) => + isOnlyChildInBlockNode(el) || isLastChildInBlockNode(el) + ? [] + : [$createLineBreakNode()], match: sel.tag('br'), name: '@lexical/html/br', }); @@ -467,6 +480,52 @@ const ParagraphRule = defineImportRule({ name: '@lexical/html/p', }); +/** + * Transparent block-container rule for any unconverted block-level DOM + * element — `
`, but also `
`, `
`, `
`, + * `
`, … (everything {@link isBlockDomNode} recognizes via the + * legacy `BLOCK_TAG_RE`). Without it these would fall through to the + * dispatcher's `$hoistChildrenOf` / `DefaultHoistRule` fallback, which + * transparently lifts children up to the enclosing context. That works + * structurally, but (a) two sibling `
`s collapse into a single + * paragraph instead of two, and (b) any `text-align` set on the element + * is lost because the synthesized paragraph (built by the enclosing + * schema) sees the *grandparent* as `domParent`. + * + * The rule is registered as a `sel.any()` wildcard and defers (via + * `$next()`) for non-block elements so inline tags still reach the inline + * rules. Higher-priority tag rules (`

`, `

  • `, ``, headings, …) + * are dispatched first and never reach here. + * + * The element's children run through {@link BlockSchema} so each inline + * run becomes its own `ParagraphNode` (with the element's `text-align` + * picked up via {@link $paragraphPackageRun}'s `domParent`), and any + * pre-existing block children get the same alignment applied via + * {@link $propagateTextAlignToBlockChildren}. The resulting block-level + * nodes are what the enclosing context sees — at the root a sibling + * paragraph is the natural shape; inside a block lexical container the + * container rule (e.g. {@link ListItemRule}) collapses paragraph + * children back into inline-with-line-break form. That way both `

    ` + * and transparent blocks (`

    `, `
    `, …) project to the same + * `ParagraphNode` intermediate, and there is no need for a marker node + * to distinguish them. + */ +const TransparentBlockRule = defineImportRule({ + $import: (ctx, el, $next) => { + if (!isBlockDomNode(el)) { + // Inline element with no dedicated rule — let the inline rules (or + // the default hoist) handle it. + return $next(); + } + return $propagateTextAlignToBlockChildren( + ctx.$importChildren(el, {schema: BlockSchema}), + el, + ); + }, + match: sel.any(), + name: '@lexical/html/transparent-block', +}); + /** * Rules covering the {@link ParagraphNode}, {@link TextNode}, * {@link LineBreakNode}, and {@link TabNode} cases that the legacy @@ -479,6 +538,7 @@ const ParagraphRule = defineImportRule({ export const CoreImportRules = [ IgnoreScriptStyleRule, ParagraphRule, + TransparentBlockRule, TextRule, LineBreakRule, InlineFormatRule, diff --git a/packages/lexical-html/src/import/index.ts b/packages/lexical-html/src/import/index.ts index 86fbe729410..7368d4857de 100644 --- a/packages/lexical-html/src/import/index.ts +++ b/packages/lexical-html/src/import/index.ts @@ -66,6 +66,7 @@ export {parseSelector} from './parseCss'; export { $distributeInlineWrapper, $isBlockLevel, + $propagateTextAlignToBlockChildren, BlockSchema, InlineSchema, NestedBlockSchema, diff --git a/packages/lexical-html/src/import/schemas.ts b/packages/lexical-html/src/import/schemas.ts index 94faf59284f..be6ccfbb204 100644 --- a/packages/lexical-html/src/import/schemas.ts +++ b/packages/lexical-html/src/import/schemas.ts @@ -12,6 +12,7 @@ import { $isBlockElementNode, $isDecoratorNode, $isElementNode, + $isLineBreakNode, type ElementNode, isHTMLElement, type LexicalNode, @@ -153,6 +154,39 @@ export function $applySchema( return schema.$finalize ? schema.$finalize(out, parent) : out; } +/** + * Apply a parent DOM element's `text-align` (when set to one of the + * supported {@link ElementFormatType} values) to each block-level child + * Lexical node that does not yet have its own format. + * + * Mirrors the part of the legacy `wrapContinuousInlines` that wrote + * `node.setFormat(textAlign)` onto pre-existing block children when the + * DOM parent carried `style.textAlign`. Pair with + * {@link $paragraphPackageRun} (which carries the same propagation onto + * paragraphs synthesized around inline runs) to fully replicate the + * legacy behavior on a run of mixed children. + * + * @experimental + */ +export function $propagateTextAlignToBlockChildren( + children: LexicalNode[], + domParent: Node | null, +): LexicalNode[] { + if (!isHTMLElement(domParent)) { + return children; + } + const textAlign = domParent.style.textAlign; + if (!isAlignmentValue(textAlign)) { + return children; + } + for (const child of children) { + if ($isBlockElementNode(child) && child.getFormatType() === '') { + child.setFormat(textAlign); + } + } + return children; +} + /** * Wrap a run of inline lexical nodes in a fresh paragraph, propagating the * `text-align` of `domParent` as the paragraph's format type (matching the @@ -163,6 +197,16 @@ function $paragraphPackageRun( _parent: LexicalNode | null, domParent: Node | null, ): LexicalNode[] { + // Mirror the legacy `$wrapInlineNodes` (driven by + // `selection.insertNodes`) shortcut where a lone `
    ` at this + // level (a `LineBreakNode` is the only thing in the rejected run) + // becomes an *empty* paragraph rather than a paragraph wrapping a + // visible line break — that's the form clipboard pastes ending in a + // trailing `
    ` (Google Docs, Gmail, …) rely on for the editor's + // "extra trailing empty line" expectation. + if (run.length === 1 && $isLineBreakNode(run[0])) { + run = []; + } const paragraph = $createParagraphNode(); if (isHTMLElement(domParent)) { const textAlign = domParent.style.textAlign; diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 6ebb29fcc70..be794bd35c4 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -81,6 +81,7 @@ export { $getImportContextValue, $inlineStylesFromStyleSheets, $isBlockLevel, + $propagateTextAlignToBlockChildren, $withImportContext, BlockSchema, CoreImportExtension, diff --git a/packages/lexical-link/src/LinkImportExtension.ts b/packages/lexical-link/src/LinkImportExtension.ts index 4a507aa0f2e..1f213f06eae 100644 --- a/packages/lexical-link/src/LinkImportExtension.ts +++ b/packages/lexical-link/src/LinkImportExtension.ts @@ -8,7 +8,6 @@ import { $distributeInlineWrapper, - CoreImportExtension, defineImportRule, DOMImportExtension, sel, @@ -51,16 +50,16 @@ const AnchorRule = defineImportRule({ export const LinkImportRules = [AnchorRule]; /** - * Bundles {@link LinkImportRules} (plus {@link CoreImportExtension}) into - * a single dependency. Equivalent to the legacy - * `LinkNode.importDOM` registration on the new - * {@link DOMImportExtension} pipeline. + * Bundles {@link LinkImportRules} together with the runtime + * {@link LinkExtension}. The application is expected to already have + * `CoreImportExtension` (or some equivalent) in its dependency graph — + * the core/text/paragraph/inline-format rules are a shared baseline, + * not something this leaf importer should re-declare. * * @experimental */ export const LinkImportExtension = defineExtension({ dependencies: [ - CoreImportExtension, LinkExtension, configExtension(DOMImportExtension, {rules: LinkImportRules}), ], diff --git a/packages/lexical-link/src/__tests__/unit/LinkImportExtension.test.ts b/packages/lexical-link/src/__tests__/unit/LinkImportExtension.test.ts index aa2ffa7c65b..862856aeb94 100644 --- a/packages/lexical-link/src/__tests__/unit/LinkImportExtension.test.ts +++ b/packages/lexical-link/src/__tests__/unit/LinkImportExtension.test.ts @@ -10,7 +10,7 @@ import { buildEditorFromExtensions, getExtensionDependencyFromEditor, } from '@lexical/extension'; -import {DOMImportExtension} from '@lexical/html'; +import {CoreImportExtension, DOMImportExtension} from '@lexical/html'; import {$isLinkNode, LinkImportExtension, LinkNode} from '@lexical/link'; import { $isHeadingNode, @@ -32,7 +32,9 @@ import {assert, describe, expect, test} from 'vitest'; function buildEditor() { return buildEditorFromExtensions( defineExtension({ - dependencies: [LinkImportExtension], + // Leaf importer extensions no longer pull `CoreImportExtension` + // in by themselves — the application is expected to add it once. + dependencies: [CoreImportExtension, LinkImportExtension], name: 'link-host', nodes: [LinkNode], }), @@ -107,7 +109,11 @@ describe('LinkImportExtension — block children lifted out of inline parent', ( function buildRichEditor() { return buildEditorFromExtensions( defineExtension({ - dependencies: [LinkImportExtension, RichTextImportExtension], + dependencies: [ + CoreImportExtension, + LinkImportExtension, + RichTextImportExtension, + ], name: 'rich-link-host', nodes: [LinkNode, HeadingNode, QuoteNode], }), diff --git a/packages/lexical-list/src/ListImportExtension.ts b/packages/lexical-list/src/ListImportExtension.ts index 04a23ca192b..9cab871bf36 100644 --- a/packages/lexical-list/src/ListImportExtension.ts +++ b/packages/lexical-list/src/ListImportExtension.ts @@ -9,13 +9,16 @@ import type {ChildSchema, DOMImportContext} from '@lexical/html'; import { - CoreImportExtension, + $isBlockLevel, + $propagateTextAlignToBlockChildren, defineImportRule, DOMImportExtension, isElementOfTag, sel, } from '@lexical/html'; import { + $createLineBreakNode, + $isElementNode, $isParagraphNode, $setDirectionFromDOM, $setFormatFromDOM, @@ -80,7 +83,21 @@ const ListRule = defineImportRule({ node = $createListNode('bullet'); } $setDirectionFromDOM(node, el); - return [node.splice(0, 0, $normalizeListChildren(ctx.$importChildren(el)))]; + // Propagate the list's `text-align` onto each `ListItemNode` child + // (legacy `wrapContinuousInlines` did the same), so pasting + // `
    ` ends up with the + // alignment on the list items where the reconciler renders it as + // `style="text-align: left"`. + return [ + node.splice( + 0, + 0, + $propagateTextAlignToBlockChildren( + $normalizeListChildren(ctx.$importChildren(el)), + el, + ), + ), + ]; }, match: sel.tag('ol', 'ul'), name: '@lexical/list/list', @@ -111,6 +128,61 @@ function $liftFormatFromSingleParagraph( return children; } +/** + * Collapse block children of a `
  • ` into inline-with-line-break form: a + * `ListItemNode` is an inline-level container, so any block child marks a + * boundary. Contiguous inline siblings are kept together as a single run and + * one {@link $createLineBreakNode} is inserted between runs — reproducing the + * legacy `wrapContinuousInlines` + `$unwrapArtificialNodes` shape + * (`
  • 1
    2
    3
  • ` → `1
    2
    3`) without the + * `ArtificialNode__DO_NOT_USE` marker. + * + * Boundaries are detected with {@link $isBlockLevel}, NOT `$isParagraphNode`: + * the `
    `/`
    `/… `TransparentBlockRule` happens to emit + * `ParagraphNode`s, but a `
    ` (`QuoteNode`), heading + * (`HeadingNode`), or block decorator (`HorizontalRuleNode`, …) is just as + * much a block boundary and must not be silently spliced into the list item + * as-is. A nested `ListNode` is the one deliberate exception — it is a valid + * list-item child that {@link $normalizeListChildren} lifts into a sibling, + * so it is preserved here rather than unwrapped. + */ +function $flattenListItemBlocks(children: LexicalNode[]): LexicalNode[] { + const $isBoundary = (node: LexicalNode): boolean => + $isBlockLevel(node) && !$isListNode(node); + if (!children.some($isBoundary)) { + return children; + } + // Partition into segments — each maximal run of inline siblings, and each + // boundary's own content — then join the segments with a single line break. + const segments: LexicalNode[][] = []; + let inlineRun: LexicalNode[] = []; + const flushInlineRun = () => { + if (inlineRun.length > 0) { + segments.push(inlineRun); + inlineRun = []; + } + }; + for (const child of children) { + if ($isBoundary(child)) { + flushInlineRun(); + // Unwrap a block ElementNode to its inline content; a childless block + // DecoratorNode stands on its own line. + segments.push($isElementNode(child) ? child.getChildren() : [child]); + } else { + inlineRun.push(child); + } + } + flushInlineRun(); + const out: LexicalNode[] = []; + for (const segment of segments) { + if (out.length > 0) { + out.push($createLineBreakNode()); + } + out.push(...segment); + } + return out; +} + const ListItemRule = defineImportRule({ $import: (ctx, el) => { const ariaChecked = el.getAttribute('aria-checked'); @@ -127,7 +199,12 @@ const ListItemRule = defineImportRule({ node.splice( 0, 0, - $liftFormatFromSingleParagraph(node, ctx.$importChildren(el)), + // Lift a sole wrapping paragraph's format onto the item *before* + // flattening, otherwise the paragraph would already be unwrapped and + // its alignment lost. + $flattenListItemBlocks( + $liftFormatFromSingleParagraph(node, ctx.$importChildren(el)), + ), ), ]; }, @@ -154,7 +231,9 @@ function $buildChecklistItem( node.splice( 0, 0, - $liftFormatFromSingleParagraph(node, ctx.$importChildren(el)), + $flattenListItemBlocks( + $liftFormatFromSingleParagraph(node, ctx.$importChildren(el)), + ), ), ]; } @@ -223,14 +302,16 @@ export const ListImportRules = [ ]; /** - * Bundles {@link ListImportRules} (plus {@link CoreImportExtension}) into - * a single dependency. + * Bundles {@link ListImportRules} together with the runtime + * {@link ListExtension}. The application is expected to already have + * `CoreImportExtension` (or some equivalent) in its dependency graph — + * the core/text/paragraph/inline-format rules are a shared baseline, + * not something this leaf importer should re-declare. * * @experimental */ export const ListImportExtension = defineExtension({ dependencies: [ - CoreImportExtension, ListExtension, configExtension(DOMImportExtension, {rules: ListImportRules}), ], diff --git a/packages/lexical-list/src/__tests__/unit/ListImportExtension.test.ts b/packages/lexical-list/src/__tests__/unit/ListImportExtension.test.ts index 3abe1686038..880e7fc3635 100644 --- a/packages/lexical-list/src/__tests__/unit/ListImportExtension.test.ts +++ b/packages/lexical-list/src/__tests__/unit/ListImportExtension.test.ts @@ -12,12 +12,14 @@ import { getExtensionDependencyFromEditor, } from '@lexical/extension'; import { + CoreImportExtension, createImportState, defineImportRule, defineOverlayRules, type DOMImportContext, DOMImportExtension, type DOMPreprocessFn, + HorizontalRuleImportExtension, ImportOverlays, InlineSchema, sel, @@ -46,7 +48,9 @@ import {assert, describe, expect, test} from 'vitest'; function buildEditor() { return buildEditorFromExtensions( defineExtension({ - dependencies: [ListImportExtension], + // Leaf importer extensions no longer pull `CoreImportExtension` + // in by themselves — the application is expected to add it once. + dependencies: [CoreImportExtension, ListImportExtension], name: 'list-host', nodes: [ListNode, ListItemNode], }), @@ -326,6 +330,7 @@ function buildWordPasteEditor() { return buildEditorFromExtensions( defineExtension({ dependencies: [ + CoreImportExtension, ListImportExtension, configExtension(DOMImportExtension, { preprocess: [$installWordOverlay], @@ -464,3 +469,72 @@ describe('MS Word paste — preprocess-installed overlay', () => { }); }); }); + +describe('ListItemNode block flattening', () => { + function $liChildTypes(): string[] { + const list = $getRoot().getFirstChild(); + assert($isListNode(list), 'expected a ListNode'); + const li = list.getFirstChild(); + assert($isListItemNode(li), 'expected a ListItemNode'); + return li.getChildren().map(c => `${c.getType()}:${c.getTextContent()}`); + } + + test('keeps contiguous inlines together, breaking only at block boundaries', () => { + using editor = buildEditor(); + importInto(editor, '
    • a b
      c
    '); + editor.read(() => { + // `a ` and `b` are inline siblings and must stay on the same line; the + //
    (lowered to a ParagraphNode) is the sole boundary. + expect($liChildTypes()).toEqual([ + 'text:a ', + 'text:b', + 'linebreak:\n', + 'text:c', + ]); + }); + }); + + test('treats a non-paragraph block (
    ) as a boundary, not inline content', () => { + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [ + CoreImportExtension, + HorizontalRuleImportExtension, + ListImportExtension, + ], + name: 'list-hr-host', + nodes: [ListNode, ListItemNode], + }), + ); + importInto(editor, '
    • x
      y
    '); + editor.read(() => { + // A HorizontalRuleNode is block-level but not a ParagraphNode; it must + // still split the run rather than be spliced inline between x and y. + expect($liChildTypes().map(s => s.split(':')[0])).toEqual([ + 'text', + 'linebreak', + 'horizontalrule', + 'linebreak', + 'text', + ]); + }); + }); + + test('preserves a nested list instead of flattening it', () => { + using editor = buildEditor(); + importInto(editor, '
    • parent
      • child
    '); + editor.read(() => { + const findNested = (node: LexicalNode): boolean => { + if ($isListItemNode(node)) { + return node.getChildren().some($isListNode); + } + return $isListNode(node) && node.getChildren().some(findNested); + }; + // The nested
      survives as a ListNode (lifted into a sibling item by + // $normalizeListChildren), not demoted to line-break-separated text. + const outer = $getRoot().getFirstChild(); + assert($isListNode(outer), 'expected a ListNode'); + expect(outer.getChildren().some(findNested)).toBe(true); + }); + }); +}); diff --git a/packages/lexical-playground/__tests__/e2e/AutoLinks.spec.mjs b/packages/lexical-playground/__tests__/e2e/AutoLinks.spec.mjs index 44a6f213eb0..ae53cde1dd0 100644 --- a/packages/lexical-playground/__tests__/e2e/AutoLinks.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/AutoLinks.spec.mjs @@ -26,7 +26,7 @@ import { test, } from '../utils/index.mjs'; -test.describe.parallel('Auto Links', () => { +test.describe('Auto Links', () => { test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); test('Can convert url-like text into links', async ({page, isPlainText}) => { diff --git a/packages/lexical-playground/__tests__/e2e/Links.spec.mjs b/packages/lexical-playground/__tests__/e2e/Links.spec.mjs index 57895fe24cb..a1904d1fe95 100644 --- a/packages/lexical-playground/__tests__/e2e/Links.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Links.spec.mjs @@ -39,7 +39,7 @@ test.beforeEach(({isPlainText}) => { test.skip(isPlainText); }); -test.describe.parallel('Links', () => { +test.describe('Links', () => { test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); test(`Can convert a text node into a link`, async ({page}) => { await focusEditor(page); diff --git a/packages/lexical-playground/__tests__/e2e/List.spec.mjs b/packages/lexical-playground/__tests__/e2e/List.spec.mjs index 7489a29f2ab..32b217445eb 100644 --- a/packages/lexical-playground/__tests__/e2e/List.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/List.spec.mjs @@ -166,7 +166,7 @@ test.describe('Checklist focus option', () => { }); }); -test.describe.parallel('Nested List', () => { +test.describe('Nested List', () => { test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); test(`Can create a list and partially copy some content out of it`, async ({ diff --git a/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs b/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs index 0fc6133f9dc..555a086b455 100644 --- a/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs @@ -52,7 +52,7 @@ async function checkHTMLExpectationsIncludingUndoRedo( await assertHTML(page, forwardHTML); } -test.describe.parallel('Markdown', () => { +test.describe('Markdown', () => { test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); const triggersAndExpectations = [ { @@ -377,7 +377,7 @@ async function assertMarkdownImportExport( await assertHTML(page, expectedHTML); } -test.describe.parallel('Markdown', () => { +test.describe('Markdown', () => { test.beforeEach(({isCollab, isPlainText, page}) => { test.skip(isPlainText); return initialize({isCollab, page}); diff --git a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs index c33a8a963f7..88ed8443c8d 100644 --- a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs @@ -56,7 +56,7 @@ import { YOUTUBE_SAMPLE_URL, } from '../utils/index.mjs'; -test.describe.parallel('Selection', () => { +test.describe('Selection', () => { test.beforeEach(({isCollab, page}) => initialize({isCollab, page, tableHorizontalScroll: false}), ); diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index ee117c50c76..dd4e9ec276c 100644 --- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs @@ -92,7 +92,7 @@ const nthTableSelector = nth => ? `div.PlaygroundEditorTheme__tableScrollableWrapper:nth-of-type(${nth}) > table` : `table:nth-of-type(${nth})`; -test.describe.parallel('Tables', () => { +test.describe('Tables', () => { test(`Can a table be inserted from the toolbar`, async ({ page, isPlainText, @@ -189,6 +189,73 @@ test.describe.parallel('Tables', () => { expect(cellTexts[cellTexts.length - 1]).toBe('last'); }); + test(`TableSelection converts to RangeSelection when DOM selection extends onto the editor root (Issue #8584 follow-up)`, async ({ + page, + isPlainText, + isCollab, + }) => { + test.skip(isPlainText || isCollab); + await initialize({isCollab, page}); + + await focusEditor(page); + await insertTable(page, 2, 2); + + // Create a TableSelection across the first header cell (th at + // {x:0,y:0}) and the last body cell (td at {x:1,y:1}). The default + // insertTable marks row 0 and column 0 as headers, so {x:1,y:1} is + // the only plain td in a 2x2 table. + await selectCellsFromTableCords( + page, + {x: 0, y: 0}, + {x: 1, y: 1}, + true, + false, + ); + + // Sanity check: the editor selection is a TableSelection. + const isTableSelection = await evaluate(page, () => { + const editor = window.lexicalEditor; + const sel = editor.getEditorState()._selection; + return Boolean(sel && 'tableKey' in sel); + }); + expect(isTableSelection).toBe(true); + + // Move the DOM focus onto the editor root element itself (outside the + // table). Before #8584 root carried no `__lexicalKey_*`, so + // `$getNearestNodeFromDOMNode(rootElement)` returned null and the + // `isFocusOutside` check in `$fixTableSelectionForSelectedTable` + // short-circuited — the TableSelection was never converted. After + // #8584 root resolves to RootNode, so `isFocusOutside` is truthy and + // the selection is correctly switched to a RangeSelection. + // + // The TableObserver clears the window selection when it enters + // TableSelection mode, so we cannot rely on the prior anchorNode — + // build a fresh range with a known cell as the anchor instead. + await evaluate(page, () => { + const root = document.querySelector('div[contenteditable="true"]'); + const firstCell = document.querySelector( + 'div[contenteditable="true"] table th, div[contenteditable="true"] table td', + ); + window + .getSelection() + .setBaseAndExtent(firstCell, 0, root, root.childNodes.length); + }); + await sleep(50); + + const selectionAfter = await evaluate(page, () => { + const editor = window.lexicalEditor; + const sel = editor.getEditorState()._selection; + return { + isRange: Boolean( + sel && 'anchor' in sel && 'focus' in sel && !('tableKey' in sel), + ), + isTable: Boolean(sel && 'tableKey' in sel), + }; + }); + expect(selectionAfter.isTable).toBe(false); + expect(selectionAfter.isRange).toBe(true); + }); + test(`Can type inside of table cell`, async ({ page, isPlainText, @@ -235,8 +302,7 @@ test.describe.parallel('Tables', () => { ); }); - test.describe - .parallel(`Can exit table with the horizontal arrow keys`, () => { + test.describe(`Can exit table with the horizontal arrow keys`, () => { test(`Can exit the first cell of a table`, async ({ page, isPlainText, @@ -673,7 +739,7 @@ test.describe.parallel('Tables', () => { }); }); - test.describe.parallel(`Can navigate table with keyboard`, () => { + test.describe(`Can navigate table with keyboard`, () => { test(`Can navigate cells horizontally`, async ({ page, isPlainText, diff --git a/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs b/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs index eaa24fdd5a7..8301b8ad5e0 100644 --- a/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs @@ -34,7 +34,7 @@ import { test, } from '../utils/index.mjs'; -test.describe.parallel('TextFormatting', () => { +test.describe('TextFormatting', () => { test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); test(`Can create bold text using the shortcut`, async ({ page, diff --git a/packages/lexical-playground/__tests__/unit/CollapsibleContainerNode.test.ts b/packages/lexical-playground/__tests__/unit/CollapsibleContainerNode.test.ts index d65b37d568e..592caa5c3d3 100644 --- a/packages/lexical-playground/__tests__/unit/CollapsibleContainerNode.test.ts +++ b/packages/lexical-playground/__tests__/unit/CollapsibleContainerNode.test.ts @@ -7,7 +7,7 @@ */ import {buildEditorFromExtensions} from '@lexical/extension'; -import {$generateNodesFromDOM} from '@lexical/html'; +import {$generateNodesFromDOMViaExtension} from '@lexical/html'; import { $createParagraphNode, $createTextNode, @@ -19,239 +19,271 @@ import { } from 'lexical'; import { $createTestDecoratorNode, - initializeUnitTest, TestDecoratorNode, } from 'lexical/src/__tests__/utils'; import {assert, describe, expect, it} from 'vitest'; +import {PlaygroundImportExtension} from '../../src/nodes/PlaygroundImportExtension'; import {CollapsibleExtension} from '../../src/plugins/CollapsibleExtension'; import { $createCollapsibleContainerNode, $isCollapsibleContainerNode, - CollapsibleContainerNode, } from '../../src/plugins/CollapsibleExtension/CollapsibleContainerNode'; import { $createCollapsibleContentNode, $isCollapsibleContentNode, - CollapsibleContentNode, } from '../../src/plugins/CollapsibleExtension/CollapsibleContentNode'; import { $createCollapsibleTitleNode, $isCollapsibleTitleNode, - CollapsibleTitleNode, } from '../../src/plugins/CollapsibleExtension/CollapsibleTitleNode'; +const CollapsibleImportTestExtension = defineExtension({ + $initialEditorState: null, + dependencies: [CollapsibleExtension, PlaygroundImportExtension], + name: '[test-collapsible-import]', +}); + +function $importHtml(html: string): void { + const parser = new DOMParser(); + const dom = parser.parseFromString(html, 'text/html'); + $insertNodes($generateNodesFromDOMViaExtension(dom)); +} + describe('CollapsibleContainerNode HTML import (issue #8407)', () => { - initializeUnitTest( - testEnv => { - describe('importDOM', () => { - it('imports
      with loose text body without crashing', async () => { - const {editor} = testEnv; - const parser = new DOMParser(); - const htmlString = + describe('importDOM', () => { + it('imports
      with loose text body without crashing', () => { + using editor = buildEditorFromExtensions(CollapsibleImportTestExtension); + + editor.update( + () => { + $getRoot().clear().select(); + $importHtml( '
      \n' + - ' Details\n' + - ' Something small enough to escape casual notice.\n' + - '
      '; - const dom = parser.parseFromString(htmlString, 'text/html'); - - await editor.update(() => { - const nodes = $generateNodesFromDOM(editor, dom); - $getRoot().select(); - $insertNodes(nodes); - }); - - editor.read(() => { - const root = $getRoot(); - const container = root - .getChildren() - .find($isCollapsibleContainerNode); - assert( - container != null, - 'CollapsibleContainerNode must be a child of RootNode', - ); - const children = container.getChildren(); - expect(children.length).toBe(2); - assert( - $isCollapsibleTitleNode(children[0]), - 'First child must be CollapsibleTitleNode', - ); - assert( - $isCollapsibleContentNode(children[1]), - 'Second child must be CollapsibleContentNode', - ); - expect(children[0].getTextContent()).toBe('Details'); - expect(children[1].getTextContent().trim()).toBe( - 'Something small enough to escape casual notice.', - ); - // No `open` attribute on the source element should map to closed. - expect(container.getOpen()).toBe(false); - }); - }); - - it('preserves the open attribute on import', async () => { - const {editor} = testEnv; - const parser = new DOMParser(); - const htmlString = '
      Sbody
      '; - const dom = parser.parseFromString(htmlString, 'text/html'); - - await editor.update(() => { - const nodes = $generateNodesFromDOM(editor, dom); - $getRoot().select(); - $insertNodes(nodes); - }); - - editor.read(() => { - const root = $getRoot(); - const container = root - .getChildren() - .find($isCollapsibleContainerNode)!; - expect(container.getOpen()).toBe(true); - }); - }); - - it('handles appearing after body content', async () => { - const {editor} = testEnv; - const parser = new DOMParser(); - const htmlString = - '

      Body before

      Title
      '; - const dom = parser.parseFromString(htmlString, 'text/html'); - - await editor.update(() => { - const nodes = $generateNodesFromDOM(editor, dom); - $getRoot().select(); - $insertNodes(nodes); - }); - - editor.read(() => { - const root = $getRoot(); - const container = root - .getChildren() - .find($isCollapsibleContainerNode)!; - const children = container.getChildren(); - expect(children.length).toBe(2); - assert( - $isCollapsibleTitleNode(children[0]), - 'First child must be CollapsibleTitleNode', - ); - assert( - $isCollapsibleContentNode(children[1]), - 'Second child must be CollapsibleContentNode', - ); - expect(children[0].getTextContent()).toBe('Title'); - expect(children[1].getTextContent().trim()).toBe('Body before'); - }); - }); - - it('imports
      with no without crashing', async () => { - const {editor} = testEnv; - const parser = new DOMParser(); - const htmlString = '
      Just some loose text
      '; - const dom = parser.parseFromString(htmlString, 'text/html'); - - await editor.update(() => { - const nodes = $generateNodesFromDOM(editor, dom); - $getRoot().select(); - $insertNodes(nodes); - }); - - editor.read(() => { - const root = $getRoot(); - const container = root - .getChildren() - .find($isCollapsibleContainerNode)!; - // The empty CollapsibleTitleNode created for missing - // is removed by CollapsibleTitleNode's $transform, leaving the - // body wrapped in a single CollapsibleContentNode. The crucial - // invariant is that no raw TextNode sits directly under the - // shadow root. - const children = container.getChildren(); - expect(children.length).toBe(1); - assert( - $isCollapsibleContentNode(children[0]), - 'Sole child must be CollapsibleContentNode', - ); - expect(children[0].getTextContent()).toBe('Just some loose text'); - }); - }); - - it('imports
      with summary and block body siblings', async () => { - const {editor} = testEnv; - const parser = new DOMParser(); - const htmlString = - '
      Title

      Para one.

      Para two.

      '; - const dom = parser.parseFromString(htmlString, 'text/html'); - - await editor.update(() => { - const nodes = $generateNodesFromDOM(editor, dom); - $getRoot().select(); - $insertNodes(nodes); - }); - - editor.read(() => { - const root = $getRoot(); - const container = root - .getChildren() - .find($isCollapsibleContainerNode)!; - const children = container.getChildren(); - expect(children.length).toBe(2); - assert( - $isCollapsibleTitleNode(children[0]), - 'First child must be CollapsibleTitleNode', - ); - assert( - $isCollapsibleContentNode(children[1]), - 'Second child must be CollapsibleContentNode', - ); - expect(children[1].getChildren().length).toBe(2); - }); - }); - - it('imports
      with element body (round-trip shape)', async () => { - const {editor} = testEnv; - const parser = new DOMParser(); - const htmlString = + ' Details\n' + + ' Something small enough to escape casual notice.\n' + + '
      ', + ); + const container = $getRoot() + .getChildren() + .find($isCollapsibleContainerNode); + assert( + container != null, + 'CollapsibleContainerNode must be a child of RootNode', + ); + const children = container.getChildren(); + expect(children.length).toBe(2); + assert( + $isCollapsibleTitleNode(children[0]), + 'First child must be CollapsibleTitleNode', + ); + assert( + $isCollapsibleContentNode(children[1]), + 'Second child must be CollapsibleContentNode', + ); + expect(children[0].getTextContent()).toBe('Details'); + expect(children[1].getTextContent().trim()).toBe( + 'Something small enough to escape casual notice.', + ); + // No `open` attribute on the source element should map to closed. + expect(container.getOpen()).toBe(false); + }, + {discrete: true}, + ); + }); + + it('preserves the open attribute on import', () => { + using editor = buildEditorFromExtensions(CollapsibleImportTestExtension); + + editor.update( + () => { + $getRoot().clear().select(); + $importHtml('
      Sbody
      '); + const container = $getRoot() + .getChildren() + .find($isCollapsibleContainerNode)!; + expect(container.getOpen()).toBe(true); + }, + {discrete: true}, + ); + }); + + it('handles appearing after body content', () => { + using editor = buildEditorFromExtensions(CollapsibleImportTestExtension); + + editor.update( + () => { + $getRoot().clear().select(); + $importHtml( + '

      Body before

      Title
      ', + ); + const container = $getRoot() + .getChildren() + .find($isCollapsibleContainerNode)!; + const children = container.getChildren(); + expect(children.length).toBe(2); + assert( + $isCollapsibleTitleNode(children[0]), + 'First child must be CollapsibleTitleNode', + ); + assert( + $isCollapsibleContentNode(children[1]), + 'Second child must be CollapsibleContentNode', + ); + expect(children[0].getTextContent()).toBe('Title'); + expect(children[1].getTextContent().trim()).toBe('Body before'); + }, + {discrete: true}, + ); + }); + + it('imports
      with no without crashing', () => { + using editor = buildEditorFromExtensions(CollapsibleImportTestExtension); + + editor.update( + () => { + $getRoot().clear().select(); + $importHtml('
      Just some loose text
      '); + const container = $getRoot() + .getChildren() + .find($isCollapsibleContainerNode)!; + // The importer always emits [Title, Content]; even when the + // is missing, the synthesized empty Title still sits + // here pre-transform. The crucial invariant is that no raw + // TextNode sits directly under the shadow root. + const children = container.getChildren(); + expect(children.length).toBe(2); + assert( + $isCollapsibleTitleNode(children[0]), + 'First child must be CollapsibleTitleNode', + ); + assert( + $isCollapsibleContentNode(children[1]), + 'Second child must be CollapsibleContentNode', + ); + expect(children[0].isEmpty()).toBe(true); + expect(children[1].getTextContent().trim()).toBe( + 'Just some loose text', + ); + }, + {discrete: true}, + ); + }); + + it('imports
      with summary and block body siblings', () => { + using editor = buildEditorFromExtensions(CollapsibleImportTestExtension); + + editor.update( + () => { + $getRoot().clear().select(); + $importHtml( + '
      Title

      Para one.

      Para two.

      ', + ); + const container = $getRoot() + .getChildren() + .find($isCollapsibleContainerNode)!; + const children = container.getChildren(); + expect(children.length).toBe(2); + assert( + $isCollapsibleTitleNode(children[0]), + 'First child must be CollapsibleTitleNode', + ); + assert( + $isCollapsibleContentNode(children[1]), + 'Second child must be CollapsibleContentNode', + ); + expect(children[1].getChildren().length).toBe(2); + }, + {discrete: true}, + ); + }); + + it('imports
      with element body (round-trip shape)', () => { + using editor = buildEditorFromExtensions(CollapsibleImportTestExtension); + + editor.update( + () => { + $getRoot().clear().select(); + $importHtml( '
      ' + - 'Title' + - '
      ' + - '

      Body

      ' + - '
      ' + - '
      '; - const dom = parser.parseFromString(htmlString, 'text/html'); - - await editor.update(() => { - const nodes = $generateNodesFromDOM(editor, dom); - $getRoot().select(); - $insertNodes(nodes); - }); - - editor.read(() => { - const root = $getRoot(); - const container = root - .getChildren() - .find($isCollapsibleContainerNode)!; - const children = container.getChildren(); - expect(children.length).toBe(2); - assert( - $isCollapsibleTitleNode(children[0]), - 'First child must be CollapsibleTitleNode', - ); - assert( - $isCollapsibleContentNode(children[1]), - 'Second child must be CollapsibleContentNode', - ); - }); - }); + 'Title' + + '
      ' + + '

      Body

      ' + + '
      ' + + '
      ', + ); + const container = $getRoot() + .getChildren() + .find($isCollapsibleContainerNode)!; + const children = container.getChildren(); + expect(children.length).toBe(2); + assert( + $isCollapsibleTitleNode(children[0]), + 'First child must be CollapsibleTitleNode', + ); + assert( + $isCollapsibleContentNode(children[1]), + 'Second child must be CollapsibleContentNode', + ); + }, + {discrete: true}, + ); + }); + }); + + describe('importDOM + transforms', () => { + it('well-formed
      survives transforms intact', () => { + using editor = buildEditorFromExtensions(CollapsibleImportTestExtension); + + editor.update( + () => { + $getRoot().clear().select(); + $importHtml( + '
      Title

      Body

      ', + ); + }, + {discrete: true}, + ); + + editor.read(() => { + const container = $getRoot().getFirstChildOrThrow(); + assert($isCollapsibleContainerNode(container)); + expect(container.getOpen()).toBe(true); + const [title, content] = container.getChildren(); + assert($isCollapsibleTitleNode(title)); + assert($isCollapsibleContentNode(content)); + expect(title.getTextContent()).toBe('Title'); + expect(content.getTextContent()).toBe('Body'); + }); + }); + + it('missing unwraps to bare paragraphs after transforms', () => { + using editor = buildEditorFromExtensions(CollapsibleImportTestExtension); + + editor.update( + () => { + $getRoot().clear().select(); + $importHtml('
      Just some loose text
      '); + }, + {discrete: true}, + ); + + // After transforms: the empty Title is removed by its $transform, + // then the resulting 1-child Container is unwrapped by + // CollapsibleExtension, and the orphaned Content is unwrapped + // again because its parent is no longer a Container — leaving the + // body paragraph at the root. + editor.read(() => { + const root = $getRoot(); + expect(root.getChildren().some($isCollapsibleContainerNode)).toBe( + false, + ); + expect(root.getTextContent().trim()).toBe('Just some loose text'); + const first = root.getFirstChildOrThrow(); + assert($isParagraphNode(first)); }); - }, - { - nodes: [ - CollapsibleContainerNode, - CollapsibleContentNode, - CollapsibleTitleNode, - ], - }, - ); + }); + }); }); describe('CollapsibleExtension transforms', () => { diff --git a/packages/lexical-playground/__tests__/unit/ImageHTML.test.ts b/packages/lexical-playground/__tests__/unit/ImageHTML.test.ts index 774c6ff82ea..ac2fb5e0b35 100644 --- a/packages/lexical-playground/__tests__/unit/ImageHTML.test.ts +++ b/packages/lexical-playground/__tests__/unit/ImageHTML.test.ts @@ -13,13 +13,12 @@ import {$selectAll, $setSelection, defineExtension} from 'lexical'; import {expectHtmlToBeEqual, html} from 'lexical/src/__tests__/utils'; import {describe, it} from 'vitest'; -import {buildHTMLConfig} from '../../src/buildHTMLConfig'; import {$createImageNode} from '../../src/nodes/ImageNode'; +import {PlaygroundDOMRenderExtension} from '../../src/PlaygroundDOMRenderExtension'; import {ImagesExtension} from '../../src/plugins/ImagesExtension'; const ImageTestExtension = defineExtension({ - dependencies: [ImagesExtension], - html: buildHTMLConfig(), + dependencies: [ImagesExtension, PlaygroundDOMRenderExtension], name: '[test]', }); diff --git a/packages/lexical-playground/__tests__/unit/LayoutContainerNode.test.ts b/packages/lexical-playground/__tests__/unit/LayoutContainerNode.test.ts index 67e0f023abf..05ee4f99ff3 100644 --- a/packages/lexical-playground/__tests__/unit/LayoutContainerNode.test.ts +++ b/packages/lexical-playground/__tests__/unit/LayoutContainerNode.test.ts @@ -6,171 +6,166 @@ * */ -import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; -import {$getRoot, $insertNodes} from 'lexical'; +import {buildEditorFromExtensions} from '@lexical/extension'; import { - expectHtmlToBeEqual, - html, - initializeUnitTest, -} from 'lexical/src/__tests__/utils'; -import {describe, expect, it} from 'vitest'; + $generateHtmlFromNodes, + $generateNodesFromDOMViaExtension, +} from '@lexical/html'; +import {$getRoot, $insertNodes, defineExtension} from 'lexical'; +import {expectHtmlToBeEqual, html} from 'lexical/src/__tests__/utils'; +import {assert, describe, expect, it} from 'vitest'; import { $createLayoutContainerNode, - LayoutContainerNode, + $isLayoutContainerNode, } from '../../src/nodes/LayoutContainerNode'; -import {LayoutItemNode} from '../../src/nodes/LayoutItemNode'; +import {LayoutExtension} from '../../src/plugins/LayoutExtension/LayoutExtension'; + +const LayoutTestExtension = defineExtension({ + $initialEditorState: null, + dependencies: [LayoutExtension], + name: '[test-layout]', +}); + +function $importHtml(source: string): void { + const parser = new DOMParser(); + const dom = parser.parseFromString(source, 'text/html'); + $insertNodes($generateNodesFromDOMViaExtension(dom)); +} describe('LayoutContainerNode HTML serialization', () => { - initializeUnitTest( - testEnv => { - describe('exportDOM', () => { - it('exports with inline grid-template-columns style', async () => { - const {editor} = testEnv; - editor.update( - () => { - const container = $createLayoutContainerNode('1fr 1fr'); - $getRoot().append(container); - }, - {discrete: true}, - ); - const htmlOutput = editor.read(() => - $generateHtmlFromNodes(editor, null), - ); - expectHtmlToBeEqual( - htmlOutput, - html` -
      - `, + describe('exportDOM', () => { + it('exports with inline grid-template-columns style', () => { + using editor = buildEditorFromExtensions(LayoutTestExtension); + editor.update( + () => { + const container = $createLayoutContainerNode('1fr 1fr'); + $getRoot().append(container); + }, + {discrete: true}, + ); + const htmlOutput = editor.read(() => + $generateHtmlFromNodes(editor, null), + ); + expectHtmlToBeEqual( + htmlOutput, + html` +
      + `, + ); + }); + }); + + describe('importDOM', () => { + it('imports layout container from inline style', () => { + using editor = buildEditorFromExtensions(LayoutTestExtension); + editor.update( + () => { + $getRoot().clear().select(); + $importHtml( + '
      ', ); - }); + }, + {discrete: true}, + ); + editor.read(() => { + const container = $getRoot().getFirstChild(); + assert( + $isLayoutContainerNode(container), + 'first child must be a LayoutContainerNode', + ); + expect(container.getTemplateColumns()).toBe('1fr 1fr'); }); + }); - describe('importDOM', () => { - it('imports layout container from inline style', async () => { - const {editor} = testEnv; - const parser = new DOMParser(); - const htmlString = - '
      '; - const dom = parser.parseFromString(htmlString, 'text/html'); - await editor.update(() => { - const nodes = $generateNodesFromDOM(editor, dom); - $getRoot().select(); - $insertNodes(nodes); - }); - editor.read(() => { - const root = $getRoot(); - const container = root.getFirstChild(); - expect(container).toBeInstanceOf(LayoutContainerNode); - expect( - (container as LayoutContainerNode).getTemplateColumns(), - ).toBe('1fr 1fr'); - }); - }); - - it('returns null for div without data-lexical-layout-container', async () => { - const {editor} = testEnv; - const parser = new DOMParser(); - const htmlString = - '
      '; - const dom = parser.parseFromString(htmlString, 'text/html'); - await editor.update(() => { - const nodes = $generateNodesFromDOM(editor, dom); - $getRoot().select(); - $insertNodes(nodes); - }); - editor.read(() => { - const root = $getRoot(); - const children = root.getChildren(); - const hasLayoutContainer = children.some( - child => child instanceof LayoutContainerNode, - ); - expect(hasLayoutContainer).toBe(false); - }); - }); + it('does not import a div without data-lexical-layout-container', () => { + using editor = buildEditorFromExtensions(LayoutTestExtension); + editor.update( + () => { + $getRoot().clear().select(); + $importHtml('
      '); + }, + {discrete: true}, + ); + editor.read(() => { + const hasLayoutContainer = $getRoot() + .getChildren() + .some($isLayoutContainerNode); + expect(hasLayoutContainer).toBe(false); }); + }); + }); - describe('export/import round-trip', () => { - it('preserves templateColumns through HTML round-trip', async () => { - const {editor} = testEnv; - const templateColumns = '1fr 1fr'; + describe('export/import round-trip', () => { + it('preserves templateColumns through HTML round-trip', () => { + using editor = buildEditorFromExtensions(LayoutTestExtension); + const templateColumns = '1fr 1fr'; - // Step 1: Create a layout container and export to HTML - editor.update( - () => { - const container = $createLayoutContainerNode(templateColumns); - $getRoot().append(container); - }, - {discrete: true}, - ); - const exportedHtml = editor.read(() => - $generateHtmlFromNodes(editor, null), - ); + // Step 1: Create a layout container and export to HTML + editor.update( + () => { + const container = $createLayoutContainerNode(templateColumns); + $getRoot().append(container); + }, + {discrete: true}, + ); + const exportedHtml = editor.read(() => + $generateHtmlFromNodes(editor, null), + ); - // Step 2: Clear the editor and re-import the exported HTML - const parser = new DOMParser(); - const dom = parser.parseFromString(exportedHtml, 'text/html'); - editor.update( - () => { - $getRoot().clear(); - const nodes = $generateNodesFromDOM(editor, dom); - $getRoot().select(); - $insertNodes(nodes); - }, - {discrete: true}, - ); + // Step 2: Clear the editor and re-import the exported HTML + editor.update( + () => { + $getRoot().clear().select(); + $importHtml(exportedHtml); + }, + {discrete: true}, + ); - // Step 3: Verify the imported node has the correct templateColumns - editor.read(() => { - const root = $getRoot(); - const container = root.getFirstChild(); - expect(container).toBeInstanceOf(LayoutContainerNode); - expect( - (container as LayoutContainerNode).getTemplateColumns(), - ).toBe(templateColumns); - }); - }); + // Step 3: Verify the imported node has the correct templateColumns + editor.read(() => { + const container = $getRoot().getFirstChild(); + assert( + $isLayoutContainerNode(container), + 'first child must be a LayoutContainerNode', + ); + expect(container.getTemplateColumns()).toBe(templateColumns); + }); + }); - it('preserves 3-column layout through HTML round-trip', async () => { - const {editor} = testEnv; - const templateColumns = '1fr 1fr 1fr'; + it('preserves 3-column layout through HTML round-trip', () => { + using editor = buildEditorFromExtensions(LayoutTestExtension); + const templateColumns = '1fr 1fr 1fr'; - editor.update( - () => { - const container = $createLayoutContainerNode(templateColumns); - $getRoot().append(container); - }, - {discrete: true}, - ); - const exportedHtml = editor.read(() => - $generateHtmlFromNodes(editor, null), - ); + editor.update( + () => { + const container = $createLayoutContainerNode(templateColumns); + $getRoot().append(container); + }, + {discrete: true}, + ); + const exportedHtml = editor.read(() => + $generateHtmlFromNodes(editor, null), + ); - const parser = new DOMParser(); - const dom = parser.parseFromString(exportedHtml, 'text/html'); - editor.update( - () => { - $getRoot().clear(); - const nodes = $generateNodesFromDOM(editor, dom); - $getRoot().select(); - $insertNodes(nodes); - }, - {discrete: true}, - ); + editor.update( + () => { + $getRoot().clear().select(); + $importHtml(exportedHtml); + }, + {discrete: true}, + ); - editor.read(() => { - const root = $getRoot(); - const container = root.getFirstChild(); - expect(container).toBeInstanceOf(LayoutContainerNode); - expect( - (container as LayoutContainerNode).getTemplateColumns(), - ).toBe(templateColumns); - }); - }); + editor.read(() => { + const container = $getRoot().getFirstChild(); + assert( + $isLayoutContainerNode(container), + 'first child must be a LayoutContainerNode', + ); + expect(container.getTemplateColumns()).toBe(templateColumns); }); - }, - {nodes: [LayoutContainerNode, LayoutItemNode]}, - ); + }); + }); }); diff --git a/packages/lexical-playground/__tests__/unit/PlaygroundNodeImporters.test.ts b/packages/lexical-playground/__tests__/unit/PlaygroundNodeImporters.test.ts new file mode 100644 index 00000000000..d5cedba508e --- /dev/null +++ b/packages/lexical-playground/__tests__/unit/PlaygroundNodeImporters.test.ts @@ -0,0 +1,220 @@ +/** + * 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 {AnyLexicalExtension, LexicalNode} from 'lexical'; + +import {buildEditorFromExtensions} from '@lexical/extension'; +import { + $generateHtmlFromNodes, + $generateNodesFromDOMViaExtension, +} from '@lexical/html'; +import { + $createParagraphNode, + $getRoot, + $insertNodes, + $isElementNode, + defineExtension, +} from 'lexical'; +import {assert, describe, expect, it} from 'vitest'; + +import { + $createDateTimeNode, + $isDateTimeNode, +} from '../../src/nodes/DateTimeNode/DateTimeNode'; +import { + $createEquationNode, + $isEquationNode, +} from '../../src/nodes/EquationNode'; +import {$createImageNode, $isImageNode} from '../../src/nodes/ImageNode'; +import {$createMentionNode, $isMentionNode} from '../../src/nodes/MentionNode'; +import { + $createPageBreakNode, + $isPageBreakNode, +} from '../../src/nodes/PageBreakNode'; +import {PlaygroundImportExtension} from '../../src/nodes/PlaygroundImportExtension'; +import { + $createPollNode, + $isPollNode, + createPollOption, +} from '../../src/nodes/PollNode'; +import {$createTweetNode, $isTweetNode} from '../../src/nodes/TweetNode'; +import {$createYouTubeNode, $isYouTubeNode} from '../../src/nodes/YouTubeNode'; +import {DateTimeExtension} from '../../src/plugins/DateTimeExtension'; +import {EquationsExtension} from '../../src/plugins/EquationsExtension'; +import {ImagesExtension} from '../../src/plugins/ImagesExtension'; +import {MentionsExtension} from '../../src/plugins/MentionsExtension'; +import {PageBreakExtension} from '../../src/plugins/PageBreakExtension'; +import {PollExtension} from '../../src/plugins/PollExtension'; +import {TwitterExtension} from '../../src/plugins/TwitterExtension'; +import {YouTubeExtension} from '../../src/plugins/YouTubeExtension'; + +// Compose each feature extension with the real playground import pipeline +// (`PlaygroundImportExtension` brings `CoreImportExtension` + every +// per-package importer + the playground style overlay). This mirrors how +// `App.tsx` wires things so the rule-dispatch ordering matches production — +// in particular the per-node `` rules must out-prioritize the core +// inline-format `` rule. +function testExtension(feature: AnyLexicalExtension): AnyLexicalExtension { + return defineExtension({ + $initialEditorState: null, + dependencies: [PlaygroundImportExtension, feature], + name: '[test-importer]', + }); +} + +function $findFirst( + predicate: (node: LexicalNode) => boolean, +): LexicalNode | null { + const stack: LexicalNode[] = [...$getRoot().getChildren()]; + while (stack.length > 0) { + const node = stack.shift()!; + if (predicate(node)) { + return node; + } + if ($isElementNode(node)) { + stack.push(...node.getChildren()); + } + } + return null; +} + +/** + * Build a playground editor, insert `makeNode()`, serialize the whole + * document to HTML, then re-import that HTML through the + * {@link DOMImportExtension} pipeline and run `$assert` on the result. This + * exercises the importer end-to-end against the node's own `exportDOM`, + * covering the `static importDOM` behavior that was migrated to a + * `defineImportRule`. + */ +function expectRoundTrip( + feature: AnyLexicalExtension, + makeNode: () => LexicalNode, + $assert: () => void, +): void { + using editor = buildEditorFromExtensions(testExtension(feature)); + editor.update( + () => { + $getRoot().clear(); + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + paragraph.selectStart(); + $insertNodes([makeNode()]); + }, + {discrete: true}, + ); + const htmlString = editor.read(() => $generateHtmlFromNodes(editor, null)); + editor.update( + () => { + $getRoot().clear().select(); + const dom = new DOMParser().parseFromString(htmlString, 'text/html'); + $insertNodes($generateNodesFromDOMViaExtension(dom)); + }, + {discrete: true}, + ); + editor.read($assert); +} + +describe('Playground node importers (DOMImportExtension round-trip)', () => { + it('ImageNode', () => { + expectRoundTrip( + ImagesExtension, + () => $createImageNode({altText: 'a flower', src: '/test/flower.jpg'}), + () => { + const node = $findFirst($isImageNode); + assert($isImageNode(node), 'expected an ImageNode'); + expect(node.getSrc()).toBe('/test/flower.jpg'); + }, + ); + }); + + it('PollNode', () => { + expectRoundTrip( + PollExtension, + () => $createPollNode('Favorite color?', [createPollOption('Red')]), + () => { + const node = $findFirst($isPollNode); + assert($isPollNode(node), 'expected a PollNode'); + expect(node.getQuestion()).toBe('Favorite color?'); + }, + ); + }); + + it('EquationNode (inline)', () => { + expectRoundTrip( + EquationsExtension, + () => $createEquationNode('x^2', true), + () => { + const node = $findFirst($isEquationNode); + assert($isEquationNode(node), 'expected an EquationNode'); + expect(node.getEquation()).toBe('x^2'); + }, + ); + }); + + it('MentionNode', () => { + expectRoundTrip( + MentionsExtension, + () => $createMentionNode('Luke'), + () => { + const node = $findFirst($isMentionNode); + assert($isMentionNode(node), 'expected a MentionNode'); + expect(node.getTextContent()).toBe('Luke'); + }, + ); + }); + + it('DateTimeNode', () => { + expectRoundTrip( + DateTimeExtension, + () => $createDateTimeNode(new Date('2026-05-29T00:00:00.000Z')), + () => { + const node = $findFirst($isDateTimeNode); + assert($isDateTimeNode(node), 'expected a DateTimeNode'); + }, + ); + }); + + it('PageBreakNode', () => { + expectRoundTrip( + PageBreakExtension, + () => $createPageBreakNode(), + () => { + const node = $findFirst($isPageBreakNode); + assert($isPageBreakNode(node), 'expected a PageBreakNode'); + }, + ); + }); + + it('TweetNode', () => { + expectRoundTrip( + TwitterExtension, + () => $createTweetNode('1234567890'), + () => { + const node = $findFirst($isTweetNode); + assert($isTweetNode(node), 'expected a TweetNode'); + expect(node.getId()).toBe('1234567890'); + }, + ); + }); + + it('YouTubeNode', () => { + expectRoundTrip( + YouTubeExtension, + () => $createYouTubeNode('dQw4w9WgXcQ'), + () => { + const node = $findFirst($isYouTubeNode); + assert($isYouTubeNode(node), 'expected a YouTubeNode'); + expect(node.getId()).toBe('dQw4w9WgXcQ'); + }, + ); + }); + + // NOTE: ExcalidrawNode's importer is exercised by the e2e suite. It can't + // be unit-tested here because importing ExcalidrawExtension pulls in the + // `@excalidraw/excalidraw` UI bundle, which doesn't resolve under jsdom. +}); diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index 4e877b27e2f..9269a892214 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -138,15 +138,56 @@ export async function initialize({ isCollab ? 'split/' : '' }?${urlParams.toString()}`; + // Start listening for uncaught page errors *before* navigating so that a + // failure during the editor's initial build (e.g. a misconfigured or + // conflicting extension set) fails the test fast, instead of silently + // waiting for the editor selector until the long per-test timeout -- which, + // multiplied across retries and every test in a mode, can hang a whole CI + // shard for hours. + const pageError = rejectOnPageError(page); + await page.goto(url); - await exposeLexicalEditor(page); + await exposeLexicalEditor(page, pageError); } /** + * Returns a promise that rejects as soon as the page emits an uncaught + * ("pageerror") exception, so callers can race it against an editor-ready + * wait and fail fast when the app crashes on load instead of timing out. + * Attach this *before* navigating so an error thrown during the initial + * render is not missed. + * * @param {import('@playwright/test').Page} page + * @returns {Promise} */ -async function exposeLexicalEditor(page) { +function rejectOnPageError(page) { + const promise = new Promise((_resolve, reject) => { + page.on('pageerror', error => { + reject( + new Error( + 'The page threw an uncaught error before the editor was ready. ' + + 'This usually means the editor failed to build for this mode ' + + '(check the extension configuration):\n' + + (error.stack || error.message || String(error)), + ), + ); + }); + }); + // The race below may not be attached yet when the error fires (it can throw + // during navigation), so swallow here to avoid an unhandled rejection; the + // race still observes the same rejection when it awaits this promise. + promise.catch(() => {}); + return promise; +} + +/** + * @param {import('@playwright/test').Page} page + * @param {Promise | null} pageError a promise that rejects if the page + * throws an uncaught error, so a broken load fails fast instead of waiting + * for the editor selector until the test timeout. See {@link rejectOnPageError}. + */ +async function exposeLexicalEditor(page, pageError = null) { if (IS_COLLAB) { // The split view loads the playground in two iframes that connect to a // single shared y-websocket server. Under parallel test load one frame @@ -187,7 +228,11 @@ async function exposeLexicalEditor(page) { ); } const leftFrame = getPageOrFrame(page); - await leftFrame.waitForSelector('.tree-view-output pre'); + await Promise.race( + [leftFrame.waitForSelector('.tree-view-output pre'), pageError].filter( + Boolean, + ), + ); await leftFrame.evaluate(() => { window.lexicalEditor = document.querySelector( '[data-lexical-editor="true"]', diff --git a/packages/lexical-playground/src/App.tsx b/packages/lexical-playground/src/App.tsx index 2f1983baf5c..1dee3512298 100644 --- a/packages/lexical-playground/src/App.tsx +++ b/packages/lexical-playground/src/App.tsx @@ -15,6 +15,7 @@ import { DecoratorTextExtension, HorizontalRuleExtension, SelectionAlwaysOnDisplayExtension, + WatchEditableExtension, } from '@lexical/extension'; import {HashtagExtension} from '@lexical/hashtag'; import {HistoryExtension} from '@lexical/history'; @@ -47,14 +48,19 @@ import { import {type JSX, useMemo} from 'react'; import {isDevPlayground} from './appSettings'; -import {buildHTMLConfig} from './buildHTMLConfig'; import {FlashMessageContext} from './context/FlashMessageContext'; import {SettingsContext, useSettings} from './context/SettingsContext'; import {ToolbarContext} from './context/ToolbarContext'; import Editor from './Editor'; +import {registerSettingsSynchronization} from './hooks/useSynchronizeSettings'; import logo from './images/logo.svg'; import {KeywordsExtension} from './nodes/KeywordNode'; +import { + PlaygroundImportExtension, + PlaygroundRichTextImportExtension, +} from './nodes/PlaygroundImportExtension'; import PlaygroundNodes from './nodes/PlaygroundNodes'; +import {PlaygroundDOMRenderExtension} from './PlaygroundDOMRenderExtension'; import {AutocompleteExtension} from './plugins/AutocompleteExtension'; import {PlaygroundAutoLinkExtension} from './plugins/AutoLinkExtension'; import {CodeHighlightExtension} from './plugins/CodeHighlightExtension'; @@ -63,13 +69,18 @@ import {DateTimeExtension} from './plugins/DateTimeExtension'; import DocsPlugin from './plugins/DocsPlugin'; import {DragDropPasteExtension} from './plugins/DragDropPasteExtension'; import {EmojisExtension} from './plugins/EmojisExtension'; +import {EquationsExtension} from './plugins/EquationsExtension'; +import {ExcalidrawExtension} from './plugins/ExcalidrawExtension'; import {FigmaExtension} from './plugins/FigmaExtension'; import {ImagesExtension} from './plugins/ImagesExtension'; +import {LayoutExtension} from './plugins/LayoutExtension/LayoutExtension'; import {PlaygroundMarkdownShortcutsExtension} from './plugins/MarkdownShortcutsExtension'; import {MaxLengthExtension} from './plugins/MaxLengthPlugin'; +import {MentionsExtension} from './plugins/MentionsExtension'; import {PageBreakExtension} from './plugins/PageBreakExtension'; import {PagesReactExtension} from './plugins/PagesReactExtension'; import PasteLogPlugin from './plugins/PasteLogPlugin'; +import {PollExtension} from './plugins/PollExtension'; import {SpecialTextExtension} from './plugins/SpecialTextExtension'; import {TabFocusExtension} from './plugins/TabFocusExtension'; import {TerseExportExtension} from './plugins/TerseExportExtension'; @@ -174,6 +185,10 @@ const PlaygroundRichTextExtension = defineExtension({ code: {arrow: true, click: true, enter: true, onlyAtBoundary: true}, }, }), + // Rich-text-only DOM importers (rich-text/list/table/code/hr); kept out of + // the always-on PlaygroundImportExtension so plain-text mode doesn't pull + // in RichTextExtension (which conflicts with PlainTextExtension). + PlaygroundRichTextImportExtension, ImagesExtension, HorizontalRuleExtension, PageBreakExtension, @@ -188,6 +203,10 @@ const PlaygroundRichTextExtension = defineExtension({ PlaygroundMarkdownShortcutsExtension, PageBreakExtension, PagesReactExtension, + PollExtension, + EquationsExtension, + LayoutExtension, + ExcalidrawExtension, ], name: '@lexical/playground/RichText', }); @@ -197,6 +216,9 @@ const AppExtension = defineExtension({ AutoFocusExtension, ClearEditorExtension, DecoratorTextExtension, + // Exposes editor.isEditable() as a signal; consumed by + // registerSettingsSynchronization to drive ClickableLinkExtension. + WatchEditableExtension, HistoryExtension, KeywordsExtension, HashtagExtension, @@ -205,6 +227,7 @@ const AppExtension = defineExtension({ SpecialTextExtension, DragDropPasteExtension, EmojisExtension, + MentionsExtension, configExtension(LinkExtension, {validateUrl}), PlaygroundAutoLinkExtension, ClickableLinkExtension, @@ -216,8 +239,15 @@ const AppExtension = defineExtension({ }), configExtension(AutocompleteExtension, {disabled: true}), configExtension(VisibleLineBreakExtension, {disabled: true}), + // DOMImportExtension pipeline — `PlaygroundImportExtension` bundles + // the shared `CoreImportExtension` baseline, every per-package + // import extension (rich-text, list, link, table, code, hr), the + // playground-specific inline-style overlay and the + // `ClipboardDOMImportExtension` paste handler. + PlaygroundImportExtension, + // Replaces the legacy `buildHTMLConfig().export` overrides. + PlaygroundDOMRenderExtension, ], - html: buildHTMLConfig(), name: '@lexical/playground', namespace: 'Playground', nodes: PlaygroundNodes, @@ -225,13 +255,29 @@ const AppExtension = defineExtension({ }); /** - * This is not a recommended pattern, extensions should be as static as - * possible, but this is a special case where we build fundamentally - * different editor configurations based on the query string. + * The *only* settings that require tearing down and rebuilding the editor, + * because they change the set of extensions in use (and therefore the initial + * editor state). Building a dynamic extension from settings at all is an + * anti-pattern — extensions should be as static as possible — and is tolerated + * here only because the playground builds fundamentally different editors from + * the query string. + * + * IMPORTANT: Do NOT add a setting here unless changing it genuinely requires a + * different extension graph. Anything a live editor can react to through an + * extension's config signals — table behavior toggles, link attributes, + * character limits, autocomplete, etc. — MUST instead be synced with + * `useSyncExtensionSignal` in `Editor.tsx`. Adding such a setting here forces a + * full editor rebuild (discarding content, selection, and history) on every + * toggle, which is exactly the bug that moving the table settings out of here + * fixed. */ -function buildExtensionFromSettings( - settings: Record<'isCollab' | 'emptyEditor' | 'isRichText', boolean>, -) { +interface DynamicSettings { + isCollab: boolean; + emptyEditor: boolean; + isRichText: boolean; +} + +function buildExtensionFromSettings(settings: DynamicSettings) { const {isCollab, emptyEditor, isRichText} = settings; return defineExtension({ $initialEditorState: isCollab @@ -245,6 +291,10 @@ function buildExtensionFromSettings( isRichText ? PlaygroundRichTextExtension : PlainTextExtension, ], name: '@lexical/playground/dynamic-config', + // Apply INITIAL_SETTINGS to the extension config signals synchronously as + // the editor is built (and wire the editable→clickable-link signal), + // before the React useSynchronizeSettings effect takes over live updates. + register: registerSettingsSynchronization, }); } @@ -253,13 +303,12 @@ function App(): JSX.Element { settings: {isCollab, emptyEditor, isRichText, measureTypingPerf}, } = useSettings(); + // Only the editor-recreating settings belong in this memo's deps. Table + // behavior toggles (and other live-reconfigurable settings) are applied + // reactively in Editor.tsx via useSyncExtensionSignal, so they must NOT + // appear here or they would rebuild the whole editor on every change. const app = useMemo( - () => - buildExtensionFromSettings({ - emptyEditor, - isCollab, - isRichText, - }), + () => buildExtensionFromSettings({emptyEditor, isCollab, isRichText}), [emptyEditor, isCollab, isRichText], ); diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx index 45fd855c204..44d2d70c700 100644 --- a/packages/lexical-playground/src/Editor.tsx +++ b/packages/lexical-playground/src/Editor.tsx @@ -8,16 +8,6 @@ import type {JSX} from 'react'; -import { - SelectionAlwaysOnDisplayExtension, - type Signal, -} from '@lexical/extension'; -import { - ClickableLinkExtension, - LinkAttributes, - LinkExtension, -} from '@lexical/link'; -import {CheckListExtension, ListExtension} from '@lexical/list'; import {CharacterLimitPlugin} from '@lexical/react/LexicalCharacterLimitPlugin'; import { CollaborationPlugin, @@ -25,11 +15,7 @@ import { } from '@lexical/react/LexicalCollaborationPlugin'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {TabIndentationPlugin} from '@lexical/react/LexicalTabIndentationPlugin'; -import {TablePlugin} from '@lexical/react/LexicalTablePlugin'; -import {useOptionalExtensionDependency} from '@lexical/react/useExtensionComponent'; -import {useLexicalEditable} from '@lexical/react/useLexicalEditable'; import {CAN_USE_DOM} from '@lexical/utils'; -import {OutputExtension} from 'lexical'; import {useEffect, useMemo, useState} from 'react'; import {Doc} from 'yjs'; @@ -38,26 +24,20 @@ import { createWebsocketProviderWithDoc, } from './collaboration'; import {useSettings} from './context/SettingsContext'; +import {useSynchronizeSettings} from './hooks/useSynchronizeSettings'; import ActionsPlugin from './plugins/ActionsPlugin'; -import {AutocompleteExtension} from './plugins/AutocompleteExtension'; import AutoEmbedPlugin from './plugins/AutoEmbedPlugin'; import CodeActionMenuPlugin from './plugins/CodeActionMenuPlugin'; -import {CodeHighlightExtension} from './plugins/CodeHighlightExtension'; import CommentPlugin from './plugins/CommentPlugin'; import ComponentPickerPlugin from './plugins/ComponentPickerPlugin'; import ContextMenuPlugin from './plugins/ContextMenuPlugin'; import DraggableBlockPlugin from './plugins/DraggableBlockPlugin'; import EmojiPickerPlugin from './plugins/EmojiPickerPlugin'; -import EquationsPlugin from './plugins/EquationsPlugin'; -import ExcalidrawPlugin from './plugins/ExcalidrawPlugin'; +import {ExcalidrawPlugin} from './plugins/ExcalidrawExtension'; import FloatingLinkEditorPlugin from './plugins/FloatingLinkEditorPlugin'; import FloatingTextFormatToolbarPlugin from './plugins/FloatingTextFormatToolbarPlugin'; -import {LayoutPlugin} from './plugins/LayoutPlugin/LayoutPlugin'; -import {MaxLengthExtension} from './plugins/MaxLengthPlugin'; -import MentionsPlugin from './plugins/MentionsPlugin'; -import PollPlugin from './plugins/PollPlugin'; +import {MentionsPlugin} from './plugins/MentionsExtension'; import ShortcutsPlugin from './plugins/ShortcutsPlugin'; -import {SpecialTextExtension} from './plugins/SpecialTextExtension'; import SpeechToTextPlugin from './plugins/SpeechToTextPlugin'; import TableCellActionMenuPlugin from './plugins/TableActionMenuPlugin'; import TableCellResizer from './plugins/TableCellResizer'; @@ -68,7 +48,6 @@ import TableScrollShadowPlugin from './plugins/TableScrollShadowPlugin'; import ToolbarPlugin from './plugins/ToolbarPlugin'; import TreeViewPlugin from './plugins/TreeViewPlugin'; import {VersionsPlugin} from './plugins/VersionsPlugin'; -import {VisibleLineBreakExtension} from './plugins/VisibleLineBreakExtension'; import ContentEditable from './ui/ContentEditable'; const COLLAB_DOC_ID = 'main'; @@ -77,36 +56,12 @@ const skipCollaborationInit = // @ts-expect-error window.parent != null && window.parent.frames.right === window; -export function useSyncExtensionSignal< - K extends string, - V, - Output extends {[Key in K]: Signal}, ->(extension: OutputExtension, prop: K, value: V) { - const signal = useOptionalExtensionDependency(extension)?.output[prop]; - useEffect(() => { - if (signal) { - // eslint-disable-next-line react-hooks/immutability - signal.value = value; - } - }, [signal, value]); -} - -const DEFAULT_LINK_ATTRIBUTES: LinkAttributes = { - rel: 'noopener noreferrer', - target: '_blank', -}; - export default function Editor(): JSX.Element { const { settings: { - isCodeHighlighted, - isCodeShiki, isCollab, useCollabV2, - isMaxLength, isCharLimit, - hasLinkAttributes, - hasNestedTables, hasFitNestedTables, isCharLimitUtf8, isRichText, @@ -114,18 +69,12 @@ export default function Editor(): JSX.Element { showTableOfContents, shouldUseLexicalContextMenu, shouldPreserveNewLinesInMarkdown, - tableCellMerge, - tableCellBackgroundColor, - tableHorizontalScroll, - shouldAllowHighlightingWithBrackets, - selectionAlwaysOnDisplay, - listStrictIndent, - shouldDisableFocusOnClickChecklist, - isAutocomplete, - isVisibleLineBreak, }, } = useSettings(); - const isEditable = useLexicalEditable(); + // Mirror the settings context onto the editor's reactive extension config + // signals (NOT via App.tsx's DynamicSettings, which would rebuild the + // editor). See the hook for details. + useSynchronizeSettings(); const placeholder = isCollab ? 'Enter some collaborative rich text...' : isRichText @@ -145,41 +94,6 @@ export default function Editor(): JSX.Element { } }; - useSyncExtensionSignal(AutocompleteExtension, 'disabled', !isAutocomplete); - useSyncExtensionSignal( - VisibleLineBreakExtension, - 'disabled', - !isVisibleLineBreak, - ); - useSyncExtensionSignal(MaxLengthExtension, 'disabled', !isMaxLength); - useSyncExtensionSignal( - CodeHighlightExtension, - 'mode', - !isCodeHighlighted ? 'off' : isCodeShiki ? 'shiki' : 'prism', - ); - useSyncExtensionSignal( - SpecialTextExtension, - 'disabled', - !shouldAllowHighlightingWithBrackets, - ); - useSyncExtensionSignal( - LinkExtension, - 'attributes', - hasLinkAttributes ? DEFAULT_LINK_ATTRIBUTES : undefined, - ); - useSyncExtensionSignal(ListExtension, 'hasStrictIndent', listStrictIndent); - useSyncExtensionSignal( - CheckListExtension, - 'disableTakeFocusOnClick', - shouldDisableFocusOnClickChecklist, - ); - useSyncExtensionSignal(ClickableLinkExtension, 'disabled', isEditable); - useSyncExtensionSignal( - SelectionAlwaysOnDisplayExtension, - 'disabled', - !selectionAlwaysOnDisplay, - ); - useEffect(() => { const updateViewPortWidth = () => { const isNextSmallWidthViewport = @@ -251,20 +165,11 @@ export default function Editor(): JSX.Element {
    - {hasFitNestedTables ? : null} - - - {floatingAnchorElem && ( <> ` but ends up wrapping any block-level child, swap the + * `

    ` for a `

    ` (carrying over all attributes) so + * the resulting HTML stays well-formed. + */ +export const PlaygroundDOMRenderExtension = defineExtension({ + dependencies: [ + configExtension(DOMRenderExtension, { + overrides: [ + domOverride([ParagraphNode], { + $exportDOM: (node, $next, editor) => { + const output = $next(); + if ( + !isHTMLElement(output.element) || + output.element.tagName !== 'P' + ) { + return output; + } + const innerAfter = output.after; + return { + ...output, + after: generatedElement => { + if (innerAfter) { + generatedElement = innerAfter(generatedElement); + } + if ( + !isHTMLElement(generatedElement) || + generatedElement.tagName !== 'P' + ) { + return generatedElement; + } + for (const childNode of generatedElement.childNodes) { + if (isBlockDomNode(childNode)) { + const div = document.createElement('div'); + div.setAttribute('role', 'paragraph'); + for (const attr of generatedElement.attributes) { + div.setAttribute(attr.name, attr.value); + } + while (generatedElement.firstChild) { + div.appendChild(generatedElement.firstChild); + } + return div; + } + } + return generatedElement; + }, + }; + }, + }), + ], + }), + ], + name: '@lexical/playground/DOMRender', +}); diff --git a/packages/lexical-playground/src/buildHTMLConfig.tsx b/packages/lexical-playground/src/buildHTMLConfig.tsx deleted file mode 100644 index 4a574044a7d..00000000000 --- a/packages/lexical-playground/src/buildHTMLConfig.tsx +++ /dev/null @@ -1,129 +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 { - $isTextNode, - DOMConversionMap, - DOMExportOutputMap, - HTMLConfig, - isBlockDomNode, - isHTMLElement, - ParagraphNode, - TextNode, -} from 'lexical'; - -import {parseAllowedFontSize} from './plugins/ToolbarPlugin/fontSize'; -import {parseAllowedColor} from './ui/ColorPicker'; - -function getExtraStyles(element: HTMLElement): string { - // Parse styles from pasted input, but only if they match exactly the - // sort of styles that would be produced by exportDOM - let extraStyles = ''; - const fontSize = parseAllowedFontSize(element.style.fontSize); - const backgroundColor = parseAllowedColor(element.style.backgroundColor); - const color = parseAllowedColor(element.style.color); - if (fontSize !== '' && fontSize !== '15px') { - extraStyles += `font-size: ${fontSize};`; - } - if (backgroundColor !== '' && backgroundColor !== 'rgb(255, 255, 255)') { - extraStyles += `background-color: ${backgroundColor};`; - } - if (color !== '' && color !== 'rgb(0, 0, 0)') { - extraStyles += `color: ${color};`; - } - return extraStyles; -} - -function buildImportMap(): DOMConversionMap { - const importMap: DOMConversionMap = {}; - - // Wrap all TextNode importers with a function that also imports - // the custom styles implemented by the playground - 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 - ) { - return output; - } - const extraStyles = getExtraStyles(element); - if (extraStyles) { - const {forChild} = output; - return { - ...output, - forChild: (child, parent) => { - const textNode = forChild(child, parent); - if ($isTextNode(textNode)) { - textNode.setStyle(textNode.getStyle() + extraStyles); - } - return textNode; - }, - }; - } - return output; - }, - }; - }; - } - - return importMap; -} -function buildExportMap(): DOMExportOutputMap { - return new Map([ - [ - ParagraphNode, - (editor, target) => { - const output = target.exportDOM(editor); - if (isHTMLElement(output.element) && output.element.tagName === 'P') { - const after = output.after; - return { - ...output, - after: generatedElement => { - if (after) { - generatedElement = after(generatedElement); - } - if ( - isHTMLElement(generatedElement) && - generatedElement.tagName === 'P' - ) { - for (const childNode of generatedElement.childNodes) { - if (isBlockDomNode(childNode)) { - const div = document.createElement('div'); - div.setAttribute('role', 'paragraph'); - for (const attr of generatedElement.attributes) { - div.setAttribute(attr.name, attr.value); - } - while (generatedElement.firstChild) { - div.appendChild(generatedElement.firstChild); - } - return div; - } - } - } - }, - }; - } - return output; - }, - ], - ]); -} - -export function buildHTMLConfig(): HTMLConfig { - return {export: buildExportMap(), import: buildImportMap()}; -} diff --git a/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts b/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts new file mode 100644 index 00000000000..715bc074fb3 --- /dev/null +++ b/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts @@ -0,0 +1,163 @@ +/** + * 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 {AnyLexicalExtension, LexicalEditor} from 'lexical'; + +import { + batch, + effect, + getExtensionDependencyFromEditor, + getPeerDependencyFromEditor, + SelectionAlwaysOnDisplayExtension, + WatchEditableExtension, +} from '@lexical/extension'; +import { + ClickableLinkExtension, + LinkAttributes, + LinkExtension, +} from '@lexical/link'; +import {CheckListExtension, ListExtension} from '@lexical/list'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {TableExtension} from '@lexical/table'; +import {useEffect} from 'react'; + +import {INITIAL_SETTINGS, type Settings} from '../appSettings'; +import {useSettings} from '../context/SettingsContext'; +import {AutocompleteExtension} from '../plugins/AutocompleteExtension'; +import {CodeHighlightExtension} from '../plugins/CodeHighlightExtension'; +import {MaxLengthExtension} from '../plugins/MaxLengthPlugin'; +import {SpecialTextExtension} from '../plugins/SpecialTextExtension'; +import {VisibleLineBreakExtension} from '../plugins/VisibleLineBreakExtension'; + +const DEFAULT_LINK_ATTRIBUTES: LinkAttributes = { + rel: 'noopener noreferrer', + target: '_blank', +}; + +/** + * Output of an extension that is always part of the playground editor (it + * lives in `AppExtension` or is pulled in by the import pipeline). Presence is + * asserted — a missing one is a wiring bug, not an expected mode difference. + */ +function output( + editor: LexicalEditor, + extension: Extension, +) { + return getExtensionDependencyFromEditor(editor, extension).output; +} + +/** + * Output of an extension that may be absent: the rich-text-only extensions + * (`List`, `Table`, `CheckList`, and the playground `CodeHighlight`) don't + * exist in plain-text mode, so resolve them with the optional (peer) form and + * skip them when not built. + */ +function peerOutput( + editor: LexicalEditor, + extension: Extension, +) { + return getPeerDependencyFromEditor(editor, extension.name)?.output; +} + +/** + * Write the playground's settings onto the live editor's reactive extension + * config signals. These are deliberately kept OUT of App.tsx's DynamicSettings + * (which would rebuild the whole editor); they are applied here without + * recreating it. + * + * A `@preact/signals-core` write is a no-op when the value is unchanged + * (strict `!==`), so this only does work for the signals that actually + * changed, and `batch` coalesces the dependent extension effects (e.g. + * TableExtension's reconcile) into one flush. + * + * Exposed as a plain function so the editor's root extension can run it from + * `register` with {@link INITIAL_SETTINGS} — applying the settings + * synchronously as the editor is built, before any React effect runs. + */ +export function synchronizeSettingsToSignals( + editor: LexicalEditor, + settings: Settings, +): void { + batch(() => { + output(editor, AutocompleteExtension).disabled.value = + !settings.isAutocomplete; + output(editor, VisibleLineBreakExtension).disabled.value = + !settings.isVisibleLineBreak; + output(editor, MaxLengthExtension).disabled.value = !settings.isMaxLength; + const codeHighlight = peerOutput(editor, CodeHighlightExtension); + if (codeHighlight) { + codeHighlight.mode.value = !settings.isCodeHighlighted + ? 'off' + : settings.isCodeShiki + ? 'shiki' + : 'prism'; + } + output(editor, SpecialTextExtension).disabled.value = + !settings.shouldAllowHighlightingWithBrackets; + output(editor, LinkExtension).attributes.value = settings.hasLinkAttributes + ? DEFAULT_LINK_ATTRIBUTES + : undefined; + const list = peerOutput(editor, ListExtension); + if (list) { + list.hasStrictIndent.value = settings.listStrictIndent; + } + const table = peerOutput(editor, TableExtension); + if (table) { + table.hasCellMerge.value = settings.tableCellMerge; + table.hasCellBackgroundColor.value = settings.tableCellBackgroundColor; + table.hasHorizontalScroll.value = + settings.tableHorizontalScroll && !settings.hasFitNestedTables; + table.hasNestedTables.value = settings.hasNestedTables; + } + const checkList = peerOutput(editor, CheckListExtension); + if (checkList) { + checkList.disableTakeFocusOnClick.value = + settings.shouldDisableFocusOnClickChecklist; + } + output(editor, SelectionAlwaysOnDisplayExtension).disabled.value = + !settings.selectionAlwaysOnDisplay; + }); +} + +/** + * Editor-build-time setup for the playground's settings synchronization, + * intended to be returned from the root extension's `register`: + * + * - Applies {@link INITIAL_SETTINGS} synchronously so the editor matches the + * playground defaults from the moment it is created (no first-render flash + * while a React effect catches up). + * - Links the editor's editable state (exposed as a signal by + * {@link WatchEditableExtension}) to {@link ClickableLinkExtension}'s + * `disabled` signal, so clickable links are active only in read-only mode. + * This is editor state rather than a setting, so it is driven by a signals + * `effect` (reactive, no React re-render) instead of `useLexicalEditable`. + */ +export function registerSettingsSynchronization( + editor: LexicalEditor, +): () => void { + synchronizeSettingsToSignals(editor, INITIAL_SETTINGS); + const editable = output(editor, WatchEditableExtension); + const clickableLink = output(editor, ClickableLinkExtension); + return effect(() => { + clickableLink.disabled.value = editable.value; + }); +} + +/** + * React side of {@link synchronizeSettingsToSignals}: re-applies the settings + * context whenever it changes. The initial application happens synchronously + * in {@link registerSettingsSynchronization}; this keeps the live editor in + * sync as the user toggles settings. + */ +export function useSynchronizeSettings(): void { + const [editor] = useLexicalComposerContext(); + const {settings} = useSettings(); + useEffect(() => { + synchronizeSettingsToSignals(editor, settings); + }, [editor, settings]); +} diff --git a/packages/lexical-playground/src/nodes/DateTimeNode/DateTimeNode.tsx b/packages/lexical-playground/src/nodes/DateTimeNode/DateTimeNode.tsx index d3e639fc533..e46271ca59e 100644 --- a/packages/lexical-playground/src/nodes/DateTimeNode/DateTimeNode.tsx +++ b/packages/lexical-playground/src/nodes/DateTimeNode/DateTimeNode.tsx @@ -9,18 +9,14 @@ import type {JSX} from 'react'; import { - applyFormatFromStyle, applyFormatToDom, DecoratorTextNode, SerializedDecoratorTextNode, } from '@lexical/extension'; import { $getState, - $isTextNode, $setState, - buildImportMap, createState, - DOMConversionOutput, DOMExportOutput, LexicalNode, Spread, @@ -62,39 +58,6 @@ export type SerializedDateTimeNode = Spread< SerializedDecoratorTextNode >; -function $convertDateTimeElement( - domNode: HTMLElement, -): DOMConversionOutput | null { - const dateTimeValue = domNode.getAttribute('data-lexical-datetime'); - if (dateTimeValue) { - const node = $createDateTimeNode(new Date(Date.parse(dateTimeValue))); - return { - after: childLexicalNodes => { - // exportDOM returns only one child text, so only the first node of the array is taken - const firstChild = childLexicalNodes[0]; - if ($isTextNode(firstChild)) { - node.setFormat(firstChild.getFormat()); - } - return childLexicalNodes; - }, - node, - }; - } - const gDocsDateTimePayload = domNode.getAttribute('data-rich-links'); - if (!gDocsDateTimePayload) { - return null; - } - const parsed = JSON.parse(gDocsDateTimePayload); - const parsedDate = - parsed?.dat_df?.dfie_ts?.tv?.tv_s * 1000 || - Date.parse(parsed?.dat_df?.dfie_dt || ''); - if (isNaN(parsedDate)) { - return null; - } - const dateTimeNode = $createDateTimeNode(new Date(parsedDate)); - return {node: applyFormatFromStyle(dateTimeNode, domNode.style)}; -} - const dateTimeState = createState('dateTime', { parse: v => new Date(v as string), unparse: v => v.toISOString(), @@ -104,19 +67,6 @@ export class DateTimeNode extends DecoratorTextNode { $config() { return this.config('datetime', { extends: DecoratorTextNode, - importDOM: buildImportMap({ - span: domNode => - domNode.getAttribute('data-lexical-datetime') !== null || - // GDocs Support - (domNode.getAttribute('data-rich-links') !== null && - JSON.parse(domNode.getAttribute('data-rich-links') || '{}').type === - 'date') - ? { - conversion: $convertDateTimeElement, - priority: 2, - } - : null, - }), stateConfigs: [{flat: true, stateConfig: dateTimeState}], }); } diff --git a/packages/lexical-playground/src/nodes/EquationNode.tsx b/packages/lexical-playground/src/nodes/EquationNode.tsx index f0a2223a4ab..38c6cddde03 100644 --- a/packages/lexical-playground/src/nodes/EquationNode.tsx +++ b/packages/lexical-playground/src/nodes/EquationNode.tsx @@ -7,8 +7,6 @@ */ import type { - DOMConversionMap, - DOMConversionOutput, EditorConfig, LexicalNode, NodeKey, @@ -31,21 +29,6 @@ export type SerializedEquationNode = Spread< SerializedLexicalNode >; -function $convertEquationElement( - domNode: HTMLElement, -): null | DOMConversionOutput { - let equation = domNode.getAttribute('data-lexical-equation'); - const inline = domNode.getAttribute('data-lexical-inline') === 'true'; - // Decode the equation from base64 - equation = atob(equation || ''); - if (equation) { - const node = $createEquationNode(equation, inline); - return {node}; - } - - return null; -} - export class EquationNode extends DecoratorNode { __equation: string; __inline: boolean; @@ -109,29 +92,6 @@ export class EquationNode extends DecoratorNode { return {element}; } - static importDOM(): DOMConversionMap | null { - return { - div: (domNode: HTMLElement) => { - if (!domNode.hasAttribute('data-lexical-equation')) { - return null; - } - return { - conversion: $convertEquationElement, - priority: 2, - }; - }, - span: (domNode: HTMLElement) => { - if (!domNode.hasAttribute('data-lexical-equation')) { - return null; - } - return { - conversion: $convertEquationElement, - priority: 1, - }; - }, - }; - } - updateDOM(prevNode: this): boolean { // If the inline property changes, replace the element return this.__inline !== prevNode.__inline; diff --git a/packages/lexical-playground/src/nodes/ExcalidrawNode/index.tsx b/packages/lexical-playground/src/nodes/ExcalidrawNode/index.tsx index 04cf65b827d..fb2e66dd4b4 100644 --- a/packages/lexical-playground/src/nodes/ExcalidrawNode/index.tsx +++ b/packages/lexical-playground/src/nodes/ExcalidrawNode/index.tsx @@ -7,8 +7,6 @@ */ import type { - DOMConversionMap, - DOMConversionOutput, DOMExportOutput, EditorConfig, LexicalEditor, @@ -35,27 +33,6 @@ export type SerializedExcalidrawNode = Spread< SerializedLexicalNode >; -function $convertExcalidrawElement( - domNode: HTMLElement, -): DOMConversionOutput | null { - const excalidrawData = domNode.getAttribute('data-lexical-excalidraw-json'); - const styleAttributes = window.getComputedStyle(domNode); - const heightStr = styleAttributes.getPropertyValue('height'); - const widthStr = styleAttributes.getPropertyValue('width'); - const height = - !heightStr || heightStr === 'inherit' ? 'inherit' : parseInt(heightStr, 10); - const width = - !widthStr || widthStr === 'inherit' ? 'inherit' : parseInt(widthStr, 10); - - if (excalidrawData) { - const node = $createExcalidrawNode(excalidrawData, width, height); - return { - node, - }; - } - return null; -} - export class ExcalidrawNode extends DecoratorNode { __data: string; __width: Dimension; @@ -118,20 +95,6 @@ export class ExcalidrawNode extends DecoratorNode { return false; } - static importDOM(): DOMConversionMap | null { - return { - span: (domNode: HTMLSpanElement) => { - if (!domNode.hasAttribute('data-lexical-excalidraw-json')) { - return null; - } - return { - conversion: $convertExcalidrawElement, - priority: 1, - }; - }, - }; - } - exportDOM(editor: LexicalEditor): DOMExportOutput { const element = document.createElement('span'); diff --git a/packages/lexical-playground/src/nodes/ImageNode.tsx b/packages/lexical-playground/src/nodes/ImageNode.tsx index 7995cdda2ba..5d4bbeb9e57 100644 --- a/packages/lexical-playground/src/nodes/ImageNode.tsx +++ b/packages/lexical-playground/src/nodes/ImageNode.tsx @@ -7,8 +7,6 @@ */ import type { - DOMConversionMap, - DOMConversionOutput, DOMExportOutput, EditorConfig, LexicalEditorWithDispose, @@ -28,7 +26,7 @@ import { } from '@lexical/extension'; import {HashtagExtension} from '@lexical/hashtag'; import {HistoryExtension} from '@lexical/history'; -import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; +import {$generateHtmlFromNodes} from '@lexical/html'; import {LinkExtension} from '@lexical/link'; import {ReactExtension} from '@lexical/react/ReactExtension'; import {ReactProviderExtension} from '@lexical/react/ReactProviderExtension'; @@ -41,17 +39,14 @@ import { $getRoot, $isElementNode, $isParagraphNode, - $selectAll, - $setSelection, configExtension, DecoratorNode, defineExtension, - SKIP_DOM_SELECTION_TAG, } from 'lexical'; import * as React from 'react'; import {EmojisExtension} from '../plugins/EmojisExtension'; -import MentionsPlugin from '../plugins/MentionsPlugin'; +import {MentionsPlugin} from '../plugins/MentionsExtension'; import ContentEditable from '../ui/ContentEditable'; import {EmojiNode} from './EmojiNode'; import {KeywordsExtension} from './KeywordNode'; @@ -105,26 +100,6 @@ export interface ImagePayload { captionsEnabled?: boolean; } -function isGoogleDocCheckboxImg(img: HTMLImageElement): boolean { - return ( - img.parentElement != null && - img.parentElement.tagName === 'LI' && - img.previousSibling === null && - img.getAttribute('aria-roledescription') === 'checkbox' - ); -} - -function $convertImageElement(domNode: Node): null | DOMConversionOutput { - const img = domNode as HTMLImageElement; - const src = img.getAttribute('src'); - if (!src || src.startsWith('file:///') || isGoogleDocCheckboxImg(img)) { - return null; - } - const {alt: altText, width, height} = img; - const node = $createImageNode({altText, height, src, width}); - return {node}; -} - export function $isCaptionEditorEmpty(): boolean { // Search the document for any non-element node // to determine if it's empty or not @@ -249,46 +224,6 @@ export class ImageNode extends DecoratorNode { return {element: imgElement}; } - static importDOM(): DOMConversionMap | null { - return { - figcaption: () => ({ - conversion: () => ({node: null}), - priority: 0, - }), - figure: () => ({ - conversion: node => { - return { - after: childNodes => { - const imageNodes = childNodes.filter($isImageNode); - const figcaption = node.querySelector('figcaption'); - if (figcaption) { - for (const imgNode of imageNodes) { - imgNode.setShowCaption(true); - imgNode.__caption.update( - () => { - $selectAll().insertNodes( - $generateNodesFromDOM(imgNode.__caption, figcaption), - ); - $setSelection(null); - }, - {tag: SKIP_DOM_SELECTION_TAG}, - ); - } - } - return imageNodes; - }, - node: null, - }; - }, - priority: 0, - }), - img: () => ({ - conversion: $convertImageElement, - priority: 0, - }), - }; - } - constructor( src: string, altText: string, diff --git a/packages/lexical-playground/src/nodes/LayoutContainerNode.ts b/packages/lexical-playground/src/nodes/LayoutContainerNode.ts index 66cde55657f..3705894c690 100644 --- a/packages/lexical-playground/src/nodes/LayoutContainerNode.ts +++ b/packages/lexical-playground/src/nodes/LayoutContainerNode.ts @@ -7,8 +7,6 @@ */ import type { - DOMConversionMap, - DOMConversionOutput, DOMExportOutput, EditorConfig, LexicalNode, @@ -28,17 +26,6 @@ export type SerializedLayoutContainerNode = Spread< SerializedElementNode >; -function $convertLayoutContainerElement( - domNode: HTMLElement, -): DOMConversionOutput | null { - const templateColumns = domNode.style.gridTemplateColumns; - if (templateColumns) { - const node = $createLayoutContainerNode(templateColumns); - return {node}; - } - return null; -} - export class LayoutContainerNode extends ElementNode { __templateColumns: string; @@ -78,20 +65,6 @@ export class LayoutContainerNode extends ElementNode { return false; } - static importDOM(): DOMConversionMap | null { - return { - div: (domNode: HTMLElement) => { - if (!domNode.hasAttribute('data-lexical-layout-container')) { - return null; - } - return { - conversion: $convertLayoutContainerElement, - priority: 2, - }; - }, - }; - } - static importJSON(json: SerializedLayoutContainerNode): LayoutContainerNode { return $createLayoutContainerNode().updateFromJSON(json); } diff --git a/packages/lexical-playground/src/nodes/LayoutItemNode.ts b/packages/lexical-playground/src/nodes/LayoutItemNode.ts index b22deb153f4..8ce536dcf82 100644 --- a/packages/lexical-playground/src/nodes/LayoutItemNode.ts +++ b/packages/lexical-playground/src/nodes/LayoutItemNode.ts @@ -6,23 +6,13 @@ * */ -import type { - DOMConversionMap, - DOMConversionOutput, - EditorConfig, - LexicalNode, - SerializedElementNode, -} from 'lexical'; +import type {EditorConfig, LexicalNode, SerializedElementNode} from 'lexical'; import {addClassNamesToElement} from '@lexical/utils'; import {$isParagraphNode, ElementNode} from 'lexical'; export type SerializedLayoutItemNode = SerializedElementNode; -function $convertLayoutItemElement(): DOMConversionOutput | null { - return {node: $createLayoutItemNode()}; -} - export function $isEmptyLayoutItemNode(node: LexicalNode): boolean { if (!$isLayoutItemNode(node) || node.getChildrenSize() !== 1) { return false; @@ -65,20 +55,6 @@ export class LayoutItemNode extends ElementNode { return false; } - static importDOM(): DOMConversionMap | null { - return { - div: (domNode: HTMLElement) => { - if (!domNode.hasAttribute('data-lexical-layout-item')) { - return null; - } - return { - conversion: $convertLayoutItemElement, - priority: 2, - }; - }, - }; - } - static importJSON(serializedNode: SerializedLayoutItemNode): LayoutItemNode { return $createLayoutItemNode().updateFromJSON(serializedNode); } diff --git a/packages/lexical-playground/src/nodes/MentionNode.ts b/packages/lexical-playground/src/nodes/MentionNode.ts index 47e02161317..61b4a3aeb6c 100644 --- a/packages/lexical-playground/src/nodes/MentionNode.ts +++ b/packages/lexical-playground/src/nodes/MentionNode.ts @@ -8,8 +8,6 @@ import { $applyNodeReplacement, - type DOMConversionMap, - type DOMConversionOutput, type DOMExportOutput, type EditorConfig, type LexicalNode, @@ -26,25 +24,6 @@ export type SerializedMentionNode = Spread< SerializedTextNode >; -function $convertMentionElement( - domNode: HTMLElement, -): DOMConversionOutput | null { - const textContent = domNode.textContent; - const mentionName = domNode.getAttribute('data-lexical-mention-name'); - - if (textContent !== null) { - const node = $createMentionNode( - typeof mentionName === 'string' ? mentionName : textContent, - textContent, - ); - return { - node, - }; - } - - return null; -} - const mentionBackgroundColor = 'rgba(24, 119, 232, 0.2)'; export class MentionNode extends TextNode { __mention: string; @@ -93,20 +72,6 @@ export class MentionNode extends TextNode { return {element}; } - static importDOM(): DOMConversionMap | null { - return { - span: (domNode: HTMLElement) => { - if (!domNode.hasAttribute('data-lexical-mention')) { - return null; - } - return { - conversion: $convertMentionElement, - priority: 1, - }; - }, - }; - } - isTextEntity(): true { return true; } diff --git a/packages/lexical-playground/src/nodes/PageBreakNode/index.tsx b/packages/lexical-playground/src/nodes/PageBreakNode/index.tsx index 61f1f116c10..54257fd380e 100644 --- a/packages/lexical-playground/src/nodes/PageBreakNode/index.tsx +++ b/packages/lexical-playground/src/nodes/PageBreakNode/index.tsx @@ -15,11 +15,8 @@ import {useLexicalNodeSelection} from '@lexical/react/useLexicalNodeSelection'; import {mergeRegister} from '@lexical/utils'; import { CLICK_COMMAND, - COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, DecoratorNode, - DOMConversionMap, - DOMConversionOutput, LexicalNode, NodeKey, SerializedLexicalNode, @@ -79,22 +76,6 @@ export class PageBreakNode extends DecoratorNode { return $createPageBreakNode().updateFromJSON(serializedNode); } - static importDOM(): DOMConversionMap | null { - return { - figure: (domNode: HTMLElement) => { - const tp = domNode.getAttribute('type'); - if (tp !== this.getType()) { - return null; - } - - return { - conversion: $convertPageBreakElement, - priority: COMMAND_PRIORITY_HIGH, - }; - }, - }; - } - createDOM(): HTMLElement { const el = document.createElement('figure'); el.style.pageBreakAfter = 'always'; @@ -119,10 +100,6 @@ export class PageBreakNode extends DecoratorNode { } } -function $convertPageBreakElement(): DOMConversionOutput { - return {node: $createPageBreakNode()}; -} - export function $createPageBreakNode(): PageBreakNode { return new PageBreakNode(); } diff --git a/packages/lexical-playground/src/nodes/PlaygroundImportExtension.ts b/packages/lexical-playground/src/nodes/PlaygroundImportExtension.ts new file mode 100644 index 00000000000..872881211f7 --- /dev/null +++ b/packages/lexical-playground/src/nodes/PlaygroundImportExtension.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 type {LexicalNode} from 'lexical'; + +import {ClipboardDOMImportExtension} from '@lexical/clipboard'; +import {CodeImportExtension} from '@lexical/code-core'; +import { + CoreImportExtension, + defineImportRule, + DOMImportExtension, + HorizontalRuleImportExtension, + sel, +} from '@lexical/html'; +import {LinkImportExtension} from '@lexical/link'; +import {ListImportExtension} from '@lexical/list'; +import {RichTextImportExtension} from '@lexical/rich-text'; +import {TableImportExtension} from '@lexical/table'; +import { + $isElementNode, + $isTextNode, + configExtension, + defineExtension, +} from 'lexical'; + +import {parseAllowedFontSize} from '../plugins/ToolbarPlugin/fontSize'; +import {parseAllowedColor} from '../ui/ColorPicker'; + +function getPlaygroundExtraStyles(element: HTMLElement): string { + // Parse styles from pasted input, but only if they match exactly the + // sort of styles that would be produced by exportDOM + let extraStyles = ''; + const fontSize = parseAllowedFontSize(element.style.fontSize); + const backgroundColor = parseAllowedColor(element.style.backgroundColor); + const color = parseAllowedColor(element.style.color); + if (fontSize !== '' && fontSize !== '15px') { + extraStyles += `font-size: ${fontSize};`; + } + if (backgroundColor !== '' && backgroundColor !== 'rgb(255, 255, 255)') { + extraStyles += `background-color: ${backgroundColor};`; + } + if (color !== '' && color !== 'rgb(0, 0, 0)') { + extraStyles += `color: ${color};`; + } + return extraStyles; +} + +function $appendStyleToTextDescendants( + node: LexicalNode, + extraStyle: string, +): void { + if ($isTextNode(node)) { + node.setStyle(node.getStyle() + extraStyle); + } else if ($isElementNode(node)) { + for (const child of node.getChildren()) { + $appendStyleToTextDescendants(child, extraStyle); + } + } +} + +/** + * Mirrors the legacy `buildHTMLConfig`-time wrapping of TextNode importers: + * for any inline-format element (b/code/em/i/mark/s/span/strong/sub/sup/u), + * if the element carries playground-allowed inline styles + * (`font-size`, `background-color`, `color`), append those to the inline + * style of every TextNode descendant produced by the lower-priority + * `InlineFormatRule`. + */ +const PlaygroundInlineStyleRule = defineImportRule({ + $import: (_ctx, el, $next) => { + const extraStyle = getPlaygroundExtraStyles(el); + const result = $next(); + if (extraStyle) { + for (const node of result) { + $appendStyleToTextDescendants(node, extraStyle); + } + } + return result; + }, + match: sel.tag( + 'b', + 'code', + 'em', + 'i', + 'mark', + 's', + 'span', + 'strong', + 'sub', + 'sup', + 'u', + ), + name: '@lexical/playground/inline-extra-styles', +}); + +/** + * Aggregate of every playground-specific DOM import rule, ordered so the + * more-specific selectors win dispatch over the generic ones (rule at + * index 0 has the highest priority). + */ +export const PlaygroundImportRules = [PlaygroundInlineStyleRule]; + +/** + * Plain-text-safe DOM-import baseline, added in `AppExtension` so it applies in + * every editor mode: + * + * - {@link CoreImportExtension} (paragraphs, text, line breaks, generic + * block/inline handling) + * - {@link LinkImportExtension} (`LinkExtension` is always in the playground) + * - {@link ClipboardDOMImportExtension} so pastes flow through the pipeline + * - the playground-specific {@link PlaygroundImportRules} overlay + * + * The rich-text-only importers (rich-text, list, table, code, horizontal-rule) + * live in {@link PlaygroundRichTextImportExtension} instead: they pull node + * extensions that must not exist in plain-text mode — notably + * `RichTextExtension`, which *conflicts* with `PlainTextExtension`. Keeping the + * importer set aligned with the node set per mode avoids that conflict. + */ +export const PlaygroundImportExtension = defineExtension({ + dependencies: [ + CoreImportExtension, + LinkImportExtension, + ClipboardDOMImportExtension, + configExtension(DOMImportExtension, {rules: PlaygroundImportRules}), + ], + name: '@lexical/playground/Import', +}); + +/** + * The rich-text-only per-package importers, mirroring the rich-text node set. + * Added to `PlaygroundRichTextExtension` (not the always-on + * {@link PlaygroundImportExtension}) so plain-text editors never pull in + * `RichText`/`List`/`Table`/`Code`/`HorizontalRule`. + */ +export const PlaygroundRichTextImportExtension = defineExtension({ + dependencies: [ + RichTextImportExtension, + ListImportExtension, + TableImportExtension, + CodeImportExtension, + HorizontalRuleImportExtension, + ], + name: '@lexical/playground/RichTextImport', +}); diff --git a/packages/lexical-playground/src/nodes/PollNode.tsx b/packages/lexical-playground/src/nodes/PollNode.tsx index a015c7d1c2c..917d697c029 100644 --- a/packages/lexical-playground/src/nodes/PollNode.tsx +++ b/packages/lexical-playground/src/nodes/PollNode.tsx @@ -11,10 +11,8 @@ import type {JSX} from 'react'; import { $getState, $setState, - buildImportMap, createState, DecoratorNode, - DOMConversionOutput, DOMExportOutput, LexicalNode, SerializedLexicalNode, @@ -69,18 +67,6 @@ export type SerializedPollNode = Spread< SerializedLexicalNode >; -function $convertPollElement( - domNode: HTMLSpanElement, -): DOMConversionOutput | null { - const question = domNode.getAttribute('data-lexical-poll-question'); - const options = domNode.getAttribute('data-lexical-poll-options'); - if (question !== null && options !== null) { - const node = $createPollNode(question, JSON.parse(options)); - return {node}; - } - return null; -} - function parseOptions(json: unknown): Options { const options = []; if (Array.isArray(json)) { @@ -112,15 +98,6 @@ export class PollNode extends DecoratorNode { $config() { return this.config('poll', { extends: DecoratorNode, - importDOM: buildImportMap({ - span: domNode => - domNode.getAttribute('data-lexical-poll-question') !== null - ? { - conversion: $convertPollElement, - priority: 2, - } - : null, - }), stateConfigs: [ {flat: true, stateConfig: questionState}, {flat: true, stateConfig: optionsState}, diff --git a/packages/lexical-playground/src/nodes/TweetNode.tsx b/packages/lexical-playground/src/nodes/TweetNode.tsx index 5cf5fc8eaa8..cc132e76720 100644 --- a/packages/lexical-playground/src/nodes/TweetNode.tsx +++ b/packages/lexical-playground/src/nodes/TweetNode.tsx @@ -7,8 +7,6 @@ */ import type { - DOMConversionMap, - DOMConversionOutput, DOMExportOutput, EditorConfig, ElementFormatType, @@ -42,17 +40,6 @@ type TweetComponentProps = Readonly<{ tweetID: string; }>; -function $convertTweetElement( - domNode: HTMLDivElement, -): DOMConversionOutput | null { - const id = domNode.getAttribute('data-lexical-tweet-id'); - if (id) { - const node = $createTweetNode(id); - return {node}; - } - return null; -} - let isTwitterScriptLoading = true; function TweetComponent({ @@ -154,20 +141,6 @@ export class TweetNode extends DecoratorBlockNode { }; } - static importDOM(): DOMConversionMap | null { - return { - div: (domNode: HTMLDivElement) => { - if (!domNode.hasAttribute('data-lexical-tweet-id')) { - return null; - } - return { - conversion: $convertTweetElement, - priority: 2, - }; - }, - }; - } - exportDOM(): DOMExportOutput { const element = document.createElement('div'); element.setAttribute('data-lexical-tweet-id', this.__id); diff --git a/packages/lexical-playground/src/nodes/YouTubeNode.tsx b/packages/lexical-playground/src/nodes/YouTubeNode.tsx index 712bb2908ca..1f9dbd5a201 100644 --- a/packages/lexical-playground/src/nodes/YouTubeNode.tsx +++ b/packages/lexical-playground/src/nodes/YouTubeNode.tsx @@ -7,8 +7,6 @@ */ import type { - DOMConversionMap, - DOMConversionOutput, DOMExportOutput, EditorConfig, ElementFormatType, @@ -67,17 +65,6 @@ export type SerializedYouTubeNode = Spread< SerializedDecoratorBlockNode >; -function $convertYoutubeElement( - domNode: HTMLElement, -): null | DOMConversionOutput { - const videoID = domNode.getAttribute('data-lexical-youtube'); - if (videoID) { - const node = $createYouTubeNode(videoID); - return {node}; - } - return null; -} - export class YouTubeNode extends DecoratorBlockNode { __id: string; @@ -126,20 +113,6 @@ export class YouTubeNode extends DecoratorBlockNode { return {element}; } - static importDOM(): DOMConversionMap | null { - return { - iframe: (domNode: HTMLElement) => { - if (!domNode.hasAttribute('data-lexical-youtube')) { - return null; - } - return { - conversion: $convertYoutubeElement, - priority: 1, - }; - }, - }; - } - updateDOM(): false { return false; } diff --git a/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx b/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx index edb14575548..dc0a686c85a 100644 --- a/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx @@ -20,7 +20,7 @@ import { } from '@lexical/file'; import { $generateHtmlFromNodes, - $generateNodesFromDOM, + $generateNodesFromDOMViaExtension, $withRenderContext, contextValue, } from '@lexical/html'; @@ -168,7 +168,7 @@ export default function ActionsPlugin({ const parser = new DOMParser(); const content = root.getTextContent(); const dom = parser.parseFromString(content, 'text/html'); - const nodes = $generateNodesFromDOM(editor, dom); + const nodes = $generateNodesFromDOMViaExtension(dom); root.clear().select(); $insertNodes(nodes); if (root.isEmpty()) { diff --git a/packages/lexical-playground/src/plugins/CollapsibleExtension/CollapsibleContainerNode.ts b/packages/lexical-playground/src/plugins/CollapsibleExtension/CollapsibleContainerNode.ts index 3719740d211..1625bf36f5a 100644 --- a/packages/lexical-playground/src/plugins/CollapsibleExtension/CollapsibleContainerNode.ts +++ b/packages/lexical-playground/src/plugins/CollapsibleExtension/CollapsibleContainerNode.ts @@ -8,13 +8,9 @@ import {IS_CHROME, IS_FIREFOX} from '@lexical/utils'; import { - $createParagraphNode, $getSiblingCaret, - $isBlockElementNode, $isElementNode, $rewindSiblingCaret, - DOMConversionMap, - DOMConversionOutput, DOMExportOutput, EditorConfig, ElementNode, @@ -27,16 +23,6 @@ import { Spread, } from 'lexical'; -import { - $createCollapsibleContentNode, - $isCollapsibleContentNode, - CollapsibleContentNode, -} from './CollapsibleContentNode'; -import { - $createCollapsibleTitleNode, - $isCollapsibleTitleNode, - CollapsibleTitleNode, -} from './CollapsibleTitleNode'; import {setDomHiddenUntilFound} from './CollapsibleUtils'; type SerializedCollapsibleContainerNode = Spread< @@ -46,74 +32,6 @@ type SerializedCollapsibleContainerNode = Spread< SerializedElementNode >; -export function $convertDetailsElement( - domNode: HTMLDetailsElement, -): DOMConversionOutput | null { - const isOpen = domNode.open !== undefined ? domNode.open : true; - const node = $createCollapsibleContainerNode(isOpen); - return { - after: childLexicalNodes => { - // CollapsibleContainerNode is a shadow root that requires exactly two - // children: a CollapsibleTitleNode (from ) followed by a - // CollapsibleContentNode. Arbitrary
    markup may include loose - // text or block siblings; reshape the imported children into the - // expected structure so the editor doesn't end up with TextNodes - // directly under the shadow root. - let titleNode: CollapsibleTitleNode | null = null; - let contentNode: CollapsibleContentNode | null = null; - const bodyNodes: LexicalNode[] = []; - for (const child of childLexicalNodes) { - if (titleNode === null && $isCollapsibleTitleNode(child)) { - titleNode = child; - } else if ($isCollapsibleContentNode(child)) { - if (contentNode === null) { - // Lexical-exported markup wraps the body in a - // CollapsibleContentNode; reuse it instead of rebuilding. - contentNode = child; - } else { - // Multiple content nodes (rare): fold the extras into bodyNodes - // so they get appended to the canonical one below. - for (const grandchild of child.getChildren()) { - bodyNodes.push(grandchild); - } - } - } else { - bodyNodes.push(child); - } - } - if (titleNode === null) { - titleNode = $createCollapsibleTitleNode(); - } - if (contentNode === null) { - contentNode = $createCollapsibleContentNode(); - } - // CollapsibleContentNode is also a shadow root, so wrap any inline - // siblings in a paragraph before appending. - let pending: LexicalNode[] = []; - const flushPending = () => { - if (pending.length === 0) { - return; - } - const paragraph = $createParagraphNode(); - paragraph.append(...pending); - contentNode.append(paragraph); - pending = []; - }; - for (const body of bodyNodes) { - if ($isBlockElementNode(body)) { - flushPending(); - contentNode.append(body); - } else { - pending.push(body); - } - } - flushPending(); - return [titleNode, contentNode]; - }, - node, - }; -} - export class CollapsibleContainerNode extends ElementNode { __open: boolean; @@ -206,17 +124,6 @@ export class CollapsibleContainerNode extends ElementNode { return false; } - static importDOM(): DOMConversionMap | null { - return { - details: (domNode: HTMLDetailsElement) => { - return { - conversion: $convertDetailsElement, - priority: 1, - }; - }, - }; - } - static importJSON( serializedNode: SerializedCollapsibleContainerNode, ): CollapsibleContainerNode { diff --git a/packages/lexical-playground/src/plugins/CollapsibleExtension/CollapsibleContentNode.ts b/packages/lexical-playground/src/plugins/CollapsibleExtension/CollapsibleContentNode.ts index dcdc9657c8a..335ebcb8019 100644 --- a/packages/lexical-playground/src/plugins/CollapsibleExtension/CollapsibleContentNode.ts +++ b/packages/lexical-playground/src/plugins/CollapsibleExtension/CollapsibleContentNode.ts @@ -8,8 +8,6 @@ import {IS_CHROME, IS_FIREFOX} from '@lexical/utils'; import { - DOMConversionMap, - DOMConversionOutput, DOMExportOutput, EditorConfig, ElementNode, @@ -23,15 +21,6 @@ import {domOnBeforeMatch, setDomHiddenUntilFound} from './CollapsibleUtils'; type SerializedCollapsibleContentNode = SerializedElementNode; -export function $convertCollapsibleContentElement( - domNode: HTMLElement, -): DOMConversionOutput | null { - const node = $createCollapsibleContentNode(); - return { - node, - }; -} - export class CollapsibleContentNode extends ElementNode { static getType(): string { return 'collapsible-content'; @@ -77,20 +66,6 @@ export class CollapsibleContentNode extends ElementNode { return false; } - static importDOM(): DOMConversionMap | null { - return { - div: (domNode: HTMLElement) => { - if (!domNode.hasAttribute('data-lexical-collapsible-content')) { - return null; - } - return { - conversion: $convertCollapsibleContentElement, - priority: 2, - }; - }, - }; - } - exportDOM(): DOMExportOutput { const element = document.createElement('div'); element.classList.add('Collapsible__content'); diff --git a/packages/lexical-playground/src/plugins/CollapsibleExtension/CollapsibleTitleNode.ts b/packages/lexical-playground/src/plugins/CollapsibleExtension/CollapsibleTitleNode.ts index a3265e1f293..a5d4756d650 100644 --- a/packages/lexical-playground/src/plugins/CollapsibleExtension/CollapsibleTitleNode.ts +++ b/packages/lexical-playground/src/plugins/CollapsibleExtension/CollapsibleTitleNode.ts @@ -10,8 +10,6 @@ import {IS_CHROME, IS_FIREFOX} from '@lexical/utils'; import { $createParagraphNode, $isElementNode, - buildImportMap, - DOMConversionOutput, EditorConfig, ElementNode, LexicalEditor, @@ -22,15 +20,6 @@ import { import {$isCollapsibleContainerNode} from './CollapsibleContainerNode'; import {$isCollapsibleContentNode} from './CollapsibleContentNode'; -export function $convertSummaryElement( - domNode: HTMLElement, -): DOMConversionOutput | null { - const node = $createCollapsibleTitleNode(); - return { - node, - }; -} - /** @noInheritDoc */ export class CollapsibleTitleNode extends ElementNode { /** @internal */ @@ -42,12 +31,6 @@ export class CollapsibleTitleNode extends ElementNode { } }, extends: ElementNode, - importDOM: buildImportMap({ - summary: () => ({ - conversion: $convertSummaryElement, - priority: 1, - }), - }), }); } diff --git a/packages/lexical-playground/src/plugins/CollapsibleExtension/index.ts b/packages/lexical-playground/src/plugins/CollapsibleExtension/index.ts index bee79b1b3b8..bf853a8f5f5 100644 --- a/packages/lexical-playground/src/plugins/CollapsibleExtension/index.ts +++ b/packages/lexical-playground/src/plugins/CollapsibleExtension/index.ts @@ -8,6 +8,12 @@ import './Collapsible.css'; +import { + BlockSchema, + defineImportRule, + DOMImportExtension, + sel, +} from '@lexical/html'; import { $findMatchingParent, $insertNodeToNearestRoot, @@ -21,6 +27,7 @@ import { $isRangeSelection, $isTextNode, COMMAND_PRIORITY_LOW, + configExtension, createCommand, defineExtension, ElementNode, @@ -29,6 +36,7 @@ import { KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, + type LexicalNode, } from 'lexical'; import { @@ -47,6 +55,77 @@ import { CollapsibleTitleNode, } from './CollapsibleTitleNode'; +const SummaryRule = defineImportRule({ + $import: (ctx, el) => [ + $createCollapsibleTitleNode().splice(0, 0, ctx.$importChildren(el)), + ], + match: sel.tag('summary'), + name: '@lexical/playground/summary', +}); + +const CollapsibleContentRule = defineImportRule({ + $import: (ctx, el) => [ + $createCollapsibleContentNode().splice( + 0, + 0, + ctx.$importChildren(el, {schema: BlockSchema}), + ), + ], + match: sel.tag('div').attr('data-lexical-collapsible-content', true), + name: '@lexical/playground/collapsible-content', +}); + +const DetailsRule = defineImportRule({ + $import: (ctx, el) => { + let titleNode: CollapsibleTitleNode | null = null; + // BlockSchema wraps inline runs in paragraphs, and `$onChild` siphons + // the synthesized CollapsibleTitleNode out before it ever reaches the + // ContentNode below. CollapsibleContentNode is itself block-level so + // BlockSchema leaves it intact in `bodyNodes`. + const bodyNodes = ctx.$importChildren(el, { + $onChild: child => { + if (titleNode === null && $isCollapsibleTitleNode(child)) { + titleNode = child; + return null; + } + return child; + }, + schema: BlockSchema, + }); + let contentNode: CollapsibleContentNode | null = null; + const restBody: LexicalNode[] = []; + for (const child of bodyNodes) { + if ($isCollapsibleContentNode(child)) { + if (contentNode === null) { + contentNode = child; + } else { + // Multiple content nodes (rare): fold the extras into restBody so + // they get appended to the canonical one below. + for (const grand of child.getChildren()) { + restBody.push(grand); + } + } + } else { + restBody.push(child); + } + } + if (titleNode === null) { + titleNode = $createCollapsibleTitleNode(); + } + if (contentNode === null) { + contentNode = $createCollapsibleContentNode(); + } + for (const node of restBody) { + contentNode.append(node); + } + return [ + $createCollapsibleContainerNode(el.open).append(titleNode, contentNode), + ]; + }, + match: sel.tag('details'), + name: '@lexical/playground/details', +}); + export const INSERT_COLLAPSIBLE_COMMAND = createCommand( 'INSERT_COLLAPSIBLE_COMMAND', ); @@ -138,6 +217,11 @@ const $wrapInlineContentChildren = (node: CollapsibleContentNode) => { }; export const CollapsibleExtension = defineExtension({ + dependencies: [ + configExtension(DOMImportExtension, { + rules: [DetailsRule, SummaryRule, CollapsibleContentRule], + }), + ], name: '@lexical/playground/Collapsible', nodes: [ CollapsibleContainerNode, diff --git a/packages/lexical-playground/src/plugins/ComponentPickerPlugin/index.tsx b/packages/lexical-playground/src/plugins/ComponentPickerPlugin/index.tsx index a267c448395..dfe182cd201 100644 --- a/packages/lexical-playground/src/plugins/ComponentPickerPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ComponentPickerPlugin/index.tsx @@ -40,12 +40,12 @@ import catTypingGif from '../../images/cat-typing.gif'; import {EmbedConfigs} from '../AutoEmbedPlugin'; import {INSERT_COLLAPSIBLE_COMMAND} from '../CollapsibleExtension'; import {INSERT_DATETIME_COMMAND} from '../DateTimeExtension'; -import {InsertEquationDialog} from '../EquationsPlugin'; -import {INSERT_EXCALIDRAW_COMMAND} from '../ExcalidrawPlugin'; +import {InsertEquationDialog} from '../EquationsExtension'; +import {INSERT_EXCALIDRAW_COMMAND} from '../ExcalidrawExtension'; import {INSERT_IMAGE_COMMAND, InsertImageDialog} from '../ImagesExtension'; -import InsertLayoutDialog from '../LayoutPlugin/InsertLayoutDialog'; +import InsertLayoutDialog from '../LayoutExtension/InsertLayoutDialog'; import {INSERT_PAGE_BREAK} from '../PageBreakExtension'; -import {InsertPollDialog} from '../PollPlugin'; +import {InsertPollDialog} from '../PollExtension'; import {InsertTableDialog} from '../TablePlugin'; export class ComponentPickerOption extends MenuOption { diff --git a/packages/lexical-playground/src/plugins/DateTimeExtension/index.tsx b/packages/lexical-playground/src/plugins/DateTimeExtension/index.tsx index a9937b32d88..0b839db33b3 100644 --- a/packages/lexical-playground/src/plugins/DateTimeExtension/index.tsx +++ b/packages/lexical-playground/src/plugins/DateTimeExtension/index.tsx @@ -6,13 +6,22 @@ * */ +import {applyFormatFromStyle} from '@lexical/extension'; +import { + CoreImportExtension, + defineImportRule, + DOMImportExtension, + sel, +} from '@lexical/html'; import {$insertNodeIntoLeaf, $wrapNodeInElement} from '@lexical/utils'; import { $createParagraphNode, $getSelection, $isRangeSelection, $isRootOrShadowRoot, + $isTextNode, COMMAND_PRIORITY_EDITOR, + configExtension, createCommand, defineExtension, LexicalCommand, @@ -30,7 +39,57 @@ type CommandPayload = { export const INSERT_DATETIME_COMMAND: LexicalCommand = createCommand('INSERT_DATETIME_COMMAND'); +const DateTimeRule = defineImportRule({ + $import: (ctx, el) => { + const dateTimeValue = el.getAttribute('data-lexical-datetime')!; + const node = $createDateTimeNode(new Date(Date.parse(dateTimeValue))); + const [firstChild] = ctx.$importChildren(el); + if ($isTextNode(firstChild)) { + node.setFormat(firstChild.getFormat()); + } + return [node]; + }, + match: sel.tag('span').attr('data-lexical-datetime', true), + name: '@lexical/playground/datetime', +}); + +const GoogleDocsDateRule = defineImportRule({ + $import: (_ctx, el, $next) => { + let parsed: {dat_df?: {dfie_ts?: {tv?: {tv_s?: number}}; dfie_dt?: string}}; + try { + parsed = JSON.parse(el.getAttribute('data-rich-links') ?? '{}'); + } catch { + return $next(); + } + if (parsed?.dat_df === undefined) { + return $next(); + } + const parsedDate = + (parsed.dat_df.dfie_ts?.tv?.tv_s ?? 0) * 1000 || + Date.parse(parsed.dat_df.dfie_dt ?? ''); + if (isNaN(parsedDate)) { + return $next(); + } + return [ + applyFormatFromStyle($createDateTimeNode(new Date(parsedDate)), el.style), + ]; + }, + match: sel.tag('span').attr('data-rich-links', /"type"\s*:\s*"date"/), + name: '@lexical/playground/datetime-google-docs', +}); + export const DateTimeExtension = defineExtension({ + // Depend on CoreImportExtension so this extension's rules are merged after + // the core rules (later-merged rules win dispatch). Without this the core + // inline-format `` rule could out-prioritize the `` rule below, depending on where the app lists this + // extension relative to the import baseline. + dependencies: [ + CoreImportExtension, + configExtension(DOMImportExtension, { + rules: [DateTimeRule, GoogleDocsDateRule], + }), + ], name: '@lexical/playground/DateTime', nodes: [DateTimeNode], register: editor => diff --git a/packages/lexical-playground/src/plugins/EquationsPlugin/index.tsx b/packages/lexical-playground/src/plugins/EquationsExtension/index.tsx similarity index 63% rename from packages/lexical-playground/src/plugins/EquationsPlugin/index.tsx rename to packages/lexical-playground/src/plugins/EquationsExtension/index.tsx index b4966cae7c4..70463d1d790 100644 --- a/packages/lexical-playground/src/plugins/EquationsPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/EquationsExtension/index.tsx @@ -10,7 +10,7 @@ import type {JSX} from 'react'; import 'katex/dist/katex.css'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {defineImportRule, DOMImportExtension, sel} from '@lexical/html'; import { $insertNodeIntoLeaf, $insertNodeToNearestRoot, @@ -20,12 +20,13 @@ import { $createParagraphNode, $isRootOrShadowRoot, COMMAND_PRIORITY_EDITOR, + configExtension, createCommand, + defineExtension, LexicalCommand, LexicalEditor, } from 'lexical'; -import * as React from 'react'; -import {useCallback, useEffect} from 'react'; +import {useCallback} from 'react'; import {$createEquationNode, EquationNode} from '../../nodes/EquationNode'; import KatexEquationAlterer from '../../ui/KatexEquationAlterer'; @@ -38,35 +39,36 @@ type CommandPayload = { export const INSERT_EQUATION_COMMAND: LexicalCommand = createCommand('INSERT_EQUATION_COMMAND'); -export function InsertEquationDialog({ - activeEditor, - onClose, -}: { - activeEditor: LexicalEditor; - onClose: () => void; -}): JSX.Element { - const onEquationConfirm = useCallback( - (equation: string, inline: boolean) => { - activeEditor.dispatchCommand(INSERT_EQUATION_COMMAND, {equation, inline}); - onClose(); - }, - [activeEditor, onClose], - ); - - return ; +function $convertEquationElement(el: HTMLElement) { + const encoded = el.getAttribute('data-lexical-equation'); + if (!encoded) { + return null; + } + const equation = atob(encoded); + if (!equation) { + return null; + } + const inline = el.getAttribute('data-lexical-inline') === 'true'; + return $createEquationNode(equation, inline); } -export default function EquationsPlugin(): JSX.Element | null { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - if (!editor.hasNodes([EquationNode])) { - throw new Error( - 'EquationsPlugins: EquationsNode not registered on editor', - ); - } +const EquationImportRule = defineImportRule({ + $import: (_ctx, el, $next) => { + const node = $convertEquationElement(el); + return node ? [node] : $next(); + }, + match: sel.tag('div', 'span').attr('data-lexical-equation', true), + name: '@lexical/playground/equation', +}); - return editor.registerCommand( +export const EquationsExtension = defineExtension({ + dependencies: [ + configExtension(DOMImportExtension, {rules: [EquationImportRule]}), + ], + name: '@lexical/playground/Equations', + nodes: [EquationNode], + register: editor => + editor.registerCommand( INSERT_EQUATION_COMMAND, payload => { const {equation, inline} = payload; @@ -84,8 +86,23 @@ export default function EquationsPlugin(): JSX.Element | null { return true; }, COMMAND_PRIORITY_EDITOR, - ); - }, [editor]); + ), +}); - return null; +export function InsertEquationDialog({ + activeEditor, + onClose, +}: { + activeEditor: LexicalEditor; + onClose: () => void; +}): JSX.Element { + const onEquationConfirm = useCallback( + (equation: string, inline: boolean) => { + activeEditor.dispatchCommand(INSERT_EQUATION_COMMAND, {equation, inline}); + onClose(); + }, + [activeEditor, onClose], + ); + + return ; } diff --git a/packages/lexical-playground/src/plugins/ExcalidrawPlugin/index.tsx b/packages/lexical-playground/src/plugins/ExcalidrawExtension/index.tsx similarity index 71% rename from packages/lexical-playground/src/plugins/ExcalidrawPlugin/index.tsx rename to packages/lexical-playground/src/plugins/ExcalidrawExtension/index.tsx index 363e67c307e..235f6aa2293 100644 --- a/packages/lexical-playground/src/plugins/ExcalidrawPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ExcalidrawExtension/index.tsx @@ -11,6 +11,7 @@ import type {JSX} from 'react'; import '@excalidraw/excalidraw/index.css'; +import {defineImportRule, DOMImportExtension, sel} from '@lexical/html'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {$wrapNodeInElement} from '@lexical/utils'; import { @@ -18,7 +19,9 @@ import { $insertNodes, $isRootOrShadowRoot, COMMAND_PRIORITY_EDITOR, + configExtension, createCommand, + defineExtension, LexicalCommand, } from 'lexical'; import {useEffect, useState} from 'react'; @@ -33,7 +36,33 @@ export const INSERT_EXCALIDRAW_COMMAND: LexicalCommand = createCommand( 'INSERT_EXCALIDRAW_COMMAND', ); -export default function ExcalidrawPlugin(): JSX.Element | null { +const ExcalidrawImportRule = defineImportRule({ + $import: (ctx, el) => { + const data = el.getAttribute('data-lexical-excalidraw-json')!; + const styles = window.getComputedStyle(el); + const parseDimension = (v: string) => + !v || v === 'inherit' ? 'inherit' : parseInt(v, 10); + return [ + $createExcalidrawNode( + data, + parseDimension(styles.getPropertyValue('width')), + parseDimension(styles.getPropertyValue('height')), + ), + ]; + }, + match: sel.tag('span').attr('data-lexical-excalidraw-json', true), + name: '@lexical/playground/excalidraw', +}); + +export const ExcalidrawExtension = defineExtension({ + dependencies: [ + configExtension(DOMImportExtension, {rules: [ExcalidrawImportRule]}), + ], + name: '@lexical/playground/Excalidraw', + nodes: [ExcalidrawNode], +}); + +export function ExcalidrawPlugin(): JSX.Element | null { const [editor] = useLexicalComposerContext(); const [isModalOpen, setModalOpen] = useState(false); diff --git a/packages/lexical-playground/src/plugins/ImagesExtension/index.tsx b/packages/lexical-playground/src/plugins/ImagesExtension/index.tsx index 25e29307a7e..d74e66cd461 100644 --- a/packages/lexical-playground/src/plugins/ImagesExtension/index.tsx +++ b/packages/lexical-playground/src/plugins/ImagesExtension/index.tsx @@ -8,6 +8,12 @@ import type {JSX} from 'react'; +import { + $generateNodesFromDOM, + defineImportRule, + DOMImportExtension, + sel, +} from '@lexical/html'; import { $isAutoLinkNode, $isLinkNode, @@ -26,10 +32,12 @@ import { $insertNodes, $isNodeSelection, $isRootOrShadowRoot, + $selectAll, $setSelection, COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, + configExtension, createCommand, defineExtension, DRAGOVER_COMMAND, @@ -39,6 +47,7 @@ import { isHTMLElement, LexicalCommand, LexicalEditor, + SKIP_DOM_SELECTION_TAG, } from 'lexical'; import {useEffect, useRef, useState} from 'react'; @@ -55,6 +64,80 @@ import {DialogActions, DialogButtonsList} from '../../ui/Dialog'; import FileInput from '../../ui/FileInput'; import TextInput from '../../ui/TextInput'; +/** + * Google Docs serializes its bullet/check list markers as a leading + * `` carrying `aria-roledescription="checkbox"`. We never want to + * import those as ImageNodes — that yields a stray bullet image in the + * editor where the list marker should be. + */ +function isGoogleDocCheckboxImg(img: HTMLImageElement): boolean { + return ( + img.parentElement != null && + img.parentElement.tagName === 'LI' && + img.previousSibling === null && + img.getAttribute('aria-roledescription') === 'checkbox' + ); +} + +const ImgRule = defineImportRule({ + $import: (_ctx, el, $next) => { + const src = el.getAttribute('src'); + if (!src || src.startsWith('file:///') || isGoogleDocCheckboxImg(el)) { + return $next(); + } + return [ + $createImageNode({ + altText: el.alt, + height: el.height, + src, + width: el.width, + }), + ]; + }, + match: sel.tag('img'), + name: '@lexical/playground/img', +}); + +/** + * `
    ` is consumed by the surrounding `
    ` rule (its + * content is imported into the ImageNode's nested caption editor), so a + * top-level handler drops it entirely. + */ +const FigcaptionRule = defineImportRule({ + $import: () => [], + match: sel.tag('figcaption'), + name: '@lexical/playground/figcaption', +}); + +const FigureRule = defineImportRule({ + $import: (ctx, el) => { + const imported = ctx.$importChildren(el); + const figcaption = el.querySelector('figcaption'); + if (figcaption) { + for (const imgNode of imported) { + if (!$isImageNode(imgNode)) { + continue; + } + imgNode.setShowCaption(true); + // The caption editor is a nested editor with its own (legacy) + // import pipeline; route the
    children through it. + imgNode.__caption.update( + () => { + $selectAll().insertNodes( + $generateNodesFromDOM(imgNode.__caption, figcaption), + ); + $setSelection(null); + }, + {tag: SKIP_DOM_SELECTION_TAG}, + ); + } + } + return imported; + }, + match: sel.tag('figure'), + name: '@lexical/playground/figure', +}); + export type InsertImagePayload = Readonly; export const INSERT_IMAGE_COMMAND: LexicalCommand = @@ -215,6 +298,11 @@ export function InsertImageDialog({ } export const ImagesExtension = defineExtension({ + dependencies: [ + configExtension(DOMImportExtension, { + rules: [FigcaptionRule, FigureRule, ImgRule], + }), + ], name: '@lexical/playground/Images', nodes: [ImageNode], register: editor => diff --git a/packages/lexical-playground/src/plugins/LayoutPlugin/InsertLayoutDialog.tsx b/packages/lexical-playground/src/plugins/LayoutExtension/InsertLayoutDialog.tsx similarity index 96% rename from packages/lexical-playground/src/plugins/LayoutPlugin/InsertLayoutDialog.tsx rename to packages/lexical-playground/src/plugins/LayoutExtension/InsertLayoutDialog.tsx index b21d733d5f5..0d2425a76d1 100644 --- a/packages/lexical-playground/src/plugins/LayoutPlugin/InsertLayoutDialog.tsx +++ b/packages/lexical-playground/src/plugins/LayoutExtension/InsertLayoutDialog.tsx @@ -14,7 +14,7 @@ import {useState} from 'react'; import Button from '../../ui/Button'; import DropDown, {DropDownItem} from '../../ui/DropDown'; -import {INSERT_LAYOUT_COMMAND} from './LayoutPlugin'; +import {INSERT_LAYOUT_COMMAND} from './LayoutExtension'; const LAYOUTS = [ {label: '2 columns (equal width)', value: '1fr 1fr'}, diff --git a/packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx b/packages/lexical-playground/src/plugins/LayoutExtension/LayoutExtension.tsx similarity index 57% rename from packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx rename to packages/lexical-playground/src/plugins/LayoutExtension/LayoutExtension.tsx index 6de6f9e7aed..bc167b31b2a 100644 --- a/packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx +++ b/packages/lexical-playground/src/plugins/LayoutExtension/LayoutExtension.tsx @@ -8,7 +8,7 @@ import type {ElementNode, LexicalCommand, LexicalNode, NodeKey} from 'lexical'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {defineImportRule, DOMImportExtension, sel} from '@lexical/html'; import { $findMatchingParent, $insertNodeToNearestRoot, @@ -21,13 +21,14 @@ import { $isRangeSelection, COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_LOW, + configExtension, createCommand, + defineExtension, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, } from 'lexical'; -import {useEffect} from 'react'; import { $createLayoutContainerNode, @@ -48,79 +49,102 @@ export const UPDATE_LAYOUT_COMMAND: LexicalCommand<{ nodeKey: NodeKey; }> = createCommand<{template: string; nodeKey: NodeKey}>(); -export function LayoutPlugin(): null { - const [editor] = useLexicalComposerContext(); - useEffect(() => { - if (!editor.hasNodes([LayoutContainerNode, LayoutItemNode])) { - throw new Error( - 'LayoutPlugin: LayoutContainerNode, or LayoutItemNode not registered on editor', - ); - } - - const $onEscape = (before: boolean) => { - const selection = $getSelection(); - if ( - $isRangeSelection(selection) && - selection.isCollapsed() && - selection.anchor.offset === 0 - ) { - const container = $findMatchingParent( - selection.anchor.getNode(), - $isLayoutContainerNode, - ); - - if ($isLayoutContainerNode(container)) { - const parent = container.getParent(); - const child = - parent && - (before - ? parent.getFirstChild() - : parent?.getLastChild()); - const descendant = before - ? container.getFirstDescendant()?.getKey() - : container.getLastDescendant()?.getKey(); - - if ( - parent !== null && - child === container && - selection.anchor.key === descendant - ) { - if (before) { - container.insertBefore($createParagraphNode()); - } else { - container.insertAfter($createParagraphNode()); - } - } - } - } +function getItemsCountFromTemplate(template: string): number { + return template.trim().split(/\s+/).length; +} - return false; - }; +const LayoutContainerImportRule = defineImportRule({ + $import: (ctx, el) => [ + $createLayoutContainerNode(el.style.gridTemplateColumns).splice( + 0, + 0, + ctx.$importChildren(el), + ), + ], + match: sel.tag('div').attr('data-lexical-layout-container', true), + name: '@lexical/playground/layout-container', +}); + +const LayoutItemImportRule = defineImportRule({ + $import: (ctx, el) => [ + $createLayoutItemNode().splice(0, 0, ctx.$importChildren(el)), + ], + match: sel.tag('div').attr('data-lexical-layout-item', true), + name: '@lexical/playground/layout-item', +}); + +const $onEscape = (before: boolean) => { + const selection = $getSelection(); + if ( + $isRangeSelection(selection) && + selection.isCollapsed() && + selection.anchor.offset === 0 + ) { + const container = $findMatchingParent( + selection.anchor.getNode(), + $isLayoutContainerNode, + ); - const $fillLayoutItemIfEmpty = (node: LayoutItemNode) => { - if (node.isEmpty()) { - node.append($createParagraphNode()); - } - }; + if ($isLayoutContainerNode(container)) { + const parent = container.getParent(); + const child = + parent && + (before + ? parent.getFirstChild() + : parent?.getLastChild()); + const descendant = before + ? container.getFirstDescendant()?.getKey() + : container.getLastDescendant()?.getKey(); - const $removeIsolatedLayoutItem = (node: LayoutItemNode): boolean => { - const parent = node.getParent(); - if (!$isLayoutContainerNode(parent)) { - const children = node.getChildren(); - for (const child of children) { - node.insertBefore(child); + if ( + parent !== null && + child === container && + selection.anchor.key === descendant + ) { + if (before) { + container.insertBefore($createParagraphNode()); + } else { + container.insertAfter($createParagraphNode()); } - node.remove(); - return true; } - return false; - }; - - return mergeRegister( - // When layout is the last child pressing down/right arrow will insert paragraph - // below it to allow adding more content. It's similar what $insertBlockNode - // (mainly for decorators), except it'll always be possible to continue adding - // new content even if trailing paragraph is accidentally deleted + } + } + + return false; +}; + +const $fillLayoutItemIfEmpty = (node: LayoutItemNode) => { + if (node.isEmpty()) { + node.append($createParagraphNode()); + } +}; + +const $removeIsolatedLayoutItem = (node: LayoutItemNode): boolean => { + const parent = node.getParent(); + if (!$isLayoutContainerNode(parent)) { + const children = node.getChildren(); + for (const child of children) { + node.insertBefore(child); + } + node.remove(); + return true; + } + return false; +}; + +export const LayoutExtension = defineExtension({ + dependencies: [ + configExtension(DOMImportExtension, { + rules: [LayoutContainerImportRule, LayoutItemImportRule], + }), + ], + name: '@lexical/playground/Layout', + nodes: [LayoutContainerNode, LayoutItemNode], + register: editor => + mergeRegister( + // When layout is the last child pressing down/right arrow will insert + // a paragraph below it, mirroring `$insertBlockNode`. Continues to + // work even if a trailing paragraph is accidentally deleted. editor.registerCommand( KEY_ARROW_DOWN_COMMAND, () => $onEscape(false), @@ -131,10 +155,7 @@ export function LayoutPlugin(): null { () => $onEscape(false), COMMAND_PRIORITY_LOW, ), - // When layout is the first child pressing up/left arrow will insert paragraph - // above it to allow adding more content. It's similar what $insertBlockNode - // (mainly for decorators), except it'll always be possible to continue adding - // new content even if leading paragraph is accidentally deleted + // Inverse: leading paragraph escape on up/left. editor.registerCommand( KEY_ARROW_UP_COMMAND, () => $onEscape(true), @@ -181,7 +202,7 @@ export function LayoutPlugin(): null { container.getTemplateColumns(), ); - // Add or remove extra columns if new template does not match existing one + // Add or remove columns to match the new template. if (itemsCount > prevItemsCount) { for (let i = prevItemsCount; i < itemsCount; i++) { container.append( @@ -206,15 +227,13 @@ export function LayoutPlugin(): null { COMMAND_PRIORITY_EDITOR, ), + // Structure-enforcing transforms. If nesting isn't `Container > Item`, + // unwrap and treat the content as regular blocks. editor.registerNodeTransform(LayoutItemNode, node => { - // Structure enforcing transformers for each node type. In case nesting structure is not - // "Container > Item" it'll unwrap nodes and convert it back - // to regular content. const isRemoved = $removeIsolatedLayoutItem(node); if (!isRemoved) { - // Layout item should always have a child. this function will listen - // for any empty layout item and fill it with a paragraph node + // Layout items should never be empty; backfill with a paragraph. $fillLayoutItemIfEmpty(node); } }), @@ -227,12 +246,5 @@ export function LayoutPlugin(): null { node.remove(); } }), - ); - }, [editor]); - - return null; -} - -function getItemsCountFromTemplate(template: string): number { - return template.trim().split(/\s+/).length; -} + ), +}); diff --git a/packages/lexical-playground/src/plugins/MentionsPlugin/index.tsx b/packages/lexical-playground/src/plugins/MentionsExtension/index.tsx similarity index 91% rename from packages/lexical-playground/src/plugins/MentionsPlugin/index.tsx rename to packages/lexical-playground/src/plugins/MentionsExtension/index.tsx index b6b8ae46e53..7cf2f2bd379 100644 --- a/packages/lexical-playground/src/plugins/MentionsPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/MentionsExtension/index.tsx @@ -8,6 +8,12 @@ import type {JSX} from 'react'; +import { + CoreImportExtension, + defineImportRule, + DOMImportExtension, + sel, +} from '@lexical/html'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { LexicalTypeaheadMenuPlugin, @@ -15,10 +21,34 @@ import { MenuTextMatch, useBasicTypeaheadTriggerMatch, } from '@lexical/react/LexicalTypeaheadMenuPlugin'; -import {TextNode} from 'lexical'; +import {configExtension, defineExtension, TextNode} from 'lexical'; import {useCallback, useEffect, useMemo, useState} from 'react'; -import {$createMentionNode} from '../../nodes/MentionNode'; +import {$createMentionNode, MentionNode} from '../../nodes/MentionNode'; + +const MentionImportRule = defineImportRule({ + $import: (_ctx, el) => { + const textContent = el.textContent ?? ''; + const mentionName = + el.getAttribute('data-lexical-mention-name') ?? textContent; + return [$createMentionNode(mentionName, textContent)]; + }, + match: sel.tag('span').attr('data-lexical-mention', true), + name: '@lexical/playground/mention', +}); + +export const MentionsExtension = defineExtension({ + // Depend on CoreImportExtension so the `` rule is + // merged after — and therefore out-prioritizes — the core inline-format + // `` rule, regardless of where the app lists this extension relative + // to the import baseline. + dependencies: [ + CoreImportExtension, + configExtension(DOMImportExtension, {rules: [MentionImportRule]}), + ], + name: '@lexical/playground/Mentions', + nodes: [MentionNode], +}); const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'; @@ -577,7 +607,7 @@ class MentionTypeaheadOption extends MenuOption { } } -export default function NewMentionsPlugin(): JSX.Element | null { +export function MentionsPlugin(): JSX.Element | null { const [editor] = useLexicalComposerContext(); const [queryString, setQueryString] = useState(null); diff --git a/packages/lexical-playground/src/plugins/PageBreakExtension/index.ts b/packages/lexical-playground/src/plugins/PageBreakExtension/index.ts index 2499814db9c..f8d8c0ebe3e 100644 --- a/packages/lexical-playground/src/plugins/PageBreakExtension/index.ts +++ b/packages/lexical-playground/src/plugins/PageBreakExtension/index.ts @@ -6,11 +6,13 @@ * */ +import {defineImportRule, DOMImportExtension, sel} from '@lexical/html'; import {$insertNodeToNearestRoot} from '@lexical/utils'; import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_EDITOR, + configExtension, createCommand, defineExtension, LexicalCommand, @@ -20,7 +22,16 @@ import {$createPageBreakNode, PageBreakNode} from '../../nodes/PageBreakNode'; export const INSERT_PAGE_BREAK: LexicalCommand = createCommand(); +const PageBreakImportRule = defineImportRule({ + $import: () => [$createPageBreakNode()], + match: sel.tag('figure').attr('type', PageBreakNode.getType()), + name: '@lexical/playground/page-break', +}); + export const PageBreakExtension = defineExtension({ + dependencies: [ + configExtension(DOMImportExtension, {rules: [PageBreakImportRule]}), + ], name: '@lexical/playground/PageBreak', nodes: [PageBreakNode], register: editor => diff --git a/packages/lexical-playground/src/plugins/PollPlugin/index.tsx b/packages/lexical-playground/src/plugins/PollExtension/index.tsx similarity index 64% rename from packages/lexical-playground/src/plugins/PollPlugin/index.tsx rename to packages/lexical-playground/src/plugins/PollExtension/index.tsx index 760a00b3473..d903d2919b4 100644 --- a/packages/lexical-playground/src/plugins/PollPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/PollExtension/index.tsx @@ -8,19 +8,20 @@ import type {JSX} from 'react'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {defineImportRule, DOMImportExtension, sel} from '@lexical/html'; import {$wrapNodeInElement} from '@lexical/utils'; import { $createParagraphNode, $insertNodes, $isRootOrShadowRoot, COMMAND_PRIORITY_EDITOR, + configExtension, createCommand, + defineExtension, LexicalCommand, LexicalEditor, } from 'lexical'; -import * as React from 'react'; -import {useEffect, useState} from 'react'; +import {useState} from 'react'; import { $createPollNode, @@ -35,6 +36,48 @@ export const INSERT_POLL_COMMAND: LexicalCommand = createCommand( 'INSERT_POLL_COMMAND', ); +function $convertPollElement(el: HTMLElement) { + const question = el.getAttribute('data-lexical-poll-question'); + const options = el.getAttribute('data-lexical-poll-options'); + if (question === null || options === null) { + return null; + } + return $createPollNode(question, JSON.parse(options)); +} + +const PollImportRule = defineImportRule({ + $import: (_ctx, el, $next) => { + const node = $convertPollElement(el); + return node ? [node] : $next(); + }, + match: sel.tag('span').attr('data-lexical-poll-question', true), + name: '@lexical/playground/poll', +}); + +export const PollExtension = defineExtension({ + dependencies: [ + configExtension(DOMImportExtension, {rules: [PollImportRule]}), + ], + name: '@lexical/playground/Poll', + nodes: [PollNode], + register: editor => + editor.registerCommand( + INSERT_POLL_COMMAND, + payload => { + const pollNode = $createPollNode(payload, [ + createPollOption(), + createPollOption(), + ]); + $insertNodes([pollNode]); + if ($isRootOrShadowRoot(pollNode.getParentOrThrow())) { + $wrapNodeInElement(pollNode, $createParagraphNode).selectEnd(); + } + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), +}); + export function InsertPollDialog({ activeEditor, onClose, @@ -60,30 +103,3 @@ export function InsertPollDialog({ ); } - -export default function PollPlugin(): JSX.Element | null { - const [editor] = useLexicalComposerContext(); - useEffect(() => { - if (!editor.hasNodes([PollNode])) { - throw new Error('PollPlugin: PollNode not registered on editor'); - } - - return editor.registerCommand( - INSERT_POLL_COMMAND, - payload => { - const pollNode = $createPollNode(payload, [ - createPollOption(), - createPollOption(), - ]); - $insertNodes([pollNode]); - if ($isRootOrShadowRoot(pollNode.getParentOrThrow())) { - $wrapNodeInElement(pollNode, $createParagraphNode).selectEnd(); - } - - return true; - }, - COMMAND_PRIORITY_EDITOR, - ); - }, [editor]); - return null; -} diff --git a/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx b/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx index 09eb581f26f..d00f8914e24 100644 --- a/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx @@ -86,17 +86,17 @@ import {sanitizeUrl} from '../../utils/url'; import {EmbedConfigs} from '../AutoEmbedPlugin'; import {INSERT_COLLAPSIBLE_COMMAND} from '../CollapsibleExtension'; import {INSERT_DATETIME_COMMAND} from '../DateTimeExtension'; -import {InsertEquationDialog} from '../EquationsPlugin'; -import {INSERT_EXCALIDRAW_COMMAND} from '../ExcalidrawPlugin'; +import {InsertEquationDialog} from '../EquationsExtension'; +import {INSERT_EXCALIDRAW_COMMAND} from '../ExcalidrawExtension'; import { INSERT_IMAGE_COMMAND, InsertImageDialog, InsertImagePayload, } from '../ImagesExtension'; -import InsertLayoutDialog from '../LayoutPlugin/InsertLayoutDialog'; +import InsertLayoutDialog from '../LayoutExtension/InsertLayoutDialog'; import {INSERT_PAGE_BREAK} from '../PageBreakExtension'; import {PagesReactExtension} from '../PagesReactExtension'; -import {InsertPollDialog} from '../PollPlugin'; +import {InsertPollDialog} from '../PollExtension'; import {SHORTCUTS} from '../ShortcutsPlugin/shortcuts'; import {InsertTableDialog} from '../TablePlugin'; import FontSize, {parseFontSizeForToolbar} from './fontSize'; diff --git a/packages/lexical-playground/src/plugins/TwitterExtension/index.ts b/packages/lexical-playground/src/plugins/TwitterExtension/index.ts index ce74d11ec52..207c51c57ae 100644 --- a/packages/lexical-playground/src/plugins/TwitterExtension/index.ts +++ b/packages/lexical-playground/src/plugins/TwitterExtension/index.ts @@ -6,9 +6,11 @@ * */ +import {defineImportRule, DOMImportExtension, sel} from '@lexical/html'; import {$insertNodeToNearestRoot} from '@lexical/utils'; import { COMMAND_PRIORITY_EDITOR, + configExtension, createCommand, defineExtension, LexicalCommand, @@ -20,7 +22,16 @@ export const INSERT_TWEET_COMMAND: LexicalCommand = createCommand( 'INSERT_TWEET_COMMAND', ); +const TweetImportRule = defineImportRule({ + $import: ctx => [$createTweetNode(ctx.captures.id[0])], + match: sel.tag('div').attr('data-lexical-tweet-id', /^.+$/, {capture: 'id'}), + name: '@lexical/playground/tweet', +}); + export const TwitterExtension = defineExtension({ + dependencies: [ + configExtension(DOMImportExtension, {rules: [TweetImportRule]}), + ], name: '@lexical/playground/Twitter', nodes: [TweetNode], register: editor => diff --git a/packages/lexical-playground/src/plugins/YouTubeExtension/index.ts b/packages/lexical-playground/src/plugins/YouTubeExtension/index.ts index 4460a561e37..41df2d4b5eb 100644 --- a/packages/lexical-playground/src/plugins/YouTubeExtension/index.ts +++ b/packages/lexical-playground/src/plugins/YouTubeExtension/index.ts @@ -6,9 +6,11 @@ * */ +import {defineImportRule, DOMImportExtension, sel} from '@lexical/html'; import {$insertNodeToNearestRoot} from '@lexical/utils'; import { COMMAND_PRIORITY_EDITOR, + configExtension, createCommand, defineExtension, LexicalCommand, @@ -20,7 +22,18 @@ export const INSERT_YOUTUBE_COMMAND: LexicalCommand = createCommand( 'INSERT_YOUTUBE_COMMAND', ); +const YouTubeImportRule = defineImportRule({ + $import: ctx => [$createYouTubeNode(ctx.captures.id[0])], + match: sel + .tag('iframe') + .attr('data-lexical-youtube', /^.+$/, {capture: 'id'}), + name: '@lexical/playground/youtube', +}); + export const YouTubeExtension = defineExtension({ + dependencies: [ + configExtension(DOMImportExtension, {rules: [YouTubeImportRule]}), + ], name: '@lexical/playground/YouTube', nodes: [YouTubeNode], register: editor => diff --git a/packages/lexical-rich-text/src/RichTextImportExtension.ts b/packages/lexical-rich-text/src/RichTextImportExtension.ts index de7149c9760..31584337e6f 100644 --- a/packages/lexical-rich-text/src/RichTextImportExtension.ts +++ b/packages/lexical-rich-text/src/RichTextImportExtension.ts @@ -6,12 +6,7 @@ * */ -import { - CoreImportExtension, - defineImportRule, - DOMImportExtension, - sel, -} from '@lexical/html'; +import {defineImportRule, DOMImportExtension, sel} from '@lexical/html'; import { $setDirectionFromDOM, $setFormatFromDOM, @@ -112,16 +107,16 @@ export const RichTextImportRules = [ ]; /** - * Bundles {@link RichTextImportRules} (plus {@link CoreImportExtension}) - * into a single dependency. Use this in editors that want the legacy - * `@lexical/rich-text` DOM import behavior under the new - * {@link DOMImportExtension} pipeline. + * Bundles {@link RichTextImportRules} together with the runtime + * {@link RichTextExtension}. The application is expected to already + * have `CoreImportExtension` (or some equivalent) in its dependency + * graph — the core/text/paragraph/inline-format rules are a shared + * baseline, not something this leaf importer should re-declare. * * @experimental */ export const RichTextImportExtension = defineExtension({ dependencies: [ - CoreImportExtension, RichTextExtension, configExtension(DOMImportExtension, {rules: RichTextImportRules}), ], diff --git a/packages/lexical-rich-text/src/__tests__/unit/RichTextImportExtension.test.ts b/packages/lexical-rich-text/src/__tests__/unit/RichTextImportExtension.test.ts index 22c4f160f6f..cc14c9a1e21 100644 --- a/packages/lexical-rich-text/src/__tests__/unit/RichTextImportExtension.test.ts +++ b/packages/lexical-rich-text/src/__tests__/unit/RichTextImportExtension.test.ts @@ -10,7 +10,7 @@ import { buildEditorFromExtensions, getExtensionDependencyFromEditor, } from '@lexical/extension'; -import {DOMImportExtension} from '@lexical/html'; +import {CoreImportExtension, DOMImportExtension} from '@lexical/html'; import { $isHeadingNode, $isQuoteNode, @@ -31,7 +31,9 @@ import {assert, describe, expect, test} from 'vitest'; function buildEditor() { return buildEditorFromExtensions( defineExtension({ - dependencies: [RichTextImportExtension], + // Leaf importer extensions no longer pull `CoreImportExtension` + // in by themselves — the application is expected to add it once. + dependencies: [CoreImportExtension, RichTextImportExtension], name: 'rich-text-host', nodes: [HeadingNode, QuoteNode], }), diff --git a/packages/lexical-table/src/TableImportExtension.ts b/packages/lexical-table/src/TableImportExtension.ts index 96139f5ca0f..8414ac6d885 100644 --- a/packages/lexical-table/src/TableImportExtension.ts +++ b/packages/lexical-table/src/TableImportExtension.ts @@ -9,8 +9,8 @@ import type {ChildSchema, ImportContextPairOrUpdater} from '@lexical/html'; import { + $propagateTextAlignToBlockChildren, contextValue, - CoreImportExtension, defineImportRule, DOMImportExtension, ImportTextFormat, @@ -75,9 +75,15 @@ function cellTextFormatMask(style: CSSStyleDeclaration): number { } /** - * Coalesce inline + line-break runs in a cell into paragraphs. Mirrors - * the legacy `removeSingleLineBreakNode` cleanup so a sole `
    ` doesn't - * survive as a paragraph's only child. + * Coalesce inline + line-break runs inside a ``/`` into their own + * `ParagraphNode`s, leaving any pre-existing `ParagraphNode` children + * (real `

    ` elements, or the `ParagraphNode` stand-ins that + * {@link TransparentBlockRule} lowers `

    `/`
    `/… to) in + * place as their own paragraph siblings. Mirrors the legacy `` + * importer where a bare `789
    000
    ` ended up as two + * paragraphs (`

    789

    000

    `), and also drops a sole leading + * `
    ` that the legacy `removeSingleLineBreakNode` cleanup would have + * removed. */ function $packageCellChildren(children: LexicalNode[]): LexicalNode[] { const result: LexicalNode[] = []; @@ -105,9 +111,12 @@ function $packageCellChildren(children: LexicalNode[]): LexicalNode[] { result.push(paragraph); } } else { + // Block children (paragraphs, nested tables, decorator blocks, …) + // start their own sibling — any inline run that was being + // accumulated into `paragraph` is closed off here. flushSingleLineBreak(); - result.push(child); paragraph = null; + result.push(child); } } flushSingleLineBreak(); @@ -238,13 +247,24 @@ const TableCellRule = defineImportRule({ if (cellStyle !== inheritedStyle) { branchContext.push(contextValue(ImportTextStyle, cellStyle)); } - return [ - cell.splice( - 0, - 0, - $packageCellChildren(ctx.$importChildren(el, {context: branchContext})), - ), - ]; + // {@link $packageCellChildren} keeps each `ParagraphNode` child + // (from a real `

    `, or from {@link TransparentBlockRule}'s + // lowering of `

    `/`
    `/…) as its own sibling paragraph + // — matching legacy ``'s `789
    000
    ` → + // `

    789

    000

    ` shape. + const packaged = $packageCellChildren( + ctx.$importChildren(el, {context: branchContext}), + ); + // Only `` propagates `text-align` onto its block children — + // mirroring legacy `wrapContinuousInlines`, which runs only for + // `isBlockDomNode` elements. `` is intentionally absent from + // the block-tag set (see `BLOCK_TAG_RE` in `LexicalUtils.ts`), so a + // `` wrapping a bare `

    ` leaves the + // paragraph format empty. + const children = isHeader + ? packaged + : $propagateTextAlignToBlockChildren(packaged, el); + return [cell.splice(0, 0, children)]; }, match: sel.tag('td', 'th'), name: '@lexical/table/cell', @@ -287,14 +307,16 @@ export const TableRowSchema: ChildSchema = { export const TableImportRules = [TableRule, TableRowRule, TableCellRule]; /** - * Bundles {@link TableImportRules} (plus {@link CoreImportExtension}) - * into a single dependency. + * Bundles {@link TableImportRules} together with the runtime + * {@link TableExtension}. The application is expected to already have + * `CoreImportExtension` (or some equivalent) in its dependency graph — + * the core/text/paragraph/inline-format rules are a shared baseline, + * not something this leaf importer should re-declare. * * @experimental */ export const TableImportExtension = defineExtension({ dependencies: [ - CoreImportExtension, TableExtension, configExtension(DOMImportExtension, {rules: TableImportRules}), ], diff --git a/packages/lexical-table/src/__tests__/unit/TableImportExtension.test.ts b/packages/lexical-table/src/__tests__/unit/TableImportExtension.test.ts index c8b15f9b5f1..e811dc43543 100644 --- a/packages/lexical-table/src/__tests__/unit/TableImportExtension.test.ts +++ b/packages/lexical-table/src/__tests__/unit/TableImportExtension.test.ts @@ -10,7 +10,7 @@ import { buildEditorFromExtensions, getExtensionDependencyFromEditor, } from '@lexical/extension'; -import {DOMImportExtension} from '@lexical/html'; +import {CoreImportExtension, DOMImportExtension} from '@lexical/html'; import { $isTableCellNode, $isTableNode, @@ -33,7 +33,9 @@ import {assert, describe, expect, test} from 'vitest'; function buildEditor() { return buildEditorFromExtensions( defineExtension({ - dependencies: [TableImportExtension], + // Leaf importer extensions no longer pull `CoreImportExtension` + // in by themselves — the application is expected to add it once. + dependencies: [CoreImportExtension, TableImportExtension], name: 'table-host', nodes: [TableNode, TableRowNode, TableCellNode], }), diff --git a/packages/lexical-website/docs/serialization/dom-import.md b/packages/lexical-website/docs/serialization/dom-import.md index c1635c3a12c..76c2ff266fa 100644 --- a/packages/lexical-website/docs/serialization/dom-import.md +++ b/packages/lexical-website/docs/serialization/dom-import.md @@ -1069,6 +1069,13 @@ rules. In the new pipeline both are stated at the call site: the rule asks for inline children, gets them packaged by `InlineSchema`, and attaches them via the normal `ElementNode.splice` primitive. +### Concrete example: playground + +The lexical-playground application was migrated in +[#8590](https://github.com/facebook/lexical/pull/8590), which should +be a good example of what it would take to move an entire application +from `importDOM` to `DOMImportExtension` (while adding test coverage). + ## Capabilities Current: diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index 9a89823b12a..85262f06411 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -941,8 +941,16 @@ declare export class ElementDOMSlot extends DOMSlot { // Drag & drop should not recompute selection until mouse up; otherwise the initially // selected content is lost. - if (!$isSelectionCapturedInDecorator(target)) { + if (!isDOMCapturingSelection(target, editor)) { isSelectionChangeFromMouseDown = true; } }); @@ -1082,14 +1082,13 @@ function onInput(event: InputEvent, editor: LexicalEditor): void { } function $handleInput(event: InputEvent): boolean { + const editor = getActiveEditor(); if ( isHTMLElement(event.target) && - $isSelectionCapturedInDecorator(event.target) + isDOMCapturingSelection(event.target, editor) ) { return true; } - - const editor = getActiveEditor(); const selection = $getSelection(); const data = event.data; const targetRange = getTargetRange(event); diff --git a/packages/lexical/src/LexicalMutations.ts b/packages/lexical/src/LexicalMutations.ts index 713fa47d81b..9d48de4db5a 100644 --- a/packages/lexical/src/LexicalMutations.ts +++ b/packages/lexical/src/LexicalMutations.ts @@ -29,7 +29,6 @@ import { getNodeKeyFromDOMNode, getParentElement, getWindow, - internalGetRoot, isDOMTextNode, isDOMUnmanaged, isFirefoxClipboardEvents, @@ -118,7 +117,6 @@ function $getNearestManagedNodePairFromDOMNode( startingDOM: Node, editor: LexicalEditor, editorState: EditorState, - rootElement: HTMLElement | null, ): [HTMLElement, LexicalNode] | undefined { for ( let dom: Node | null = startingDOM; @@ -134,8 +132,6 @@ function $getNearestManagedNodePairFromDOMNode( ? undefined : [dom, node]; } - } else if (dom === rootElement) { - return [rootElement, internalGetRoot(editorState)]; } } } @@ -152,7 +148,6 @@ function flushMutations( updateEditorSync(editor, () => { const selection = $getSelection() || getLastSelection(editor); const badDOMTargets = new Map(); - const rootElement = editor.getRootElement(); // We use the current editor state, as that reflects what is // actually "on screen". const currentEditorState = editor._editorState; @@ -168,7 +163,6 @@ function flushMutations( targetDOM, editor, currentEditorState, - rootElement, ); if (!pair) { continue; diff --git a/packages/lexical/src/LexicalNode.ts b/packages/lexical/src/LexicalNode.ts index b4a42d7458e..c5bbc95b120 100644 --- a/packages/lexical/src/LexicalNode.ts +++ b/packages/lexical/src/LexicalNode.ts @@ -272,6 +272,14 @@ export interface LexicalPrivateDOM { | undefined; __lexicalDir?: 'ltr' | 'rtl' | null | undefined; __lexicalUnmanaged?: boolean | undefined; + /** + * When true, the DOM subtree owns its own window selection — analogous to + * a DecoratorNode subtree. Resolution logic that would otherwise force the + * Lexical selection back onto a managed position treats the caret as + * intentional and leaves it alone. Set via the `captureSelection` option + * on {@link setDOMUnmanaged}. + */ + __lexicalCapturedSelection?: boolean | undefined; } export function $removeNode( diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index 7d6eca6ee31..9d7eb964a4c 100644 --- a/packages/lexical/src/LexicalReconciler.ts +++ b/packages/lexical/src/LexicalReconciler.ts @@ -51,6 +51,7 @@ import { $isRootOrShadowRoot, cloneDecorators, getElementByKeyOrThrow, + setDOMUnmanaged, setMutatedNode, setNodeKeyOnDOMNode, } from './LexicalUtils'; @@ -458,6 +459,13 @@ function $createNode(key: NodeKey, slot: ElementDOMSlot | null): HTMLElement { dom.setAttribute('data-lexical-text', 'true'); } else if ($isDecoratorNode(node)) { dom.setAttribute('data-lexical-decorator', 'true'); + // DecoratorNode DOM is selection-captured: window selection inside + // a decorator subtree (e.g. an embedded input) is owned by the + // decorator, not by Lexical's caret management. Marking it via + // setDOMUnmanaged unifies the decorator case with extension-owned + // unmanaged subtrees so callers only need isDOMCapturingSelection / + // isDOMUnmanaged. + setDOMUnmanaged(dom, {captureSelection: true}); } if ($isElementNode(node)) { diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 31a12b30a7d..9d90974cb15 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -80,7 +80,6 @@ import { $getRoot, $hasAncestor, $isRootOrShadowRoot, - $isSelectionCapturedInDecorator, $isTokenOrSegmented, $isTokenOrTab, $setCompositionKey, @@ -90,6 +89,7 @@ import { getNodeKeyFromDOMNode, getWindow, INTERNAL_$isBlock, + isDOMCapturingSelection, isHTMLElement, isSelectionCapturedInDecoratorInput, isSelectionWithinEditor, @@ -2322,8 +2322,7 @@ function $internalResolveSelectionPoint( } if ( getNodeKeyFromDOMNode(dom, editor) === undefined && - dom !== editor.getRootElement() && - !$isSelectionCapturedInDecorator(dom) + !isDOMCapturingSelection(dom, editor) ) { // The DOM caret is sitting on a node that has no Lexical key // (e.g. inside an unmanaged , or any unmanaged @@ -2334,14 +2333,14 @@ function $internalResolveSelectionPoint( // selection dirty so the reconciler writes a valid DOM caret back // at the resolved Lexical position. // - // Exceptions where the DOM caret is intentionally somewhere - // Lexical doesn't own and we should NOT force-sync it: - // - the editor root element (tracked separately in - // _keyToDOMMap as 'root'; has no __lexicalKey_* attribute); - // - anything inside a DecoratorNode subtree (the decorator owns - // its own DOM and may manage its own selection — for inputs - // isSelectionCapturedInDecoratorInput rejects earlier, but - // non-input decorator content also shouldn't be force-synced). + // Exclusions split across the two guard clauses: + // - The first clause (`key !== undefined`) covers any DOM node + // with a `__lexicalKey_*` attribute — Lexical-managed elements + // and the editor root (stashed in `resetEditor`). + // - `isDOMCapturingSelection` covers DecoratorNode subtrees (which + // own their own DOM) and subtrees marked via + // `setDOMUnmanaged(dom, {captureSelection: true})` — + // extension-owned widgets that keep a native caret. // // Void elements that ARE Lexical nodes (LineBreakNode
    , // empty decorator containers, etc.) have keys, so this check diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index 5d25e6793a1..330b9545f90 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -159,10 +159,6 @@ export const scheduleMicroTask: (fn: () => void) => void = Promise.resolve().then(fn); }; -export function $isSelectionCapturedInDecorator(node: Node): boolean { - return $isDecoratorNode($getNearestNodeFromDOMNode(node)); -} - export function isSelectionCapturedInDecoratorInput(anchorDOM: Node): boolean { const activeElement = document.activeElement; @@ -571,6 +567,11 @@ export function setNodeKeyOnDOMNode( (dom as Node & Record)[prop] = key; } +export function clearNodeKeyOnDOMNode(dom: Node, editor: LexicalEditor) { + const prop = `__lexicalKey_${editor._key}`; + delete (dom as Node & Record)[prop]; +} + export function getNodeKeyFromDOMNode( dom: Node, editor: LexicalEditor, @@ -683,10 +684,6 @@ export function $getNodeFromDOM(dom: Node): null | LexicalNode { const editor = getActiveEditor(); const nodeKey = getNodeKeyFromDOMTree(dom, editor); if (nodeKey === null) { - const rootElement = editor.getRootElement(); - if (dom === rootElement) { - return $getNodeByKey('root'); - } return null; } return $getNodeByKey(nodeKey); @@ -2265,18 +2262,45 @@ export function $setFormatFromDOM( : node; } +/** + * Options accepted by {@link setDOMUnmanaged}. + * + * @experimental + */ +export interface SetDOMUnmanagedOptions { + /** + * When true, the marked subtree owns its own window selection — analogous + * to a DecoratorNode subtree. Selection resolution that would otherwise + * mark the selection dirty for a caret position inside unmanaged DOM + * leaves it alone, so the embedded interaction (custom input, focusable + * widget, etc.) can keep its native caret. + * + * Pass `false` to clear a previously-set marker; omit the field to leave + * `__lexicalCapturedSelection` untouched. + */ + captureSelection?: boolean; +} + /** * Mark this DOM element as unmanaged by lexical's mutation observer (like * decorator nodes are). Extensions that inject non-lexical decoration * elements into a node's DOM should mark them so the mutation observer * doesn't evict them as "unknown DOM children" during cleanup. * + * Pass `{captureSelection: true}` to additionally treat the subtree's + * window selection as decorator-like, so resolution does not force-sync + * the caret out of unmanaged DOM (see {@link isDOMCapturingSelection}). + * * @experimental */ export function setDOMUnmanaged( elementDom: HTMLElement & LexicalPrivateDOM, + options?: SetDOMUnmanagedOptions, ): void { elementDom.__lexicalUnmanaged = true; + if (options && options.captureSelection !== undefined) { + elementDom.__lexicalCapturedSelection = options.captureSelection; + } } /** @@ -2288,6 +2312,40 @@ export function isDOMUnmanaged(elementDom: Node & LexicalPrivateDOM): boolean { return elementDom.__lexicalUnmanaged === true; } +/** + * True if the DOM node sits inside a subtree marked with + * `{captureSelection: true}` via {@link setDOMUnmanaged}. Walks ancestors + * so any descendant of a marked subtree (e.g. an `` inside a marked + * `

    `) reports as captured too. + * + * The walk aborts at the first DOM node that corresponds to a Lexical + * node in `editor` — that boundary is the implicit owner of the subtree's + * selection, so a captureSelection marker above it (in non-Lexical + * scaffolding around the editor) does not leak in. + * + * DecoratorNode DOM is marked with `setDOMUnmanaged({captureSelection: + * true})` by the reconciler, so decorator subtrees also report as + * captured here. + * + * @experimental + */ +export function isDOMCapturingSelection( + elementDom: Node & LexicalPrivateDOM, + editor: LexicalEditor, +): boolean { + let dom: (Node & LexicalPrivateDOM) | null = elementDom; + while (dom != null) { + if (dom.__lexicalCapturedSelection === true) { + return true; + } + if (getNodeKeyFromDOMNode(dom, editor) !== undefined) { + return false; + } + dom = getParentElement(dom); + } + return false; +} + /** * @internal * diff --git a/packages/lexical/src/__tests__/unit/LexicalDOMCapturedSelection.test.ts b/packages/lexical/src/__tests__/unit/LexicalDOMCapturedSelection.test.ts new file mode 100644 index 00000000000..40b35af19c1 --- /dev/null +++ b/packages/lexical/src/__tests__/unit/LexicalDOMCapturedSelection.test.ts @@ -0,0 +1,196 @@ +/** + * 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, + type LexicalEditorWithDispose, +} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + isDOMCapturingSelection, + isDOMUnmanaged, + setDOMUnmanaged, +} from 'lexical'; +import { + $createTestDecoratorNode, + TestDecoratorNode, +} from 'lexical/src/__tests__/utils'; +import {assert, describe, expect, test} from 'vitest'; + +function createEditor(): LexicalEditorWithDispose { + const editor = buildEditorFromExtensions({ + $initialEditorState: () => { + $getRoot() + .clear() + .append($createParagraphNode().append($createTextNode('hello'))); + }, + dependencies: [RichTextExtension], + name: 'test', + nodes: [TestDecoratorNode], + }); + editor.setRootElement(document.createElement('div')); + return editor; +} + +describe('setDOMUnmanaged options (Issue #8584)', () => { + test('default options: marks unmanaged, leaves captureSelection off', () => { + using editor = createEditor(); + const dom = document.createElement('div'); + setDOMUnmanaged(dom); + expect(isDOMUnmanaged(dom)).toBe(true); + editor.read(() => { + assert(!isDOMCapturingSelection(dom, editor)); + }); + }); + + test('captureSelection: true marks both unmanaged and capturing', () => { + using editor = createEditor(); + const dom = document.createElement('div'); + setDOMUnmanaged(dom, {captureSelection: true}); + expect(isDOMUnmanaged(dom)).toBe(true); + editor.read(() => { + assert(isDOMCapturingSelection(dom, editor)); + }); + }); + + test('captureSelection: false equivalent to default', () => { + using editor = createEditor(); + const dom = document.createElement('div'); + setDOMUnmanaged(dom, {captureSelection: false}); + expect(isDOMUnmanaged(dom)).toBe(true); + editor.read(() => { + assert(!isDOMCapturingSelection(dom, editor)); + }); + }); + + test('captureSelection: false clears an existing flag', () => { + using editor = createEditor(); + const dom = document.createElement('div'); + setDOMUnmanaged(dom, {captureSelection: true}); + setDOMUnmanaged(dom, {captureSelection: false}); + editor.read(() => { + assert(!isDOMCapturingSelection(dom, editor)); + }); + }); + + test('descendant cannot opt out when ancestor is captured (walk)', () => { + using editor = createEditor(); + const parent = document.createElement('div'); + const child = document.createElement('span'); + parent.appendChild(child); + setDOMUnmanaged(parent, {captureSelection: true}); + setDOMUnmanaged(child, {captureSelection: false}); + editor.read(() => { + assert(isDOMCapturingSelection(child, editor)); + }); + }); + + test('unmarked DOM is neither unmanaged nor capturing', () => { + using editor = createEditor(); + const dom = document.createElement('div'); + expect(isDOMUnmanaged(dom)).toBe(false); + editor.read(() => { + assert(!isDOMCapturingSelection(dom, editor)); + }); + }); +}); + +describe('isDOMCapturingSelection (Issue #8584)', () => { + test('captureSelection-marked DOM returns true', () => { + using editor = createEditor(); + const widget = document.createElement('div'); + setDOMUnmanaged(widget, {captureSelection: true}); + editor.read(() => { + assert(isDOMCapturingSelection(widget, editor)); + }); + }); + + test('descendant of captureSelection-marked DOM returns true', () => { + using editor = createEditor(); + const widget = document.createElement('div'); + const child = document.createElement('span'); + widget.appendChild(child); + setDOMUnmanaged(widget, {captureSelection: true}); + editor.read(() => { + assert(isDOMCapturingSelection(child, editor)); + }); + }); + + test('unmanaged-only DOM (no captureSelection) is not captured', () => { + using editor = createEditor(); + const widget = document.createElement('div'); + setDOMUnmanaged(widget); + editor.read(() => { + assert(!isDOMCapturingSelection(widget, editor)); + }); + }); + + test('DecoratorNode subtree still returns true (BC preserved)', () => { + using editor = createEditor(); + editor.update( + () => { + $getRoot() + .clear() + .append($createParagraphNode().append($createTestDecoratorNode())); + }, + {discrete: true}, + ); + editor.read(() => { + const rootElement = editor.getRootElement(); + const decoratorDom = rootElement!.querySelector( + '[data-lexical-decorator]', + ); + expect(decoratorDom).not.toBeNull(); + assert(isDOMCapturingSelection(decoratorDom!, editor)); + }); + }); + + test('plain DOM outside the editor returns false', () => { + using editor = createEditor(); + const dom = document.createElement('div'); + editor.read(() => { + assert(!isDOMCapturingSelection(dom, editor)); + }); + }); + + test('editor root element returns false', () => { + using editor = createEditor(); + const rootElement = editor.getRootElement(); + editor.read(() => { + assert(!isDOMCapturingSelection(rootElement!, editor)); + }); + }); + + test('walk aborts at a Lexical-node DOM — ancestor capture above editor does not leak in', () => { + using editor = createEditor(); + // outerWrapper is non-Lexical scaffolding that wraps the editor; mark it + // as captureSelection. The walk from a text DOM inside the editor must + // NOT see this marker because there is a Lexical-node DOM (the + // paragraph or text element) on the path that aborts it. + const outerWrapper = document.createElement('div'); + setDOMUnmanaged(outerWrapper, {captureSelection: true}); + const rootElement = editor.getRootElement()!; + outerWrapper.appendChild(rootElement); + editor.update( + () => { + $getRoot() + .clear() + .append($createParagraphNode().append($createTextNode('hello'))); + }, + {discrete: true}, + ); + editor.read(() => { + const textSpan = rootElement.querySelector('[data-lexical-text]'); + expect(textSpan).not.toBeNull(); + assert(!isDOMCapturingSelection(textSpan!, editor)); + }); + }); +}); diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx index 59531a099b2..af41d460402 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx +++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx @@ -43,6 +43,7 @@ import { $getRoot, $isElementNode, $isParagraphNode, + $isRootNode, $isTextNode, $parseSerializedNode, $setCompositionKey, @@ -390,8 +391,9 @@ describe('LexicalEditor tests', () => { editor.read(() => { const rootElement = editor.getRootElement(); expect(rootElement).toBeDefined(); - // The root never works for this call - expect($getNearestNodeFromDOMNode(rootElement!)).toBe(null); + // The root element now carries __lexicalKey_* = 'root' so + // $getNearestNodeFromDOMNode resolves it to the RootNode. + assert($isRootNode($getNearestNodeFromDOMNode(rootElement!))); const paragraphDom = rootElement!.querySelector('p'); expect(paragraphDom).toBeDefined(); expect( diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index aaf0322790d..7368cdb3922 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -309,6 +309,7 @@ export { INTERNAL_$isBlock, isBlockDomNode, isDocumentFragment, + isDOMCapturingSelection, isDOMDocumentNode, isDOMNode, isDOMTextNode, @@ -326,6 +327,7 @@ export { removeFromParent, resetRandomKey, setDOMUnmanaged, + type SetDOMUnmanagedOptions, setNodeIndentFromDOM, toggleTextFormatType, } from './LexicalUtils'; @@ -340,6 +342,8 @@ export type {SerializedLineBreakNode} from './nodes/LexicalLineBreakNode'; export { $createLineBreakNode, $isLineBreakNode, + isLastChildInBlockNode, + isOnlyChildInBlockNode, LineBreakNode, } from './nodes/LexicalLineBreakNode'; export type {SerializedParagraphNode} from './nodes/LexicalParagraphNode'; diff --git a/packages/lexical/src/nodes/LexicalLineBreakNode.ts b/packages/lexical/src/nodes/LexicalLineBreakNode.ts index e9e7a2d6bd1..376ae273ad7 100644 --- a/packages/lexical/src/nodes/LexicalLineBreakNode.ts +++ b/packages/lexical/src/nodes/LexicalLineBreakNode.ts @@ -90,7 +90,15 @@ export function $isLineBreakNode( return node instanceof LineBreakNode; } -function isOnlyChildInBlockNode(node: Node): boolean { +/** + * True when `node` is the sole non-whitespace child of a block DOM + * element. Used by the LineBreak importer to drop stray `
    ` elements + * that the legacy `$generateNodesFromDOM` also skipped (matches the + * behavior of `LineBreakNode.importDOM`). + * + * @experimental + */ +export function isOnlyChildInBlockNode(node: Node): boolean { const parentElement = node.parentElement; if (parentElement !== null && isBlockDomNode(parentElement)) { const firstChild = parentElement.firstChild!; @@ -111,7 +119,15 @@ function isOnlyChildInBlockNode(node: Node): boolean { return false; } -function isLastChildInBlockNode(node: Node): boolean { +/** + * True when `node` is the trailing non-whitespace child of a block DOM + * element (excluding the only-child case). Used by the LineBreak + * importer to drop trailing `
    ` elements like the Apple-interchange + * clipboard artifact (matches `LineBreakNode.importDOM`). + * + * @experimental + */ +export function isLastChildInBlockNode(node: Node): boolean { const parentElement = node.parentElement; if (parentElement !== null && isBlockDomNode(parentElement)) { // check if node is first child, because only child dont count diff --git a/playwright.config.mjs b/playwright.config.mjs index 578481e96d6..4ceaf4ae3cd 100644 --- a/playwright.config.mjs +++ b/playwright.config.mjs @@ -6,7 +6,7 @@ * */ -import {devices} from '@playwright/test'; +import {defineConfig, devices} from '@playwright/test'; const {CI, E2E_EDITOR_MODE, PWDEBUG} = process.env; const IS_CI = CI === 'true'; @@ -21,47 +21,36 @@ const IS_COLLAB = // doesn't wrap and break CMD+ArrowRight/Left navigation. const viewport = {height: 1000, width: IS_COLLAB ? 3000 : 1250}; -const config = { - forbidOnly: IS_CI, - projects: [ - { - name: 'chromium', - testDir: './packages/lexical-playground/__tests__/', - use: { - ...devices['Desktop Chrome'], - launchOptions: { - slowMo: 50, - }, - userAgent: undefined, - viewport, - }, - }, - { - name: 'firefox', - testDir: './packages/lexical-playground/__tests__/', - use: { - ...devices['Desktop Firefox'], - launchOptions: { - slowMo: 50, - }, - userAgent: undefined, - viewport, - }, - }, - { - name: 'webkit', - testDir: './packages/lexical-playground/__tests__/', - use: { - ...devices['Desktop Safari'], - launchOptions: { - slowMo: 50, - }, - userAgent: undefined, - viewport, +/** + * + * @param {string} name + * @param {keyof typeof devices} deviceName + * @returns + */ +function project(name, deviceName) { + return { + name, + testDir: './packages/lexical-playground/__tests__/', + use: { + ...devices[deviceName], + launchOptions: { + slowMo: 50, }, + userAgent: undefined, + viewport, }, + }; +} + +const config = defineConfig({ + forbidOnly: IS_CI, + fullyParallel: !IS_DEBUG, + projects: [ + project('chromium', 'Dekstop Chrome'), + project('firefox', 'Desktop Firefox'), + project('webkit', 'Desktop Safari'), ], - retries: IS_DEBUG ? 0 : IS_CI ? 4 : 1, + retries: IS_DEBUG ? 0 : IS_CI ? 2 : 1, testIgnore: /\/__tests__\/unit\//, timeout: 150000, use: { @@ -74,11 +63,11 @@ const config = { webServer: IS_CI ? { command: 'pnpm run start-test-server', - port: 4000, - reuseExistingServer: true, + reuseExistingServer: false, timeout: 120 * 1000, + url: 'http://localhost:4000', } : undefined, - workers: 4, -}; + workers: IS_DEBUG ? 1 : IS_CI ? 4 : undefined, +}); export default config; diff --git a/scripts/__tests__/integration/utils.mjs b/scripts/__tests__/integration/utils.mjs index c11d1d6a6bc..b1c39a20089 100644 --- a/scripts/__tests__/integration/utils.mjs +++ b/scripts/__tests__/integration/utils.mjs @@ -7,6 +7,7 @@ */ // @ts-check import fs from 'fs-extra'; +import {glob} from 'glob'; import path from 'node:path'; import {beforeAll, describe, expect, test} from 'vitest'; @@ -67,7 +68,7 @@ function expectSuccessfulExec(cmd) { * @param {ExampleContext} ctx * @returns {Promise>} The installed monorepo dependency map */ -async function buildExample({packageJson, exampleDir}) { +async function buildExample({packageJson, packageJsonPath, exampleDir}) { let hasPlaywright = false; /** @type {Map} */ const allDeps = new Map(); @@ -89,22 +90,54 @@ async function buildExample({packageJson, exampleDir}) { if (depsMap.size === 0) { throw new Error(`No lexical dependencies detected: ${exampleDir}`); } - const installDeps = Array.from(depsMap.entries(), ([dep, pkg]) => - path.resolve('npm', `${pkg.getDirectoryName()}-${monorepoVersion}.tgz`), - ); - ['node_modules', 'dist', 'build', '.next', '.svelte-kit'].forEach(cleanDir => - fs.removeSync(path.resolve(exampleDir, cleanDir)), + // Build pnpm.overrides entries pointing each monorepo dep at its + // freshly built tarball. We layer them on top of any pnpm.overrides + // the example already declares (e.g. agent-example's stubs for + // onnxruntime-node / sharp) so the existing overrides keep firing. + const lexicalOverrides = Object.fromEntries( + Array.from(depsMap.entries(), ([dep, pkg]) => [ + dep, + `file:${path.resolve( + 'npm', + `${pkg.getDirectoryName()}-${monorepoVersion}.tgz`, + )}`, + ]), ); + const augmentedPackageJson = { + ...packageJson, + pnpm: { + ...(packageJson.pnpm || {}), + overrides: { + ...((packageJson.pnpm && packageJson.pnpm.overrides) || {}), + ...lexicalOverrides, + }, + }, + }; + const originalPackageJsonBytes = fs.readFileSync(packageJsonPath); + [ + 'node_modules', + 'dist', + 'build', + '.next', + '.svelte-kit', + 'pnpm-lock.yaml', + ].forEach(cleanPath => fs.removeSync(path.resolve(exampleDir, cleanPath))); - await withCwd(exampleDir, async () => { - await expectSuccessfulExec( - `npm install --no-save ${installDeps.map(fn => `'${fn}'`).join(' ')}`, - ); - await expectSuccessfulExec('npm run build'); - if (hasPlaywright) { - await expectSuccessfulExec('npx playwright install'); - } - }); + try { + fs.writeJsonSync(packageJsonPath, augmentedPackageJson, {spaces: 2}); + await withCwd(exampleDir, async () => { + await expectSuccessfulExec('pnpm install --ignore-workspace'); + await expectSuccessfulExec('pnpm run build'); + if (hasPlaywright) { + await expectSuccessfulExec('pnpm exec playwright install'); + } + }); + } finally { + // Restore the unmodified package.json so the test doesn't leave a + // dirty working tree behind (the file-path overrides reference an + // absolute path on the runner that wouldn't make sense elsewhere). + fs.writeFileSync(packageJsonPath, originalPackageJsonBytes); + } return depsMap; } @@ -132,24 +165,43 @@ function describeExample(packageJsonPath, bodyFun = undefined) { const packageNames = deps.map(pkg => pkg.getNpmName()); expect(packageNames).toContain('lexical'); for (const pkg of deps) { - const installedPath = path.join( - exampleDir, - 'node_modules', - pkg.getNpmName(), - ); - expect({[installedPath]: fs.existsSync(installedPath)}).toEqual({ - [installedPath]: true, + const name = pkg.getNpmName(); + // Direct deps surface as `node_modules//` symlinks (pnpm) + // or real dirs (npm). Transitive deps without a top-level entry + // live under `node_modules/.pnpm/@[+peer-hash]/ + // node_modules//`, so glob both shapes and pick the first + // package.json with a matching name + version. + const candidates = [ + path.join(exampleDir, 'node_modules', name, 'package.json'), + ...glob.sync( + `node_modules/.pnpm/*/node_modules/${name}/package.json`, + { + absolute: true, + cwd: exampleDir, + }, + ), + ]; + const match = candidates.find(candidate => { + if (!fs.existsSync(candidate)) { + return false; + } + const json = fs.readJsonSync(candidate); + return json.name === name && json.version === monorepoVersion; }); - expect( - fs.readJsonSync(path.join(installedPath, 'package.json')), - ).toMatchObject({name: pkg.getNpmName(), version: monorepoVersion}); + if (match === undefined) { + throw new Error( + `Could not find ${name}@${monorepoVersion} under ${exampleDir}/node_modules (searched ${candidates.length} candidate${candidates.length === 1 ? '' : 's'})`, + ); + } } }); if (packageJson.scripts.test) { test( 'tests pass', async () => { - await withCwd(exampleDir, () => expectSuccessfulExec('npm run test')); + await withCwd(exampleDir, () => + expectSuccessfulExec('pnpm run test'), + ); }, LONG_TIMEOUT, );