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
@@ -0,0 +1,172 @@
/**
* 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 {
assertHTML,
click,
expect,
focusEditor,
getEditorElement,
html,
initialize,
test,
} from '../utils/index.mjs';

// this issue usually becomes apparent when you apply block formats to the selected line
test.describe.parallel('Regression test #2648', () => {
test.beforeEach(({isCollab, page}) => initialize({isCollab, page}));

// some distinct formats
const blockFormats = [
{
dropDownSelector: '.block-controls',
expected: html`
<h1
class="PlaygroundEditorTheme__h1 PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">line of text</span>
</h1>
`,
formatSelector: '.dropdown .icon.h1',
name: 'Heading 1',
},
{
dropDownSelector: '.block-controls',
expected: html`
<blockquote
class="PlaygroundEditorTheme__quote PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">line of text</span>
</blockquote>
`,
formatSelector: '.dropdown .icon.quote',
name: 'Quote',
},
{
dropDownSelector: '.block-controls',
expected: html`
<code
class="PlaygroundEditorTheme__code PlaygroundEditorTheme__ltr"
dir="ltr"
spellcheck="false"
data-gutter="1"
data-highlight-language="javascript"
data-language="javascript">
<span data-lexical-text="true">line</span>
<span
class="PlaygroundEditorTheme__tokenAttr"
data-lexical-text="true">
of
</span>
<span data-lexical-text="true">text</span>
</code>
`,
formatSelector: '.dropdown .icon.code',
name: 'Code Block',
},
{
dropDownSelector: '.alignment',
expected: html`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr"
style="text-align: right">
<span data-lexical-text="true">line of text</span>
</p>
`,
formatSelector: '.dropdown .icon.right-align',
name: 'Text Align',
},
{
dropDownSelector: '.alignment',
expected: html`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr PlaygroundEditorTheme__indent"
dir="ltr"
style="padding-inline-start: calc(40px)">
<span data-lexical-text="true">line of text</span>
</p>
`,
formatSelector: '.dropdown .icon.indent',
name: 'Indent',
},
];

test.describe
.parallel('Block formatting after selecting the entire line', () => {
blockFormats.forEach(
({name, dropDownSelector, formatSelector, expected}) => {
test(`when ${name} format is applied`, async ({
page,
isPlainText,
isCollab,
}) => {
test.skip(isPlainText);

await focusEditor(page);
await page.keyboard.type('line of text');
await page.keyboard.press('Enter');
await page.keyboard.type('line of text');
await page.keyboard.press('Enter');
await page.keyboard.type('line of text');

// code block test is failing
// consider adding more formats too
const firstLine = 'text="line of text" >> nth=0 >> xpath=..';
const secondLine = `text="line of text" >> nth=${
name === 'Code Block' ? 0 : 1
} >> xpath=..`;

const unFormattedLine = html`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">line of text</span>
</p>
`;

//format and check the first line
await click(page, firstLine, {clickCount: 4}); // Note: the issue sometimes shows up on triple click but the 4th click seems to be a more reliable way to replicate it
expect(
await getEditorElement(page).evaluate(() =>
window.getSelection()?.toString(),
),
).toBe('line of text');
await click(page, dropDownSelector);
await click(page, formatSelector);
await assertHTML(
page,
html`
${expected} ${unFormattedLine} ${unFormattedLine}
`,
);

await page.keyboard.press('Escape');

// format and test the middle line
await click(page, secondLine, {clickCount: 4});
expect(
await getEditorElement(page).evaluate(() =>
window.getSelection()?.toString(),
),
).toBe('line of text');
await click(page, dropDownSelector);
await click(page, formatSelector);
await assertHTML(
page,
html`
${expected} ${expected} ${unFormattedLine}
`,
);
});
},
);
});

// need to add some more tests for other types of blocks besides paragraph
});
21 changes: 9 additions & 12 deletions packages/lexical/src/LexicalEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
CAN_USE_BEFORE_INPUT,
IS_ANDROID_CHROME,
IS_APPLE_WEBKIT,
IS_CHROME,
IS_FIREFOX,
IS_IOS,
IS_SAFARI,
Expand All @@ -31,6 +32,7 @@ import {
$isRangeSelection,
$isRootNode,
$isTextNode,
$normalizeSelectionByPosition,
$setCompositionKey,
BLUR_COMMAND,
CLICK_COMMAND,
Expand Down Expand Up @@ -79,7 +81,6 @@ import {
} from './LexicalSelection';
import {getActiveEditor, updateEditorSync} from './LexicalUpdates';
import {
$findMatchingParent,
$flushMutations,
$getAdjacentNode,
$getNodeByKey,
Expand Down Expand Up @@ -498,21 +499,17 @@ function onClick(event: PointerEvent, editor: LexicalEditor): void {
) {
domSelection.removeAllRanges();
selection.dirty = true;
} else if (event.detail === 3 && !selection.isCollapsed()) {
} else if (
!selection.isCollapsed() &&
(event.detail === 3 || (event.detail > 3 && (IS_CHROME || IS_SAFARI)))
) {
// Triple click causing selection to overflow into the nearest element. In that
// case visually it looks like a single element content is selected, focus node
// is actually at the beginning of the next element (if present) and any manipulations
// with selection (formatting) are affecting second element as well
const focus = selection.focus;
const focusNode = focus.getNode();
if (anchorNode !== focusNode) {
const parentNode = $findMatchingParent(
anchorNode,
(node) => $isElementNode(node) && !node.isInline(),
);
if ($isElementNode(parentNode)) {
parentNode.select(0);
}

if (selection) {
$normalizeSelectionByPosition(selection);
}
}
} else if (event.pointerType === 'touch' || event.pointerType === 'pen') {
Expand Down
50 changes: 50 additions & 0 deletions packages/lexical/src/LexicalSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2717,6 +2717,56 @@ function $validatePoint(name: 'anchor' | 'focus', point: PointType): void {
}
}

export function $normalizeSelectionByPosition(selection: RangeSelection): void {
const focusSlice = $caretRangeFromSelection(selection).getTextSlices()[1];

if (!focusSlice || focusSlice.distance === 0) {
const focusNode = selection.focus.getNode();

let currentNode = focusNode.getPreviousSibling();
if (!currentNode) {
const parent = focusNode.getParent();
currentNode = parent ? parent.getPreviousSibling() : null;
}

if (currentNode) {
const isFocusParent = currentNode.isParentOf(selection.focus.getNode());
const isLineBreakNode = $isLineBreakNode(currentNode);

if (!isFocusParent || isLineBreakNode) {
if (
$isTextNode(currentNode) ||
$isElementNode(currentNode) ||
$isLineBreakNode(currentNode)
) {
const nodeToFocus = $isLineBreakNode(currentNode)
? currentNode.getPreviousSibling()
: currentNode;

if (nodeToFocus) {
const elNode = $isElementNode(nodeToFocus);
const offset = elNode
? nodeToFocus.getChildrenSize()
: nodeToFocus.getTextContentSize();

selection.focus.set(
nodeToFocus.getKey(),
offset,
elNode ? 'element' : 'text',
);
}
} else {
const parentNode = currentNode.getParent();
if (parentNode) {
const childIndex = currentNode.getIndexWithinParent();
selection.focus.set(parentNode.getKey(), childIndex + 1, 'element');
}
}
}
}
}
}

export function $getSelection(): null | BaseSelection {
const editorState = getActiveEditorState();
return editorState._selection;
Expand Down
1 change: 1 addition & 0 deletions packages/lexical/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ export {
$isBlockElementNode,
$isNodeSelection,
$isRangeSelection,
$normalizeSelectionByPosition,
} from './LexicalSelection';
export {$parseSerializedNode, isCurrentlyReadOnlyMode} from './LexicalUpdates';
export {
Expand Down
Loading