diff --git a/packages/lexical-extension/src/NormalizeTripleClickSelectionExtension.ts b/packages/lexical-extension/src/NormalizeTripleClickSelectionExtension.ts new file mode 100644 index 00000000000..dd8be031251 --- /dev/null +++ b/packages/lexical-extension/src/NormalizeTripleClickSelectionExtension.ts @@ -0,0 +1,210 @@ +/** + * 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 { + $caretRangeFromSelection, + $getCaretRange, + $getCaretRangeInDirection, + $getChildCaret, + $getEditor, + $getPreviousSelection, + $getSelection, + $getSiblingCaret, + $isChildCaret, + $isElementNode, + $isLineBreakNode, + $isRangeSelection, + $isSiblingCaret, + $isTextPointCaret, + $normalizeCaret, + $rewindSiblingCaret, + $setSelectionFromCaretRange, + $updateDOMSelection, + COMMAND_PRIORITY_BEFORE_CRITICAL, + defineExtension, + getDOMSelection, + mergeRegister, + safeCast, + SELECTION_CHANGE_COMMAND, + SKIP_SCROLL_INTO_VIEW_TAG, + SKIP_SELECTION_FOCUS_TAG, +} from 'lexical'; + +import {namedSignals} from './namedSignals'; +import {effect, type Signal} from './signals'; + +export interface NormalizeTripleClickSelectionConfig { + /** `true` to disable this extension */ + disabled: boolean; + /** The maximum number of msec from the triple click to expect a selection change, default `100` */ + thresholdMsec: number; + /** The clock function used for delay-based merging, default `Date.now` */ + dateNow: () => number; + /** The update function to call when triple click is detected */ + $fixFocusOverselection: () => void; +} + +export interface NormalizeTripleClickSelectionOutput { + /** `true` to disable this extension */ + disabled: Signal; + /** The maximum number of msec from the triple click to expect a selection change, default `100` */ + thresholdMsec: Signal; + /** The clock function used for delay-based merging, default `Date.now` */ + dateNow: Signal<() => number>; + /** The update function to call when triple click is detected */ + $fixFocusOverselection: Signal<() => void>; +} + +const SKIP_TAGS = new Set([ + SKIP_SELECTION_FOCUS_TAG, + SKIP_SCROLL_INTO_VIEW_TAG, +]); + +function $fixFocusOverselection() { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + if (!selection.isCollapsed()) { + // 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 range = $getCaretRangeInDirection( + $caretRangeFromSelection(selection), + 'next', + ); + let focusCaret = range.focus; + // Move it out of the next TextNode if none of it is selected + if ( + $isTextPointCaret(focusCaret) && + range.anchor.origin !== focusCaret.origin && + focusCaret.offset === 0 + ) { + focusCaret = $rewindSiblingCaret(focusCaret.getSiblingCaret()); + } + // Move it behind a single LineBreakNode + if ( + $isSiblingCaret(focusCaret) && + range.anchor.origin !== focusCaret.origin && + $isLineBreakNode(focusCaret.origin) + ) { + focusCaret = $rewindSiblingCaret(focusCaret); + } + // Move the focus out of the start of any elements + while ( + $isChildCaret(focusCaret) && + range.anchor.origin !== focusCaret.origin + ) { + focusCaret = $rewindSiblingCaret( + $getSiblingCaret(focusCaret.origin, 'next'), + ); + } + // Move it inside the containing element + if ($isSiblingCaret(focusCaret) && $isElementNode(focusCaret.origin)) { + focusCaret = $normalizeCaret( + $getChildCaret(focusCaret.origin, 'previous'), + ).getFlipped(); + } + focusCaret = $normalizeCaret(focusCaret); + if (!focusCaret.isSamePointCaret(range.focus)) { + const sel = $setSelectionFromCaretRange( + $getCaretRange(range.anchor, focusCaret), + ); + const editor = $getEditor(); + const rootElement = editor.getRootElement(); + const domSelection = + rootElement && getDOMSelection(rootElement.ownerDocument.defaultView); + if (domSelection) { + $updateDOMSelection( + $getPreviousSelection(), + sel, + $getEditor(), + domSelection, + SKIP_TAGS, + rootElement, + ); + } + } + } +} + +/** + * This extension handles triple-click events and will move the focus + * towards the anchor in certain conditions to meet expectations. + * Simply speaking, the focus should prefer to land at the end of a node + * rather than the beginning of its next sibling, and it should not skip + * over a LineBreakNode. + * + * In order to fix the result visually and avoid a flash of over-selection + * it will also eagerly manipulate the DOM selection directly. + * + * It is conservative in that it only fires this + * `$fixFocusOverselection` callback when it has detected a triple click, + * but it provides the function as an output signal so that it can both + * be called from other places and it can be replaced or wrapped with + * different functionality. + */ +export const NormalizeTripleClickSelectionExtension = defineExtension({ + build: (editor, config, state): NormalizeTripleClickSelectionOutput => + namedSignals(config), + config: safeCast({ + $fixFocusOverselection, + dateNow: Date.now, + disabled: false, + thresholdMsec: 100, + }), + name: '@lexical/NormalizeTripleClickSelection', + register: (editor, config, state) => + effect(() => { + const stores = state.getOutput(); + if (stores.disabled.value) { + return; + } + return editor.registerRootListener(rootElement => { + if (!rootElement) { + return; + } + let lastTripleClick = 0; + const refreshTripleClick = (event: null | MouseEvent) => { + if (event ? event.detail === 3 : lastTripleClick > 0) { + const now = stores.dateNow.peek()(); + lastTripleClick = + (event && event.type === 'mousedown') || + now - lastTripleClick <= stores.thresholdMsec.peek() + ? now + : 0; + } + return lastTripleClick; + }; + return mergeRegister( + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + if (refreshTripleClick(null)) { + lastTripleClick = 0; + stores.$fixFocusOverselection.peek()(); + } + return false; + }, + COMMAND_PRIORITY_BEFORE_CRITICAL, + ), + (() => { + const events = ['mouseup', 'mousedown'] as const; + events.forEach(v => + rootElement.addEventListener(v, refreshTripleClick, true), + ); + return () => + events.forEach(v => + rootElement.removeEventListener(v, refreshTripleClick, true), + ); + })(), + ); + }); + }), +}); diff --git a/packages/lexical-extension/src/index.ts b/packages/lexical-extension/src/index.ts index 30673776d58..3a6dc4d8ff5 100644 --- a/packages/lexical-extension/src/index.ts +++ b/packages/lexical-extension/src/index.ts @@ -51,6 +51,11 @@ export { type NormalizeInlineElementsConfig, NormalizeInlineElementsExtension, } from './NormalizeInlineElementsExtension'; +export { + type NormalizeTripleClickSelectionConfig, + NormalizeTripleClickSelectionExtension, + type NormalizeTripleClickSelectionOutput, +} from './NormalizeTripleClickSelectionExtension'; export {SelectionAlwaysOnDisplayExtension} from './SelectionAlwaysOnDisplayExtension'; export { batch, diff --git a/packages/lexical-plain-text/src/index.ts b/packages/lexical-plain-text/src/index.ts index 77816685db1..0099f10ae6a 100644 --- a/packages/lexical-plain-text/src/index.ts +++ b/packages/lexical-plain-text/src/index.ts @@ -15,7 +15,10 @@ import { $writeDragSourceToDataTransfer, } from '@lexical/clipboard'; import {DragonExtension} from '@lexical/dragon'; -import {NormalizeInlineElementsExtension} from '@lexical/extension'; +import { + NormalizeInlineElementsExtension, + NormalizeTripleClickSelectionExtension, +} from '@lexical/extension'; import { $moveCharacter, $shouldOverrideDefaultCharacterSelection, @@ -423,7 +426,11 @@ export function registerPlainText(editor: LexicalEditor): () => void { */ export const PlainTextExtension = defineExtension({ conflictsWith: ['@lexical/rich-text'], - dependencies: [DragonExtension, NormalizeInlineElementsExtension], + dependencies: [ + DragonExtension, + NormalizeInlineElementsExtension, + NormalizeTripleClickSelectionExtension, + ], name: '@lexical/plain-text', register: registerPlainText, }); diff --git a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs index b14085672be..58188e26dc6 100644 --- a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs @@ -912,31 +912,25 @@ test.describe.parallel('Selection', () => { ); }); - test( - 'Can delete sibling elements forward', - { - tag: '@flaky', - }, - async ({page, isPlainText}) => { - test.skip(isPlainText); + test('Can delete sibling elements forward', async ({page, isPlainText}) => { + test.skip(isPlainText); - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.keyboard.type('# Title'); - await page.keyboard.press('ArrowUp'); - await deleteForward(page); - await assertHTML( - page, - html` -

- Title -

- `, - ); - }, - ); + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.keyboard.type('# Title'); + await page.keyboard.press('ArrowUp'); + await deleteForward(page); + await assertHTML( + page, + html` +

+ Title +

+ `, + ); + }); - test('Can adjust triple click selection', async ({ + test('Can adjust triple click selection paragraph', async ({ page, isPlainText, isCollab, @@ -950,6 +944,28 @@ test.describe.parallel('Selection', () => { .locator('div[contenteditable="true"] > p') .first() .click({clickCount: 3}); + const expectedSelection = createHumanReadableSelection( + 'the whole first paragraph', + { + anchorOffset: {desc: 'start of Paragraph 1 text', value: 0}, + anchorPath: [ + {desc: 'first paragraph', value: 0}, + {desc: 'first span', value: 0}, + {desc: 'Text node', value: 0}, + ], + focusOffset: { + desc: 'end of Paragraph 1 text', + value: 'Paragraph 1'.length, + }, + focusPath: [ + {desc: 'first paragraph', value: 0}, + {desc: 'first span', value: 0}, + {desc: 'Text node', value: 0}, + ], + }, + ); + + await assertSelection(page, expectedSelection); await click(page, '.block-controls'); await click(page, '.dropdown .item:has(.icon.h1)'); @@ -967,6 +983,72 @@ test.describe.parallel('Selection', () => { ); }); + test('Can adjust triple click selection linebreak', async ({ + page, + isCollab, + }) => { + test.skip(isCollab); + + await page.keyboard.type('Line 1'); + await page.keyboard.down('Shift'); + await page.keyboard.press('Enter'); + await page.keyboard.down('Shift'); + await page.keyboard.type('Line 2'); + await page.keyboard.down('Shift'); + await page.keyboard.press('Enter'); + await page.keyboard.down('Shift'); + await page.keyboard.type('Line 3'); + await assertHTML( + page, + html` +

+ Line 1 +
+ Line 2 +
+ Line 3 +

+ `, + ); + await page + .locator('div[contenteditable="true"] > p > span') + .nth(1) + .click({clickCount: 3}); + const expectedSelection = createHumanReadableSelection( + 'the whole second line', + { + anchorOffset: {desc: 'start of Line 2 text', value: 0}, + anchorPath: [ + {desc: 'first paragraph', value: 0}, + {desc: 'second span after br', value: 2}, + {desc: 'Text node', value: 0}, + ], + focusOffset: { + desc: 'end of Line 2 text', + value: 'Line 2'.length, + }, + focusPath: [ + {desc: 'first paragraph', value: 0}, + {desc: 'second span after br', value: 2}, + {desc: 'Text node', value: 0}, + ], + }, + ); + + await assertSelection(page, expectedSelection); + + expect( + await evaluate(page, () => { + const editor = document.querySelector( + 'div[contenteditable="true"]', + ).__lexicalEditor; + return editor.read(() => + editor._editorState._selection.getTextContent(), + ); + }), + ).toEqual('Line 2'); + }); + test('Can adjust triple click selection with', async ({ page, isPlainText, @@ -1569,78 +1651,81 @@ test.describe.parallel('Selection', () => { }); }); - test( - 'shift+arrowdown into a table, when the table is the last node, selects the whole table', - {tag: '@flaky'}, - async ({page, isPlainText, isCollab, browserName}) => { - test.skip(isPlainText); - await focusEditor(page); - await insertTable(page, 2, 2); - await moveToEditorEnd(page); - await deleteBackward(page); - await moveToEditorBeginning(page); - await page.keyboard.down('Shift'); - await page.keyboard.press('ArrowDown'); - await page.keyboard.up('Shift'); - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0], - focusOffset: 1, - focusPath: [1, 2, 1], - }); - }, - ); + test('shift+arrowdown into a table, when the table is the last node, selects the whole table', async ({ + page, + isPlainText, + isCollab, + browserName, + }) => { + test.skip(isPlainText); + await focusEditor(page); + await insertTable(page, 2, 2); + await moveToEditorEnd(page); + await deleteBackward(page); + await moveToEditorBeginning(page); + await page.keyboard.down('Shift'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.up('Shift'); + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 1, + focusPath: [1, 2, 1], + }); + }); - test( - 'shift+arrowup into a table, when the table is the first node, selects the whole table', - {tag: '@flaky'}, - async ({page, isPlainText, isCollab, browserName}) => { - test.skip(isPlainText); - await focusEditor(page); - await insertTable(page, 2, 2); - await moveToEditorBeginning(page); - await deleteBackward(page); - await moveToEditorEnd(page); - await page.keyboard.down('Shift'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.up('Shift'); - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1], - focusOffset: 0, - focusPath: [0, 1, 0], - }); - }, - ); + test('shift+arrowup into a table, when the table is the first node, selects the whole table', async ({ + page, + isPlainText, + isCollab, + browserName, + }) => { + test.skip(isPlainText); + await focusEditor(page); + await insertTable(page, 2, 2); + await moveToEditorBeginning(page); + await deleteBackward(page); + await moveToEditorEnd(page); + await page.keyboard.down('Shift'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.up('Shift'); + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1], + focusOffset: 0, + focusPath: [0, 1, 0], + }); + }); - test( - 'shift+arrowdown into a table, when the table is the only node, selects the whole table', - {tag: '@flaky'}, - async ({page, isPlainText, isCollab, browserName}) => { - test.skip(isPlainText); - await focusEditor(page); - await insertTable(page, 2, 2); - await moveToEditorBeginning(page); - await deleteBackward(page); - await moveToEditorEnd(page); - await deleteBackward(page); - await moveToEditorBeginning(page); - await moveUp(page, 1); - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [], - focusOffset: 0, - focusPath: [], - }); - await page.keyboard.down('Shift'); - await page.keyboard.press('ArrowDown'); - await page.keyboard.up('Shift'); - await assertTableSelectionCoordinates(page, { - anchor: {x: 0, y: 0}, - focus: {x: 1, y: 1}, - }); - }, - ); + test('shift+arrowdown into a table, when the table is the only node, selects the whole table', async ({ + page, + isPlainText, + isCollab, + browserName, + }) => { + test.skip(isPlainText); + await focusEditor(page); + await insertTable(page, 2, 2); + await moveToEditorBeginning(page); + await deleteBackward(page); + await moveToEditorEnd(page); + await deleteBackward(page); + await moveToEditorBeginning(page); + await moveUp(page, 1); + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [], + focusOffset: 0, + focusPath: [], + }); + await page.keyboard.down('Shift'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.up('Shift'); + await assertTableSelectionCoordinates(page, { + anchor: {x: 0, y: 0}, + focus: {x: 1, y: 1}, + }); + }); test('shift+arrowup into a table, when the table is the only node, selects the whole table', async ({ page, diff --git a/packages/lexical-rich-text/src/index.ts b/packages/lexical-rich-text/src/index.ts index a795a350aa9..ace8e71b716 100644 --- a/packages/lexical-rich-text/src/index.ts +++ b/packages/lexical-rich-text/src/index.ts @@ -39,6 +39,7 @@ import { effect, namedSignals, NormalizeInlineElementsExtension, + NormalizeTripleClickSelectionExtension, ReadonlySignal, signal, } from '@lexical/extension'; @@ -1385,7 +1386,11 @@ export const RichTextExtension = defineExtension({ build: (_editor, config) => namedSignals(config), config: safeCast(DEFAULT_RICH_TEXT_CONFIG), conflictsWith: ['@lexical/plain-text'], - dependencies: [DragonExtension, NormalizeInlineElementsExtension], + dependencies: [ + DragonExtension, + NormalizeInlineElementsExtension, + NormalizeTripleClickSelectionExtension, + ], mergeConfig: mergeRichTextConfig, name: '@lexical/rich-text', nodes: () => [HeadingNode, QuoteNode], diff --git a/packages/lexical-website/docs/extensions/included-extensions.md b/packages/lexical-website/docs/extensions/included-extensions.md index 329e7f90f8a..1d8bfff0559 100644 --- a/packages/lexical-website/docs/extensions/included-extensions.md +++ b/packages/lexical-website/docs/extensions/included-extensions.md @@ -15,7 +15,7 @@ [@lexical/dragon](/docs/api/modules/lexical_dragon) -- [DragonExtension](/docs/api/modules/lexical_dragon#dragonextension) - Dragon (speech to text) support, included by default with RichTextExtension and PlainTextExtension +- [DragonExtension](/docs/api/modules/lexical_dragon#dragonextension) - Dragon (speech to text) support, included by default with `RichTextExtension` and `PlainTextExtension` [@lexical/extension](/docs/api/modules/lexical_extension) @@ -25,6 +25,8 @@ - [HorizontalRuleExtension](/docs/api/modules/lexical_extension#horizontalruleextension) - HorizontalRuleNode (`
` tag) - [InitialStateExtension](/docs/api/modules/lexical_extension#initialstateextension) - Sets the initial state of the editor (always included) - [NodeSelectionExtension](/docs/api/modules/lexical_extension#nodeselectionextension) - Tracks selection, typically for DecoratorNodes +- [NormalizeInlineElementsExtension](/docs/api/modules/lexical_extension#normalizeinlineelementsextension) - Removes empty inline elements, included by default with `RichTextExtension` and `PlainTextExtension` +- [NormalizeTripleClickSelectionExtension](/docs/api/modules/lexical_extension#normalizetripleclickselectionextension) - Corrects over-selection after triple click events, included by default with `RichTextExtension` and `PlainTextExtension` - [TabIndentationExtension](/docs/api/modules/lexical_extension#tabindentationextension) - Changes Tab key to insert tabs and indent instead of natively focusing the next field [@lexical/hashtag](/docs/api/modules/lexical_hashtag) diff --git a/packages/lexical/src/LexicalEvents.ts b/packages/lexical/src/LexicalEvents.ts index 1f3c370fcdf..42f889520b5 100644 --- a/packages/lexical/src/LexicalEvents.ts +++ b/packages/lexical/src/LexicalEvents.ts @@ -521,22 +521,6 @@ function onClick(event: PointerEvent, editor: LexicalEditor): void { ) { domSelection.removeAllRanges(); selection.dirty = true; - } else if (event.detail === 3 && !selection.isCollapsed()) { - // 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); - } - } } } else if (event.pointerType === 'touch' || event.pointerType === 'pen') { // This is used to update the selection on touch devices (including Apple Pencil) when the user clicks on text after a diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index f6bc1b1c1ee..2d048b4619d 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -3043,6 +3043,7 @@ function $getElementAndOffsetForPoint( return [element, offset]; } +/** @internal */ export function $updateDOMSelection( prevSelection: BaseSelection | null, nextSelection: BaseSelection | null, @@ -3050,7 +3051,6 @@ export function $updateDOMSelection( domSelection: Selection, tags: Set, rootElement: HTMLElement, - nodeCount: number, ): void { const activeElement = document.activeElement; diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index b20bd5dd486..7349224f90c 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -605,7 +605,6 @@ export function $commitPendingUpdates( const normalizedNodes = editor._normalizedNodes; const tags = editor._updateTags; const deferred = editor._deferred; - const nodeCount = pendingEditorState._nodeMap.size; if (needsUpdate) { editor._dirtyType = NO_DIRTY_NODES; @@ -656,7 +655,6 @@ export function $commitPendingUpdates( domSelection, tags, rootElement, - nodeCount, ); } updateDOMBlockCursorElement(editor, rootElement, pendingSelection); diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 0eadc778e6f..833785f040d 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -245,6 +245,7 @@ export { $isBlockElementNode, $isNodeSelection, $isRangeSelection, + $updateDOMSelection, } from './LexicalSelection'; export {$parseSerializedNode, isCurrentlyReadOnlyMode} from './LexicalUpdates'; export {