Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
f16b957
[lexical][lexical-utils][lexical-rich-text][lexical-plain-text] Featu…
mayrang Jun 5, 2026
121d8aa
[lexical-yjs] Feature: Sync named slots across collaborative clients
mayrang Jun 5, 2026
52cf106
[lexical-clipboard][lexical-html] Feature: Serialize named slots for …
mayrang Jun 5, 2026
1c96b62
[lexical-playground] Feature: Card and Figure named-slot demos
mayrang Jun 5, 2026
4cb870d
[lexical][lexical-utils][lexical-clipboard][lexical-yjs] Chore: Slot …
mayrang Jun 5, 2026
e16f05a
[lexical-playground] Test: Stabilize empty title-slot backspace e2e
mayrang Jun 5, 2026
53f880e
[lexical-playground] Bug Fix: Keep arrow keys in the Figure equation …
mayrang Jun 5, 2026
9c2d07c
[lexical-html][lexical-playground] Feature: Opt-in slot HTML export v…
mayrang Jun 5, 2026
1b7c8b5
[lexical-extension][lexical-playground] Refactor: Extract data-select…
mayrang Jun 5, 2026
f826d4b
[lexical-extension] Feature: Match registered subclasses in NodeSelec…
mayrang Jun 5, 2026
d25c3f5
[lexical-utils] Refactor: Split slot-aware dfs into separate $dfsWith…
mayrang Jun 5, 2026
217d878
[lexical-utils] Fix: Avoid bare `<=` in $reverseDfsWithSlotsIterator …
mayrang Jun 5, 2026
4d7dfaa
[lexical][lexical-yjs] Refactor: setSlot detaches replaced and parent…
mayrang Jun 5, 2026
1ca9327
[lexical-playground] Bug Fix: Scope Figure slot double-click e2e to t…
mayrang Jun 5, 2026
0ea0354
[lexical] Refactor: Brand slot-container DOM guard and drop getSlotHo…
mayrang Jun 6, 2026
108b56e
[lexical][lexical-yjs][lexical-react] Feature: DecoratorNode named-sl…
mayrang Jun 7, 2026
7a7d002
[lexical][lexical-yjs][lexical-clipboard][lexical-html][lexical-utils…
mayrang Jun 7, 2026
fc29e29
[lexical-playground] Test: Cross-client convergence e2e for named-slo…
mayrang Jun 7, 2026
eb92ced
[lexical-utils] Bug Fix: $dfsWithSlots / $reverseDfsWithSlots descend…
mayrang Jun 7, 2026
00af293
[lexical][lexical-html][lexical-extension] Refactor: Extract subclass…
mayrang Jun 7, 2026
a550ac3
[lexical-rich-text] Refactor: Revert incidental KEY_ENTER restructure…
mayrang Jun 7, 2026
04c26f2
[lexical] Chore: Link _slotsUsed JSDoc to $setSlot (etrepum #8603)
mayrang Jun 7, 2026
f02c018
[lexical-yjs] Bug Fix: Diff decorator-valued slots in place across ho…
mayrang Jun 7, 2026
3d56e54
[lexical] Refactor: Gate slot reconcile/clamp work and extract $getSl…
mayrang Jun 7, 2026
47f24e6
[lexical][lexical-yjs] Bug Fix: Slot cycle guard up-link walk + V1 sa…
mayrang Jun 7, 2026
3053f59
[lexical][lexical-html][lexical-utils][lexical-yjs] Refactor: Slot AP…
mayrang Jun 7, 2026
938f0e6
[lexical][lexical-yjs][lexical-react] Bug Fix: Named-slot reconciler …
mayrang Jun 7, 2026
52c4601
[lexical][lexical-utils][lexical-react][lexical-table][lexical-clipbo…
mayrang Jun 8, 2026
ccbdfd1
[lexical-react][lexical-playground][lexical-utils][lexical-clipboard]…
mayrang Jun 8, 2026
097f0f1
[lexical-playground] Bug Fix: Figure atomic host + Card/Figure HTML r…
mayrang Jun 8, 2026
97bcc7e
[lexical-playground] Tests: HTML round-trip + EquationNode malformed …
mayrang Jun 8, 2026
6ea93c4
[lexical-clipboard] Tests: Use select() over selectStart() for empty …
mayrang Jun 8, 2026
be7b9e0
[lexical-react][lexical-code-core] Chore: Run update-packages after r…
mayrang Jun 8, 2026
0f9c8a2
[lexical] Feature: $getSlotNameWithinHost helper for slot-child name …
mayrang Jun 9, 2026
0b15475
[lexical][lexical-clipboard][lexical-playground] Refactor: CardNode a…
mayrang Jun 9, 2026
9d604f3
[lexical-playground] Tests: Update Card e2e for body-as-children (etr…
mayrang Jun 9, 2026
ba74cd1
[lexical-playground] Feature: Tab handler PoC for Card slot navigatio…
mayrang Jun 9, 2026
d8ae17f
[lexical-playground] Refactor: Narrow Card Tab handler + caret-slot f…
mayrang Jun 9, 2026
da8e156
[lexical-playground] Refactor: Card-like UX + caret-slot focus indica…
mayrang Jun 9, 2026
8441980
[lexical-devtools-core] Feature: Surface named slots in the tree view…
mayrang Jun 9, 2026
d64ae40
[lexical] Refactor: Polish R1 audit nits on the slot core (#8603)
mayrang Jun 10, 2026
f398225
[lexical] Bug Fix: $selectAll on slot-value anchor + setEditorState s…
mayrang Jun 10, 2026
715ef24
[lexical-yjs] Bug Fix: V2 slot subtree mapping leak + YMap routing gu…
mayrang Jun 10, 2026
f25561a
[lexical-html][lexical-playground][lexical-utils] Bug Fix: HTML round…
mayrang Jun 10, 2026
2f52d23
[lexical-playground] Bug Fix: Slot wrapper click promotion + R5 audit…
mayrang Jun 10, 2026
9ec2751
[lexical-devtools-core] Refactor: Gate slot-value marker on slotName …
mayrang Jun 10, 2026
ee8bd34
[lexical-playground] Tests: e2e coverage for slot wrapper click / Tab…
mayrang Jun 10, 2026
8cbea16
[lexical-playground] Refactor: Figure (Equation) → PullQuote (quote +…
mayrang Jun 10, 2026
6e1bbef
[lexical][lexical-playground][lexical-yjs] Refactor: Comprehensive au…
mayrang Jun 10, 2026
51f1b66
[lexical] Bug Fix: $selectAll guards RootNode anchor instead of throw…
mayrang Jun 10, 2026
f246683
[lexical-playground] Bug Fix: Restore rich-text import extension impo…
mayrang Jun 10, 2026
47cadd9
Merge facebook/lexical main (c713d5c) into feat/5930-element-decorate…
claude Jun 11, 2026
17cf93a
[lexical][lexical-yjs][lexical-clipboard][lexical-html][lexical-utils…
claude Jun 11, 2026
bc4a593
[lexical][lexical-yjs][lexical-utils][lexical-rich-text][lexical-plai…
claude Jun 12, 2026
302dd19
[lexical][lexical-clipboard][lexical-yjs][lexical-extension][lexical-…
claude Jun 12, 2026
6a9a339
[lexical-playground] Tests: Skip command-scoped SELECT_ALL slot e2e o…
claude Jun 12, 2026
fca4066
[lexical-playground] Tests: Press the real shortcut for selectAll on …
claude Jun 12, 2026
027f0f6
Merge remote-tracking branch 'etrepum/claude/happy-planck-7ogimy' int…
etrepum Jun 12, 2026
4ceb1c9
Merge branch 'main' into feat/5930-element-decorate
etrepum Jun 12, 2026
b78a3ff
[lexical-extension] Feature: SelectBlockExtension expands through slo…
claude Jun 12, 2026
fd86622
Merge remote-tracking branch 'etrepum/claude/happy-planck-7ogimy' int…
etrepum Jun 12, 2026
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
1 change: 1 addition & 0 deletions .flowconfig
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ module.name_mapper='^@lexical/react/useExtensionSignalValue$' -> '<PROJECT_ROOT>
module.name_mapper='^@lexical/react/useLexicalEditable$' -> '<PROJECT_ROOT>/packages/lexical-react/flow/useLexicalEditable.js.flow'
module.name_mapper='^@lexical/react/useLexicalIsTextContentEmpty$' -> '<PROJECT_ROOT>/packages/lexical-react/flow/useLexicalIsTextContentEmpty.js.flow'
module.name_mapper='^@lexical/react/useLexicalNodeSelection$' -> '<PROJECT_ROOT>/packages/lexical-react/flow/useLexicalNodeSelection.js.flow'
module.name_mapper='^@lexical/react/useLexicalSlot$' -> '<PROJECT_ROOT>/packages/lexical-react/flow/useLexicalSlot.js.flow'
module.name_mapper='^@lexical/react/useLexicalSubscription$' -> '<PROJECT_ROOT>/packages/lexical-react/flow/useLexicalSubscription.js.flow'
module.name_mapper='^@lexical/react/useLexicalTextEntity$' -> '<PROJECT_ROOT>/packages/lexical-react/flow/useLexicalTextEntity.js.flow'
module.name_mapper='^@lexical/rich-text$' -> '<PROJECT_ROOT>/packages/lexical-rich-text/flow/LexicalRichText.js.flow'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,350 @@
/**
* 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 {
$generateJSONFromSelectedNodes,
$generateNodesFromSerializedNodes,
$insertGeneratedNodes,
} from '@lexical/clipboard';
import {createHeadlessEditor} from '@lexical/headless';
import {$generateHtmlFromNodes} from '@lexical/html';
import {
$create,
$createParagraphNode,
$createRangeSelection,
$createTextNode,
$getNodeByKey,
$getRoot,
$getSelection,
$getSlot,
$getSlotHost,
$getSlotNames,
$isTextNode,
$setSelection,
$setSlot,
ElementNode,
type SerializedElementNode,
} from 'lexical';
import {assert, describe, expect, test} from 'vitest';

// A plain shadow-root ElementNode used as a slot value for the positive
// round-trip test. Mirrors the production playground's slot-value shape
// (shadow-root container holding regular block content) without the
// excludeFromCopy override that ExcludedShadowRootNode adds.
class PlainShadowRootNode extends ElementNode {
$config() {
return this.config('plain_shadow_root', {extends: ElementNode});
}
createDOM(): HTMLElement {
return document.createElement('div');
}
updateDOM(): boolean {
return false;
}
isShadowRoot(): boolean {
return true;
}
}

function $createPlainShadowRootNode(): PlainShadowRootNode {
return $create(PlainShadowRootNode);
}

// A shadow-root slot value that also excludes itself from copy — an
// unsupported combination the export guard must reject loudly instead of
// emitting a dangling slot entry that breaks on paste.
class ExcludedShadowRootNode extends ElementNode {
$config() {
return this.config('excluded_shadow_root', {extends: ElementNode});
}
createDOM(): HTMLElement {
return document.createElement('div');
}
updateDOM(): boolean {
return false;
}
isShadowRoot(): boolean {
return true;
}
excludeFromCopy(): boolean {
return true;
}
}

function $createExcludedShadowRootNode(): ExcludedShadowRootNode {
return $create(ExcludedShadowRootNode);
}

// A Card-shaped host: shadow root that opts in to whole-host child export
// when selected via NodeSelection. Used to pin that the opt-in does NOT
// promote partial RangeSelections.
class CardLikeNode extends ElementNode {
$config() {
return this.config('card_like', {extends: ElementNode});
}
createDOM(): HTMLElement {
return document.createElement('div');
}
updateDOM(): boolean {
return false;
}
isShadowRoot(): boolean {
return true;
}
includeChildrenWhenSelected(): boolean {
return true;
}
}

function $createCardLikeNode(): CardLikeNode {
return $create(CardLikeNode);
}

describe('slot clipboard export', () => {
test('throws when a slot value is excluded from copy', () => {
const editor = createHeadlessEditor({
namespace: 'slot-exclude',
nodes: [ExcludedShadowRootNode],
});
editor.update(
() => {
const host = $createParagraphNode();
$getRoot().append(host);
$setSlot(host, 'title', $createExcludedShadowRootNode());
},
{discrete: true},
);
editor.read(() => {
expect(() => $generateJSONFromSelectedNodes(editor, null)).toThrow(
/did not serialize to exactly the slot value node/,
);
});
});

test('a host outside the selection does not gate the export on its slot', () => {
const editor = createHeadlessEditor({
namespace: 'slot-exclude',
nodes: [ExcludedShadowRootNode],
});
let beforeKey = '';
editor.update(
() => {
const before = $createTextNode('BEFORE');
$getRoot().append($createParagraphNode().append(before));
const host = $createParagraphNode();
host.append($createTextNode('CHILD'));
$setSlot(host, 'title', $createExcludedShadowRootNode());
$getRoot().append(host);
beforeKey = before.getKey();
},
{discrete: true},
);
editor.update(
() => {
const before = $getNodeByKey(beforeKey);
assert(before !== null && $isTextNode(before));
before.select(0, 6);
},
{discrete: true},
);
editor.read(() => {
// The selection covers only "BEFORE"; the slot-bearing host is outside
// it, so its excluded slot must not be reached and must not throw.
expect(() =>
$generateJSONFromSelectedNodes(editor, $getSelection()),
).not.toThrow();
});
});

// Positive round-trip: a slot-bearing host (host + named slot whose value is
// a shadow-root ElementNode holding regular content) serializes through
// $generateJSONFromSelectedNodes and restores via
// $generateNodesFromSerializedNodes + $insertGeneratedNodes with the slot
// name and slot subtree text preserved.
test('a slot-bearing host round-trips through JSON copy + insert', () => {
const editor = createHeadlessEditor({
namespace: 'slot-roundtrip',
nodes: [PlainShadowRootNode],
});
editor.update(
() => {
const host = $createParagraphNode();
host.append($createTextNode('HostChild'));
const slot = $createPlainShadowRootNode();
slot.append($createParagraphNode().append($createTextNode('SlotText')));
$getRoot().append(host);
$setSlot(host, 'media', slot);
},
{discrete: true},
);

// Copy: null selection → whole tree.
let serialized: ReturnType<typeof $generateJSONFromSelectedNodes>;
editor.read(() => {
serialized = $generateJSONFromSelectedNodes(editor, null);
});
expect(serialized!.nodes).toHaveLength(1);
const hostJson = serialized!.nodes[0] as SerializedElementNode & {
slots?: Record<string, SerializedElementNode>;
};
expect(hostJson.slots).toBeDefined();
expect(hostJson.slots!.media).toBeDefined();

// Paste into a fresh editor and verify the slot survived.
const editor2 = createHeadlessEditor({
namespace: 'slot-roundtrip',
nodes: [PlainShadowRootNode],
});
editor2.update(
() => {
const target = $createParagraphNode();
$getRoot().append(target);
target.select();
const nodes = $generateNodesFromSerializedNodes(serialized!.nodes);
$insertGeneratedNodes(editor2, nodes, $getSelection()!);
},
{discrete: true},
);

editor2.read(() => {
const inserted = $getRoot()
.getChildren()
.find(n => $getSlotNames(n).length > 0);
expect(inserted).toBeDefined();
expect($getSlotNames(inserted!)).toEqual(['media']);
const slot = $getSlot(inserted!, 'media');
expect(slot).not.toBeNull();
expect(slot!.getTextContent()).toContain('SlotText');
// The slot up-link must point back to the inserted host (not the
// original copy-side host whose key may not exist in this editor).
expect($getSlotHost(slot!)!.is(inserted!)).toBe(true);
});
});

// A RangeSelection wholly inside a slot never contains the host, so the
// exporters must walk the selection's slot frame instead of only the root —
// otherwise copy returns an empty payload and cut is silent data loss.
test('a selection inside a slot exports its content on both channels', () => {
const editor = createHeadlessEditor({
namespace: 'slot-inner-copy',
nodes: [PlainShadowRootNode],
});
let slotTextKey = '';
editor.update(
() => {
const host = $createParagraphNode();
host.append($createTextNode('HostChild'));
const slot = $createPlainShadowRootNode();
const slotText = $createTextNode('SlotText');
slot.append($createParagraphNode().append(slotText));
$getRoot().append(host);
$setSlot(host, 'media', slot);
slotTextKey = slotText.getKey();
},
{discrete: true},
);
editor.update(
() => {
const slotText = $getNodeByKey(slotTextKey);
assert(slotText !== null && $isTextNode(slotText));
slotText.select(0, 'SlotText'.length);
},
{discrete: true},
);
editor.read(() => {
const selection = $getSelection();
const json = $generateJSONFromSelectedNodes(editor, selection);
expect(JSON.stringify(json.nodes)).toContain('SlotText');
// The host (and its unselected child) stays out of an in-slot copy.
expect(JSON.stringify(json.nodes)).not.toContain('HostChild');
const html = $generateHtmlFromNodes(editor, selection);
expect(html).toContain('SlotText');
expect(html).not.toContain('HostChild');
});
});

// includeChildrenWhenSelected is a whole-host (NodeSelection) opt-in; a
// partial RangeSelection that happens to contain the host must keep
// slicing per child or a drag into the host over-exports content the user
// never selected.
test('a partial range over an includeChildrenWhenSelected host does not over-export', () => {
const editor = createHeadlessEditor({
namespace: 'slot-partial-range',
nodes: [CardLikeNode, PlainShadowRootNode],
});
let introTextKey = '';
let bodyTextKey = '';
editor.update(
() => {
const intro = $createTextNode('Intro');
$getRoot().append($createParagraphNode().append(intro));
const card = $createCardLikeNode();
const bodyText = $createTextNode('Body');
card.append($createParagraphNode().append(bodyText));
card.append(
$createParagraphNode().append($createTextNode('UNSELECTED')),
);
const title = $createPlainShadowRootNode();
title.append($createParagraphNode().append($createTextNode('Title')));
$getRoot().append(card);
$setSlot(card, 'title', title);
introTextKey = intro.getKey();
bodyTextKey = bodyText.getKey();
},
{discrete: true},
);
editor.update(
() => {
const selection = $createRangeSelection();
selection.anchor.set(introTextKey, 0, 'text');
selection.focus.set(bodyTextKey, 2, 'text');
$setSelection(selection);
},
{discrete: true},
);
editor.read(() => {
const selection = $getSelection();
const json = JSON.stringify(
$generateJSONFromSelectedNodes(editor, selection).nodes,
);
expect(json).toContain('Intro');
expect(json).toContain('"Bo"');
expect(json).not.toContain('"Body"');
expect(json).not.toContain('UNSELECTED');
const html = $generateHtmlFromNodes(editor, selection);
expect(html).toContain('Bo');
expect(html).not.toContain('Body');
expect(html).not.toContain('UNSELECTED');
});
});

// The 0-children excluded case is covered above; with exactly one child the
// child used to be spliced up and exported AS the slot value, silently
// corrupting the payload. The guard must compare the exported type too.
test('throws when a 1-child excluded slot value would export its child instead', () => {
const editor = createHeadlessEditor({
namespace: 'slot-exclude-one-child',
nodes: [ExcludedShadowRootNode],
});
editor.update(
() => {
const host = $createParagraphNode();
$getRoot().append(host);
const slot = $createExcludedShadowRootNode();
slot.append($createParagraphNode().append($createTextNode('inner')));
$setSlot(host, 'title', slot);
},
{discrete: true},
);
editor.read(() => {
expect(() => $generateJSONFromSelectedNodes(editor, null)).toThrow(
/did not serialize to exactly the slot value node/,
);
});
});
});
Loading