Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/call-e2e-all-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ e2e-screenshots
test-results
playwright-report
/SOURCE_CODE_REVIEW.md
/playwright.local.config.mjs

npm-debug.log*

Expand Down
6 changes: 6 additions & 0 deletions examples/agent-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
buildEditorFromExtensions,
EditorStateExtension,
effect,
watchedSignal
WatchEditableExtension
} from '@lexical/extension';
import { RichTextExtension } from '@lexical/rich-text';
import { TailwindExtension } from '@lexical/tailwind';
Expand Down Expand Up @@ -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 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
).output;
const editableSignal = extension.getExtensionDependencyFromEditor(
editor,
buildEditor.WatchEditableExtension
extension.WatchEditableExtension
).output;
$effect(() => {
if (browser && editorRef) {
Expand Down
31 changes: 31 additions & 0 deletions packages/lexical-extension/src/WatchEditableExtension.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>` 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',
});
1 change: 1 addition & 0 deletions packages/lexical-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export {
type TabIndentationConfig,
TabIndentationExtension,
} from './TabIndentationExtension';
export {WatchEditableExtension} from './WatchEditableExtension';
export {watchedSignal} from './watchedSignal';
export {
type AnyLexicalExtension,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,4 +371,26 @@ describe('CoreImportExtension', () => {
expect(para.getTextContent()).toBe('x');
});
});

test('unconverted block elements (not just <div>) 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, '<section>a</section><article>b</article>');
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-<div> block element is propagated to its paragraph', () => {
using editor = buildEditor();
importInto(editor, '<header style="text-align: right">x</header>');
editor.read(() => {
const para = $getRoot().getFirstChild();
assert($isParagraphNode(para), 'expected paragraph');
expect(para.getFormatType()).toBe('right');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
}),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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}),
],
Expand Down
62 changes: 61 additions & 1 deletion packages/lexical-html/src/import/coreImportRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ import {
IS_SUBSCRIPT,
IS_SUPERSCRIPT,
IS_UNDERLINE,
isBlockDomNode,
isDOMTextNode,
isLastChildInBlockNode,
isOnlyChildInBlockNode,
type LexicalNode,
setNodeIndentFromDOM,
} from 'lexical';
Expand All @@ -35,6 +38,7 @@ import {
ImportWhitespaceConfig,
type WhitespaceImportConfig,
} from './ImportContext';
import {$propagateTextAlignToBlockChildren, BlockSchema} from './schemas';
import {selBase} from './sel';

const sel = selBase;
Expand Down Expand Up @@ -437,7 +441,16 @@ const IgnoreScriptStyleRule = defineImportRule({
});

const LineBreakRule = defineImportRule({
$import: () => [$createLineBreakNode()],
// Mirror the legacy LineBreakNode.importDOM filter: stray `<br>` that
// are the sole or trailing child of a block parent (e.g. Apple's
// `<br class="Apple-interchange-newline">` clipboard sentinel, or the
// trailing `<br>` browsers insert after the last text in a `<div>`)
// 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',
});
Expand Down Expand Up @@ -467,6 +480,52 @@ const ParagraphRule = defineImportRule({
name: '@lexical/html/p',
});

/**
* Transparent block-container rule for any unconverted block-level DOM
* element — `<div>`, but also `<section>`, `<article>`, `<header>`,
* `<figure>`, … (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 `<section>`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 (`<p>`, `<li>`, `<td>`, 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 `<p>`
* and transparent blocks (`<div>`, `<section>`, …) 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
Expand All @@ -479,6 +538,7 @@ const ParagraphRule = defineImportRule({
export const CoreImportRules = [
IgnoreScriptStyleRule,
ParagraphRule,
TransparentBlockRule,
TextRule,
LineBreakRule,
InlineFormatRule,
Expand Down
1 change: 1 addition & 0 deletions packages/lexical-html/src/import/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export {parseSelector} from './parseCss';
export {
$distributeInlineWrapper,
$isBlockLevel,
$propagateTextAlignToBlockChildren,
BlockSchema,
InlineSchema,
NestedBlockSchema,
Expand Down
44 changes: 44 additions & 0 deletions packages/lexical-html/src/import/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
$isBlockElementNode,
$isDecoratorNode,
$isElementNode,
$isLineBreakNode,
type ElementNode,
isHTMLElement,
type LexicalNode,
Expand Down Expand Up @@ -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
Expand All @@ -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 `<br>` 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 `<br>` (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;
Expand Down
1 change: 1 addition & 0 deletions packages/lexical-html/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export {
$getImportContextValue,
$inlineStylesFromStyleSheets,
$isBlockLevel,
$propagateTextAlignToBlockChildren,
$withImportContext,
BlockSchema,
CoreImportExtension,
Expand Down
Loading
Loading