Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ test.describe('Clear All Formatting', () => {
await assertHTML(
page,
html`
<p class="PlaygroundEditorTheme__paragraph" dir="auto" style="">
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">Hello World Test</span>
</p>
`,
Expand All @@ -221,7 +221,7 @@ test.describe('Clear All Formatting', () => {
await assertHTML(
page,
html`
<p class="PlaygroundEditorTheme__paragraph" dir="auto" style="">
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">Hello World Test</span>
</p>
`,
Expand All @@ -242,7 +242,7 @@ test.describe('Clear All Formatting', () => {
await assertHTML(
page,
html`
<p class="PlaygroundEditorTheme__paragraph" dir="auto" style="">
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">Hello World Test</span>
</p>
`,
Expand All @@ -269,7 +269,7 @@ test.describe('Clear All Formatting', () => {
await assertHTML(
page,
html`
<p class="PlaygroundEditorTheme__paragraph" dir="auto" style="">
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">Hello World Test</span>
</p>
`,
Expand All @@ -288,7 +288,7 @@ test.describe('Clear All Formatting', () => {
await assertHTML(
page,
html`
<p class="PlaygroundEditorTheme__paragraph" dir="auto" style="">
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">Hello World</span>
</p>
`,
Expand Down
12 changes: 6 additions & 6 deletions packages/lexical-playground/__tests__/e2e/Indentation.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -404,13 +404,13 @@ test.describe('Identation', () => {
await assertHTML(
page,
html`
<p class="PlaygroundEditorTheme__paragraph" dir="auto" style="">
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">foo</span>
</p>
<p class="PlaygroundEditorTheme__paragraph" dir="auto" style="">
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">bar</span>
</p>
<p class="PlaygroundEditorTheme__paragraph" dir="auto" style="">
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">yar</span>
</p>
<ul class="PlaygroundEditorTheme__ul" dir="auto">
Expand All @@ -434,7 +434,7 @@ test.describe('Identation', () => {
data-gutter="1">
<span data-lexical-text="true">code</span>
</code>
<p class="PlaygroundEditorTheme__paragraph" dir="auto" style="">
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<br data-lexical-managed-linebreak="true" />
</p>
<table
Expand All @@ -447,13 +447,13 @@ test.describe('Identation', () => {
<th
class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellHeader PlaygroundEditorTheme__tableCellSelected"
dir="auto">
<p class="PlaygroundEditorTheme__paragraph" dir="auto" style="">
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">foo</span>
</p>
</th>
</tr>
</table>
<p class="PlaygroundEditorTheme__paragraph" dir="auto" style="">
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<br data-lexical-managed-linebreak="true" />
</p>
`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ test.describe('Keyboard shortcuts', () => {
await assertHTML(
page,
html`
<p class="PlaygroundEditorTheme__paragraph" dir="auto" style="">
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">abc</span>
</p>
`,
Expand Down
50 changes: 50 additions & 0 deletions packages/lexical-playground/__tests__/e2e/Links.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
selectCharacters,
STANDARD_KEYPRESS_DELAY_MS,
toggleBold,
undo,
} from '../keyboardShortcuts/index.mjs';
import {
assertHTML,
Expand Down Expand Up @@ -2370,6 +2371,55 @@ test.describe('Links', () => {
{ignoreClasses: false},
);
});

test('Undo from the link URL input undoes in the editor rather than natively (#6714)', async ({
page,
browserName,
isCollab,
}) => {
// Firefox keeps the undo scoped to the focused control and never
// dispatches `beforeinput`/`historyUndo` on the editor root, so there is
// nothing for the editor to mishandle.
test.skip(browserName === 'firefox' || isCollab);
await focusEditor(page);
await page.keyboard.type('Hello world ');
await withExclusiveClipboardAccess(async () => {
await pasteFromClipboard(page, {'text/plain': 'https://lexical.dev'});
});
await assertHTML(
page,
html`
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">Hello world</span>
<a class="PlaygroundEditorTheme__link" href="https://lexical.dev">
<span data-lexical-text="true">https://lexical.dev</span>
</a>
</p>
`,
);

// Open the URL field of the link popup, which moves focus out of the
// editor and leaves the editor without a selection.
await click(page, '.link-edit');
await focus(page, '.link-input');

// Chromium and WebKit exhaust the URL field's own (empty) undo stack and
// then dispatch the undo at the editor root. Nothing has been typed into
// the URL field, so this is the very first undo the user presses.
await undo(page);

// The pasted link is undone. Before the fix the browser ran its native
// undo over the editor's DOM instead, producing "Hhttps://lexical.dev":
// a state that was never in Lexical's history.
await assertHTML(
page,
html`
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">Hello world</span>
</p>
`,
);
});
});

test.describe('Link attributes', () => {
Expand Down
50 changes: 50 additions & 0 deletions packages/lexical-playground/__tests__/unit/CardNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,56 @@ describe('CardNode named slots', () => {
});
});

// A Card is a shadow root, so a caret placed between two of them renders a
// block cursor rather than a text position. Backspace there has to delete
// the Card it sits against — including its named slots — the same way it
// deletes an adjacent block DecoratorNode (#8939).
it.for([
{isBackward: true, survivor: 'B'},
{isBackward: false, survivor: 'A'},
])(
'block cursor between two Cards deletes one (isBackward: $isBackward)',
({isBackward, survivor}) => {
using editor = buildEditorFromExtensions(CardTestExtension);

editor.update(
() => {
const a = $createCardNode();
const b = $createCardNode();
for (const [card, label] of [
[a, 'A'],
[b, 'B'],
] as const) {
const title = $getSlot(card, 'title');
assert($isParagraphNode(title), 'title slot must be a paragraph');
title.append($createTextNode(label));
}
$getRoot().clear().append(a, b);
// The block cursor sits between the two Cards.
$getRoot().select(1, 1);
},
{discrete: true},
);

editor.update(
() => {
const selection = $getSelection();
assert($isRangeSelection(selection), 'Expected a RangeSelection');
selection.deleteCharacter(isBackward);
},
{discrete: true},
);

editor.read(() => {
expect($getRoot().getChildrenSize()).toBe(1);
const card = $getRoot().getFirstChild();
assert($isCardNode(card), 'The other Card must survive');
// The slot went with the deleted host, and the survivor kept its own.
expect($getSlot(card, 'title')?.getTextContent()).toBe(survivor);
});
},
);

// Mid-text deletion inside the bare title value rides core's
// deleteCharacter: $getNearestRootOrShadowRoot treats the slotted value as
// its own scope root (the slot link is a virtual shadow root), so the
Expand Down
165 changes: 165 additions & 0 deletions packages/lexical-playground/__tests__/unit/Issue6938Repro.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import {buildEditorFromExtensions} from '@lexical/extension';
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$isElementNode,
$isParagraphNode,
$isRangeSelection,
$selectAll,
defineExtension,
type LexicalNode,
} from 'lexical';
import {assert, describe, expect, it} from 'vitest';

import {$createLayoutContainerNode} from '../../src/nodes/LayoutContainerNode';
import {$createLayoutItemNode} from '../../src/nodes/LayoutItemNode';
import {LayoutExtension} from '../../src/plugins/LayoutExtension/LayoutExtension';

const LayoutTestExtension = defineExtension({
$initialEditorState: null,
dependencies: [LayoutExtension],
name: '[test-layout]',
});

function $createColumns() {
const container = $createLayoutContainerNode('1fr 1fr');
const item1 = $createLayoutItemNode();
item1.append($createParagraphNode().append($createTextNode('col1')));
const item2 = $createLayoutItemNode();
item2.append($createParagraphNode().append($createTextNode('col2')));
return container.append(item1, item2);
}

function describeNode(node: LexicalNode): string {
return $isElementNode(node)
? `${node.getType()}(${node.getChildren().map(describeNode).join(',')})`
: `${node.getType()}[${node.getTextContent()}]`;
}

function $describeRoot(): string {
return $getRoot().getChildren().map(describeNode).join('|');
}

describe('Select-all + delete over a columns layout (#6938)', () => {
it('removes the widget when it is the first node', () => {
using editor = buildEditorFromExtensions(LayoutTestExtension);
editor.update(() => void $getRoot().clear().append($createColumns()), {
discrete: true,
});

editor.update(
() => {
const selection = $selectAll();
assert($isRangeSelection(selection), 'Expected RangeSelection');
selection.deleteCharacter(true);
},
{discrete: true},
);

editor.read(() => {
// Previously the container survived with two empty layout items.
expect($describeRoot()).toBe('paragraph()');
const first = $getRoot().getFirstChild();
assert($isParagraphNode(first), 'Expected ParagraphNode');
expect(first.isEmpty()).toBe(true);
});
});

it('removes the widget when it is the first node via removeText', () => {
using editor = buildEditorFromExtensions(LayoutTestExtension);
editor.update(() => void $getRoot().clear().append($createColumns()), {
discrete: true,
});

editor.update(() => void $selectAll().removeText(), {discrete: true});

editor.read(() => {
expect($describeRoot()).toBe('paragraph()');
});
});

it('replaces the widget when typing over a select-all', () => {
using editor = buildEditorFromExtensions(LayoutTestExtension);
editor.update(() => void $getRoot().clear().append($createColumns()), {
discrete: true,
});

editor.update(
() => {
const selection = $selectAll();
assert($isRangeSelection(selection), 'Expected RangeSelection');
selection.insertText('typed');
},
{discrete: true},
);

editor.read(() => {
expect($describeRoot()).toBe('paragraph(text[typed])');
});
});

it('already worked, and still works, with a paragraph before the widget', () => {
using editor = buildEditorFromExtensions(LayoutTestExtension);
editor.update(
() =>
void $getRoot()
.clear()
.append(
$createParagraphNode().append($createTextNode('before')),
$createColumns(),
),
{discrete: true},
);

editor.update(
() => {
const selection = $selectAll();
assert($isRangeSelection(selection), 'Expected RangeSelection');
selection.deleteCharacter(true);
},
{discrete: true},
);

editor.read(() => {
expect($describeRoot()).toBe('paragraph()');
});
});

it('keeps select-all scoped to a single column when the caret is inside one', () => {
using editor = buildEditorFromExtensions(LayoutTestExtension);
editor.update(() => void $getRoot().clear().append($createColumns()), {
discrete: true,
});

editor.update(
() => {
const container = $getRoot().getFirstChild();
assert($isElementNode(container), 'Expected ElementNode');
const item = container.getFirstChild();
assert($isElementNode(item), 'Expected ElementNode');
const paragraph = item.getFirstChild();
assert($isElementNode(paragraph), 'Expected ElementNode');
const selection = $selectAll(paragraph.select(0, 0));
selection.removeText();
},
{discrete: true},
);

editor.read(() => {
// Only the first column is cleared; the widget and the other column
// are untouched.
expect($describeRoot()).toBe(
'layout-container(layout-item(paragraph()),layout-item(paragraph(text[col2])))',
);
});
});
});
Loading
Loading