diff --git a/packages/lexical-extension/src/__tests__/browser/SelectBlock.test.ts b/packages/lexical-extension/src/__tests__/browser/SelectBlock.test.ts index 4ebe99536d9..5330be5d84c 100644 --- a/packages/lexical-extension/src/__tests__/browser/SelectBlock.test.ts +++ b/packages/lexical-extension/src/__tests__/browser/SelectBlock.test.ts @@ -101,6 +101,21 @@ function setup($children: () => LexicalNode[]) { return {editor, root}; } +// The reconciler parks a zero-size boundary anchor `` before a block's +// first child when that child is a decorator (#8922, #7158). It occupies a DOM +// child slot but is not a Lexical child, so leave it out of DOM paths and +// offsets computed from Lexical child indices. +function isBoundaryAnchor(node: Node): boolean { + return ( + node instanceof HTMLElement && + node.hasAttribute('data-lexical-decorator-boundary') + ); +} + +function managedChildNodes(node: Node): Node[] { + return Array.from(node.childNodes).filter(child => !isBoundaryAnchor(child)); +} + function domSelection() { const selection = window.getSelection()!; return [ @@ -192,13 +207,21 @@ describe('native select-all block expansion', () => { return last.getChildrenSize(); }); const endNode = endPath.reduce( - (node, index) => node.childNodes[index], + (node, index) => managedChildNodes(node)[index], paragraph, ); + const leadingAnchors = Array.from(paragraph.childNodes).findIndex( + child => !isBoundaryAnchor(child), + ); await selectAll(); await expect .poll(domSelection) - .toEqual([paragraph, 0, paragraph, childCount]); + .toEqual([ + paragraph, + leadingAnchors, + paragraph, + childCount + leadingAnchors, + ]); editor.read('latest', () => { const selection = $getSelection(); assert($isRangeSelection(selection)); diff --git a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/lexical/CopyAndPaste.spec.mjs b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/lexical/CopyAndPaste.spec.mjs index 322a27995d5..e2ac84ee47c 100644 --- a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/lexical/CopyAndPaste.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/lexical/CopyAndPaste.spec.mjs @@ -6,10 +6,12 @@ * */ import { + moveLeft, + moveRight, moveToEditorBeginning, moveToEditorEnd, + moveToLineBeginning, moveToLineEnd, - moveToPrevWord, selectAll, } from '../../../keyboardShortcuts/index.mjs'; import { @@ -268,11 +270,7 @@ test.describe('CopyAndPaste', () => { }); }); - test(`Copy and paste between sections`, async ({ - isRichText, - page, - browserName, - }) => { + test(`Copy and paste between sections`, async ({isRichText, page}) => { await focusEditor(page); await page.keyboard.type('Hello world #foobar test #foobar2 when #not'); @@ -516,14 +514,16 @@ test.describe('CopyAndPaste', () => { }); } - await moveToPrevWord(page); + // Select backwards from "#|foo" in the second section to " |test " in + // the first. Shift+ArrowUp keeps the caret's x position, so where it + // lands on the line above depends on glyph widths, and how many word + // moves reach " test " from there depends on the platform. Extending to + // the start of that line and counting characters works with any font. + await moveLeft(page, 'foo'.length); await page.keyboard.down('Shift'); await page.keyboard.press('ArrowUp'); - await moveToPrevWord(page); - // Once more for linux on Chromium - if (IS_LINUX && browserName === 'chromium') { - await moveToPrevWord(page); - } + await moveToLineBeginning(page); + await moveRight(page, 'Hello world #foobar '.length); await page.keyboard.up('Shift'); if (isRichText) { diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index 72c232cfb24..2d9682dd925 100644 --- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs @@ -9097,11 +9097,12 @@ test.describe('Tables', () => { page, anchorBox, {height: 0, width: 0, x: wrapperBox.x + wrapperBox.width - 5, y: holdY}, - {mouseDown: true, mouseUp: false, slow: true}, + {mouseDown: true, mouseUp: false}, ); // While the pointer is held near the edge, the requestAnimationFrame loop - // scrolls the wrapper all the way to the end... + // scrolls the wrapper all the way to the end, at a speed that does not + // depend on how often the browser renders a frame... await expect .poll(async () => (await getScroll()).left, {timeout: 5000}) .toBeGreaterThanOrEqual(max - 1); diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index d98728ab8c1..8fbb0174fa4 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -382,8 +382,11 @@ export async function clickSelectors(page, selectors) { } } +// WebKit, and Chromium since #7158, render the managed line break after a +// trailing inline decorator as an img+br pair; keep the img out of the HTML +// the specs assert on. function removeSafariLinebreakImgHack(actualHtml) { - return E2E_BROWSER === 'webkit' + return E2E_BROWSER === 'webkit' || E2E_BROWSER === 'chromium' ? actualHtml.replaceAll( /]+ )?data-lexical-managed-linebreak="true"(?: [^>]+)?>/g, '', diff --git a/packages/lexical-playground/src/__tests__/browser/SlotHostNavigation.test.ts b/packages/lexical-playground/src/__tests__/browser/SlotHostNavigation.test.ts index 7d85b933f4c..d605ca15f01 100644 --- a/packages/lexical-playground/src/__tests__/browser/SlotHostNavigation.test.ts +++ b/packages/lexical-playground/src/__tests__/browser/SlotHostNavigation.test.ts @@ -142,14 +142,16 @@ for (const kind of ['Review', 'Card', 'PullQuote'] as const) { } const originalText = root.querySelector(config.selector)!.textContent; if (side === 'after') { - editor.update( - () => { - const last = $getRoot().getLastChild(); - if ($isParagraphNode(last) && last.getTextContent() === '') - last.remove(); - }, - {discrete: true}, - ); + await act(async () => { + editor.update( + () => { + const last = $getRoot().getLastChild(); + if ($isParagraphNode(last) && last.getTextContent() === '') + last.remove(); + }, + {discrete: true}, + ); + }); await click(root, selector); } // A click can land on a later wrapped line. The precondition for @@ -217,21 +219,25 @@ for (const kind of ['Review', 'PullQuote'] as const) { await typeText('Jane'); } if (kind === 'Review' && direction === 'down') { - editor.update(() => $getRoot().getLastChildOrThrow().remove(), { - discrete: true, + await act(async () => { + editor.update(() => $getRoot().getLastChildOrThrow().remove(), { + discrete: true, + }); }); } const before = root.children.length; await click(root, direction === 'down' ? config.first : config.last); if (kind === 'PullQuote' && direction === 'down') { // The seeded quote wraps; use its actual end, as in the E2E case. - editor.update( - () => { - const slot = $getSlot($getRoot().getFirstChildOrThrow(), 'quote'); - if ($isElementNode(slot)) slot.selectEnd(); - }, - {discrete: true}, - ); + await act(async () => { + editor.update( + () => { + const slot = $getSlot($getRoot().getFirstChildOrThrow(), 'quote'); + if ($isElementNode(slot)) slot.selectEnd(); + }, + {discrete: true}, + ); + }); } else { await press( direction === 'down' diff --git a/packages/lexical-playground/src/__tests__/browser/htmlPasteUtils.ts b/packages/lexical-playground/src/__tests__/browser/htmlPasteUtils.ts index b84cb275b92..013f976c80d 100644 --- a/packages/lexical-playground/src/__tests__/browser/htmlPasteUtils.ts +++ b/packages/lexical-playground/src/__tests__/browser/htmlPasteUtils.ts @@ -66,6 +66,12 @@ export async function pasteFromClipboard( for (const [type, value] of Object.entries(payload)) { clipboardData.setData(type, value); } + // A paste uses the editor's selection, which follows a key that moved the + // caret only once its selectionchange is handled. A person cannot paste + // before that, but a test can on a busy runner, so let it be delivered. + await new Promise(resolve => + requestAnimationFrame(() => setTimeout(resolve, 0)), + ); await act(async () => { root.dispatchEvent(clipboardEvent('paste', clipboardData)); }); diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css index 263d534928a..8a727bdd3ae 100644 --- a/packages/lexical-playground/src/index.css +++ b/packages/lexical-playground/src/index.css @@ -1226,7 +1226,6 @@ i.page-setup, cursor: default; display: inline-block; position: relative; - user-select: none; overflow: hidden; } diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts index 38b2a6bf819..a53fb91eb5a 100644 --- a/packages/lexical-table/src/LexicalTableNode.ts +++ b/packages/lexical-table/src/LexicalTableNode.ts @@ -273,15 +273,36 @@ export function attachStickyScrollbarListeners( scrollable.style.scrollbarWidth = 'auto'; return () => {}; } + // A scroll event is dispatched on the frame after its scroll, so the event + // for the position one listener copies onto the other element arrives a + // frame late. By then the element it was copied from may have moved on — + // drag auto-scroll moves the wrapper every frame, and so does dragging the + // proxy's thumb — and copying that stale position back would undo the newer + // scroll, which halved the speed of drag auto-scroll. So each listener skips + // an event that only reports the position its element was last synced to. + let syncedWrapperLeft = scrollable.scrollLeft; + let syncedScrollbarLeft = scrollbar.scrollLeft; const onWrapperScroll = () => { - if (scrollbar.scrollLeft !== scrollable.scrollLeft) { - scrollbar.scrollLeft = scrollable.scrollLeft; + const left = scrollable.scrollLeft; + if (left === syncedWrapperLeft) { + return; + } + if (scrollbar.scrollLeft !== left) { + scrollbar.scrollLeft = left; } + syncedWrapperLeft = left; + syncedScrollbarLeft = scrollbar.scrollLeft; }; const onScrollbarScroll = () => { - if (scrollable.scrollLeft !== scrollbar.scrollLeft) { - scrollable.scrollLeft = scrollbar.scrollLeft; + const left = scrollbar.scrollLeft; + if (left === syncedScrollbarLeft) { + return; + } + if (scrollable.scrollLeft !== left) { + scrollable.scrollLeft = left; } + syncedScrollbarLeft = left; + syncedWrapperLeft = scrollable.scrollLeft; }; scrollable.addEventListener('scroll', onWrapperScroll, { passive: true, diff --git a/packages/lexical-table/src/LexicalTablePluginHelpers.ts b/packages/lexical-table/src/LexicalTablePluginHelpers.ts index 8c521e36503..2f31a90f404 100644 --- a/packages/lexical-table/src/LexicalTablePluginHelpers.ts +++ b/packages/lexical-table/src/LexicalTablePluginHelpers.ts @@ -26,6 +26,7 @@ import { $isElementNode, $isRangeSelection, $isTextNode, + $normalizeSelection__EXPERIMENTAL, $setSelection, CLICK_COMMAND, COMMAND_PRIORITY_EDITOR, @@ -205,7 +206,11 @@ function $tableClickCommand(event: MouseEvent): boolean { if (!$isTableCellNode(rootNode)) { return false; } - blockNode.select(0); + // Select the block with the text points a native selection of it resolves + // to. Outside Apple platforms, WebKit fires no selectionchange when element + // points cover the same text as the DOM selection (as after a double click + // on a one-word block), so nothing would replace them in the DOM. + $normalizeSelection__EXPERIMENTAL(blockNode.select(0)); return true; } diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index f7dc95c11cc..31e71875878 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -137,9 +137,17 @@ const isPointerDownOnEvent = (event: PointerEvent) => { const TAP_SLOP = 10; // Distance (px) from a scroll container edge at which drag auto-scroll kicks -// in, and the maximum scroll delta applied per animation frame. +// in, and the maximum scroll delta applied per 60Hz animation frame. const AUTO_SCROLL_EDGE_ZONE = 40; const AUTO_SCROLL_MAX_STEP = 18; +// The deltas are per frame at AUTO_SCROLL_FPS, and each tick scales them +// by the time since the previous one, so the scroll speed doesn't follow the +// frame rate: a 120Hz display would otherwise scroll twice as fast, and a +// page whose frames slow down under load would barely creep. A tick covers at +// most AUTO_SCROLL_MAX_ELAPSED_MS, so a long stall between frames resumes with +// a jump of a few frames rather than one past most of the table. +const AUTO_SCROLL_FPS = 60; +const AUTO_SCROLL_MAX_ELAPSED_MS = 100; // Given a pointer position and the start/end edges of a scroll container on one // axis, return the signed per-frame scroll delta: negative near the start edge, @@ -164,6 +172,23 @@ function autoScrollStep(pos: number, start: number, end: number): number { return 0; } +// The time (ms) an auto-scroll tick at `time` covers, given the previous +// tick's. The first tick of a run covers one frame, as does a tick whose time +// can't be measured (a callback run without a timestamp, or a clock that did +// not advance). +function autoScrollElapsed(time: number, prevTime: number | null): number { + const frameMs = 1000 / AUTO_SCROLL_FPS; + const elapsed = prevTime === null ? frameMs : time - prevTime; + return elapsed > 0 ? Math.min(elapsed, AUTO_SCROLL_MAX_ELAPSED_MS) : frameMs; +} + +// Scale a per-frame delta from autoScrollStep to a tick covering `elapsed` ms. +// It stays at least one pixel: the loop stops on a tick that scrolls nothing. +function scaleAutoScrollStep(step: number, elapsed: number): number { + const scaled = Math.round((step * elapsed * AUTO_SCROLL_FPS) / 1000); + return step < 0 ? Math.min(-1, scaled) : Math.max(1, scaled); +} + export function isHTMLTableElement(el: unknown): el is HTMLTableElement { return isHTMLElement(el) && el.nodeName === 'TABLE'; } @@ -346,6 +371,9 @@ function $handleTableClick( let lastClientX = event.clientX; let lastClientY = event.clientY; let autoScrollRafId: number | null = null; + // The timestamp of the previous tick of the running auto-scroll, or null + // before its first tick. + let lastAutoScrollTime: number | null = null; // Removes every listener below. Assigned once they are defined; nothing // can call stopSelecting before then, since only those listeners do. let removeGestureListeners: (() => void) | null = null; @@ -462,11 +490,13 @@ function $handleTableClick( }; // Scroll `container` (or the window when null) on `axis` if the pointer is - // within the edge zone. Returns whether it actually scrolled. + // within the edge zone, by as much as `elapsed` ms of auto-scroll covers. + // Returns whether it actually scrolled. const scrollAxis = ( container: HTMLElement | null, pos: number, axis: 'x' | 'y', + elapsed: number, ): boolean => { let start: number; let end: number; @@ -478,10 +508,11 @@ function $handleTableClick( start = axis === 'x' ? rect.left : rect.top; end = axis === 'x' ? rect.right : rect.bottom; } - const step = autoScrollStep(pos, start, end); - if (step === 0) { + const frameStep = autoScrollStep(pos, start, end); + if (frameStep === 0) { return false; } + const step = scaleAutoScrollStep(frameStep, elapsed); if (container === null) { const before = axis === 'x' ? editorWindow.scrollX : editorWindow.scrollY; @@ -543,18 +574,21 @@ function $handleTableClick( return autoScrollStep(lastClientY, vStart, vEnd) !== 0; }; - const tickAutoScroll = () => { + const tickAutoScroll = (time: number) => { autoScrollRafId = null; if (!tableObserver.isSelecting) { return; } + const elapsed = autoScrollElapsed(time, lastAutoScrollTime); + lastAutoScrollTime = time; const hContainer = findScrollContainer('x'); const vContainer = findScrollContainer('y'); // Only the table's own wrapper scrolls horizontally; pages don't // auto-scroll sideways. Vertically we fall back to the window. const scrolledX = - hContainer !== null && scrollAxis(hContainer, lastClientX, 'x'); - const scrolledY = scrollAxis(vContainer, lastClientY, 'y'); + hContainer !== null && + scrollAxis(hContainer, lastClientX, 'x', elapsed); + const scrolledY = scrollAxis(vContainer, lastClientY, 'y', elapsed); if (scrolledX || scrolledY) { const [hitX, hitY] = clampHitPoint(hContainer, vContainer); const focusCell = resolveFocusCellFromPoint(hitX, hitY); @@ -577,6 +611,7 @@ function $handleTableClick( ) { return; } + lastAutoScrollTime = null; autoScrollRafId = editorWindow.requestAnimationFrame(tickAutoScroll); }; diff --git a/packages/lexical-table/src/__tests__/browser/DragAutoScroll.test.ts b/packages/lexical-table/src/__tests__/browser/DragAutoScroll.test.ts new file mode 100644 index 00000000000..6f13df9ab23 --- /dev/null +++ b/packages/lexical-table/src/__tests__/browser/DragAutoScroll.test.ts @@ -0,0 +1,144 @@ +/** + * 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 {RichTextExtension} from '@lexical/rich-text'; +import {$createTableNodeWithDimensions, TableExtension} from '@lexical/table'; +import {$getRoot} from 'lexical'; +import {assert, expect, onTestFinished, test, vi} from 'vitest'; + +// Drag auto-scroll (#7153) runs on animation frames. This test runs those +// frames itself, with the timestamps of a chosen frame rate, so it measures +// how far a table scrolls in a given time without depending on how fast the +// browser happens to render. + +function mount() { + const style = document.createElement('style'); + // Ten columns of at least 100px overflow the 300px wide editor. + style.textContent = 'th, td { min-width: 100px; }'; + document.head.appendChild(style); + const container = document.createElement('div'); + // Clear of the top of the viewport, which has an edge zone of its own. + container.style.margin = '100px 0'; + container.style.width = '300px'; + document.body.appendChild(container); + const root = document.createElement('div'); + root.contentEditable = 'true'; + container.appendChild(root); + const editor = buildEditorFromExtensions({ + dependencies: [RichTextExtension, TableExtension], + name: 'test/drag-auto-scroll', + theme: {tableScrollableWrapper: ''}, + }); + editor.setRootElement(root); + let mounted = true; + const unmount = () => { + if (mounted) { + mounted = false; + editor.dispose(); + container.remove(); + style.remove(); + } + }; + onTestFinished(unmount); + editor.update( + () => { + $getRoot() + .clear() + .append($createTableNodeWithDimensions(2, 10, false)); + }, + {discrete: true}, + ); + const table = root.querySelector('table'); + assert(table !== null, 'Expected the table to be rendered'); + const wrapper = table.parentElement; + assert(wrapper !== null, 'Expected the table to have a scroll wrapper'); + expect(wrapper.scrollWidth).toBeGreaterThan(wrapper.clientWidth); + return {table, unmount, wrapper}; +} + +/** + * Takes over requestAnimationFrame until `restore` is called. `runFrame` runs + * the callbacks requested so far as one frame at `time`. + */ +function captureAnimationFrames() { + const callbacks = new Map(); + let nextId = 1; + const requestSpy = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation(callback => { + const id = nextId++; + callbacks.set(id, callback); + return id; + }); + const cancelSpy = vi + .spyOn(window, 'cancelAnimationFrame') + .mockImplementation(id => { + callbacks.delete(id); + }); + return { + restore() { + requestSpy.mockRestore(); + cancelSpy.mockRestore(); + }, + runFrame(time: number) { + const frame = [...callbacks.values()]; + callbacks.clear(); + for (const callback of frame) { + callback(time); + } + }, + }; +} + +/** + * Presses the mouse in the first cell of the second row and holds it 5px + * inside the right edge of the table's scroll wrapper, then runs frames at + * `frameMs` intervals for 100ms. Returns how far the wrapper scrolled. + */ +function autoScrollFor100ms(frameMs: number): number { + const {table, unmount, wrapper} = mount(); + const cell = table.querySelectorAll('tr')[1].querySelector('td'); + assert(cell !== null, 'Expected the second row to have a cell'); + const cellRect = cell.getBoundingClientRect(); + const clientY = cellRect.top + cellRect.height / 2; + const pointer = (type: string, clientX: number) => + cell.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + button: 0, + buttons: type === 'pointerup' ? 0 : 1, + clientX, + clientY, + pointerId: 1, + pointerType: 'mouse', + }), + ); + const holdX = wrapper.getBoundingClientRect().right - 5; + const {restore, runFrame} = captureAnimationFrames(); + try { + pointer('pointerdown', cellRect.left + cellRect.width / 2); + pointer('pointermove', holdX); + const frames = Math.round(100 / frameMs); + for (let frame = 0; frame <= frames; frame++) { + runFrame(1000 + frame * frameMs); + } + pointer('pointerup', holdX); + return wrapper.scrollLeft; + } finally { + restore(); + unmount(); + } +} + +test('drag auto-scroll covers the same distance in the same time at any frame rate', () => { + const at60Hz = autoScrollFor100ms(1000 / 60); + const at10Hz = autoScrollFor100ms(100); + expect(at60Hz).toBeGreaterThan(0); + expect(at10Hz).toBe(at60Hz); +}); diff --git a/packages/lexical-table/src/__tests__/browser/StickyScrollbar.test.ts b/packages/lexical-table/src/__tests__/browser/StickyScrollbar.test.ts index 3eefa4befbc..48d3ed17f93 100644 --- a/packages/lexical-table/src/__tests__/browser/StickyScrollbar.test.ts +++ b/packages/lexical-table/src/__tests__/browser/StickyScrollbar.test.ts @@ -135,6 +135,27 @@ describe('sticky scrollbar (browser)', () => { await expect.poll(() => Math.round(scrollable.scrollLeft)).toBe(40); }); + test('keeps a wrapper scrolled on every frame, as drag auto-scroll does', async () => { + const {editor} = setUpEditor({ + css: [OVERFLOW_CSS, THEMED_SCROLLBAR_CSS].join('\n'), + theme: {tableStickyScrollbar: 'test-sticky-scrollbar'}, + }); + editor.update($insertTable, {discrete: true}); + const {scrollable, scrollbar} = getParts(editor); + await expect.poll(() => scrollbar.style.display).toBe(''); + + // The wrapper's scroll event copies its position onto the scrollbar, and + // the scrollbar's own scroll event for that copy arrives on the next + // frame, after the wrapper has moved again. Copying that position back + // would undo every other step. + for (let frame = 0; frame < 10; frame++) { + await new Promise(resolve => requestAnimationFrame(resolve)); + scrollable.scrollLeft += 10; + } + await expect.poll(() => Math.round(scrollbar.scrollLeft)).toBe(100); + expect(Math.round(scrollable.scrollLeft)).toBe(100); + }); + test('hides the scrollbar when frozen rows make the wrapper unscrollable', async () => { // Mirrors the playground's frozen-rows styling, which switches the // wrapper to overflow-x: clip (scrollLeft writes are inert there). The diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts index 4d59adb41ea..edd952e7e39 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts @@ -1087,6 +1087,69 @@ describe('TableExtension', () => { }); }); + describe('triple click in a cell', () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + container.contentEditable = 'true'; + document.body.appendChild(container); + editor.setRootElement(container); + }); + + afterEach(() => { + editor.setRootElement(null); + container.remove(); + }); + + it('selects the paragraph with text points, as a native selection of it resolves', () => { + let firstKey = ''; + let lastKey = ''; + editor.update( + () => { + const table = $createTableNodeWithDimensions(1, 2, false); + $getRoot().clear().append(table); + const row = $assertNodeType(table.getFirstChild(), $isTableRowNode); + const cell = $assertNodeType(row.getLastChild(), $isTableCellNode); + const paragraph = $assertNodeType( + cell.getFirstChild(), + $isParagraphNode, + ); + const first = $createTextNode('Foo'); + const last = $createTextNode('Bar').toggleFormat('bold'); + paragraph.append(first, last); + firstKey = first.getKey(); + lastKey = last.getKey(); + }, + {discrete: true}, + ); + const target = editor.getElementByKey(lastKey); + assert(target !== null, 'Expected the clicked text to be rendered'); + + // The third click's click event, with detail 3. The table cancels that + // click's mousedown, so the browser makes no selection of its own. + target.dispatchEvent( + new MouseEvent('click', {bubbles: true, cancelable: true, detail: 3}), + ); + + editor.read('force-commit', () => { + const selection = $getSelection(); + assert($isRangeSelection(selection), 'Expected a RangeSelection'); + const {anchor, focus} = selection; + expect([anchor.key, anchor.offset, anchor.type]).toEqual([ + firstKey, + 0, + 'text', + ]); + expect([focus.key, focus.offset, focus.type]).toEqual([ + lastKey, + 3, + 'text', + ]); + }); + }); + }); + describe('FORMAT_ELEMENT_COMMAND on a full table selection (#8880)', () => { let container: HTMLDivElement; diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index 238a3b0fe8f..696fedef910 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -1123,7 +1123,7 @@ declare export class ElementDOMSlot extends DOMSlot` on the outside of such a boundary * decorator gives the browser an editable inline box to canonicalize the * boundary position against, which restores the highlight — including over the @@ -67,19 +96,8 @@ const IS_WEBKIT_BROWSER = IS_APPLE_WEBKIT || IS_IOS || IS_SAFARI; * would add a stray blank line here). */ function $createDecoratorBoundaryAnchor(): HTMLImageElement { - const img = $getDocument().createElement('img'); - img.setAttribute('data-lexical-decorator-boundary', 'true'); - img.alt = ''; - for (const [property, value] of [ - ['position', 'absolute'], - ['width', '0px'], - ['height', '0px'], - ['border', '0px'], - ['margin', '0px'], - ['padding', '0px'], - ]) { - img.style.setProperty(property, value, 'important'); - } + const img = $createZeroImg(DATA_LEXICAL_DECORATOR_BOUNDARY); + img.style.setProperty('position', 'absolute', 'important'); return img; } @@ -98,7 +116,7 @@ export function isDecoratorBoundaryAnchorDOM( return ( node !== null && node.nodeType === 1 && - (node as Element).hasAttribute('data-lexical-decorator-boundary') + (node as Element).hasAttribute(DATA_LEXICAL_DECORATOR_BOUNDARY) ); } @@ -288,6 +306,28 @@ function $topLevelChildOf(parent: HTMLElement, descendant: Node): Node | null { return node; } +const ZERO_IMG_STYLE = { + border: '0px !important', + display: 'inline !important', + height: '0px !important', + margin: '0px !important', + 'min-height': '0px !important', + 'min-width': '0px !important', + padding: '0px !important', + width: '0px !important', +}; +function $createZeroImg( + attr: + | typeof DATA_LEXICAL_MANAGED_LINEBREAK + | typeof DATA_LEXICAL_DECORATOR_BOUNDARY, +): HTMLImageElement { + const img = $getDocument().createElement('img'); + img.setAttribute(attr, 'true'); + img.alt = ''; + setDOMStyleObject(img.style, ZERO_IMG_STYLE); + return img; +} + /** * A utility class for managing the DOM children of an ElementNode. * @@ -447,8 +487,37 @@ export class ElementDOMSlot< if (nextLineBreakType === null) { this.removeManagedLineBreak(); } else { - const webkitHack = nextLineBreakType === 'decorator' && IS_WEBKIT_BROWSER; - this.insertManagedLineBreak(webkitHack); + this.insertManagedLineBreak( + nextLineBreakType === 'decorator' && NEEDS_INLINE_DECORATOR_EDGE_BOX, + ); + } + } + + /** + * @internal + * + * Rebuild the managed line break after something outside the reconciler + * (native editing, an IME, an extension) removed part of it. The `` of + * the img+br pair is the tracked node, so a lone `
` or `` left + * behind is dropped and the pair is inserted again in its usual place, + * inside the trailing boundary. + */ + restoreManagedLineBreak(): void { + const element: HTMLElement & LexicalPrivateDOM = this.element; + for (const child of Array.from(element.childNodes)) { + if ( + child.nodeType === 1 && + (child as Element).hasAttribute(DATA_LEXICAL_MANAGED_LINEBREAK) + ) { + element.removeChild(child); + } + } + element.__lexicalLineBreak = undefined; + const kind = element.__lexicalLastChildKind; + if (kind != null) { + this.insertManagedLineBreak( + kind === 'decorator' && NEEDS_INLINE_DECORATOR_EDGE_BOX, + ); } } @@ -466,10 +535,10 @@ export class ElementDOMSlot< } } /** @internal */ - insertManagedLineBreak(webkitHack: boolean): void { + insertManagedLineBreak(withEdgeImg: boolean): void { const prevBreak = this.getManagedLineBreak(); if (prevBreak) { - if (webkitHack === (prevBreak.nodeName === 'IMG')) { + if (withEdgeImg === (prevBreak.nodeName === 'IMG')) { return; } this.removeManagedLineBreak(); @@ -479,15 +548,10 @@ export class ElementDOMSlot< // end of the managed range. const before = this.before || this.getDecoratorBoundaryAnchor('trailing'); const br = $getDocument().createElement('br'); - br.setAttribute('data-lexical-managed-linebreak', 'true'); + br.setAttribute(DATA_LEXICAL_MANAGED_LINEBREAK, 'true'); element.insertBefore(br, before); - if (webkitHack) { - const img = $getDocument().createElement('img'); - img.setAttribute('data-lexical-managed-linebreak', 'true'); - img.style.setProperty('display', 'inline', 'important'); - img.style.setProperty('border', '0px', 'important'); - img.style.setProperty('margin', '0px', 'important'); - img.alt = ''; + if (withEdgeImg) { + const img = $createZeroImg(DATA_LEXICAL_MANAGED_LINEBREAK); element.insertBefore(img, br); element.__lexicalLineBreak = img; } else { diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 4e5f0b47b22..4ced6afaaef 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -303,6 +303,26 @@ export interface InputState { focusOffset: number; }; isSelectionChangeFromMouseDown: boolean; + /** + * A single primary-button mouse press in the root element that may start a + * text selection drag, from its pointerdown until the next pointerup or + * pointercancel anywhere in its document, or a drag and drop: where it was + * pressed, the DOM anchor of its selection once the pointer has crossed + * the drag threshold, with what that point was next to (false when the + * anchor is outside this editor, or the DOM there has changed since), and + * whether Lexical has changed the drag's selection. Only tracked on the + * engines in TRACKS_MOUSE_SELECTION_DRAG, and not for a press on a + * non-collapsed selection or a double or triple click; null otherwise. + */ + mouseSelectionDrag: null | { + anchor: + | null + | false + | [node: Node, offset: number, context: Node | string | null]; + clientX: number; + clientY: number; + hasExtended: boolean; + }; /** * The pointerType of the latest pointerdown in the root element ('touch', * 'pen' or 'mouse'), so the compatibility mousedown and click that follow a @@ -348,6 +368,7 @@ export function createInputState(): InputState { lastKeyCode: null, lastKeyDownTimeStamp: 0, lastPointerType: '', + mouseSelectionDrag: null, postDeleteSelectionToRestore: null, savedInputMode: undefined, selectionChangeFromDOMUpdatePoints: null, diff --git a/packages/lexical/src/LexicalEvents.ts b/packages/lexical/src/LexicalEvents.ts index 1fd37c5a01e..4043b712a46 100644 --- a/packages/lexical/src/LexicalEvents.ts +++ b/packages/lexical/src/LexicalEvents.ts @@ -92,6 +92,7 @@ import { DOUBLE_LINE_BREAK, IS_ALL_FORMATTING, } from './LexicalConstants'; +import {NEEDS_INLINE_DECORATOR_EDGE_BOX} from './LexicalDOMSlot'; import { compileKeyboardShortcuts, CONTROL_OR_ALT, @@ -114,6 +115,7 @@ import { $getAdjacentNode, $getDOMTextNode, $getNodeByKey, + $getNodeFromDOMNode, $isTokenOrSegmented, $isTokenOrTab, $setSelection, @@ -130,6 +132,7 @@ import { getDOMSelection, getDOMSelectionFromTarget, getDOMSelectionPoints, + getDOMSelectionRange, getEditorPropertyFromDOMNode, getEditorsToPropagate, getNearestEditorFromDOMNode, @@ -150,6 +153,14 @@ import { import {mergeRegister} from './utils/mergeRegister'; import {registerEventListener} from './utils/registerEventListener'; +/** + * The engines whose mouse drags {@link onPointerMove} extends across inline + * decorators. Firefox needs no edge boxes, but its drags do not cross an + * inline decorator whose content is `user-select: none` either. + */ +const TRACKS_MOUSE_SELECTION_DRAG: boolean = + NEEDS_INLINE_DECORATOR_EDGE_BOX || IS_FIREFOX; + type RootElementRemoveHandles = (() => void)[]; type RootElementEvents = [ string, @@ -157,6 +168,8 @@ type RootElementEvents = [ ][]; const PASS_THROUGH_COMMAND = /* @__PURE__ */ Object.freeze({}); const ANDROID_COMPOSITION_LATENCY = 30; +// Chromium's mouse drag threshold is 4px and WebKit's is 3px. +const DRAG_THRESHOLD_PX = 4; let rootElementEvents: RootElementEvents | undefined; function getRootElementEvents(): RootElementEvents { @@ -186,11 +199,23 @@ function getRootElementEvents(): RootElementEvents { (event, editor) => onBeforeInput(event as InputEvent, editor), ]); } + if (TRACKS_MOUSE_SELECTION_DRAG) { + events.push([ + 'pointermove', + (event, editor) => onPointerMove(event as PointerEvent, editor), + ]); + } if (IS_IOS) { - events.push( - ['keyup', (event, editor) => onKeyUp(event as KeyboardEvent, editor)], - ['mousedown', onMouseDownIOS], - ); + events.push([ + 'keyup', + (event, editor) => onKeyUp(event as KeyboardEvent, editor), + ]); + } + if (TRACKS_MOUSE_SELECTION_DRAG || IS_IOS) { + events.push([ + 'mousedown', + (event, editor) => onMouseDown(event as MouseEvent, editor), + ]); } rootElementEvents = events; return events; @@ -829,6 +854,382 @@ function onPointerDown(event: PointerEvent, editor: LexicalEditor) { } }); } + if (TRACKS_MOUSE_SELECTION_DRAG) { + const rootElement = editor.getRootElement(); + const drag = + isDOMNode(target) && + rootElement !== null && + pointerType === 'mouse' && + event.button === 0 && + !isDOMCapturingSelection(target, editor) && + !isPointOverDOMSelection( + getDOMSelectionFromTarget(target), + rootElement, + event.clientX, + event.clientY, + ) + ? { + anchor: null, + clientX: event.clientX, + clientY: event.clientY, + hasExtended: false, + } + : null; + editor._inputState.mouseSelectionDrag = drag; + const doc = + drag !== null && isDOMNode(target) ? target.ownerDocument : null; + if (drag !== null && doc !== null) { + // The button may be released outside the root element, and a press + // that starts a drag and drop gets a pointercancel instead. + const endDrag = () => { + if (editor._inputState.mouseSelectionDrag === drag) { + editor._inputState.mouseSelectionDrag = null; + } + doc.removeEventListener('pointerup', endDrag, true); + doc.removeEventListener('pointercancel', endDrag, true); + }; + doc.addEventListener('pointerup', endDrag, true); + doc.addEventListener('pointercancel', endDrag, true); + } + } +} + +/** + * Whether the point at (`clientX`, `clientY`) is over the non-collapsed DOM + * selection, where a press drags the selection rather than starting a new + * one. + */ +function isPointOverDOMSelection( + domSelection: Selection | null, + rootElement: HTMLElement, + clientX: number, + clientY: number, +): boolean { + const range = + domSelection !== null && domSelection.rangeCount > 0 + ? getDOMSelectionRange(domSelection, rootElement) + : null; + if (range === null || range.collapsed) { + return false; + } + const rects = range.getClientRects(); + for (let i = 0; i < rects.length; i++) { + const rect = rects[i]; + if ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ) { + return true; + } + } + return false; +} + +/** + * The browsers that report a click count on pointerdown (Chromium and + * Firefox report 0) all report it on the mousedown that follows, so stop + * tracking a press there when it is a double or triple click, which extends + * the selection by words or paragraphs. + */ +function onMouseDown(event: MouseEvent, editor: LexicalEditor): void { + if (IS_IOS) { + onMouseDownIOS(event, editor); + } + if (TRACKS_MOUSE_SELECTION_DRAG && event.detail > 1) { + editor._inputState.mouseSelectionDrag = null; + } +} + +/** + * Extends a mouse drag selection across an inline DecoratorNode (#7158). + * + * Browsers move the focus of a drag selection by hit-testing the pointer, and + * over an inline decorator's contentEditable=false DOM the hit test often + * finds no position the selection can extend to: in Chromium and WebKit + * always when the decorator's content is `user-select: none` or holds a block + * box, and intermittently otherwise, and in Firefox when its content is + * `user-select: none`. The drag then stays where it was until the pointer + * reaches text or the end of the line. While the pointer is over an inline + * decorator, or the browser has put the focus inside one, put the DOM focus + * on the side of the decorator nearest the pointer. + * + * A drag also keeps extending from the selection's current anchor, so when + * something replaces the selection mid-drag (on macOS Chrome the drag can + * restart from the pointer), restore the anchor the drag had once it crossed + * the drag threshold. + * + * A press on a non-collapsed selection is not tracked, so it still drags the + * selection: Firefox starts that drag and drop only past the platform's drag + * threshold, which can be larger than this one. Once the selection has been + * extended, it contains the mousedown point, and Chromium keeps checking for + * a selection drag and drop until its own hit test has moved the selection; + * {@link onSelectionDragStart} cancels that drag. + */ +function onPointerMove(event: PointerEvent, editor: LexicalEditor) { + const drag = editor._inputState.mouseSelectionDrag; + if ( + drag === null || + event.pointerType !== 'mouse' || + (event.buttons & 1) === 0 + ) { + return; + } + const target = getComposedEventTarget(event); + const rootElement = editor.getRootElement(); + if (!isDOMNode(target) || rootElement === null) { + return; + } + const domSelection = getDOMSelectionFromTarget(target); + if (domSelection === null || domSelection.rangeCount === 0) { + return; + } + if (drag.anchor === null) { + if ( + Math.abs(event.clientX - drag.clientX) > DRAG_THRESHOLD_PX || + Math.abs(event.clientY - drag.clientY) > DRAG_THRESHOLD_PX + ) { + // The browser has placed the drag's anchor, and Lexical has handled + // it, by the time the pointer crosses the drag threshold. + const {anchorNode, anchorOffset} = getDOMSelectionPoints( + domSelection, + rootElement, + ); + // Firefox puts a press beyond the end of a line that ends in an + // unselectable inline decorator before that decorator. + if ( + anchorNode === null || + getNearestEditorFromDOMNode(anchorNode) !== editor + ) { + drag.anchor = false; + } else { + const [node, offset] = movePastInlineDecorators( + editor, + rootElement, + anchorNode, + anchorOffset, + drag.clientX, + drag.clientY, + ); + drag.anchor = [node, offset, getDOMPointContext(node, offset)]; + } + } + return; + } + const dragAnchor = drag.anchor; + if (dragAnchor === false) { + return; + } + // The browser moves the selection for this pointer position in the + // default action of the mousemove that follows, which may put the focus + // somewhere else on the line (macOS Chrome picks the start or the end of + // it) or inside the decorator, so adjust it once that has run. + setTimeout(() => { + if (editor._inputState.mouseSelectionDrag !== drag) { + return; + } + const [anchorNode, anchorOffset, anchorContext] = dragAnchor; + if ( + !anchorNode.isConnected || + getDOMPointContext(anchorNode, anchorOffset) !== anchorContext + ) { + // An update (such as a collaborator's edit) has changed the DOM at the + // drag's anchor, so the recorded point may no longer be where the drag + // started; leave the rest of the drag to the browser. + drag.anchor = false; + return; + } + const points = + domSelection.rangeCount > 0 + ? getDOMSelectionPoints(domSelection, rootElement) + : null; + let focusNode = points ? points.focusNode : null; + let focusOffset = points ? points.focusOffset : 0; + const decoratorDOM = + getInlineDecoratorDOM(editor, target, rootElement) || + (focusNode && getInlineDecoratorDOM(editor, focusNode, rootElement)); + const parentDOM = decoratorDOM && decoratorDOM.parentNode; + if (decoratorDOM && parentDOM) { + const rect = decoratorDOM.getBoundingClientRect(); + const index = Array.prototype.indexOf.call( + parentDOM.childNodes, + decoratorDOM, + ); + const isRTL = + isHTMLElement(parentDOM) && + getWindow(editor).getComputedStyle(parentDOM).direction === 'rtl'; + const isAfter = event.clientX > rect.left + rect.width / 2 !== isRTL; + focusNode = parentDOM; + focusOffset = isAfter ? index + 1 : index; + } + if (focusNode === null) { + return; + } + [focusNode, focusOffset] = movePastInlineDecorators( + editor, + rootElement, + focusNode, + focusOffset, + event.clientX, + event.clientY, + ); + if ( + points === null || + points.anchorNode !== anchorNode || + points.anchorOffset !== anchorOffset + ) { + // Something reset the selection during the drag, and the browser + // would carry on from wherever it now is. + drag.hasExtended = true; + domSelection.setBaseAndExtent( + anchorNode, + anchorOffset, + focusNode, + focusOffset, + ); + } else if ( + points.focusNode !== focusNode || + points.focusOffset !== focusOffset + ) { + drag.hasExtended = true; + domSelection.extend(focusNode, focusOffset); + } + }, 0); +} + +/** + * Moves a drag point past the inline decorators next to it that the pointer + * at (`clientX`, `clientY`) has already passed on the same line. Firefox's + * hit test never puts a point after an inline decorator whose content is + * `user-select: none`, so beyond the end of a line that ends in one, the + * point stays before it. + */ +function movePastInlineDecorators( + editor: LexicalEditor, + rootElement: HTMLElement, + node: Node, + offset: number, + clientX: number, + clientY: number, +): [node: Node, offset: number] { + for (const isForward of [true, false]) { + for (;;) { + const sibling = getDOMSiblingAt(node, offset, isForward, rootElement); + const parentDOM = sibling && sibling.parentNode; + if ( + sibling === null || + parentDOM === null || + getInlineDecoratorDOM(editor, sibling, rootElement) !== sibling + ) { + break; + } + const rect = (sibling as HTMLElement).getBoundingClientRect(); + if (clientY < rect.top || clientY > rect.bottom) { + break; + } + const isRTL = + isHTMLElement(parentDOM) && + getWindow(editor).getComputedStyle(parentDOM).direction === 'rtl'; + const hasPassed = + isForward !== isRTL ? clientX > rect.right : clientX < rect.left; + if (!hasPassed) { + break; + } + const index = Array.prototype.indexOf.call(parentDOM.childNodes, sibling); + node = parentDOM; + offset = isForward ? index + 1 : index; + } + } + return [node, offset]; +} + +/** + * The DOM node right after (or, when `isForward` is false, right before) the + * point at `offset` in `node`, leaving any text node or element that the + * point is at the edge of, or null at the edge of `rootElement`. + */ +function getDOMSiblingAt( + node: Node, + offset: number, + isForward: boolean, + rootElement: HTMLElement, +): Node | null { + if (isDOMTextNode(node)) { + if (isForward ? offset < node.length : offset > 0) { + return null; + } + } else { + const child = node.childNodes[isForward ? offset : offset - 1]; + if (child) { + return child; + } + } + let dom: Node | null = node; + while (dom !== null && dom !== rootElement) { + const sibling: Node | null = isForward + ? dom.nextSibling + : dom.previousSibling; + if (sibling !== null) { + return sibling; + } + dom = dom.parentNode; + } + return null; +} + +/** + * The DOM of the inline DecoratorNode of `editor` that contains `dom`, or + * null when `dom` is not inside one. + */ +function getInlineDecoratorDOM( + editor: LexicalEditor, + dom: Node, + rootElement: HTMLElement, +): HTMLElement | null { + return editor.read('latest', () => { + let node: Node | null = dom; + while (node !== null && node !== rootElement) { + const lexicalNode = $getNodeFromDOMNode(node); + if (lexicalNode !== null) { + return $isDecoratorNode(lexicalNode) && + lexicalNode.isInline() && + isHTMLElement(node) + ? node + : null; + } + node = getParentElement(node); + } + return null; + }); +} + +/** + * Cancels the drag and drop that a press would otherwise start once + * {@link onPointerMove} has extended its selection over the mousedown point, + * so the press keeps selecting. Returns true when it did. Any other drag and + * drop ends the press's selection drag. + */ +function onSelectionDragStart(event: DragEvent, editor: LexicalEditor) { + const drag = editor._inputState.mouseSelectionDrag; + if (drag === null) { + return false; + } + if (!drag.hasExtended) { + editor._inputState.mouseSelectionDrag = null; + return false; + } + event.preventDefault(); + return true; +} + +/** + * What the DOM point at `offset` in `node` is next to: the text of a text + * node, or the child before the point, so that a point recorded earlier can + * be checked against later changes to the DOM. + */ +function getDOMPointContext(node: Node, offset: number): Node | string | null { + return isDOMTextNode(node) ? node.data : node.childNodes[offset - 1] || null; } function getTargetRange(event: InputEvent): null | StaticRange { @@ -2300,6 +2701,9 @@ export function addRootElementEvents( ); case 'dragstart': + if (onSelectionDragStart(event as DragEvent, editor)) { + return true; + } return ( isEditable && dispatchCommand(editor, DRAGSTART_COMMAND, event as DragEvent) diff --git a/packages/lexical/src/LexicalMutations.ts b/packages/lexical/src/LexicalMutations.ts index c668f84bc14..a0af5cfe158 100644 --- a/packages/lexical/src/LexicalMutations.ts +++ b/packages/lexical/src/LexicalMutations.ts @@ -14,6 +14,7 @@ import type {BaseSelection} from './LexicalSelection'; import { $getSelection, $isDecoratorNode, + $isElementNode, $isRangeSelection, $isTextNode, $setSelection, @@ -22,8 +23,10 @@ import { } from '.'; import {IS_FIREFOX} from './environment'; import {isDecoratorBoundaryAnchorDOM} from './LexicalDOMSlot'; +import {$reconcileDecoratorBoundaryAnchors} from './LexicalReconciler'; import {updateEditorSync} from './LexicalUpdates'; import { + $getDOMSlot, $getNodeByKey, $getNodeFromDOMNode, $updateTextNodeFromDOMContent, @@ -75,6 +78,15 @@ function isEditorManagedLineBreak( ); } +// A piece of an element's managed line break: the `
`, or the `` that +// precedes it after an inline decorator. Recognized by its attribute, which +// a removed node keeps after it loses its siblings. +function isManagedLineBreakDOM(dom: Node): boolean { + return ( + isHTMLElement(dom) && dom.hasAttribute('data-lexical-managed-linebreak') + ); +} + function getLastSelection(editor: LexicalEditor): null | BaseSelection { return editor.read('latest', () => { const selection = $getSelection(); @@ -248,23 +260,52 @@ function flushMutations( if (removedDOMsLength > 0) { let unremovedBRs = 0; + // The element's own scaffolding has a fixed place in its DOM (the + // managed line break inside the trailing boundary, with its img + // first; a boundary anchor on the edge it belongs to), so it is + // rebuilt in place below rather than re-appended at the end. + const elementSlot = $isElementNode(targetNode) + ? $getDOMSlot(targetNode, nodeDOM, editor) + : null; + const slot = + elementSlot !== null && elementSlot.element === targetDOM + ? elementSlot + : null; + let restoreLineBreak = false; + let restoreAnchors = false; for (let s = 0; s < removedDOMsLength; s++) { const removedDOM = removedDOMs[s]; if ( + slot !== null && + (removedDOM === slot.getManagedLineBreak() || + isManagedLineBreakDOM(removedDOM)) + ) { + restoreLineBreak = true; + unremovedBRs++; + } else if ( isEditorManagedLineBreak(removedDOM, targetDOM, editor) || blockCursorElement === removedDOM ) { targetDOM.appendChild(removedDOM); unremovedBRs++; } else if (isDecoratorBoundaryAnchorDOM(removedDOM)) { - // Position matters for these (leading vs trailing), so don't - // blindly re-append — the next reconcile of this element puts - // a fresh anchor on the right edge. + restoreAnchors = true; unremovedBRs++; } } + if (slot !== null && restoreLineBreak) { + slot.restoreManagedLineBreak(); + } + if (restoreAnchors && $isElementNode(targetNode)) { + $reconcileDecoratorBoundaryAnchors( + targetNode, + nodeDOM, + editor, + currentEditorState._nodeMap, + ); + } if (removedDOMsLength !== unremovedBRs) { badDOMTargets.set(nodeDOM, targetNode); diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index a297447e8d6..5dc5d8ae05f 100644 --- a/packages/lexical/src/LexicalReconciler.ts +++ b/packages/lexical/src/LexicalReconciler.ts @@ -44,6 +44,7 @@ import { IS_ALIGN_RIGHT, IS_ALIGN_START, } from './LexicalConstants'; +import {NEEDS_INLINE_DECORATOR_EDGE_BOX} from './LexicalDOMSlot'; import {cloneMap} from './LexicalGenMap'; import {$isSlotChild, $isSlotHost, EMPTY_SLOTS} from './LexicalSlot'; import { @@ -954,6 +955,30 @@ function $isBlockDecoratorChild( return $isDecoratorNode(node) && !node.isInline(); } +/** + * Whether the element's first child needs the leading boundary anchor: a + * block DecoratorNode, or on the engines in + * {@link NEEDS_INLINE_DECORATOR_EDGE_BOX} an inline one too. A line that starts with an inline decorator + * has no editable caret position before it either, so a browser canonicalizes + * a caret at the start of the line into the decorator's non-editable DOM and + * will not extend a mouse drag out of it (#7158). (The trailing edge of an + * inline decorator already has an editable position: the managed line break + * that {@link $reconcileElementTerminatingLineBreak} appends after it.) + */ +function $isLeadingDecoratorChild( + key: null | NodeKey, + nodeMap: NodeMap, +): boolean { + if (!key) { + return false; + } + const node = nodeMap.get(key); + return ( + $isDecoratorNode(node) && + (!node.isInline() || NEEDS_INLINE_DECORATOR_EDGE_BOX) + ); +} + /** * Browsers drop the selection highlight for the whole document when a range * endpoint lands on an element boundary that is immediately adjacent to a @@ -962,19 +987,28 @@ function $isBlockDecoratorChild( * out-of-flow anchor parked outside each such boundary child so the browser has * an editable inline box to resolve the boundary position against. Interior * decorators are unaffected, so only the first / last child is considered. + * The leading anchor is also parked before an inline first-child decorator + * where the engine needs it, so a mouse drag can start at the start of such a + * line (see {@link $isLeadingDecoratorChild}, #7158). + * + * Also called outside a reconcile, with the editor and node map passed + * explicitly, to put back an anchor that something else removed from the DOM + * (see `flushMutations`). */ -function $reconcileDecoratorBoundaryAnchors( +export function $reconcileDecoratorBoundaryAnchors( nextElement: ElementNode, dom: HTMLElement & LexicalPrivateDOM, + editor: LexicalEditor = activeEditor, + nodeMap: NodeMap = activeNextNodeMap, ): void { - const slot = $getDOMSlot(nextElement, dom, activeEditor); + const slot = $getDOMSlot(nextElement, dom, editor); slot.setDecoratorBoundaryAnchor( 'leading', - $isBlockDecoratorChild(nextElement.__first, activeNextNodeMap), + $isLeadingDecoratorChild(nextElement.__first, nodeMap), ); slot.setDecoratorBoundaryAnchor( 'trailing', - $isBlockDecoratorChild(nextElement.__last, activeNextNodeMap), + $isBlockDecoratorChild(nextElement.__last, nodeMap), ); } diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 6176e38a0c1..de055cfd1ca 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -115,6 +115,7 @@ import { isDOMShadowRoot, isDOMTextNode, isHTMLElement, + isMouseSelectionDragging, isSelectionWithinEditor, removeDOMBlockCursorElement, scrollIntoViewIfNeeded, @@ -3888,6 +3889,25 @@ function $getElementPointScrollTarget( : child; } +/** + * The offset in `node` before any managed line break children that precede + * `offset`, which is the same position as far as Lexical is concerned. + */ +function skipManagedLineBreak(node: Node, offset: number): number { + const children = node.childNodes; + while (offset > 0) { + const child = children[offset - 1]; + if ( + !isHTMLElement(child) || + !child.hasAttribute('data-lexical-managed-linebreak') + ) { + break; + } + offset--; + } + return offset; +} + /** @internal */ export function $updateDOMSelection( prevSelection: BaseSelection | null, @@ -3942,8 +3962,14 @@ export function $updateDOMSelection( // of editor.setRootElement(). If this occurs on init when the // editor is already focused, then this can cause the editor to // lose focus. + // During a mouse selection drag, the browser's selection is the drag in + // progress, and removing it would restart the drag from wherever the + // pointer moves next. It is null here when a drag point lands inside a + // decorator, which the next pointermove moves out. A click still removes + // it: Firefox relies on that to place a click between block decorators. if ( prevSelection !== null && + !isMouseSelectionDragging(editor) && isSelectionWithinEditor( editor, currentPoints.anchorNode, @@ -4061,6 +4087,25 @@ export function $updateDOMSelection( } } + // During a mouse drag past the end of a line that ends in an inline + // decorator, the browser puts the focus after the managed line break's + // , which is the same Lexical point as before it. Writing the + // selection back would only make the browser move it again on the next + // pointer move, turning every move into two selection changes. + if ( + isMouseSelectionDragging(editor) && + currentPoints.anchorNode === nextAnchorNode && + currentPoints.focusNode === nextFocusNode && + (currentPoints.anchorOffset !== nextAnchorOffset || + currentPoints.focusOffset !== nextFocusOffset) && + skipManagedLineBreak(nextAnchorNode, currentPoints.anchorOffset) === + nextAnchorOffset && + skipManagedLineBreak(nextFocusNode, currentPoints.focusOffset) === + nextFocusOffset + ) { + return; + } + // Apply the updated selection to the DOM. Note: this will trigger // a "selectionchange" event, although it will be asynchronous. setDOMSelectionBaseAndExtent( diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index ac087948e3b..ece7edb8308 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -342,6 +342,19 @@ export function $isTokenOrSegmented(node: TextNode): boolean { return $isTokenOrTab(node) || node.isSegmented(); } +/** + * Whether a mouse press in `editor` has moved past the drag threshold, so the + * browser is extending a selection drag rather than handling a click. Only + * tracked on the engines whose drags Lexical extends across inline + * decorators. + * + * @internal + */ +export function isMouseSelectionDragging(editor: LexicalEditor): boolean { + const drag = editor._inputState.mouseSelectionDrag; + return drag !== null && drag.anchor !== null; +} + /** * @param node - The element being tested * @returns Returns true if node is an DOM Text node, false otherwise. diff --git a/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts b/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts new file mode 100644 index 00000000000..1ce2e2f9396 --- /dev/null +++ b/packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts @@ -0,0 +1,625 @@ +/** + * 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. + * + */ + +/** + * #7158: a real mouse drag that starts beside the first or last inline + * decorator of a line must extend across the decorators it crosses. jsdom + * cannot model this (it has no hit testing, and a browser's mouse drag keeps + * its own anchor regardless of later Selection API calls), so the drag is + * driven through Playwright's mouse. + */ + +import {buildEditorFromExtensions, defineExtension} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $create, + $createNodeSelection, + $createParagraphNode, + $createTextNode, + $getNodeByKey, + $getRoot, + $getSelection, + $isDecoratorNode, + $isRangeSelection, + $setSelection, + COMMAND_PRIORITY_CRITICAL, + DecoratorNode, + getDOMSelection, + type LexicalEditor, + type ParagraphNode, + SELECTION_CHANGE_COMMAND, + type TextNode, +} from 'lexical'; +import {assert, describe, onTestFinished, test, vi} from 'vitest'; +import {commands} from 'vitest/browser'; + +declare module 'vitest/browser' { + interface BrowserCommands { + mouseDrag: ( + selector: string, + points: [x: number, y: number][], + options?: {clickCount?: number}, + ) => Promise; + } +} + +const COUNT = 6; +const SIZE = 40; + +class ImageLikeDecoratorNode extends DecoratorNode { + $config() { + return this.config('test_7158_mouse_image', {extends: DecoratorNode}); + } + createDOM(): HTMLElement { + const span = document.createElement('span'); + const img = document.createElement('img'); + img.alt = ''; + img.style.cssText = `display:inline-block;width:${SIZE}px;height:${SIZE}px;background:#888`; + span.appendChild(img); + return span; + } + updateDOM(): false { + return false; + } + isInline(): true { + return true; + } + decorate(): null { + return null; + } +} + +/** + * Styled like the playground's ImageNode: an unselectable inline-block whose + * image sits in a block wrapper. Chromium's hit test finds no position inside + * it that a drag selection can extend to. + */ +class BoxedImageDecoratorNode extends ImageLikeDecoratorNode { + $config() { + return this.config('test_7158_mouse_boxed_image', { + extends: ImageLikeDecoratorNode, + }); + } + createDOM(): HTMLElement { + const span = document.createElement('span'); + span.style.cssText = + 'display:inline-block;position:relative;user-select:none;overflow:hidden'; + const div = document.createElement('div'); + const img = document.createElement('img'); + img.alt = ''; + img.style.cssText = `display:block;width:${SIZE}px;height:${SIZE}px;background:#888`; + div.appendChild(img); + span.appendChild(div); + return span; + } +} + +/** + * The same block-wrapped image, but selectable: Chromium's hit test can put + * a drag point inside it, where the point resolves to no Lexical selection. + */ +class SelectableBoxedImageDecoratorNode extends ImageLikeDecoratorNode { + $config() { + return this.config('test_7158_mouse_selectable_boxed_image', { + extends: ImageLikeDecoratorNode, + }); + } + createDOM(): HTMLElement { + const span = new BoxedImageDecoratorNode().createDOM(); + span.style.userSelect = ''; + return span; + } +} + +type DecoratorClass = + | typeof ImageLikeDecoratorNode + | typeof BoxedImageDecoratorNode + | typeof SelectableBoxedImageDecoratorNode; + +const pointerLog: string[] = []; + +function mount( + textBetween: boolean, + klass: DecoratorClass, + trailingText: string = '', +) { + const root = document.createElement('div'); + root.id = 'issue-7158-root'; + root.contentEditable = 'true'; + // Narrower than the test frame (414px), which would scroll sideways when a + // drag nears its edge, or to show a point mouseDrag hovers. + root.style.cssText = 'width:380px;padding:10px;font-size:16px'; + document.body.appendChild(root); + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: () => { + const paragraph = $createParagraphNode(); + for (let i = 0; i < COUNT; i++) { + if (textBetween) { + paragraph.append($createTextNode(' ')); + } + paragraph.append($create(klass)); + } + if (trailingText !== '') { + paragraph.append($createTextNode(trailingText)); + } + $getRoot().clear().append(paragraph); + }, + dependencies: [RichTextExtension], + name: '[7158-mouse]', + nodes: [ + ImageLikeDecoratorNode, + BoxedImageDecoratorNode, + SelectableBoxedImageDecoratorNode, + ], + }), + ); + editor.setRootElement(root); + // Where each pointer event landed (relative to the root) and the DOM + // selection once the browser has handled it, for the failure message. + pointerLog.length = 0; + const logPointer = (event: PointerEvent) => { + const rect = root.getBoundingClientRect(); + const x = Math.round(event.clientX - rect.left); + const y = Math.round(event.clientY - rect.top); + setTimeout(() => { + const dom = getDOMSelection(window); + pointerLog.push( + `${event.type.slice(7)}(${x},${y} b${event.buttons})=` + + `${describeDOMPoint(dom?.anchorNode ?? null, dom?.anchorOffset ?? 0)}->` + + `${describeDOMPoint(dom?.focusNode ?? null, dom?.focusOffset ?? 0)}`, + ); + }); + }; + const pointerTypes = ['pointerdown', 'pointermove', 'pointerup'] as const; + for (const type of pointerTypes) { + document.addEventListener(type, logPointer, true); + } + onTestFinished(() => { + for (const type of pointerTypes) { + document.removeEventListener(type, logPointer, true); + } + editor.dispose(); + root.remove(); + }); + const images = Array.from( + root.querySelectorAll( + 'img:not([data-lexical-decorator-boundary]):not([data-lexical-managed-linebreak])', + ), + ); + const rootRect = root.getBoundingClientRect(); + // Points relative to the root's top-left, on the images' center line. + const y = images[0].getBoundingClientRect().top + SIZE / 2 - rootRect.top; + const left = (i: number) => + images[i].getBoundingClientRect().left - rootRect.left; + const right = (i: number) => + images[i].getBoundingClientRect().right - rootRect.left; + // The box of `text` within the trailing text, relative to the root. + const textRect = (text: string) => { + const textNode = root.lastChild!.lastChild!.firstChild!; + const index = textNode.textContent!.indexOf(text); + const range = document.createRange(); + range.setStart(textNode, index); + range.setEnd(textNode, index + text.length); + const rect = range.getBoundingClientRect(); + return { + bottom: rect.bottom - rootRect.top, + left: rect.left - rootRect.left, + right: rect.right - rootRect.left, + top: rect.top - rootRect.top, + }; + }; + pointerLog.push( + `left(0)=${Math.round(left(0))} right(${COUNT - 1})=${Math.round(right(COUNT - 1))} y=${Math.round(y)} dpr=${window.devicePixelRatio}`, + ); + return {editor, left, right, root, textRect, y}; +} + +function describeDOMPoint(node: Node | null, offset: number): string { + if (node === null) { + return 'null'; + } + const name = + node.nodeType === Node.TEXT_NODE + ? `#text(${JSON.stringify(node.textContent)})` + : node.nodeName.toLowerCase(); + return `${name}:${offset}`; +} + +// The last selectionchange is handled asynchronously, and on a busy runner it +// can land well after the mouse is released, so wait for the editor's +// selection to settle rather than for a fixed time. A failure reports the +// selection that was reached, to tell a selection that never extended apart +// from one that extended too far. +async function expectSelectedDecoratorCount( + editor: LexicalEditor, + expected: number, +): Promise { + await vi.waitFor( + () => { + const [count, lexical] = editor.read(() => { + const selection = $getSelection(); + return $isRangeSelection(selection) + ? [ + selection.getNodes().filter($isDecoratorNode).length, + `${selection.anchor.key}:${selection.anchor.offset}->` + + `${selection.focus.key}:${selection.focus.offset}`, + ] + : [-1, String(selection)]; + }); + const dom = getDOMSelection(window); + assert( + count === expected, + `expected ${expected} selected decorators, got ${count} ` + + `(lexical ${lexical}; dom ` + + `${describeDOMPoint(dom?.anchorNode ?? null, dom?.anchorOffset ?? 0)}->` + + `${describeDOMPoint(dom?.focusNode ?? null, dom?.focusOffset ?? 0)}; ` + + `pointer ${pointerLog.join(' ')})`, + ); + }, + {interval: 50, timeout: 2000}, + ); +} + +function dragFromLineEnd(klass: DecoratorClass, textBetween: boolean) { + return async () => { + const {editor, left, right, y} = mount(textBetween, klass); + await commands.mouseDrag('#issue-7158-root', [ + [right(COUNT - 1) + 30, y], + [left(COUNT - 2) + SIZE / 2, y], + [left(3) + SIZE / 2, y], + [left(2) - 2, y], + ]); + await expectSelectedDecoratorCount(editor, COUNT - 2); + }; +} + +function dragFromLineStart(klass: DecoratorClass, textBetween: boolean) { + return async () => { + const {editor, left, right, y} = mount(textBetween, klass); + await commands.mouseDrag('#issue-7158-root', [ + [left(0) - 5, y], + [left(1) + SIZE / 2, y], + [left(COUNT - 2) + SIZE / 2, y], + [right(COUNT - 1) + 30, y], + ]); + await expectSelectedDecoratorCount(editor, COUNT); + }; +} + +describe.each([ + ['only inline decorators', false], + ['text between inline decorators', true], +])('Issue #7158: mouse drag over %s', (_name, textBetween) => { + test( + 'a drag from right of the last decorator selects back to the third', + dragFromLineEnd(ImageLikeDecoratorNode, textBetween), + ); + test( + 'a drag from left of the first decorator selects to the end of the line', + dragFromLineStart(ImageLikeDecoratorNode, textBetween), + ); +}); + +// The browser puts a drag's focus below or past the end of the line after the +// managed line break's , which Lexical maps to the point before it. +// Writing that point back only has the browser move it again on the next +// pointer move, so each move would be two selection changes. +test('Issue #7158: a drag past the end of the line does not keep changing the selection', async () => { + const {editor, right, y} = mount(false, ImageLikeDecoratorNode); + let selectionChanges = 0; + onTestFinished( + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + selectionChanges++; + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + ); + const x = right(COUNT - 1) + 30; + await commands.mouseDrag('#issue-7158-root', [ + [x, y], + [x + 50, y + SIZE], + [x + 100, y + SIZE], + [x + 50, y + 2 * SIZE], + ]); + await vi.waitFor(() => + assert( + editor.read(() => { + const selection = $getSelection(); + return $isRangeSelection(selection) && selection.isCollapsed(); + }), + 'expected a collapsed selection', + ), + ); + // The press and the first move can each change the selection once; the + // other ten or so moves must not. + assert( + selectionChanges <= 3, + `expected at most 3 selection changes, got ${selectionChanges}`, + ); +}); + +// Chromium and Firefox never extend a drag across these decorators by +// themselves, so these exercise the pointermove fallback. A drag that starts +// left of an unselectable first decorator is not covered: Chromium anchors it +// after that decorator, and a drag keeps the anchor it took on mousedown. +describe('Issue #7158: mouse drag over unselectable inline decorators', () => { + test( + 'a drag from right of the last decorator selects back to the third', + dragFromLineEnd(BoxedImageDecoratorNode, false), + ); + test( + 'with text between, a drag from right of the last decorator selects back to the third', + dragFromLineEnd(BoxedImageDecoratorNode, true), + ); + test( + 'with text between, a drag from left of the first text selects to the end of the line', + dragFromLineStart(BoxedImageDecoratorNode, true), + ); +}); + +describe('Issue #7158: mouse drag over selectable boxed inline decorators', () => { + test( + 'a drag from right of the last decorator selects back to the third', + dragFromLineEnd(SelectableBoxedImageDecoratorNode, false), + ); + test( + 'a drag from left of the first decorator selects to the end of the line', + dragFromLineStart(SelectableBoxedImageDecoratorNode, false), + ); + test('a drag that returns to its start and leaves again keeps its anchor', async () => { + const {editor, left, right, y} = mount( + false, + SelectableBoxedImageDecoratorNode, + ); + await commands.mouseDrag('#issue-7158-root', [ + [right(COUNT - 1) + 30, y], + [left(2) + SIZE / 2, y], + [right(COUNT - 1) + 10, y], + [left(COUNT - 1) + 5, y], + [left(2) - 2, y], + ]); + await expectSelectedDecoratorCount(editor, COUNT - 2); + }); + + // On macOS Chrome a drag can restart from the pointer, collapsing the + // selection inside a decorator, where it resolves to no Lexical selection. + // Linux Chromium does not do this, so do it the way it would: after the + // browser and Lexical have handled a pointermove. + test('a drag that restarts inside a decorator keeps its anchor', async () => { + const {editor, left, right, y} = mount( + false, + SelectableBoxedImageDecoratorNode, + ); + const root = document.getElementById('issue-7158-root')!; + const decorators = root.querySelectorAll('span > div'); + let injected = false; + const inject = (event: PointerEvent) => { + const x = event.clientX - root.getBoundingClientRect().left; + if (!injected && event.buttons === 1 && x < left(COUNT - 2)) { + injected = true; + requestAnimationFrame(() => + getDOMSelection(window)?.collapse(decorators[COUNT - 2], 0), + ); + } + }; + document.addEventListener('pointermove', inject, true); + onTestFinished(() => + document.removeEventListener('pointermove', inject, true), + ); + await commands.mouseDrag('#issue-7158-root', [ + [right(COUNT - 1) + 30, y], + [left(2) - 2, y], + ]); + assert(injected, 'the drag never reached the injection point'); + await expectSelectedDecoratorCount(editor, COUNT - 2); + }); +}); + +describe('Issue #7158: mouse presses the drag fallback leaves alone', () => { + // A double-click drag extends the selection by words, keeping the word it + // selected; the drag's anchor moves to whichever end of that word is + // farther from the pointer. + test('a double-click drag keeps the double-clicked word', async () => { + const {editor, left, textRect, y} = mount( + false, + ImageLikeDecoratorNode, + ' hello world', + ); + const word = textRect('world'); + const wordY = (word.top + word.bottom) / 2; + await commands.mouseDrag( + '#issue-7158-root', + [ + [(word.left + word.right) / 2, wordY], + [left(COUNT - 1) + SIZE / 2, y], + [left(2) - 2, y], + ], + {clickCount: 2}, + ); + await vi.waitFor(() => { + const text = editor.read(() => { + const selection = $getSelection(); + return $isRangeSelection(selection) ? selection.getTextContent() : ''; + }); + assert( + text.endsWith(' hello world'), + `expected the selection to end with the double-clicked word, got ${JSON.stringify(text)}`, + ); + }); + }); + + // A collaborator's edit (simulated here) can change the text at the drag's + // anchor mid-drag, and move the editor's selection with it. + test('a drag whose anchor text changes mid-drag continues from the moved anchor', async () => { + const {editor, left, root, textRect, y} = mount( + false, + ImageLikeDecoratorNode, + ' hello world', + ); + const textKey = editor.read(() => + $getRoot() + .getFirstChildOrThrow() + .getLastChildOrThrow() + .getKey(), + ); + const errors: unknown[] = []; + const onError = (event: ErrorEvent) => errors.push(event.error); + window.addEventListener('error', onError); + onTestFinished(() => window.removeEventListener('error', onError)); + let edited = false; + const edit = () => { + const drag = editor._inputState.mouseSelectionDrag; + if (!edited && drag !== null && Array.isArray(drag.anchor)) { + edited = true; + editor.update( + () => { + const textNode = $getNodeByKey(textKey)!; + textNode.setTextContent(' hi'); + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.anchor.set(textKey, 3, 'text'); + if (selection.focus.key === textKey) { + selection.focus.set(textKey, 3, 'text'); + } + } + }, + {discrete: true}, + ); + } + }; + root.addEventListener('pointermove', edit, true); + onTestFinished(() => root.removeEventListener('pointermove', edit, true)); + const end = textRect('world').right + 30; + await commands.mouseDrag('#issue-7158-root', [ + [end, y], + [end - 30, y], + [left(COUNT - 1) + SIZE / 2, y], + [left(2) - 2, y], + ]); + assert(edited, 'the drag never recorded its anchor'); + assert(errors.length === 0, `uncaught errors: ${errors.join(', ')}`); + await vi.waitFor(() => { + const anchor = editor.read(() => { + const selection = $getSelection(); + return $isRangeSelection(selection) + ? `${selection.anchor.key}:${selection.anchor.offset}` + : String(selection); + }); + assert( + anchor === `${textKey}:3`, + `expected the anchor at the end of the edited text, got ${anchor}`, + ); + }); + }); + + // A press on an inline decorator never starts a selection drag, so these + // press the text after the decorators. + test('a press on a non-collapsed selection is not tracked as a selection drag', async () => { + const {editor, root, textRect} = mount( + false, + ImageLikeDecoratorNode, + ' hello world', + ); + const textKey = editor.read(() => + $getRoot() + .getFirstChildOrThrow() + .getLastChildOrThrow() + .getKey(), + ); + editor.update( + () => { + $getNodeByKey(textKey)!.select(1, 6); + }, + {discrete: true}, + ); + const press = (text: string) => { + const rect = textRect(text); + const rootRect = root.getBoundingClientRect(); + const init = { + bubbles: true, + button: 0, + buttons: 1, + clientX: rootRect.left + (rect.left + rect.right) / 2, + clientY: rootRect.top + (rect.top + rect.bottom) / 2, + composed: true, + pointerType: 'mouse', + }; + const target = document.elementFromPoint(init.clientX, init.clientY)!; + target.dispatchEvent(new PointerEvent('pointerdown', init)); + const tracked = editor._inputState.mouseSelectionDrag !== null; + target.dispatchEvent(new PointerEvent('pointerup', init)); + return tracked; + }; + assert(!press('hello'), 'a press on the selection would drag it'); + assert(press('world'), 'a press beside the selection selects'); + }); + + test.each(['pointercancel', 'dragstart'])( + 'a press ended by %s lets a NodeSelection remove the DOM selection', + async type => { + const {editor, root, textRect} = mount( + false, + ImageLikeDecoratorNode, + ' hello world', + ); + editor.update( + () => { + $getRoot().getFirstChildOrThrow().selectEnd(); + }, + {discrete: true}, + ); + const rect = textRect('world'); + const rootRect = root.getBoundingClientRect(); + const init = (dx: number) => ({ + bubbles: true, + button: 0, + buttons: 1, + clientX: rootRect.left + rect.left + dx, + clientY: rootRect.top + (rect.top + rect.bottom) / 2, + composed: true, + pointerType: 'mouse', + }); + const target = document.elementFromPoint( + init(0).clientX, + init(0).clientY, + )!; + target.dispatchEvent(new PointerEvent('pointerdown', init(0))); + target.dispatchEvent(new PointerEvent('pointermove', init(10))); + assert( + Array.isArray(editor._inputState.mouseSelectionDrag?.anchor), + 'the press never became a selection drag', + ); + target.dispatchEvent( + type === 'dragstart' + ? new DragEvent(type, {bubbles: true, cancelable: true}) + : new PointerEvent(type, {bubbles: true, pointerType: 'mouse'}), + ); + editor.update( + () => { + const selection = $createNodeSelection(); + selection.add( + $getRoot() + .getFirstChildOrThrow() + .getFirstChildOrThrow() + .getKey(), + ); + $setSelection(selection); + }, + {discrete: true}, + ); + assert( + getDOMSelection(window)!.rangeCount === 0, + 'expected the DOM selection to be removed', + ); + }, + ); +}); diff --git a/packages/lexical/src/__tests__/browser/WebkitLinebreakImg.test.ts b/packages/lexical/src/__tests__/browser/WebkitLinebreakImg.test.ts index 41c64aeb2d2..77a71faa351 100644 --- a/packages/lexical/src/__tests__/browser/WebkitLinebreakImg.test.ts +++ b/packages/lexical/src/__tests__/browser/WebkitLinebreakImg.test.ts @@ -7,13 +7,15 @@ */ /** - * Characterization tests for the WebKit managed-linebreak img hack. + * Characterization tests for the managed-linebreak img hack. * - * On WebKit, when a block's last child is an *inline* DecoratorNode the + * On WebKit and desktop Chromium (since #7158), when a block's last child is + * an *inline* DecoratorNode the * managed line break is rendered as an in-flow `` followed by the usual - * `
` (see `ElementDOMSlot.insertManagedLineBreak`'s `webkitHack`), giving - * Safari an editable inline box between the `contenteditable=false` decorator - * and the break. The user-visible symptom it fixes involves native Safari + * `
` (see `ElementDOMSlot.insertManagedLineBreak`'s `withEdgeImg`), giving + * the browser an editable inline box between the `contenteditable=false` + * decorator and the break. In Chromium that box is what lets a mouse drag + * start to the right of a line's last inline decorator (#7158). The user-visible symptom it fixes involves native Safari * caret behavior that headless Linux WebKit does not reproduce, so these * tests pin the *mechanism* — the DOM contract of * `setManagedLineBreak('decorator')` on each real engine — such that removing @@ -21,8 +23,8 @@ * would (no other test exercises it). * * Running in browser mode means the real environment detection is live: the - * webkit instance asserts the img+br shape and chromium / firefox assert the - * plain-br shape, all from the same file. + * webkit and chromium instances assert the img+br shape and firefox asserts + * the plain-br shape, all from the same file. */ import {buildEditorFromExtensions, defineExtension} from '@lexical/extension'; @@ -34,16 +36,19 @@ import { $getRoot, $isParagraphNode, DecoratorNode, + IS_ANDROID, IS_APPLE_WEBKIT, + IS_CHROME, IS_IOS, IS_SAFARI, } from 'lexical'; -import {describe, expect, onTestFinished, test} from 'vitest'; +import {assert, describe, expect, onTestFinished, test} from 'vitest'; import {$assertNodeType} from '../utils/assertNodeType'; -// Matches the `webkitHack` gate in ElementDOMSlot.setManagedLineBreak. -const EXPECTS_IMG_HACK = IS_SAFARI || IS_IOS || IS_APPLE_WEBKIT; +// Restates the internal NEEDS_INLINE_DECORATOR_EDGE_BOX in LexicalDOMSlot.ts. +const EXPECTS_IMG_HACK = + IS_SAFARI || IS_IOS || IS_APPLE_WEBKIT || (IS_CHROME && !IS_ANDROID); const DECORATOR_LINEBREAK = EXPECTS_IMG_HACK ? ['img', 'br'] : ['br']; class TestInlineDecoratorNode extends DecoratorNode { @@ -98,7 +103,7 @@ function linebreakScaffold(contentEditable: HTMLElement): string[] { ); } -describe('WebKit managed-linebreak img hack (inline decorator last child)', () => { +describe('managed-linebreak img hack (inline decorator last child)', () => { test('a block ending with an inline decorator gets the engine-appropriate scaffold', () => { const {contentEditable, editor} = mountEditor(); editor.update( @@ -115,9 +120,10 @@ describe('WebKit managed-linebreak img hack (inline decorator last child)', () = {discrete: true}, ); - // WebKit: an in-flow img gives Safari an editable inline box between the - // contenteditable=false decorator and the break, and must precede the br - // (the e2e selection utils rely on that order). Everywhere else: plain br. + // WebKit and desktop Chromium: an in-flow img gives the browser an + // editable inline box between the contenteditable=false decorator and the + // break, and must precede the br (the e2e selection utils rely on that + // order). Everywhere else: plain br. expect(linebreakScaffold(contentEditable)).toEqual(DECORATOR_LINEBREAK); const img = contentEditable.querySelector( 'p img[data-lexical-managed-linebreak="true"]', @@ -227,3 +233,70 @@ describe('WebKit managed-linebreak img hack (inline decorator last child)', () = expect(linebreakScaffold(contentEditable)).toEqual(['br']); }); }); + +/** The paragraph's DOM children, with the reconciler's scaffolding named. */ +function paragraphShape(contentEditable: HTMLElement): string[] { + const paragraph = contentEditable.querySelector('p'); + assert(paragraph !== null); + return Array.from(paragraph.childNodes, node => { + assert(node instanceof Element); + if (node.hasAttribute('data-lexical-decorator-boundary')) { + return 'anchor'; + } + if (node.hasAttribute('data-lexical-managed-linebreak')) { + return `managed-${node.nodeName.toLowerCase()}`; + } + return node.hasAttribute('data-lexical-decorator') ? 'decorator' : 'text'; + }); +} + +// Lets the MutationObserver deliver its records and the editor flush them. +function flushMutations(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} + +describe('scaffolding removed from outside the reconciler is restored', () => { + const EXPECTED_SHAPE = [ + ...(EXPECTS_IMG_HACK ? ['anchor'] : []), + 'decorator', + 'text', + 'decorator', + ...DECORATOR_LINEBREAK.map(tag => `managed-${tag}`), + ]; + + function mountDecoratorLine() { + const mounted = mountEditor(); + mounted.editor.update( + () => { + $getRoot() + .clear() + .append( + $createParagraphNode().append( + new TestInlineDecoratorNode(), + $createTextNode('text'), + new TestInlineDecoratorNode(), + ), + ); + }, + {discrete: true}, + ); + expect(paragraphShape(mounted.contentEditable)).toEqual(EXPECTED_SHAPE); + return mounted; + } + + test.each([ + ['the leading boundary anchor', '[data-lexical-decorator-boundary]'], + ['the managed line break img', 'img[data-lexical-managed-linebreak]'], + ['the managed line break br', 'br[data-lexical-managed-linebreak]'], + ])('%s', async (_name, selector) => { + const {contentEditable} = mountDecoratorLine(); + const removed = contentEditable.querySelector(`p > ${selector}`); + if (removed === null) { + // Not part of this engine's scaffold. + return; + } + removed.remove(); + await flushMutations(); + expect(paragraphShape(contentEditable)).toEqual(EXPECTED_SHAPE); + }); +}); diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx index fc7f06f67e4..52d1afc9397 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx +++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx @@ -521,14 +521,16 @@ describe('LexicalEditor tests', () => { // Wait for update to complete await Promise.resolve().then(); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

This works!

', ); const initialEditorState = initialEditor.getEditorState(); initialEditor.setRootElement(null); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '
', ); @@ -539,7 +541,8 @@ describe('LexicalEditor tests', () => { editor.setRootElement(rootElement); expect(editor.getEditorState()).toEqual(initialEditorState); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

This works!

', ); }); @@ -788,7 +791,8 @@ describe('LexicalEditor tests', () => { boldListener(); underlineListener(); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

foo

', ); }); @@ -854,7 +858,8 @@ describe('LexicalEditor tests', () => { boldListener(); italicsListener(); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

foo

', ); }); @@ -925,7 +930,8 @@ describe('LexicalEditor tests', () => { megaFooListener(); boldFooListener(); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

Foo!!

', ); }); @@ -1562,13 +1568,17 @@ describe('LexicalEditor tests', () => { paragraph.append(text); }); - expect(container.innerHTML).toBe('
'); + expectHtmlToBeEqual( + container.innerHTML, + '
', + ); act(() => { reactRoot.render(); }); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

This works!

', ); }); @@ -1590,7 +1600,8 @@ describe('LexicalEditor tests', () => { // Wait for update to complete await Promise.resolve().then(); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

This works!

', ); expect(errorListener).toHaveBeenCalledTimes(0); @@ -1607,7 +1618,8 @@ describe('LexicalEditor tests', () => { }); expect(errorListener).toHaveBeenCalledTimes(1); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

This works!

', ); }); @@ -1663,7 +1675,8 @@ describe('LexicalEditor tests', () => { reactRoot.render(); }); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

Not changed

', ); @@ -1673,7 +1686,8 @@ describe('LexicalEditor tests', () => { expect(rootListener).toHaveBeenCalledTimes(3); expect(updateListener).toHaveBeenCalledTimes(4); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

Change successful

', ); }); @@ -1818,7 +1832,8 @@ describe('LexicalEditor tests', () => { }); expect(listener).toHaveBeenCalledTimes(1); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '

' + 'Hello world

', ); @@ -1861,7 +1876,8 @@ describe('LexicalEditor tests', () => { }); expect(listener).toHaveBeenCalledTimes(1); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '


', ); @@ -1872,7 +1888,8 @@ describe('LexicalEditor tests', () => { }); expect(listener).toHaveBeenCalledTimes(5); - expect(container.innerHTML).toBe( + expectHtmlToBeEqual( + container.innerHTML, '


', ); @@ -2245,6 +2262,7 @@ describe('LexicalEditor tests', () => { expect(editor._editorState._nodeMap.size).toBe(keys.length + 1); // + root expect(editor._keyToDOMMap.size).toBe(keys.length + 1); // + root + // A
inside a

is not valid HTML, so this is compared raw. expect(container.innerHTML).toBe( '

A
B

', ); @@ -2289,6 +2307,7 @@ describe('LexicalEditor tests', () => { elementNode2.append(elementNode1); }); + // A
inside a

is not valid HTML, so this is compared raw. expect(container.innerHTML).toBe( '

B
A

', ); @@ -2342,6 +2361,7 @@ describe('LexicalEditor tests', () => { elementNode1.append(elementNode3); }); + // A
inside a

is not valid HTML, so this is compared raw. expect(container.innerHTML).toBe( '

A
C
B

', ); @@ -3770,7 +3790,8 @@ describe('LexicalEditor tests', () => { await editor.update(() => { $getRoot().append($createParagraphNode().append($createTextNode('foo'))); editor.read(() => {}); - expect(editor.getRootElement()?.innerHTML).toBe( + expectHtmlToBeEqual( + editor.getRootElement()?.innerHTML ?? '', '

foo

', ); editor.update(() => { @@ -3779,7 +3800,8 @@ describe('LexicalEditor tests', () => { ); }); }); - expect(editor.getRootElement()?.innerHTML).toBe( + expectHtmlToBeEqual( + editor.getRootElement()?.innerHTML ?? '', '

foo

bar

', ); }); @@ -3796,7 +3818,8 @@ describe('LexicalEditor tests', () => { ); }); - expect(container.firstElementChild?.innerHTML).toBe( + expectHtmlToBeEqual( + container.firstElementChild?.innerHTML ?? '', '

Hello

', ); }); diff --git a/packages/lexical/src/__tests__/utils/index.tsx b/packages/lexical/src/__tests__/utils/index.tsx index 4306e0d1a3a..8cc86aa46a7 100644 --- a/packages/lexical/src/__tests__/utils/index.tsx +++ b/packages/lexical/src/__tests__/utils/index.tsx @@ -603,9 +603,11 @@ export function polyfillContentEditable() { * first / last edge. */ export const DECORATOR_BOUNDARY_ANCHOR_HTML = - ''; + ''; /** * Assert that a node's generated exporters write exactly what the schema-driven diff --git a/vitest.config.mts b/vitest.config.mts index 6ca6b287c4f..272f7a914d3 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -6,6 +6,8 @@ * */ +import type {Plugin} from 'vite'; + import react from '@vitejs/plugin-react'; import {playwright} from '@vitest/browser-playwright'; import * as fs from 'node:fs'; @@ -13,6 +15,28 @@ import * as path from 'node:path'; import {fileURLToPath} from 'node:url'; import {configDefaults, defineConfig} from 'vitest/config'; +// @vitest/browser adds @vitest/mocker's interceptor plugin with +// `registerWebSocketEvents: false`, which still defines a configureServer +// hook that does nothing, so Vite warns on every browser run that the hook is +// ignored. Vitest installs its own logger, so drop the warning from the +// resolved one. +const IGNORED_INTERCEPTOR_WARNING = + 'Plugin "vitest:mocks:interceptor" defines Vite-specific hooks (configureServer)'; +function ignoreInterceptorWarning(): Plugin { + return { + configResolved(config) { + const {logger} = config; + const {warnOnce} = logger; + logger.warnOnce = (message, options) => { + if (!message.startsWith(IGNORED_INTERCEPTOR_WARNING)) { + warnOnce(message, options); + } + }; + }, + name: 'lexical:ignore-interceptor-warning', + }; +} + // Resolve monorepo imports to TypeScript source from the test tsconfig's // `paths`. This includes the cross-package and deep `*/src/__tests__/utils` // aliases that the unit tests rely on but that the lean root tsconfig (which @@ -126,11 +150,170 @@ export default defineConfig({ 'react-dom/client', ], }, - plugins: [react()], + plugins: [react(), ignoreInterceptorWarning()], test: { api: {port: 8315}, browser: { commands: { + // Drive a real mouse drag through `points` (relative to the + // top-left of `selector`), so the browser's own hit testing + // and drag-extension logic decide the selection. Synthetic + // Selection.setBaseAndExtent/extend calls do not model that. + // A clickCount above 1 makes the press that drags the last + // click of a double or triple click. + mouseDrag: async ( + {frame, page}, + selector: string, + points: [x: number, y: number][], + {clickCount = 1}: {clickCount?: number} = {}, + ) => { + const testFrame = await frame(); + const target = testFrame.locator(selector); + const [first, ...rest] = points; + await target.hover({ + force: true, + position: {x: first[0], y: first[1]}, + }); + // hover() looks the element up, scrolls it into view and + // checks it on every call. The element stays put during a + // drag, so move the mouse to page coordinates from its box. + const box = await target.boundingBox(); + if (box === null) { + throw new Error( + `mouseDrag target ${selector} is not visible`, + ); + } + // A person's mouse events arrive far enough apart that the + // browser and the editor have handled each one, and the + // selection changes it caused, before the next. Count the + // pointer events the frame has dispatched, and remember the + // selection each selectionchange reported, so that each step + // can wait for exactly that instead of for a while. + await testFrame.evaluate(() => { + const getPoints = () => { + const selection = document.getSelection(); + return selection === null + ? [] + : [ + selection.anchorNode, + selection.anchorOffset, + selection.focusNode, + selection.focusOffset, + ]; + }; + const state = {events: 0, notified: getPoints()}; + const onPointer = () => { + state.events++; + }; + const onSelectionChange = () => { + state.notified = getPoints(); + }; + const types = ['pointerdown', 'pointermove', 'pointerup']; + for (const type of types) { + window.addEventListener(type, onPointer, true); + } + window.addEventListener( + 'selectionchange', + onSelectionChange, + true, + ); + Object.assign(window, { + __mouseDrag: { + dispose() { + for (const type of types) { + window.removeEventListener(type, onPointer, true); + } + window.removeEventListener( + 'selectionchange', + onSelectionChange, + true, + ); + }, + getPoints, + state, + }, + }); + }); + let events = 0; + // Wait until the frame has dispatched the step's pointer + // event, then until the tasks it queued have run (the editor + // adjusts a drag's selection in one) and no selectionchange + // is still to be dispatched and handled. + const settle = () => + testFrame.evaluate(async expected => { + const {getPoints, state} = ( + window as unknown as { + __mouseDrag: { + getPoints: () => unknown[]; + state: {events: number; notified: unknown[]}; + }; + } + ).__mouseDrag; + const nextTask = () => + new Promise(resolve => setTimeout(resolve, 0)); + const isPending = () => + getPoints().some( + (value, i) => value !== state.notified[i], + ); + // Never hang a test on an event the browser coalesced + // or a selection that keeps changing. + const deadline = performance.now() + 1000; + do { + await nextTask(); + } while ( + (state.events < expected || isPending()) && + performance.now() < deadline + ); + }, ++events); + for (let count = 1; count < clickCount; count++) { + await page.mouse.down({clickCount: count}); + await settle(); + await page.mouse.up({clickCount: count}); + await settle(); + } + await page.mouse.down({clickCount}); + await settle(); + // Chromium collapses the selection at the pointer on the + // first drag move after a press that placed a caret, and + // extends it from there, so a drag's anchor is where it first + // moved. A hand starts a drag with a move of a pixel or two, + // so make the first move one; after that, move in steps of + // at most STEP pixels, half the width of the tests' inline + // decorators, so the pointer stops over each one it crosses. + const STEP = 20; + let [lastX, lastY] = first; + if (rest.length > 0) { + const [x, y] = rest[0]; + const distance = Math.hypot(x - lastX, y - lastY); + if (distance > 2) { + lastX += ((x - lastX) * 2) / distance; + lastY += ((y - lastY) * 2) / distance; + await page.mouse.move(box.x + lastX, box.y + lastY); + await settle(); + } + } + for (const [x, y] of rest) { + const steps = Math.max( + 1, + Math.ceil(Math.hypot(x - lastX, y - lastY) / STEP), + ); + for (let i = 1; i <= steps; i++) { + await page.mouse.move( + box.x + lastX + ((x - lastX) * i) / steps, + box.y + lastY + ((y - lastY) * i) / steps, + ); + await settle(); + } + [lastX, lastY] = [x, y]; + } + await page.mouse.up(); + await settle(); + await testFrame.evaluate(() => + ( + window as unknown as {__mouseDrag: {dispose: () => void}} + ).__mouseDrag.dispose(), + ); + }, // Vitest's keyboard descriptor parser splits non-BMP text into // UTF-16 code units. Native Playwright typing preserves code // points, matching the E2E driver's input behavior. Bound each