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
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
*
*/

import {$createLinkNode} from '@lexical/link';
import {buildEditorFromExtensions} from '@lexical/extension';
import {$createLinkNode, LinkExtension} from '@lexical/link';
import {
$createListItemNode,
$createListNode,
Expand All @@ -22,7 +23,11 @@ import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
import {ListPlugin} from '@lexical/react/LexicalListPlugin';
import {MarkdownShortcutPlugin} from '@lexical/react/LexicalMarkdownShortcutPlugin';
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
import {$createHeadingNode} from '@lexical/rich-text';
import {
$createHeadingNode,
$isHeadingNode,
RichTextExtension,
} from '@lexical/rich-text';
import {
$getSelectionStyleValueForProperty,
$patchStyleText,
Expand All @@ -38,6 +43,7 @@ import {
$getSelection,
$isElementNode,
$isLineBreakNode,
$isParagraphNode,
$isRangeSelection,
$isTextNode,
$setSelection,
Expand Down Expand Up @@ -3152,6 +3158,202 @@ describe('LexicalSelection tests', () => {
);
});

test('Triple-click overselection: focus at element offset 0 of non-empty next block is skipped', () => {
using testEditor = buildEditorFromExtensions({
$initialEditorState: () => {
const text1 = $createTextNode('text 1');
const paragraph2 = $createParagraphNode();
$getRoot().append(
$createParagraphNode().append(text1),
paragraph2.append($createLineBreakNode()),
);

// Browser triple-click: focus lands on the text node of the
// following block at offset 0, even though visually only
// paragraph1's content is selected.
const selection = text1.selectStart();
selection.focus.set(paragraph2.getKey(), 0, 'element');

$setBlocksType(selection, () => $createHeadingNode('h1'));
},
dependencies: [RichTextExtension],
name: '@test',
});
testEditor.read(() => {
const rootChildren = $getRoot().getChildren();
expect($isHeadingNode(rootChildren[0])).toBe(true);
expect($isParagraphNode(rootChildren[1])).toBe(true);
expect(rootChildren.length).toBe(2);
});
});

test('Triple-click overselection: focus at element offset 0 of empty next block is converted', () => {
using testEditor = buildEditorFromExtensions({
$initialEditorState: () => {
const text1 = $createTextNode('text 1');
const paragraph2 = $createParagraphNode();
$getRoot().append($createParagraphNode().append(text1), paragraph2);

// Browser triple-click: focus lands on the text node of the
// following block at offset 0, even though visually only
// paragraph1's content is selected.
const selection = text1.selectStart();
selection.focus.set(paragraph2.getKey(), 0, 'element');

$setBlocksType(selection, () => $createHeadingNode('h1'));
},
dependencies: [RichTextExtension],
name: '@test',
});
testEditor.read(() => {
const rootChildren = $getRoot().getChildren();
expect($isHeadingNode(rootChildren[0])).toBe(true);
expect($isHeadingNode(rootChildren[1])).toBe(true);
expect(rootChildren.length).toBe(2);
});
});

test('Triple-click overselection: focus at offset 0 of next block is skipped', () => {
using testEditor = buildEditorFromExtensions({
$initialEditorState: () => {
const text1 = $createTextNode('text 1');
const text2 = $createTextNode('text 2');
$getRoot().append(
$createParagraphNode().append(text1),
$createParagraphNode().append(text2),
);

// Browser triple-click: focus lands on the text node of the
// following block at offset 0, even though visually only
// paragraph1's content is selected.
const selection = text1.select().setTextNodeRange(text1, 0, text2, 0);

$setBlocksType(selection, () => $createHeadingNode('h1'));
},
dependencies: [RichTextExtension],
name: '@test',
});
testEditor.read(() => {
const rootChildren = $getRoot().getChildren();
expect($isHeadingNode(rootChildren[0])).toBe(true);
expect($isParagraphNode(rootChildren[1])).toBe(true);
expect(rootChildren.length).toBe(2);
});
});

test('Triple-click overselection: focus inside nested inline at offset 0 is skipped', () => {
using testEditor = buildEditorFromExtensions({
$initialEditorState: () => {
const text1 = $createTextNode('text 1');
const text2 = $createTextNode('text 2');
$getRoot().append(
$createParagraphNode().append(text1),
$createParagraphNode().append(
$createLinkNode('https://lexical.dev').append(text2),
),
);

// Browser triple-click: focus lands on the text node of the
// following block at offset 0, even though visually only
// paragraph1's content is selected.
const selection = text1.select().setTextNodeRange(text1, 0, text2, 0);

$setBlocksType(selection, () => $createHeadingNode('h1'));
},
dependencies: [RichTextExtension, LinkExtension],
name: '@test',
});
testEditor.read(() => {
const rootChildren = $getRoot().getChildren();
expect($isHeadingNode(rootChildren[0])).toBe(true);
expect($isParagraphNode(rootChildren[1])).toBe(true);
expect(rootChildren.length).toBe(2);
});
});

test('Non-zero focus offset in next block still converts both blocks', () => {
using testEditor = buildEditorFromExtensions({
$initialEditorState: () => {
const text1 = $createTextNode('text 1');
const text2 = $createTextNode('text 2');
$getRoot().append(
$createParagraphNode().append(text1),
$createParagraphNode().append(text2),
);

const selection = text1.select().setTextNodeRange(text1, 0, text2, 1);

$setBlocksType(selection, () => $createHeadingNode('h1'));
},
dependencies: [RichTextExtension],
name: '@test',
});
testEditor.read(() => {
const rootChildren = $getRoot().getChildren();
expect($isHeadingNode(rootChildren[0])).toBe(true);
expect($isHeadingNode(rootChildren[1])).toBe(true);
expect(rootChildren.length).toBe(2);
});
});

test('Triple-click overselection spanning multiple blocks skips only the focus block', () => {
using testEditor = buildEditorFromExtensions({
$initialEditorState: () => {
const text1 = $createTextNode('text 1');
const text2 = $createTextNode('text 2');
const text3 = $createTextNode('text 3');
$getRoot().append(
$createParagraphNode().append(text1),
$createParagraphNode().append(text2),
$createParagraphNode().append(text3),
);

const selection = text1.select().setTextNodeRange(text1, 0, text3, 0);

$setBlocksType(selection, () => $createHeadingNode('h1'));
},
dependencies: [RichTextExtension],
name: '@test',
});
testEditor.read(() => {
const rootChildren = $getRoot().getChildren();
expect($isHeadingNode(rootChildren[0])).toBe(true);
expect($isHeadingNode(rootChildren[1])).toBe(true);
expect($isParagraphNode(rootChildren[2])).toBe(true);
expect(rootChildren.length).toBe(3);
});
});

test('Focus at offset 0 in next block whose first descendant has a prior sibling still converts focus block', () => {
using testEditor = buildEditorFromExtensions({
$initialEditorState: () => {
const text1 = $createTextNode('text 1');
const text2a = $createTextNode('foo');
const text2b = $createTextNode('bar').setFormat('bold');
$getRoot().append(
$createParagraphNode().append(text1),
// text2b is the second child; offset 0 of text2b is NOT at
// the start of paragraph2 (text2a precedes it).
$createParagraphNode().append(text2a, text2b),
);

const selection = text1
.select()
.setTextNodeRange(text1, 0, text2b, 0);

$setBlocksType(selection, () => $createHeadingNode('h1'));
},
dependencies: [RichTextExtension],
name: '@test',
});
testEditor.read(() => {
const rootChildren = $getRoot().getChildren();
expect($isHeadingNode(rootChildren[0])).toBe(true);
expect($isHeadingNode(rootChildren[1])).toBe(true);
expect(rootChildren.length).toBe(2);
});
});

test('Nested list with listItem twice indented from its parent', async () => {
const testEditor = createTestEditor();
const element = document.createElement('div');
Expand Down
44 changes: 39 additions & 5 deletions packages/lexical-selection/src/range-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import type {
BaseSelection,
DecoratorNode,
ElementNode,
LexicalNode,
NodeKey,
Expand Down Expand Up @@ -53,6 +54,30 @@ export function $copyBlockFormatIndent(
}
}

function $isPointAtBlockStart(point: Point, block: ElementNode): boolean {
if (point.offset !== 0) {
return false;
}
let node: LexicalNode = point.getNode();
// When an ElementNode is empty it's not possible to distinguish if
// the selection's intent is the entire block or the edge so we consider
// it to be the entire block
if ($isElementNode(node) && node.isEmpty()) {
return false;
}
while (!node.is(block)) {
if (node.getPreviousSibling() !== null) {
return false;
}
const parent = node.getParent();
if (parent === null) {
return false;
}
node = parent;
}
return true;
}

/**
* Converts all nodes in the selection that are of one block type to another.
* @param selection - The selected blocks to be converted.
Expand All @@ -67,31 +92,40 @@ export function $setBlocksType<T extends ElementNode>(
newNodeDest: T,
) => void = $copyBlockFormatIndent,
): void {
if (selection === null) {
if (!selection) {
return;
}
// Selections tend to not include their containing blocks so we effectively
// expand it here
const anchorAndFocus = selection.getStartEndPoints();
let skipFocusAtBlockStart = false;
let focusBlock: ElementNode | DecoratorNode<unknown> | null = null;
const blockMap = new Map<NodeKey, ElementNode>();
if (anchorAndFocus) {
const [anchor, focus] = anchorAndFocus;
const anchorBlock = $findMatchingParent(
anchor.getNode(),
INTERNAL_$isBlock,
);
const focusBlock = $findMatchingParent(focus.getNode(), INTERNAL_$isBlock);
focusBlock = $findMatchingParent(focus.getNode(), INTERNAL_$isBlock);
skipFocusAtBlockStart =
$isElementNode(focusBlock) &&
!focusBlock.is(anchorBlock) &&
$isPointAtBlockStart(focus, focusBlock);
if ($isElementNode(anchorBlock)) {
blockMap.set(anchorBlock.getKey(), anchorBlock);
}
if ($isElementNode(focusBlock)) {
if ($isElementNode(focusBlock) && !skipFocusAtBlockStart) {
blockMap.set(focusBlock.getKey(), focusBlock);
}
}
for (const node of selection.getNodes()) {
if ($isElementNode(node) && INTERNAL_$isBlock(node)) {
if (skipFocusAtBlockStart && node.is(focusBlock)) {
continue;
}
blockMap.set(node.getKey(), node);
} else if (anchorAndFocus === null) {
} else if (!anchorAndFocus) {
const ancestorBlock = $findMatchingParent(node, INTERNAL_$isBlock);
if ($isElementNode(ancestorBlock)) {
blockMap.set(ancestorBlock.getKey(), ancestorBlock);
Expand All @@ -101,7 +135,7 @@ export function $setBlocksType<T extends ElementNode>(
// Selection remapping is delegated to LexicalNode.replace (and the
// ListItemNode.replace override): both remap an element-anchored point
// on the replaced block to {key: replacement, offset: prevSize + offset}.
for (const [, prevNode] of blockMap) {
for (const prevNode of blockMap.values()) {
const element = $createElement();
$afterCreateElement(prevNode, element);
prevNode.replace(element, true);
Expand Down
3 changes: 2 additions & 1 deletion packages/lexical/src/LexicalSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,9 +580,10 @@ export class RangeSelection implements BaseSelection {
anchorOffset: number,
focusNode: TextNode,
focusOffset: number,
): void {
): this {
this.anchor.set(anchorNode.__key, anchorOffset, 'text');
this.focus.set(focusNode.__key, focusOffset, 'text');
return this;
}

/**
Expand Down
4 changes: 3 additions & 1 deletion packages/lexical/src/LexicalUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1942,6 +1942,8 @@ export function isBlockDomNode(
: BLOCK_TAG_RE.test(node.nodeName);
}

const BlockNodeBrand: unique symbol = Symbol.for('@lexical/BlockNodeBrand');

/**
* @internal
*
Expand All @@ -1957,7 +1959,7 @@ export function isBlockDomNode(
*/
export function INTERNAL_$isBlock(
node: LexicalNode,
): node is ElementNode | DecoratorNode<unknown> {
): node is (ElementNode | DecoratorNode<unknown>) & {[BlockNodeBrand]: never} {
if ($isDecoratorNode(node) && !node.isInline()) {
return true;
}
Expand Down