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
18 changes: 15 additions & 3 deletions packages/lexical/src/LexicalDOMSlot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,11 +431,23 @@ export class ElementDOMSlot<
lineBreakType: null | 'empty' | 'line-break' | 'decorator',
): void {
const element: HTMLElement & LexicalPrivateDOM = this.element;
element.__lexicalLastChildKind = lineBreakType;
if (lineBreakType === null) {
// Slot containers are a contiguous prefix of the managed range, so only
// its first node needs to be inspected. This also excludes containers
// outside an `after` boundary.
const firstNode =
this.after === null ? element.firstChild : this.after.nextSibling;
const nextLineBreakType =
lineBreakType === 'empty' && isSlotContainerDOM(firstNode)
? null
: lineBreakType;
if (element.__lexicalLastChildKind === nextLineBreakType) {
return;
}
element.__lexicalLastChildKind = nextLineBreakType;
if (nextLineBreakType === null) {
this.removeManagedLineBreak();
} else {
const webkitHack = lineBreakType === 'decorator' && IS_WEBKIT_BROWSER;
const webkitHack = nextLineBreakType === 'decorator' && IS_WEBKIT_BROWSER;
this.insertManagedLineBreak(webkitHack);
}
}
Expand Down
19 changes: 4 additions & 15 deletions packages/lexical/src/LexicalReconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -911,12 +911,7 @@ function $isLastChildLineBreakOrDecorator(
: null;
}
}
// A host with slots but no linked-list children is not empty (the slots
// carry its content). The 'empty' line break exists to give a truly empty
// block a caret target; on a slots-only host that <br> would instead be a
// stray caret target in the host's own child area, after the slot
// containers — text typed there leaks out of the slot. Skip it.
return $readSlots(element).size > 0 ? null : 'empty';
return 'empty';
}
return null;
}
Expand Down Expand Up @@ -962,20 +957,14 @@ function $reconcileElementTerminatingLineBreak(
nextElement: ElementNode,
dom: HTMLElement & LexicalPrivateDOM,
): void {
// Read previous render's last-child kind from the slot element's cache
// so the prev-state DecoratorNode reference's isInline() (which routes
// through getLatest() and would throw once the key is detached from the
// active node map) is never called.
const slot = $getDOMSlot(nextElement, dom, activeEditor);
const slotElement: HTMLElement & LexicalPrivateDOM = slot.element;
const prevLineBreak = slotElement.__lexicalLastChildKind ?? null;
const nextLineBreak = $isLastChildLineBreakOrDecorator(
nextElement,
activeNextNodeMap,
);
if (prevLineBreak !== nextLineBreak) {
slot.setManagedLineBreak(nextLineBreak);
}
// ElementDOMSlot normalizes the empty state against the actual content
// range, including named-slot containers, and caches the result.
slot.setManagedLineBreak(nextLineBreak);
}

function reconcileTextFormat(element: ElementNode): void {
Expand Down
13 changes: 13 additions & 0 deletions packages/lexical/src/__tests__/unit/LexicalDOMSlot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,19 @@ describe('ElementDOMSlot class', () => {
expect(updated).toBe(original);
});

test('setManagedLineBreak ignores slot containers before the managed range', () => {
const el = makeElement();
const slotContainer = document.createElement('div');
slotContainer.setAttribute('data-lexical-slot', 'title');
const after = document.createElement('span');
el.append(slotContainer, after);
const slot = new ElementDOMSlot(el, null, after);

inEditor(() => slot.setManagedLineBreak('empty'));

expect(slot.getManagedLineBreak()).not.toBe(null);
});

test('insertChild appends when before is null', () => {
const el = makeElement();
const slot = new ElementDOMSlot(el);
Expand Down
77 changes: 77 additions & 0 deletions packages/lexical/src/__tests__/unit/LexicalSlot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,30 @@ class ReservedDeclaredHostNode extends ElementNode {
}
}

class WrappedSlotHostNode extends ElementNode {
$config() {
return this.config('wrapped_slot_host', {
extends: ElementNode,
slots: ['title'],
});
}
createDOM() {
const host = document.createElement('div');
const content = document.createElement('span');
content.dataset.content = 'true';
host.appendChild(content);
return host;
}
updateDOM() {
return false;
}
getDOMSlot(dom: HTMLElement) {
return super
.getDOMSlot(dom)
.withElement(dom.querySelector<HTMLElement>('[data-content]') ?? dom);
}
}

const mountedRoots: HTMLElement[] = [];
afterEach(() => {
while (mountedRoots.length > 0) {
Expand Down Expand Up @@ -170,6 +194,7 @@ function createSlotEditor(): LexicalEditorWithDispose {
ReorderedHostNode,
DupDeclaredHostNode,
ReservedDeclaredHostNode,
WrappedSlotHostNode,
],
}),
);
Expand All @@ -182,6 +207,35 @@ function createSlotEditor(): LexicalEditorWithDispose {
}

describe('named-slots: core foundation', () => {
test('an empty wrapped child area gets a managed line break', () => {
using editor = createSlotEditor();
let hostKey = '';

editor.update(
() => {
const host = $create(WrappedSlotHostNode).append(
$createTextNode('Body'),
);
$getRoot().append(host);
$setSlot(host, 'title', $slotContainer('Title'));
hostKey = host.getKey();
},
{discrete: true},
);
editor.update(
() => {
$assertNodeType($getNodeByKey(hostKey), $isElementNode).clear();
},
{discrete: true},
);

const hostDom = editor.getElementByKey(hostKey)!;
const content = hostDom.querySelector('[data-content]')!;
expect(
content.querySelector('[data-lexical-managed-linebreak="true"]'),
).not.toBe(null);
});

test('a slotted node is reachable, parentless, and attached', () => {
using editor = createSlotEditor();
let hostKey = '';
Expand Down Expand Up @@ -1398,6 +1452,29 @@ describe('named-slots: core foundation', () => {
// A truly empty host (no slots, no children) still gets the br — the
// gate is scoped to slots-only hosts.
expect(directBr(editor.getElementByKey(emptyHostKey)!)).not.toBe(undefined);

editor.update(
() => {
$removeSlot(
$assertNodeType($getNodeByKey(slotHostKey), $isElementNode),
'title',
);
},
{discrete: true},
);
expect(directBr(editor.getElementByKey(slotHostKey)!)).not.toBe(undefined);

editor.update(
() => {
$setSlot(
$assertNodeType($getNodeByKey(slotHostKey), $isElementNode),
'title',
$slotContainer('New title'),
);
},
{discrete: true},
);
expect(directBr(editor.getElementByKey(slotHostKey)!)).toBeUndefined();
});

test('descendant navigation stays children-only (slots stay out of selection)', () => {
Expand Down