From d9014f328a92a3486fef2b345555c9772f1ec53c Mon Sep 17 00:00:00 2001 From: Fadekemi Adebayo <82163647+Shopiley@users.noreply.github.com> Date: Sat, 23 Nov 2024 19:04:53 +0100 Subject: [PATCH 1/4] [Lexical] Chore: Update default skipInitialization to false for registerMutationListener (#6857) --- packages/lexical-website/docs/concepts/listeners.md | 2 +- packages/lexical/src/LexicalEditor.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/lexical-website/docs/concepts/listeners.md b/packages/lexical-website/docs/concepts/listeners.md index 834db37c0bf..ef95f1c6d0b 100644 --- a/packages/lexical-website/docs/concepts/listeners.md +++ b/packages/lexical-website/docs/concepts/listeners.md @@ -84,7 +84,7 @@ handle external UI state and UI features relating to specific types of node. If any existing nodes are in the DOM, and skipInitialization is not true, the listener will be called immediately with an updateTag of 'registerMutationListener' where all nodes have the 'created' NodeMutation. This can be controlled with the skipInitialization option -(default is currently true for backwards compatibility in 0.17.x but will change to false in 0.18.0). +(whose default was previously true for backwards compatibility with <=0.16.1 but has been changed to false as of 0.21.0). ```js const removeMutationListener = editor.registerMutationListener( diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 174b18cb62c..9223e571544 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -216,13 +216,13 @@ export interface MutationListenerOptions { /** * Skip the initial call of the listener with pre-existing DOM nodes. * - * The default is currently true for backwards compatibility with <= 0.16.1 - * but this default is expected to change to false in 0.17.0. + * The default was previously true for backwards compatibility with <= 0.16.1 + * but this default has been changed to false as of 0.21.0. */ skipInitialization?: boolean; } -const DEFAULT_SKIP_INITIALIZATION = true; +const DEFAULT_SKIP_INITIALIZATION = false; export type UpdateListener = (arg0: { dirtyElements: Map; @@ -844,7 +844,7 @@ export class LexicalEditor { * If any existing nodes are in the DOM, and skipInitialization is not true, the listener * will be called immediately with an updateTag of 'registerMutationListener' where all * nodes have the 'created' NodeMutation. This can be controlled with the skipInitialization option - * (default is currently true for backwards compatibility in 0.16.x but will change to false in 0.17.0). + * (whose default was previously true for backwards compatibility with <=0.16.1 but has been changed to false as of 0.21.0). * * @param klass - The class of the node that you want to listen to mutations on. * @param listener - The logic you want to run when the node is mutated. From fcb76667080c49d67148f480d549ed7dbf647e4e Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 24 Nov 2024 10:58:06 -0800 Subject: [PATCH 2/4] [lexical-table] Bug Fix: Resolve table selection issue when the mouse crosses over a portal (#6834) --- package-lock.json | 61 +- packages/lexical-clipboard/src/clipboard.ts | 5 +- .../__tests__/e2e/Tables.spec.mjs | 5 - .../4697-repeated-table-selection.spec.mjs | 2 + .../__tests__/utils/index.mjs | 56 +- packages/lexical-playground/package.json | 3 +- packages/lexical-playground/src/index.css | 13 +- .../src/plugins/CommentPlugin/index.tsx | 3 +- .../FloatingLinkEditorPlugin/index.tsx | 3 +- .../FloatingTextFormatToolbarPlugin/index.tsx | 5 +- .../src/plugins/ImagesPlugin/index.tsx | 5 +- .../src/plugins/InlineImagePlugin/index.tsx | 5 +- .../plugins/TableActionMenuPlugin/index.tsx | 154 ++-- .../src/plugins/TestRecorderPlugin/index.tsx | 13 +- packages/lexical-playground/src/setupEnv.ts | 4 + packages/lexical-playground/vite.config.ts | 2 + .../lexical-playground/vite.prod.config.ts | 2 + .../viteCopyExcalidrawAssets.ts | 56 ++ .../src/LexicalTypeaheadMenuPlugin.tsx | 3 +- .../lexical-table/flow/LexicalTable.js.flow | 10 +- .../lexical-table/src/LexicalTableObserver.ts | 362 +++++----- .../src/LexicalTableSelection.ts | 306 ++++---- .../src/LexicalTableSelectionHelpers.ts | 668 ++++++++++++------ .../lexical-table/src/LexicalTableUtils.ts | 126 +++- packages/lexical/src/LexicalEvents.ts | 6 +- packages/lexical/src/LexicalUtils.ts | 7 + packages/lexical/src/index.ts | 1 + packages/shared/viteModuleResolution.ts | 3 +- 28 files changed, 1252 insertions(+), 637 deletions(-) create mode 100644 packages/lexical-playground/viteCopyExcalidrawAssets.ts diff --git a/package-lock.json b/package-lock.json index 25e868bc531..99142fd872d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36908,6 +36908,38 @@ "vite": "^2" } }, + "node_modules/vite-plugin-static-copy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-2.1.0.tgz", + "integrity": "sha512-n8lEOIVM00Y/zronm0RG8RdPyFd0SAAFR0sii3NWmgG3PSCyYMsvUNRQTlb3onp1XeMrKIDwCrPGxthKvqX9OQ==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.3", + "fast-glob": "^3.2.11", + "fs-extra": "^11.1.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -39137,7 +39169,8 @@ "@vitejs/plugin-react": "^4.2.1", "rollup-plugin-copy": "^3.5.0", "vite": "^5.2.11", - "vite-plugin-replace": "^0.1.1" + "vite-plugin-replace": "^0.1.1", + "vite-plugin-static-copy": "^2.1.0" } }, "packages/lexical-react": { @@ -56239,6 +56272,7 @@ "rollup-plugin-copy": "^3.5.0", "vite": "^5.2.11", "vite-plugin-replace": "^0.1.1", + "vite-plugin-static-copy": "^2.1.0", "y-websocket": "^1.5.4", "yjs": ">=13.5.42" } @@ -64710,6 +64744,31 @@ "integrity": "sha512-v+okl3JNt2pf1jDYijw+WPVt6h9FWa/atTi+qnSFBqmKThLTDhlesx0r3bh+oFPmxRJmis5tNx9HtN6lGFoqWg==", "dev": true }, + "vite-plugin-static-copy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-2.1.0.tgz", + "integrity": "sha512-n8lEOIVM00Y/zronm0RG8RdPyFd0SAAFR0sii3NWmgG3PSCyYMsvUNRQTlb3onp1XeMrKIDwCrPGxthKvqX9OQ==", + "dev": true, + "requires": { + "chokidar": "^3.5.3", + "fast-glob": "^3.2.11", + "fs-extra": "^11.1.0", + "picocolors": "^1.0.0" + }, + "dependencies": { + "fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + } + } + }, "vlq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", diff --git a/packages/lexical-clipboard/src/clipboard.ts b/packages/lexical-clipboard/src/clipboard.ts index 3de6860e998..9f9155be596 100644 --- a/packages/lexical-clipboard/src/clipboard.ts +++ b/packages/lexical-clipboard/src/clipboard.ts @@ -22,6 +22,7 @@ import { BaseSelection, COMMAND_PRIORITY_CRITICAL, COPY_COMMAND, + getDOMSelection, isSelectionWithinEditor, LexicalEditor, LexicalNode, @@ -29,12 +30,8 @@ import { SerializedElementNode, SerializedTextNode, } from 'lexical'; -import {CAN_USE_DOM} from 'shared/canUseDOM'; import invariant from 'shared/invariant'; -const getDOMSelection = (targetWindow: Window | null): Selection | null => - CAN_USE_DOM ? (targetWindow || window).getSelection() : null; - export interface LexicalClipboardData { 'text/html'?: string | undefined; 'application/x-lexical-editor'?: string | undefined; diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index d9362c28aed..689897d45be 100644 --- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs @@ -2274,7 +2274,6 @@ test.describe.parallel('Tables', () => {


`, ); - await unmergeTableCell(page); await assertHTML( page, @@ -3447,8 +3446,6 @@ test.describe.parallel('Tables', () => { `, }); - await page.pause(); - await assertHTML( page, html` @@ -3537,8 +3534,6 @@ test.describe.parallel('Tables', () => { `, }); - await page.pause(); - await assertHTML( page, html` diff --git a/packages/lexical-playground/__tests__/regression/4697-repeated-table-selection.spec.mjs b/packages/lexical-playground/__tests__/regression/4697-repeated-table-selection.spec.mjs index 9711bab390b..00c34ff7c27 100644 --- a/packages/lexical-playground/__tests__/regression/4697-repeated-table-selection.spec.mjs +++ b/packages/lexical-playground/__tests__/regression/4697-repeated-table-selection.spec.mjs @@ -38,6 +38,7 @@ test.describe('Regression test #4697', () => { false, false, ); + await page.pause(); await selectCellsFromTableCords( page, @@ -46,6 +47,7 @@ test.describe('Regression test #4697', () => { false, false, ); + await page.pause(); await assertTableSelectionCoordinates(page, { anchor: {x: 2, y: 1}, diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index 9f43f61e5fd..b8b79e9e76c 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -396,7 +396,7 @@ async function assertSelectionOnPageOrFrame(page, expected) { focusOffset: fixOffset(focusNode, focusOffset), focusPath: getPathFromNode(focusNode), }; - }, expected); + }); expect(selection.anchorPath).toEqual(expected.anchorPath); expect(selection.focusPath).toEqual(expected.focusPath); if (Array.isArray(expected.anchorOffset)) { @@ -738,9 +738,6 @@ export async function dragMouse( fromX += fromBoundingBox.width; fromY += fromBoundingBox.height; } - await page.mouse.move(fromX, fromY); - await page.mouse.down(); - let toX = toBoundingBox.x; let toY = toBoundingBox.y; if (positionEnd === 'middle') { @@ -751,13 +748,9 @@ export async function dragMouse( toY += toBoundingBox.height; } - if (slow) { - //simulate more than 1 mouse move event to replicate human slow dragging - await page.mouse.move((fromX + toX) / 2, (fromY + toY) / 2); - } - - await page.mouse.move(toX, toY); - + await page.mouse.move(fromX, fromY); + await page.mouse.down(); + await page.mouse.move(toX, toY, slow ? 10 : 1); if (mouseUp) { await page.mouse.up(); } @@ -907,72 +900,75 @@ export async function selectCellsFromTableCords( }:nth-child(${secondCords.x + 1})`, ); - // Focus on inside the iFrame or the boundingBox() below returns null. await firstRowFirstColumnCell.click(); + await page.keyboard.down('Shift'); + await secondRowSecondCell.click(); + await page.keyboard.up('Shift'); - await dragMouse( + // const firstBox = await firstRowFirstColumnCell.boundingBox(); + // const secondBox = await secondRowSecondCell.boundingBox(); + // await dragMouse(page, firstBox, secondBox, 'middle', 'middle', true, true); +} + +export async function clickTableCellActiveButton(page) { + await click( page, - await firstRowFirstColumnCell.boundingBox(), - await secondRowSecondCell.boundingBox(), - 'middle', - 'middle', - true, - true, + '.table-cell-action-button-container--active > .table-cell-action-button', ); } export async function insertTableRowAbove(page) { - await click(page, '.table-cell-action-button-container'); + await clickTableCellActiveButton(page); await click(page, '.item[data-test-id="table-insert-row-above"]'); } export async function insertTableRowBelow(page) { - await click(page, '.table-cell-action-button-container'); + await clickTableCellActiveButton(page); await click(page, '.item[data-test-id="table-insert-row-below"]'); } export async function insertTableColumnBefore(page) { - await click(page, '.table-cell-action-button-container'); + await clickTableCellActiveButton(page); await click(page, '.item[data-test-id="table-insert-column-before"]'); } export async function insertTableColumnAfter(page) { - await click(page, '.table-cell-action-button-container'); + await clickTableCellActiveButton(page); await click(page, '.item[data-test-id="table-insert-column-after"]'); } export async function mergeTableCells(page) { - await click(page, '.table-cell-action-button-container'); + await clickTableCellActiveButton(page); await click(page, '.item[data-test-id="table-merge-cells"]'); } export async function unmergeTableCell(page) { - await click(page, '.table-cell-action-button-container'); + await clickTableCellActiveButton(page); await click(page, '.item[data-test-id="table-unmerge-cells"]'); } export async function toggleColumnHeader(page) { - await click(page, '.table-cell-action-button-container'); + await clickTableCellActiveButton(page); await click(page, '.item[data-test-id="table-column-header"]'); } export async function deleteTableRows(page) { - await click(page, '.table-cell-action-button-container'); + await clickTableCellActiveButton(page); await click(page, '.item[data-test-id="table-delete-rows"]'); } export async function deleteTableColumns(page) { - await click(page, '.table-cell-action-button-container'); + await clickTableCellActiveButton(page); await click(page, '.item[data-test-id="table-delete-columns"]'); } export async function deleteTable(page) { - await click(page, '.table-cell-action-button-container'); + await clickTableCellActiveButton(page); await click(page, '.item[data-test-id="table-delete"]'); } export async function setBackgroundColor(page) { - await click(page, '.table-cell-action-button-container'); + await clickTableCellActiveButton(page); await click(page, '.item[data-test-id="table-background-color"]'); } diff --git a/packages/lexical-playground/package.json b/packages/lexical-playground/package.json index 244dbe2a692..7c298206228 100644 --- a/packages/lexical-playground/package.json +++ b/packages/lexical-playground/package.json @@ -45,7 +45,8 @@ "@vitejs/plugin-react": "^4.2.1", "rollup-plugin-copy": "^3.5.0", "vite": "^5.2.11", - "vite-plugin-replace": "^0.1.1" + "vite-plugin-replace": "^0.1.1", + "vite-plugin-static-copy": "^2.1.0" }, "sideEffects": false } diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css index 31718a446af..87aeb604a82 100644 --- a/packages/lexical-playground/src/index.css +++ b/packages/lexical-playground/src/index.css @@ -1307,14 +1307,23 @@ i.page-break, left: 0; will-change: transform; } +.table-cell-action-button-container.table-cell-action-button-container--active { + pointer-events: auto; + opacity: 1; +} +.table-cell-action-button-container.table-cell-action-button-container--inactive { + pointer-events: none; + opacity: 0; +} .table-cell-action-button { - background-color: none; display: flex; justify-content: center; align-items: center; border: 0; - position: relative; + position: absolute; + top: 10px; + right: 10px; border-radius: 15px; color: #222; display: inline-block; diff --git a/packages/lexical-playground/src/plugins/CommentPlugin/index.tsx b/packages/lexical-playground/src/plugins/CommentPlugin/index.tsx index 67aa66662c5..2367d0a165e 100644 --- a/packages/lexical-playground/src/plugins/CommentPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/CommentPlugin/index.tsx @@ -47,6 +47,7 @@ import { CLEAR_EDITOR_COMMAND, COMMAND_PRIORITY_EDITOR, createCommand, + getDOMSelection, KEY_ESCAPE_COMMAND, } from 'lexical'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -923,7 +924,7 @@ export default function CommentPlugin({ editor.registerCommand( INSERT_INLINE_COMMAND, () => { - const domSelection = window.getSelection(); + const domSelection = getDOMSelection(editor._window); if (domSelection !== null) { domSelection.removeAllRanges(); } diff --git a/packages/lexical-playground/src/plugins/FloatingLinkEditorPlugin/index.tsx b/packages/lexical-playground/src/plugins/FloatingLinkEditorPlugin/index.tsx index 4dd0cb2fce7..d3a3fef97ed 100644 --- a/packages/lexical-playground/src/plugins/FloatingLinkEditorPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/FloatingLinkEditorPlugin/index.tsx @@ -24,6 +24,7 @@ import { COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, + getDOMSelection, KEY_ESCAPE_COMMAND, LexicalEditor, SELECTION_CHANGE_COMMAND, @@ -77,7 +78,7 @@ function FloatingLinkEditor({ } } const editorElem = editorRef.current; - const nativeSelection = window.getSelection(); + const nativeSelection = getDOMSelection(editor._window); const activeElement = document.activeElement; if (editorElem === null) { diff --git a/packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx b/packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx index 8c9795b09ef..2404f88dca9 100644 --- a/packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx @@ -19,6 +19,7 @@ import { $isTextNode, COMMAND_PRIORITY_LOW, FORMAT_TEXT_COMMAND, + getDOMSelection, LexicalEditor, SELECTION_CHANGE_COMMAND, } from 'lexical'; @@ -113,7 +114,7 @@ function TextFormatFloatingToolbar({ const selection = $getSelection(); const popupCharStylesEditorElem = popupCharStylesEditorRef.current; - const nativeSelection = window.getSelection(); + const nativeSelection = getDOMSelection(editor._window); if (popupCharStylesEditorElem === null) { return; @@ -293,7 +294,7 @@ function useFloatingTextFormatToolbar( return; } const selection = $getSelection(); - const nativeSelection = window.getSelection(); + const nativeSelection = getDOMSelection(editor._window); const rootElement = editor.getRootElement(); if ( diff --git a/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx b/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx index cceabbc52e9..b2c2120220e 100644 --- a/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx @@ -22,12 +22,12 @@ import { DRAGOVER_COMMAND, DRAGSTART_COMMAND, DROP_COMMAND, + getDOMSelection, LexicalCommand, LexicalEditor, } from 'lexical'; import {useEffect, useRef, useState} from 'react'; import * as React from 'react'; -import {CAN_USE_DOM} from 'shared/canUseDOM'; import landscapeImage from '../../images/landscape.jpg'; import yellowFlowerImage from '../../images/yellow-flower.jpg'; @@ -44,9 +44,6 @@ import TextInput from '../../ui/TextInput'; export type InsertImagePayload = Readonly; -const getDOMSelection = (targetWindow: Window | null): Selection | null => - CAN_USE_DOM ? (targetWindow || window).getSelection() : null; - export const INSERT_IMAGE_COMMAND: LexicalCommand = createCommand('INSERT_IMAGE_COMMAND'); diff --git a/packages/lexical-playground/src/plugins/InlineImagePlugin/index.tsx b/packages/lexical-playground/src/plugins/InlineImagePlugin/index.tsx index c09c7987aa8..98cea7f6888 100644 --- a/packages/lexical-playground/src/plugins/InlineImagePlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/InlineImagePlugin/index.tsx @@ -26,12 +26,12 @@ import { DRAGOVER_COMMAND, DRAGSTART_COMMAND, DROP_COMMAND, + getDOMSelection, LexicalCommand, LexicalEditor, } from 'lexical'; import * as React from 'react'; import {useEffect, useRef, useState} from 'react'; -import {CAN_USE_DOM} from 'shared/canUseDOM'; import { $createInlineImageNode, @@ -47,9 +47,6 @@ import TextInput from '../../ui/TextInput'; export type InsertInlineImagePayload = Readonly; -const getDOMSelection = (targetWindow: Window | null): Selection | null => - CAN_USE_DOM ? (targetWindow || window).getSelection() : null; - export const INSERT_INLINE_IMAGE_COMMAND: LexicalCommand = createCommand('INSERT_INLINE_IMAGE_COMMAND'); diff --git a/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx b/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx index 2a3fc2bdff1..2e17272cbf7 100644 --- a/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx @@ -28,9 +28,11 @@ import { getTableObserverFromTableElement, TableCellHeaderStates, TableCellNode, + TableObserver, TableRowNode, TableSelection, } from '@lexical/table'; +import {mergeRegister} from '@lexical/utils'; import { $createParagraphNode, $getRoot, @@ -39,6 +41,9 @@ import { $isParagraphNode, $isRangeSelection, $isTextNode, + COMMAND_PRIORITY_CRITICAL, + getDOMSelection, + SELECTION_CHANGE_COMMAND, } from 'lexical'; import * as React from 'react'; import {ReactPortal, useCallback, useEffect, useRef, useState} from 'react'; @@ -242,7 +247,7 @@ function TableActionMenu({ const tableObserver = getTableObserverFromTableElement(tableElement); if (tableObserver !== null) { - tableObserver.clearHighlight(); + tableObserver.$clearHighlight(); } tableNode.markDirty(); @@ -630,8 +635,8 @@ function TableCellActionMenuContainer({ }): JSX.Element { const [editor] = useLexicalComposerContext(); - const menuButtonRef = useRef(null); - const menuRootRef = useRef(null); + const menuButtonRef = useRef(null); + const menuRootRef = useRef(null); const [isMenuOpen, setIsMenuOpen] = useState(false); const [tableCellNode, setTableMenuCellNode] = useState( @@ -643,15 +648,23 @@ function TableCellActionMenuContainer({ const $moveMenu = useCallback(() => { const menu = menuButtonRef.current; const selection = $getSelection(); - const nativeSelection = window.getSelection(); + const nativeSelection = getDOMSelection(editor._window); const activeElement = document.activeElement; + function disable() { + if (menu) { + menu.classList.remove('table-cell-action-button-container--active'); + menu.classList.add('table-cell-action-button-container--inactive'); + } + setTableMenuCellNode(null); + } if (selection == null || menu == null) { - setTableMenuCellNode(null); - return; + return disable(); } const rootElement = editor.getRootElement(); + let tableObserver: TableObserver | null = null; + let tableCellParentNodeDOM: HTMLElement | null = null; if ( $isRangeSelection(selection) && @@ -664,56 +677,111 @@ function TableCellActionMenuContainer({ ); if (tableCellNodeFromSelection == null) { - setTableMenuCellNode(null); - return; + return disable(); } - const tableCellParentNodeDOM = editor.getElementByKey( + tableCellParentNodeDOM = editor.getElementByKey( tableCellNodeFromSelection.getKey(), ); - if (tableCellParentNodeDOM == null) { - setTableMenuCellNode(null); - return; + if ( + tableCellParentNodeDOM == null || + !tableCellNodeFromSelection.isAttached() + ) { + return disable(); } + const tableNode = $getTableNodeFromLexicalNodeOrThrow( + tableCellNodeFromSelection, + ); + const tableElement = getTableElement( + tableNode, + editor.getElementByKey(tableNode.getKey()), + ); + + invariant( + tableElement !== null, + 'TableActionMenu: Expected to find tableElement in DOM', + ); + + tableObserver = getTableObserverFromTableElement(tableElement); setTableMenuCellNode(tableCellNodeFromSelection); + } else if ($isTableSelection(selection)) { + const anchorNode = $getTableCellNodeFromLexicalNode( + selection.anchor.getNode(), + ); + invariant( + $isTableCellNode(anchorNode), + 'TableSelection anchorNode must be a TableCellNode', + ); + const tableNode = $getTableNodeFromLexicalNodeOrThrow(anchorNode); + const tableElement = getTableElement( + tableNode, + editor.getElementByKey(tableNode.getKey()), + ); + invariant( + tableElement !== null, + 'TableActionMenu: Expected to find tableElement in DOM', + ); + tableObserver = getTableObserverFromTableElement(tableElement); + tableCellParentNodeDOM = editor.getElementByKey(anchorNode.getKey()); } else if (!activeElement) { - setTableMenuCellNode(null); + return disable(); } - }, [editor]); - - useEffect(() => { - return editor.registerUpdateListener(() => { - editor.getEditorState().read(() => { - $moveMenu(); - }); - }); - }); + if (tableObserver === null || tableCellParentNodeDOM === null) { + return disable(); + } + const enabled = !tableObserver || !tableObserver.isSelecting; + menu.classList.toggle( + 'table-cell-action-button-container--active', + enabled, + ); + menu.classList.toggle( + 'table-cell-action-button-container--inactive', + !enabled, + ); + if (enabled) { + const tableCellRect = tableCellParentNodeDOM.getBoundingClientRect(); + const anchorRect = anchorElem.getBoundingClientRect(); + const top = tableCellRect.top - anchorRect.top; + const left = tableCellRect.right - anchorRect.left; + menu.style.transform = `translate(${left}px, ${top}px)`; + } + }, [editor, anchorElem]); useEffect(() => { - const menuButtonDOM = menuButtonRef.current as HTMLButtonElement | null; - - if (menuButtonDOM != null && tableCellNode != null) { - const tableCellNodeDOM = editor.getElementByKey(tableCellNode.getKey()); - - if (tableCellNodeDOM != null) { - const tableCellRect = tableCellNodeDOM.getBoundingClientRect(); - const menuRect = menuButtonDOM.getBoundingClientRect(); - const anchorRect = anchorElem.getBoundingClientRect(); - - const top = tableCellRect.top - anchorRect.top + 4; - const left = - tableCellRect.right - menuRect.width - 10 - anchorRect.left; - - menuButtonDOM.style.opacity = '1'; - menuButtonDOM.style.transform = `translate(${left}px, ${top}px)`; - } else { - menuButtonDOM.style.opacity = '0'; - menuButtonDOM.style.transform = 'translate(-10000px, -10000px)'; + // We call the $moveMenu callback every time the selection changes, + // once up front, and once after each mouseUp + let timeoutId: ReturnType | undefined = undefined; + const callback = () => { + timeoutId = undefined; + editor.getEditorState().read($moveMenu); + }; + const delayedCallback = () => { + if (timeoutId === undefined) { + timeoutId = setTimeout(callback, 0); } - } - }, [menuButtonRef, tableCellNode, editor, anchorElem]); + return false; + }; + return mergeRegister( + editor.registerUpdateListener(delayedCallback), + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + delayedCallback, + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerRootListener((rootElement, prevRootElement) => { + if (prevRootElement) { + prevRootElement.removeEventListener('mouseup', delayedCallback); + } + if (rootElement) { + rootElement.addEventListener('mouseup', delayedCallback); + delayedCallback(); + } + }), + () => clearTimeout(timeoutId), + ); + }); const prevTableCellDOM = useRef(tableCellNode); diff --git a/packages/lexical-playground/src/plugins/TestRecorderPlugin/index.tsx b/packages/lexical-playground/src/plugins/TestRecorderPlugin/index.tsx index 838568397f3..c97b3de3248 100644 --- a/packages/lexical-playground/src/plugins/TestRecorderPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/TestRecorderPlugin/index.tsx @@ -9,7 +9,12 @@ import type {BaseSelection, LexicalEditor} from 'lexical'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + getDOMSelection, +} from 'lexical'; import * as React from 'react'; import {useCallback, useEffect, useRef, useState} from 'react'; import {IS_APPLE} from 'shared/environment'; @@ -167,7 +172,7 @@ function useTestRecorder( const generateTestContent = useCallback(() => { const rootElement = editor.getRootElement(); - const browserSelection = window.getSelection(); + const browserSelection = getDOMSelection(editor._window); if ( rootElement == null || @@ -322,7 +327,7 @@ ${steps.map(formatStep).join(`\n`)} dirtyElements.size === 0 && !skipNextSelectionChange ) { - const browserSelection = window.getSelection(); + const browserSelection = getDOMSelection(editor._window); if ( browserSelection && (browserSelection.anchorNode == null || @@ -379,7 +384,7 @@ ${steps.map(formatStep).join(`\n`)} if (!isRecording) { return; } - const browserSelection = window.getSelection(); + const browserSelection = getDOMSelection(getCurrentEditor()._window); if ( browserSelection === null || browserSelection.anchorNode == null || diff --git a/packages/lexical-playground/src/setupEnv.ts b/packages/lexical-playground/src/setupEnv.ts index 076cd430a9c..abf4b21ded3 100644 --- a/packages/lexical-playground/src/setupEnv.ts +++ b/packages/lexical-playground/src/setupEnv.ts @@ -30,5 +30,9 @@ export default (() => { // @ts-expect-error delete window.InputEvent.prototype.getTargetRanges; } + + // @ts-ignore + window.EXCALIDRAW_ASSET_PATH = process.env.EXCALIDRAW_ASSET_PATH; + return INITIAL_SETTINGS; })(); diff --git a/packages/lexical-playground/vite.config.ts b/packages/lexical-playground/vite.config.ts index 12490da5cf2..82018c7c74b 100644 --- a/packages/lexical-playground/vite.config.ts +++ b/packages/lexical-playground/vite.config.ts @@ -15,6 +15,7 @@ import {replaceCodePlugin} from 'vite-plugin-replace'; import moduleResolution from '../shared/viteModuleResolution'; import viteCopyEsm from './viteCopyEsm'; +import viteCopyExcalidrawAssets from './viteCopyExcalidrawAssets'; const require = createRequire(import.meta.url); @@ -76,6 +77,7 @@ export default defineConfig(({command}) => { presets: [['@babel/preset-react', {runtime: 'automatic'}]], }), react(), + ...viteCopyExcalidrawAssets(), viteCopyEsm(), commonjs({ // This is required for React 19 (at least 19.0.0-beta-26f2496093-20240514) diff --git a/packages/lexical-playground/vite.prod.config.ts b/packages/lexical-playground/vite.prod.config.ts index 97fc5a760cd..59c8f368f05 100644 --- a/packages/lexical-playground/vite.prod.config.ts +++ b/packages/lexical-playground/vite.prod.config.ts @@ -14,6 +14,7 @@ import {replaceCodePlugin} from 'vite-plugin-replace'; import moduleResolution from '../shared/viteModuleResolution'; import viteCopyEsm from './viteCopyEsm'; +import viteCopyExcalidrawAssets from './viteCopyExcalidrawAssets'; // https://vitejs.dev/config/ export default defineConfig({ @@ -69,6 +70,7 @@ export default defineConfig({ presets: [['@babel/preset-react', {runtime: 'automatic'}]], }), react(), + ...viteCopyExcalidrawAssets(), viteCopyEsm(), commonjs({ // This is required for React 19 (at least 19.0.0-beta-26f2496093-20240514) diff --git a/packages/lexical-playground/viteCopyExcalidrawAssets.ts b/packages/lexical-playground/viteCopyExcalidrawAssets.ts new file mode 100644 index 00000000000..3e19da05892 --- /dev/null +++ b/packages/lexical-playground/viteCopyExcalidrawAssets.ts @@ -0,0 +1,56 @@ +/** + * 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 type {Plugin} from 'vite'; + +import {createRequire} from 'node:module'; +import * as path from 'node:path'; +import {normalizePath} from 'vite'; +import {Target, viteStaticCopy} from 'vite-plugin-static-copy'; + +const require = createRequire(import.meta.url); + +export default function viteCopyExcalidrawAssets(): Plugin[] { + const targets: Target[] = [ + 'excalidraw-assets', + 'excalidraw-assets-dev', + ].flatMap((assetDir) => { + const srcDir = path.join( + require.resolve('@excalidraw/excalidraw'), + '..', + 'dist', + assetDir, + ); + return [ + { + dest: `${assetDir}/`, + src: [path.join(srcDir, '*.js'), path.join(srcDir, '*.woff2')].map( + normalizePath, + ), + }, + { + dest: `${assetDir}/locales/`, + src: [path.join(srcDir, 'locales', '*.js')].map(normalizePath), + }, + ]; + }); + return [ + { + config() { + return { + define: { + 'process.env.EXCALIDRAW_ASSET_PATH': JSON.stringify('/'), + }, + }; + }, + name: 'viteCopyExcalidrawAssets', + }, + ...viteStaticCopy({ + targets, + }), + ]; +} diff --git a/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx b/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx index c8958d7aa51..da497dc9aad 100644 --- a/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx +++ b/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx @@ -21,6 +21,7 @@ import { COMMAND_PRIORITY_LOW, CommandListenerPriority, createCommand, + getDOMSelection, LexicalCommand, LexicalEditor, RangeSelection, @@ -53,7 +54,7 @@ function tryToPositionRange( range: Range, editorWindow: Window, ): boolean { - const domSelection = editorWindow.getSelection(); + const domSelection = getDOMSelection(editorWindow); if (domSelection === null || !domSelection.isCollapsed) { return false; } diff --git a/packages/lexical-table/flow/LexicalTable.js.flow b/packages/lexical-table/flow/LexicalTable.js.flow index 19014ebd897..2674a125f50 100644 --- a/packages/lexical-table/flow/LexicalTable.js.flow +++ b/packages/lexical-table/flow/LexicalTable.js.flow @@ -286,11 +286,11 @@ declare export class TableObserver { getTable(): TableDOMTable; removeListeners(): void; trackTable(): void; - clearHighlight(): void; - setFocusCellForSelection(cell: TableDOMCell): void; - setAnchorCellForSelection(cell: TableDOMCell): void; - formatCells(type: TextFormatType): void; - clearText(): void; + $clearHighlight(): void; + $setFocusCellForSelection(cell: TableDOMCell, ignoreStart?: boolean): void; + $setAnchorCellForSelection(cell: TableDOMCell): void; + $formatCells(type: TextFormatType): void; + $clearText(): void; } /** diff --git a/packages/lexical-table/src/LexicalTableObserver.ts b/packages/lexical-table/src/LexicalTableObserver.ts index 9d3ffbbc690..0d148257121 100644 --- a/packages/lexical-table/src/LexicalTableObserver.ts +++ b/packages/lexical-table/src/LexicalTableObserver.ts @@ -24,6 +24,7 @@ import { $isElementNode, $isParagraphNode, $setSelection, + getDOMSelection, SELECTION_CHANGE_COMMAND, } from 'lexical'; import invariant from 'shared/invariant'; @@ -32,13 +33,13 @@ import {$isTableCellNode, TableCellNode} from './LexicalTableCellNode'; import {$isTableNode, TableNode} from './LexicalTableNode'; import { $createTableSelection, + $createTableSelectionFrom, $isTableSelection, type TableSelection, } from './LexicalTableSelection'; import { $findTableNode, $updateDOMForSelection, - getDOMSelection, getTable, getTableElement, HTMLTableElementWithWithTableSelectionState, @@ -105,6 +106,7 @@ export class TableObserver { shouldCheckSelection: boolean; abortController: AbortController; listenerOptions: {signal: AbortSignal}; + nextFocus: {focusCell: TableDOMCell; override: boolean} | null; constructor(editor: LexicalEditor, tableNodeKey: string) { this.isHighlightingCells = false; @@ -130,6 +132,7 @@ export class TableObserver { this.shouldCheckSelection = false; this.abortController = new AbortController(); this.listenerOptions = {signal: this.abortController.signal}; + this.nextFocus = null; this.trackTable(); } @@ -198,7 +201,7 @@ export class TableObserver { ); } - clearHighlight() { + $clearHighlight(): void { const editor = this.editor; this.isHighlightingCells = false; this.anchorX = -1; @@ -212,61 +215,54 @@ export class TableObserver { this.focusCell = null; this.hasHijackedSelectionStyles = false; - this.enableHighlightStyle(); + this.$enableHighlightStyle(); - editor.update(() => { - const {tableNode, tableElement} = this.$lookup(); - const grid = getTable(tableNode, tableElement); - $updateDOMForSelection(editor, grid, null); + const {tableNode, tableElement} = this.$lookup(); + const grid = getTable(tableNode, tableElement); + $updateDOMForSelection(editor, grid, null); + if ($getSelection() !== null) { $setSelection(null); editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); - }); + } } - enableHighlightStyle() { + $enableHighlightStyle() { const editor = this.editor; - editor.getEditorState().read( - () => { - const {tableElement} = this.$lookup(); + const {tableElement} = this.$lookup(); - removeClassNamesFromElement( - tableElement, - editor._config.theme.tableSelection, - ); - tableElement.classList.remove('disable-selection'); - this.hasHijackedSelectionStyles = false; - }, - {editor}, + removeClassNamesFromElement( + tableElement, + editor._config.theme.tableSelection, ); + tableElement.classList.remove('disable-selection'); + this.hasHijackedSelectionStyles = false; } - disableHighlightStyle() { - const editor = this.editor; - editor.getEditorState().read( - () => { - const {tableElement} = this.$lookup(); - addClassNamesToElement( - tableElement, - editor._config.theme.tableSelection, - ); - this.hasHijackedSelectionStyles = true; - }, - {editor}, + $disableHighlightStyle() { + const {tableElement} = this.$lookup(); + addClassNamesToElement( + tableElement, + this.editor._config.theme.tableSelection, ); + this.hasHijackedSelectionStyles = true; } - updateTableTableSelection(selection: TableSelection | null): void { - if (selection !== null && selection.tableKey === this.tableNodeKey) { + $updateTableTableSelection(selection: TableSelection | null): void { + if (selection !== null) { + invariant( + selection.tableKey === this.tableNodeKey, + "TableObserver.$updateTableTableSelection: selection.tableKey !== this.tableNodeKey ('%s' !== '%s')", + selection.tableKey, + this.tableNodeKey, + ); const editor = this.editor; this.tableSelection = selection; this.isHighlightingCells = true; - this.disableHighlightStyle(); + this.$disableHighlightStyle(); + this.updateDOMSelection(); $updateDOMForSelection(editor, this.table, this.tableSelection); - } else if (selection == null) { - this.clearHighlight(); } else { - this.tableNodeKey = selection.tableKey; - this.updateTableTableSelection(selection); + this.$clearHighlight(); } } @@ -292,168 +288,208 @@ export class TableObserver { } return false; } - setFocusCellForSelection(cell: TableDOMCell, ignoreStart = false) { - const editor = this.editor; - editor.update(() => { - const {tableNode} = this.$lookup(); - - const cellX = cell.x; - const cellY = cell.y; - this.focusCell = cell; - - if (this.anchorCell !== null) { - const domSelection = getDOMSelection(editor._window); - // Collapse the selection - if (domSelection) { - domSelection.setBaseAndExtent( - this.anchorCell.elem, - 0, - this.focusCell.elem, - 0, - ); - } - } - if ( - !this.isHighlightingCells && - (this.anchorX !== cellX || this.anchorY !== cellY || ignoreStart) - ) { - this.isHighlightingCells = true; - this.disableHighlightStyle(); - } else if (cellX === this.focusX && cellY === this.focusY) { - return; + /** + * @internal + * When handling mousemove events we track what the focus cell should be, but + * the DOM selection may end up somewhere else entirely. We don't have an elegant + * way to handle this after the DOM selection has been resolved in a + * SELECTION_CHANGE_COMMAND callback. + */ + setNextFocus( + nextFocus: null | {focusCell: TableDOMCell; override: boolean}, + ): void { + this.nextFocus = nextFocus; + } + + /** @internal */ + getAndClearNextFocus(): { + focusCell: TableDOMCell; + override: boolean; + } | null { + const {nextFocus} = this; + if (nextFocus !== null) { + this.nextFocus = null; + } + return nextFocus; + } + + /** @internal */ + updateDOMSelection() { + if (this.anchorCell !== null && this.focusCell !== null) { + const domSelection = getDOMSelection(this.editor._window); + // We are not using a native selection for tables, and if we + // set one then the reconciler will undo it. + // TODO - it would make sense to have one so that native + // copy/paste worked. Right now we have to emulate with + // keyboard events but it won't fire if trigged from the menu + if (domSelection && domSelection.rangeCount > 0) { + domSelection.removeAllRanges(); } + } + } + + $setFocusCellForSelection(cell: TableDOMCell, ignoreStart = false): boolean { + const editor = this.editor; + const {tableNode} = this.$lookup(); - this.focusX = cellX; - this.focusY = cellY; + const cellX = cell.x; + const cellY = cell.y; + this.focusCell = cell; - if (this.isHighlightingCells) { - const focusTableCellNode = $getNearestNodeFromDOMNode(cell.elem); + if ( + !this.isHighlightingCells && + (this.anchorX !== cellX || this.anchorY !== cellY || ignoreStart) + ) { + this.isHighlightingCells = true; + this.$disableHighlightStyle(); + } else if (cellX === this.focusX && cellY === this.focusY) { + return false; + } - if ( - this.tableSelection != null && - this.anchorCellNodeKey != null && - $isTableCellNode(focusTableCellNode) && - tableNode.is($findTableNode(focusTableCellNode)) - ) { - const focusNodeKey = focusTableCellNode.getKey(); + this.focusX = cellX; + this.focusY = cellY; - this.tableSelection = - this.tableSelection.clone() || $createTableSelection(); + if (this.isHighlightingCells) { + const focusTableCellNode = $getNearestNodeFromDOMNode(cell.elem); - this.focusCellNodeKey = focusNodeKey; - this.tableSelection.set( - this.tableNodeKey, - this.anchorCellNodeKey, - this.focusCellNodeKey, - ); + if ( + this.tableSelection != null && + this.anchorCellNodeKey != null && + $isTableCellNode(focusTableCellNode) && + tableNode.is($findTableNode(focusTableCellNode)) + ) { + this.focusCellNodeKey = focusTableCellNode.getKey(); + this.tableSelection = $createTableSelectionFrom( + tableNode, + this.$getAnchorTableCellOrThrow(), + focusTableCellNode, + ); - $setSelection(this.tableSelection); + $setSelection(this.tableSelection); - editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); + editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); - $updateDOMForSelection(editor, this.table, this.tableSelection); - } + $updateDOMForSelection(editor, this.table, this.tableSelection); + return true; } - }); + } + return false; } - setAnchorCellForSelection(cell: TableDOMCell) { + $getAnchorTableCell(): TableCellNode | null { + return this.anchorCellNodeKey + ? $getNodeByKey(this.anchorCellNodeKey) + : null; + } + $getAnchorTableCellOrThrow(): TableCellNode { + const anchorTableCell = this.$getAnchorTableCell(); + invariant( + anchorTableCell !== null, + 'TableObserver anchorTableCell is null', + ); + return anchorTableCell; + } + + $getFocusTableCell(): TableCellNode | null { + return this.focusCellNodeKey ? $getNodeByKey(this.focusCellNodeKey) : null; + } + + $getFocusTableCellOrThrow(): TableCellNode { + const focusTableCell = this.$getFocusTableCell(); + invariant(focusTableCell !== null, 'TableObserver focusTableCell is null'); + return focusTableCell; + } + + $setAnchorCellForSelection(cell: TableDOMCell) { this.isHighlightingCells = false; this.anchorCell = cell; this.anchorX = cell.x; this.anchorY = cell.y; - this.editor.update(() => { - const anchorTableCellNode = $getNearestNodeFromDOMNode(cell.elem); + const anchorTableCellNode = $getNearestNodeFromDOMNode(cell.elem); - if ($isTableCellNode(anchorTableCellNode)) { - const anchorNodeKey = anchorTableCellNode.getKey(); - this.tableSelection = - this.tableSelection != null - ? this.tableSelection.clone() - : $createTableSelection(); - this.anchorCellNodeKey = anchorNodeKey; - } - }); + if ($isTableCellNode(anchorTableCellNode)) { + const anchorNodeKey = anchorTableCellNode.getKey(); + this.tableSelection = + this.tableSelection != null + ? this.tableSelection.clone() + : $createTableSelection(); + this.anchorCellNodeKey = anchorNodeKey; + } } - formatCells(type: TextFormatType) { - this.editor.update(() => { - const selection = $getSelection(); + $formatCells(type: TextFormatType) { + const selection = $getSelection(); - if (!$isTableSelection(selection)) { - invariant(false, 'Expected grid selection'); - } + invariant($isTableSelection(selection), 'Expected Table selection'); - const formatSelection = $createRangeSelection(); + const formatSelection = $createRangeSelection(); - const anchor = formatSelection.anchor; - const focus = formatSelection.focus; + const anchor = formatSelection.anchor; + const focus = formatSelection.focus; - const cellNodes = selection.getNodes().filter($isTableCellNode); - const paragraph = cellNodes[0].getFirstChild(); - const alignFormatWith = $isParagraphNode(paragraph) - ? paragraph.getFormatFlags(type, null) - : null; + const cellNodes = selection.getNodes().filter($isTableCellNode); + invariant(cellNodes.length > 0, 'No table cells present'); + const paragraph = cellNodes[0].getFirstChild(); + const alignFormatWith = $isParagraphNode(paragraph) + ? paragraph.getFormatFlags(type, null) + : null; - cellNodes.forEach((cellNode: TableCellNode) => { - anchor.set(cellNode.getKey(), 0, 'element'); - focus.set(cellNode.getKey(), cellNode.getChildrenSize(), 'element'); - formatSelection.formatText(type, alignFormatWith); - }); + cellNodes.forEach((cellNode: TableCellNode) => { + anchor.set(cellNode.getKey(), 0, 'element'); + focus.set(cellNode.getKey(), cellNode.getChildrenSize(), 'element'); + formatSelection.formatText(type, alignFormatWith); + }); - $setSelection(selection); + $setSelection(selection); - this.editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); - }); + this.editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); } - clearText() { - const editor = this.editor; - editor.update(() => { - const tableNode = $getNodeByKey(this.tableNodeKey); + $clearText() { + const {editor} = this; + const tableNode = $getNodeByKey(this.tableNodeKey); - if (!$isTableNode(tableNode)) { - throw new Error('Expected TableNode.'); - } + if (!$isTableNode(tableNode)) { + throw new Error('Expected TableNode.'); + } - const selection = $getSelection(); + const selection = $getSelection(); - if (!$isTableSelection(selection)) { - invariant(false, 'Expected grid selection'); - } + if (!$isTableSelection(selection)) { + invariant(false, 'Expected grid selection'); + } - const selectedNodes = selection.getNodes().filter($isTableCellNode); + const selectedNodes = selection.getNodes().filter($isTableCellNode); - if (selectedNodes.length === this.table.columns * this.table.rows) { - tableNode.selectPrevious(); - // Delete entire table - tableNode.remove(); - const rootNode = $getRoot(); - rootNode.selectStart(); - return; - } + if (selectedNodes.length === this.table.columns * this.table.rows) { + tableNode.selectPrevious(); + // Delete entire table + tableNode.remove(); + const rootNode = $getRoot(); + rootNode.selectStart(); + return; + } - selectedNodes.forEach((cellNode) => { - if ($isElementNode(cellNode)) { - const paragraphNode = $createParagraphNode(); - const textNode = $createTextNode(); - paragraphNode.append(textNode); - cellNode.append(paragraphNode); - cellNode.getChildren().forEach((child) => { - if (child !== paragraphNode) { - child.remove(); - } - }); - } - }); + selectedNodes.forEach((cellNode) => { + if ($isElementNode(cellNode)) { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode(); + paragraphNode.append(textNode); + cellNode.append(paragraphNode); + cellNode.getChildren().forEach((child) => { + if (child !== paragraphNode) { + child.remove(); + } + }); + } + }); - $updateDOMForSelection(editor, this.table, null); + $updateDOMForSelection(editor, this.table, null); - $setSelection(null); + $setSelection(null); - editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); - }); + editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); } } diff --git a/packages/lexical-table/src/LexicalTableSelection.ts b/packages/lexical-table/src/LexicalTableSelection.ts index 03c8543cb73..185d95100f3 100644 --- a/packages/lexical-table/src/LexicalTableSelection.ts +++ b/packages/lexical-table/src/LexicalTableSelection.ts @@ -9,24 +9,31 @@ import {$findMatchingParent} from '@lexical/utils'; import { $createPoint, - $getNodeByKey, + $getSelection, $isElementNode, $isParagraphNode, $normalizeSelection__EXPERIMENTAL, BaseSelection, + ElementNode, isCurrentlyReadOnlyMode, LexicalNode, NodeKey, PointType, TEXT_TYPE_TO_FORMAT, TextFormatType, + TextNode, } from 'lexical'; import invariant from 'shared/invariant'; import {$isTableCellNode, TableCellNode} from './LexicalTableCellNode'; -import {$isTableNode} from './LexicalTableNode'; -import {$isTableRowNode} from './LexicalTableRowNode'; -import {$computeTableMap, $getTableCellNodeRect} from './LexicalTableUtils'; +import {$isTableNode, TableNode} from './LexicalTableNode'; +import {$isTableRowNode, TableRowNode} from './LexicalTableRowNode'; +import {$findTableNode} from './LexicalTableSelectionHelpers'; +import { + $computeTableCellRectBoundary, + $computeTableMap, + $getTableCellNodeRect, +} from './LexicalTableUtils'; export type TableSelectionShape = { fromX: number; @@ -42,6 +49,62 @@ export type TableMapValueType = { }; export type TableMapType = Array>; +function $getCellNodes(tableSelection: TableSelection): { + anchorCell: TableCellNode; + anchorNode: TextNode | ElementNode; + anchorRow: TableRowNode; + anchorTable: TableNode; + focusCell: TableCellNode; + focusNode: TextNode | ElementNode; + focusRow: TableRowNode; + focusTable: TableNode; +} { + const [ + [anchorNode, anchorCell, anchorRow, anchorTable], + [focusNode, focusCell, focusRow, focusTable], + ] = (['anchor', 'focus'] as const).map( + (k): [ElementNode | TextNode, TableCellNode, TableRowNode, TableNode] => { + const node = tableSelection[k].getNode(); + const cellNode = $findMatchingParent(node, $isTableCellNode); + invariant( + $isTableCellNode(cellNode), + 'Expected TableSelection %s to be (or a child of) TableCellNode, got key %s of type %s', + k, + node.getKey(), + node.getType(), + ); + const rowNode = cellNode.getParent(); + invariant( + $isTableRowNode(rowNode), + 'Expected TableSelection %s cell parent to be a TableRowNode', + k, + ); + const tableNode = rowNode.getParent(); + invariant( + $isTableNode(tableNode), + 'Expected TableSelection %s row parent to be a TableNode', + k, + ); + return [node, cellNode, rowNode, tableNode]; + }, + ); + // TODO: nested tables may violate this + invariant( + anchorTable.is(focusTable), + 'Expected TableSelection anchor and focus to be in the same table', + ); + return { + anchorCell, + anchorNode, + anchorRow, + anchorTable, + focusCell, + focusNode, + focusRow, + focusTable, + }; +} + export class TableSelection implements BaseSelection { tableKey: NodeKey; anchor: PointType; @@ -63,6 +126,23 @@ export class TableSelection implements BaseSelection { return [this.anchor, this.focus]; } + /** + * {@link $createTableSelection} unfortunately makes it very easy to create + * nonsense selections, so we have a method to see if the selection probably + * makes sense. + * + * @returns true if the TableSelection is (probably) valid + */ + isValid(): boolean { + return ( + this.tableKey !== 'root' && + this.anchor.key !== 'root' && + this.anchor.type === 'element' && + this.focus.key !== 'root' && + this.focus.type === 'element' + ); + } + /** * Returns whether the Selection is "backwards", meaning the focus * logically precedes the anchor in the EditorState. @@ -81,10 +161,8 @@ export class TableSelection implements BaseSelection { } is(selection: null | BaseSelection): boolean { - if (!$isTableSelection(selection)) { - return false; - } return ( + $isTableSelection(selection) && this.tableKey === selection.tableKey && this.anchor.is(selection.anchor) && this.focus.is(selection.focus) @@ -92,7 +170,12 @@ export class TableSelection implements BaseSelection { } set(tableKey: NodeKey, anchorCellKey: NodeKey, focusCellKey: NodeKey): void { - this.dirty = true; + // note: closure compiler's acorn does not support ||= + this.dirty = + this.dirty || + tableKey !== this.tableKey || + anchorCellKey !== this.anchor.key || + focusCellKey !== this.focus.key; this.tableKey = tableKey; this.anchor.key = anchorCellKey; this.focus.key = focusCellKey; @@ -100,7 +183,11 @@ export class TableSelection implements BaseSelection { } clone(): TableSelection { - return new TableSelection(this.tableKey, this.anchor, this.focus); + return new TableSelection( + this.tableKey, + $createPoint(this.anchor.key, this.anchor.offset, this.anchor.type), + $createPoint(this.focus.key, this.focus.offset, this.focus.type), + ); } isCollapsed(): boolean { @@ -155,23 +242,13 @@ export class TableSelection implements BaseSelection { // TODO Deprecate this method. It's confusing when used with colspan|rowspan getShape(): TableSelectionShape { - const anchorCellNode = $getNodeByKey(this.anchor.key); - invariant( - $isTableCellNode(anchorCellNode), - 'Expected TableSelection anchor to be (or a child of) TableCellNode', - ); - const anchorCellNodeRect = $getTableCellNodeRect(anchorCellNode); + const {anchorCell, focusCell} = $getCellNodes(this); + const anchorCellNodeRect = $getTableCellNodeRect(anchorCell); invariant( anchorCellNodeRect !== null, 'getCellRect: expected to find AnchorNode', ); - - const focusCellNode = $getNodeByKey(this.focus.key); - invariant( - $isTableCellNode(focusCellNode), - 'Expected TableSelection focus to be (or a child of) TableCellNode', - ); - const focusCellNodeRect = $getTableCellNodeRect(focusCellNode); + const focusCellNodeRect = $getTableCellNodeRect(focusCell); invariant( focusCellNodeRect !== null, 'getCellRect: expected to find focusCellNode', @@ -204,34 +281,15 @@ export class TableSelection implements BaseSelection { } getNodes(): Array { + if (!this.isValid()) { + return []; + } const cachedNodes = this._cachedNodes; if (cachedNodes !== null) { return cachedNodes; } - const anchorNode = this.anchor.getNode(); - const focusNode = this.focus.getNode(); - const anchorCell = $findMatchingParent(anchorNode, $isTableCellNode); - // todo replace with triplet - const focusCell = $findMatchingParent(focusNode, $isTableCellNode); - invariant( - $isTableCellNode(anchorCell), - 'Expected TableSelection anchor to be (or a child of) TableCellNode', - ); - invariant( - $isTableCellNode(focusCell), - 'Expected TableSelection focus to be (or a child of) TableCellNode', - ); - const anchorRow = anchorCell.getParent(); - invariant( - $isTableRowNode(anchorRow), - 'Expected anchorCell to have a parent TableRowNode', - ); - const tableNode = anchorRow.getParent(); - invariant( - $isTableNode(tableNode), - 'Expected tableNode to have a parent TableNode', - ); + const {anchorTable: tableNode, anchorCell, focusCell} = $getCellNodes(this); const focusCellGrid = focusCell.getParents()[1]; if (focusCellGrid !== tableNode) { @@ -261,82 +319,15 @@ export class TableSelection implements BaseSelection { anchorCell, focusCell, ); - - let minColumn = Math.min(cellAMap.startColumn, cellBMap.startColumn); - let minRow = Math.min(cellAMap.startRow, cellBMap.startRow); - let maxColumn = Math.max( - cellAMap.startColumn + cellAMap.cell.__colSpan - 1, - cellBMap.startColumn + cellBMap.cell.__colSpan - 1, - ); - let maxRow = Math.max( - cellAMap.startRow + cellAMap.cell.__rowSpan - 1, - cellBMap.startRow + cellBMap.cell.__rowSpan - 1, - ); - let exploredMinColumn = minColumn; - let exploredMinRow = minRow; - let exploredMaxColumn = minColumn; - let exploredMaxRow = minRow; - function expandBoundary(mapValue: TableMapValueType): void { - const { - cell, - startColumn: cellStartColumn, - startRow: cellStartRow, - } = mapValue; - minColumn = Math.min(minColumn, cellStartColumn); - minRow = Math.min(minRow, cellStartRow); - maxColumn = Math.max(maxColumn, cellStartColumn + cell.__colSpan - 1); - maxRow = Math.max(maxRow, cellStartRow + cell.__rowSpan - 1); - } - while ( - minColumn < exploredMinColumn || - minRow < exploredMinRow || - maxColumn > exploredMaxColumn || - maxRow > exploredMaxRow - ) { - if (minColumn < exploredMinColumn) { - // Expand on the left - const rowDiff = exploredMaxRow - exploredMinRow; - const previousColumn = exploredMinColumn - 1; - for (let i = 0; i <= rowDiff; i++) { - expandBoundary(map[exploredMinRow + i][previousColumn]); - } - exploredMinColumn = previousColumn; - } - if (minRow < exploredMinRow) { - // Expand on top - const columnDiff = exploredMaxColumn - exploredMinColumn; - const previousRow = exploredMinRow - 1; - for (let i = 0; i <= columnDiff; i++) { - expandBoundary(map[previousRow][exploredMinColumn + i]); - } - exploredMinRow = previousRow; - } - if (maxColumn > exploredMaxColumn) { - // Expand on the right - const rowDiff = exploredMaxRow - exploredMinRow; - const nextColumn = exploredMaxColumn + 1; - for (let i = 0; i <= rowDiff; i++) { - expandBoundary(map[exploredMinRow + i][nextColumn]); - } - exploredMaxColumn = nextColumn; - } - if (maxRow > exploredMaxRow) { - // Expand on the bottom - const columnDiff = exploredMaxColumn - exploredMinColumn; - const nextRow = exploredMaxRow + 1; - for (let i = 0; i <= columnDiff; i++) { - expandBoundary(map[nextRow][exploredMinColumn + i]); - } - exploredMaxRow = nextRow; - } - } + const {minColumn, maxColumn, minRow, maxRow} = + $computeTableCellRectBoundary(map, cellAMap, cellBMap); // We use a Map here because merged cells in the grid would otherwise // show up multiple times in the nodes array const nodeMap: Map = new Map([ [tableNode.getKey(), tableNode], ]); - let lastRow = null; + let lastRow: null | TableRowNode = null; for (let i = minRow; i <= maxRow; i++) { for (let j = minColumn; j <= maxColumn; j++) { const {cell} = map[i][j]; @@ -347,12 +338,13 @@ export class TableSelection implements BaseSelection { ); if (currentRow !== lastRow) { nodeMap.set(currentRow.getKey(), currentRow); + lastRow = currentRow; } - nodeMap.set(cell.getKey(), cell); - for (const child of $getChildrenRecursively(cell)) { - nodeMap.set(child.getKey(), child); + if (!nodeMap.has(cell.getKey())) { + $visitRecursively(cell, (childNode) => { + nodeMap.set(childNode.getKey(), childNode); + }); } - lastRow = currentRow; } } const nodes = Array.from(nodeMap.values()); @@ -381,26 +373,76 @@ export function $isTableSelection(x: unknown): x is TableSelection { } export function $createTableSelection(): TableSelection { + // TODO this is a suboptimal design, it doesn't make sense to have + // a table selection that isn't associated with a table. This + // constructor should have required argumnets and in __DEV__ we + // should check that they point to a table and are element points to + // cell nodes of that table. const anchor = $createPoint('root', 0, 'element'); const focus = $createPoint('root', 0, 'element'); return new TableSelection('root', anchor, focus); } -export function $getChildrenRecursively(node: LexicalNode): Array { - const nodes = []; - const stack = [node]; - while (stack.length > 0) { - const currentNode = stack.pop(); +export function $createTableSelectionFrom( + tableNode: TableNode, + anchorCell: TableCellNode, + focusCell: TableCellNode, +): TableSelection { + const tableNodeKey = tableNode.getKey(); + const anchorCellKey = anchorCell.getKey(); + const focusCellKey = focusCell.getKey(); + if (__DEV__) { invariant( - currentNode !== undefined, - "Stack.length > 0; can't be undefined", + tableNode.isAttached(), + '$createTableSelectionFrom: tableNode %s is not attached', + tableNodeKey, ); - if ($isElementNode(currentNode)) { - stack.unshift(...currentNode.getChildren()); - } - if (currentNode !== node) { - nodes.push(currentNode); + invariant( + tableNode.is($findTableNode(anchorCell)), + '$createTableSelectionFrom: anchorCell %s is not in table %s', + anchorCellKey, + tableNodeKey, + ); + invariant( + tableNode.is($findTableNode(focusCell)), + '$createTableSelectionFrom: focusCell %s is not in table %s', + focusCellKey, + tableNodeKey, + ); + // TODO: Check for rectangular grid + } + const prevSelection = $getSelection(); + const nextSelection = $isTableSelection(prevSelection) + ? prevSelection.clone() + : $createTableSelection(); + nextSelection.set( + tableNode.getKey(), + anchorCell.getKey(), + focusCell.getKey(), + ); + return nextSelection; +} + +/** + * Depth first visitor + * @param node The starting node + * @param $visit The function to call for each node. If the function returns false, then children of this node will not be explored + */ +export function $visitRecursively( + node: LexicalNode, + $visit: (childNode: LexicalNode) => boolean | undefined | void, +): void { + const stack = [[node]]; + for ( + let currentArray = stack.at(-1); + currentArray !== undefined && stack.length > 0; + currentArray = stack.at(-1) + ) { + const currentNode = currentArray.pop(); + if (currentNode === undefined) { + stack.pop(); + } else if ($visit(currentNode) !== false && $isElementNode(currentNode)) { + stack.push(currentNode.getChildren()); } } - return nodes; } diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index 740af8e121f..2a1e4945037 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -51,6 +51,7 @@ import { FOCUS_COMMAND, FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, + getDOMSelection, INSERT_PARAGRAPH_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, @@ -63,7 +64,7 @@ import { SELECTION_CHANGE_COMMAND, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, } from 'lexical'; -import {CAN_USE_DOM} from 'shared/canUseDOM'; +import {IS_FIREFOX} from 'shared/environment'; import invariant from 'shared/invariant'; import {$isTableCellNode} from './LexicalTableCellNode'; @@ -75,15 +76,16 @@ import { import {TableDOMTable, TableObserver} from './LexicalTableObserver'; import {$isTableRowNode} from './LexicalTableRowNode'; import {$isTableSelection} from './LexicalTableSelection'; -import {$computeTableMap, $getNodeTriplet} from './LexicalTableUtils'; +import { + $computeTableCellRectBoundary, + $computeTableCellRectSpans, + $computeTableMap, + $getNodeTriplet, + TableCellRectBoundary, +} from './LexicalTableUtils'; const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection'; -export const getDOMSelection = ( - targetWindow: Window | null, -): Selection | null => - CAN_USE_DOM ? (targetWindow || window).getSelection() : null; - const isMouseDownOnEvent = (event: MouseEvent) => { return (event.buttons & 1) === 1; }; @@ -106,6 +108,44 @@ export function getTableElement( return element; } +export function getEditorWindow(editor: LexicalEditor): Window | null { + return editor._window; +} + +export function $findParentTableCellNodeInTable( + tableNode: LexicalNode, + node: LexicalNode | null, +): TableCellNode | null { + for ( + let currentNode = node, lastTableCellNode: TableCellNode | null = null; + currentNode !== null; + currentNode = currentNode.getParent() + ) { + if (tableNode.is(currentNode)) { + return lastTableCellNode; + } else if ($isTableCellNode(currentNode)) { + lastTableCellNode = currentNode; + } + } + return null; +} + +const ARROW_KEY_COMMANDS_WITH_DIRECTION = [ + [KEY_ARROW_DOWN_COMMAND, 'down'], + [KEY_ARROW_UP_COMMAND, 'up'], + [KEY_ARROW_LEFT_COMMAND, 'backward'], + [KEY_ARROW_RIGHT_COMMAND, 'forward'], +] as const; +const DELETE_TEXT_COMMANDS = [ + DELETE_WORD_COMMAND, + DELETE_LINE_COMMAND, + DELETE_CHARACTER_COMMAND, +] as const; +const DELETE_KEY_COMMANDS = [ + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, +] as const; + export function applyTableHandlers( tableNode: TableNode, element: HTMLElement, @@ -113,13 +153,13 @@ export function applyTableHandlers( hasTabHandler: boolean, ): TableObserver { const rootElement = editor.getRootElement(); - - if (rootElement === null) { - throw new Error('No root element.'); - } + const editorWindow = getEditorWindow(editor); + invariant( + rootElement !== null && editorWindow !== null, + 'applyTableHandlers: editor has no root element set', + ); const tableObserver = new TableObserver(editor, tableNode.getKey()); - const editorWindow = editor._window || window; const tableElement = getTableElement(tableNode, element); attachTableObserverToTableElement(tableElement, tableObserver); @@ -128,6 +168,9 @@ export function applyTableHandlers( ); const createMouseHandlers = () => { + if (tableObserver.isSelecting) { + return; + } const onMouseUp = () => { tableObserver.isSelecting = false; editorWindow.removeEventListener('mouseup', onMouseUp); @@ -135,57 +178,104 @@ export function applyTableHandlers( }; const onMouseMove = (moveEvent: MouseEvent) => { - // delaying mousemove handler to allow selectionchange handler from LexicalEvents.ts to be executed first - setTimeout(() => { - if (!isMouseDownOnEvent(moveEvent) && tableObserver.isSelecting) { - tableObserver.isSelecting = false; - editorWindow.removeEventListener('mouseup', onMouseUp); - editorWindow.removeEventListener('mousemove', onMouseMove); - return; - } - const focusCell = getDOMCellFromTarget(moveEvent.target as Node); - if ( - focusCell !== null && - (tableObserver.anchorX !== focusCell.x || - tableObserver.anchorY !== focusCell.y) - ) { - moveEvent.preventDefault(); - tableObserver.setFocusCellForSelection(focusCell); + if (!isMouseDownOnEvent(moveEvent) && tableObserver.isSelecting) { + tableObserver.isSelecting = false; + editorWindow.removeEventListener('mouseup', onMouseUp); + editorWindow.removeEventListener('mousemove', onMouseMove); + return; + } + const override = !tableElement.contains(moveEvent.target as Node); + let focusCell: null | TableDOMCell = null; + if (!override) { + focusCell = getDOMCellFromTarget(moveEvent.target as Node); + } else { + for (const el of document.elementsFromPoint( + moveEvent.clientX, + moveEvent.clientY, + )) { + focusCell = tableElement.contains(el) + ? getDOMCellFromTarget(el) + : null; + if (focusCell) { + break; + } } - }, 0); + } + if ( + focusCell && + (tableObserver.focusCell === null || + focusCell.elem !== tableObserver.focusCell.elem) + ) { + tableObserver.setNextFocus({focusCell, override}); + editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); + } }; - return {onMouseMove, onMouseUp}; + tableObserver.isSelecting = true; + editorWindow.addEventListener( + 'mouseup', + onMouseUp, + tableObserver.listenerOptions, + ); + editorWindow.addEventListener( + 'mousemove', + onMouseMove, + tableObserver.listenerOptions, + ); }; const onMouseDown = (event: MouseEvent) => { - setTimeout(() => { - if (event.button !== 0) { - return; - } + if (event.button !== 0) { + return; + } - if (!editorWindow) { - return; - } + if (!editorWindow) { + return; + } - const anchorCell = getDOMCellFromTarget(event.target as Node); - if (anchorCell !== null) { - stopEvent(event); - tableObserver.setAnchorCellForSelection(anchorCell); - } + const targetCell = getDOMCellFromTarget(event.target as Node); + if (targetCell !== null) { + editor.update(() => { + const prevSelection = $getPreviousSelection(); + // We can't trust Firefox to do the right thing with the selection and + // we don't have a proper state machine to do this "correctly" but + // if we go ahead and make the table selection now it will work + if ( + IS_FIREFOX && + event.shiftKey && + $isSelectionInTable(prevSelection, tableNode) && + ($isRangeSelection(prevSelection) || $isTableSelection(prevSelection)) + ) { + const prevAnchorNode = prevSelection.anchor.getNode(); + const prevAnchorCell = $findParentTableCellNodeInTable( + tableNode, + prevSelection.anchor.getNode(), + ); + if (prevAnchorCell) { + tableObserver.$setAnchorCellForSelection( + $getObserverCellFromCellNodeOrThrow( + tableObserver, + prevAnchorCell, + ), + ); + tableObserver.$setFocusCellForSelection(targetCell); + stopEvent(event); + } else { + const newSelection = tableNode.isBefore(prevAnchorNode) + ? tableNode.selectStart() + : tableNode.selectEnd(); + newSelection.anchor.set( + prevSelection.anchor.key, + prevSelection.anchor.offset, + prevSelection.anchor.type, + ); + } + } else { + tableObserver.$setAnchorCellForSelection(targetCell); + } + }); + } - const {onMouseUp, onMouseMove} = createMouseHandlers(); - tableObserver.isSelecting = true; - editorWindow.addEventListener( - 'mouseup', - onMouseUp, - tableObserver.listenerOptions, - ); - editorWindow.addEventListener( - 'mousemove', - onMouseMove, - tableObserver.listenerOptions, - ); - }, 0); + createMouseHandlers(); }; tableElement.addEventListener( 'mousedown', @@ -207,7 +297,7 @@ export function applyTableHandlers( selection.tableKey === tableObserver.tableNodeKey && rootElement.contains(target) ) { - tableObserver.clearHighlight(); + tableObserver.$clearHighlight(); } }); }; @@ -218,40 +308,16 @@ export function applyTableHandlers( tableObserver.listenerOptions, ); - tableObserver.listenersToRemove.add( - editor.registerCommand( - KEY_ARROW_DOWN_COMMAND, - (event) => - $handleArrowKey(editor, event, 'down', tableNode, tableObserver), - COMMAND_PRIORITY_HIGH, - ), - ); - - tableObserver.listenersToRemove.add( - editor.registerCommand( - KEY_ARROW_UP_COMMAND, - (event) => $handleArrowKey(editor, event, 'up', tableNode, tableObserver), - COMMAND_PRIORITY_HIGH, - ), - ); - - tableObserver.listenersToRemove.add( - editor.registerCommand( - KEY_ARROW_LEFT_COMMAND, - (event) => - $handleArrowKey(editor, event, 'backward', tableNode, tableObserver), - COMMAND_PRIORITY_HIGH, - ), - ); - - tableObserver.listenersToRemove.add( - editor.registerCommand( - KEY_ARROW_RIGHT_COMMAND, - (event) => - $handleArrowKey(editor, event, 'forward', tableNode, tableObserver), - COMMAND_PRIORITY_HIGH, - ), - ); + for (const [command, direction] of ARROW_KEY_COMMANDS_WITH_DIRECTION) { + tableObserver.listenersToRemove.add( + editor.registerCommand( + command, + (event) => + $handleArrowKey(editor, event, direction, tableNode, tableObserver), + COMMAND_PRIORITY_HIGH, + ), + ); + } tableObserver.listenersToRemove.add( editor.registerCommand( @@ -259,11 +325,11 @@ export function applyTableHandlers( (event) => { const selection = $getSelection(); if ($isTableSelection(selection)) { - const focusCellNode = $findMatchingParent( + const focusCellNode = $findParentTableCellNodeInTable( + tableNode, selection.focus.getNode(), - $isTableCellNode, ); - if ($isTableCellNode(focusCellNode)) { + if (focusCellNode !== null) { stopEvent(event); focusCellNode.selectEnd(); return true; @@ -284,13 +350,13 @@ export function applyTableHandlers( } if ($isTableSelection(selection)) { - tableObserver.clearText(); + tableObserver.$clearText(); return true; } else if ($isRangeSelection(selection)) { - const tableCellNode = $findMatchingParent( + const tableCellNode = $findParentTableCellNodeInTable( + tableNode, selection.anchor.getNode(), - (n) => $isTableCellNode(n), ); if (!$isTableCellNode(tableCellNode)) { @@ -307,7 +373,7 @@ export function applyTableHandlers( (isFocusInside && !isAnchorInside); if (selectionContainsPartialTable) { - tableObserver.clearText(); + tableObserver.$clearText(); return true; } @@ -342,17 +408,15 @@ export function applyTableHandlers( return false; }; - [DELETE_WORD_COMMAND, DELETE_LINE_COMMAND, DELETE_CHARACTER_COMMAND].forEach( - (command) => { - tableObserver.listenersToRemove.add( - editor.registerCommand( - command, - deleteTextHandler(command), - COMMAND_PRIORITY_CRITICAL, - ), - ); - }, - ); + for (const command of DELETE_TEXT_COMMANDS) { + tableObserver.listenersToRemove.add( + editor.registerCommand( + command, + deleteTextHandler(command), + COMMAND_PRIORITY_CRITICAL, + ), + ); + } const $deleteCellHandler = ( event: KeyboardEvent | ClipboardEvent | null, @@ -390,7 +454,7 @@ export function applyTableHandlers( event.preventDefault(); event.stopPropagation(); } - tableObserver.clearText(); + tableObserver.$clearText(); return true; } @@ -398,21 +462,15 @@ export function applyTableHandlers( return false; }; - tableObserver.listenersToRemove.add( - editor.registerCommand( - KEY_BACKSPACE_COMMAND, - $deleteCellHandler, - COMMAND_PRIORITY_CRITICAL, - ), - ); - - tableObserver.listenersToRemove.add( - editor.registerCommand( - KEY_DELETE_COMMAND, - $deleteCellHandler, - COMMAND_PRIORITY_CRITICAL, - ), - ); + for (const command of DELETE_KEY_COMMANDS) { + tableObserver.listenersToRemove.add( + editor.registerCommand( + command, + $deleteCellHandler, + COMMAND_PRIORITY_CRITICAL, + ), + ); + } tableObserver.listenersToRemove.add( editor.registerCommand( @@ -456,7 +514,7 @@ export function applyTableHandlers( } if ($isTableSelection(selection)) { - tableObserver.formatCells(payload); + tableObserver.$formatCells(payload); return true; } else if ($isRangeSelection(selection)) { @@ -499,19 +557,27 @@ export function applyTableHandlers( anchorNode, focusNode, ); - const maxRow = Math.max(anchorCell.startRow, focusCell.startRow); + const maxRow = Math.max( + anchorCell.startRow + anchorCell.cell.__rowSpan - 1, + focusCell.startRow + focusCell.cell.__rowSpan - 1, + ); const maxColumn = Math.max( - anchorCell.startColumn, - focusCell.startColumn, + anchorCell.startColumn + anchorCell.cell.__colSpan - 1, + focusCell.startColumn + focusCell.cell.__colSpan - 1, ); const minRow = Math.min(anchorCell.startRow, focusCell.startRow); const minColumn = Math.min( anchorCell.startColumn, focusCell.startColumn, ); + const visited = new Set(); for (let i = minRow; i <= maxRow; i++) { for (let j = minColumn; j <= maxColumn; j++) { const cell = tableMap[i][j].cell; + if (visited.has(cell)) { + continue; + } + visited.add(cell); cell.setFormat(formatType); const cellChildren = cell.getChildren(); @@ -540,7 +606,7 @@ export function applyTableHandlers( } if ($isTableSelection(selection)) { - tableObserver.clearHighlight(); + tableObserver.$clearHighlight(); return false; } else if ($isRangeSelection(selection)) { @@ -625,20 +691,6 @@ export function applyTableHandlers( ), ); - function getObserverCellFromCellNode( - tableCellNode: TableCellNode, - ): TableDOMCell { - const currentCords = tableNode.getCordsFromCellNode( - tableCellNode, - tableObserver.table, - ); - return tableNode.getDOMCellFromCordsOrThrow( - currentCords.x, - currentCords.y, - tableObserver.table, - ); - } - tableObserver.listenersToRemove.add( editor.registerCommand( SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, @@ -766,11 +818,39 @@ export function applyTableHandlers( () => { const selection = $getSelection(); const prevSelection = $getPreviousSelection(); + const nextFocus = tableObserver.getAndClearNextFocus(); + if (nextFocus !== null) { + const {focusCell} = nextFocus; + if ( + $isTableSelection(selection) && + selection.tableKey === tableObserver.tableNodeKey + ) { + if ( + focusCell.x === tableObserver.focusX && + focusCell.y === tableObserver.focusY + ) { + // The selection is already the correct table selection + return false; + } else { + tableObserver.$setFocusCellForSelection(focusCell); + return true; + } + } else if ( + focusCell !== tableObserver.anchorCell && + $isSelectionInTable(selection, tableNode) + ) { + // The selection has crossed cells + tableObserver.$setFocusCellForSelection(focusCell); + return true; + } + } + const shouldCheckSelection = + tableObserver.getAndClearShouldCheckSelection(); // If they pressed the down arrow with the selection outside of the // table, and then the selection ends up in the table but not in the // first cell, then move the selection to the first cell. if ( - tableObserver.getAndClearShouldCheckSelection() && + shouldCheckSelection && $isRangeSelection(prevSelection) && $isRangeSelection(selection) && selection.isCollapsed() @@ -810,11 +890,11 @@ export function applyTableHandlers( const isFocusInside = !!( focusCellNode && tableNode.is($findTableNode(focusCellNode)) ); - const isPartialyWithinTable = isAnchorInside !== isFocusInside; + const isPartiallyWithinTable = isAnchorInside !== isFocusInside; const isWithinTable = isAnchorInside && isFocusInside; const isBackward = selection.isBackward(); - if (isPartialyWithinTable) { + if (isPartiallyWithinTable) { const newSelection = selection.clone(); if (isFocusInside) { const [tableMap] = $computeTableMap( @@ -855,23 +935,21 @@ export function applyTableHandlers( $addHighlightStyleToTable(editor, tableObserver); } else if (isWithinTable) { // Handle case when selection spans across multiple cells but still - // has range selection, then we convert it into grid selection + // has range selection, then we convert it into table selection if (!anchorCellNode.is(focusCellNode)) { - tableObserver.setAnchorCellForSelection( - getObserverCellFromCellNode(anchorCellNode), + tableObserver.$setAnchorCellForSelection( + $getObserverCellFromCellNodeOrThrow( + tableObserver, + anchorCellNode, + ), ); - tableObserver.setFocusCellForSelection( - getObserverCellFromCellNode(focusCellNode), + tableObserver.$setFocusCellForSelection( + $getObserverCellFromCellNodeOrThrow( + tableObserver, + focusCellNode, + ), true, ); - if (!tableObserver.isSelecting) { - setTimeout(() => { - const {onMouseUp, onMouseMove} = createMouseHandlers(); - tableObserver.isSelecting = true; - editorWindow.addEventListener('mouseup', onMouseUp); - editorWindow.addEventListener('mousemove', onMouseMove); - }, 0); - } } } } else if ( @@ -881,7 +959,7 @@ export function applyTableHandlers( selection.tableKey === tableNode.getKey() ) { // if selection goes outside of the table we need to change it to Range selection - const domSelection = getDOMSelection(editor._window); + const domSelection = getDOMSelection(editorWindow); if ( domSelection && domSelection.anchorNode && @@ -932,13 +1010,13 @@ export function applyTableHandlers( $isTableSelection(selection) && selection.tableKey === tableObserver.tableNodeKey ) { - tableObserver.updateTableTableSelection(selection); + tableObserver.$updateTableTableSelection(selection); } else if ( !$isTableSelection(selection) && $isTableSelection(prevSelection) && prevSelection.tableKey === tableObserver.tableNodeKey ) { - tableObserver.updateTableTableSelection(null); + tableObserver.$updateTableTableSelection(null); } return false; } @@ -1195,7 +1273,7 @@ export function $addHighlightStyleToTable( editor: LexicalEditor, tableSelection: TableObserver, ) { - tableSelection.disableHighlightStyle(); + tableSelection.$disableHighlightStyle(); $forEachTableCell(tableSelection.table, (cell) => { cell.highlighted = true; $addHighlightToDOM(editor, cell); @@ -1206,7 +1284,7 @@ export function $removeHighlightStyleToTable( editor: LexicalEditor, tableObserver: TableObserver, ) { - tableObserver.enableHighlightStyle(); + tableObserver.$enableHighlightStyle(); $forEachTableCell(tableObserver.table, (cell) => { const elem = cell.elem; cell.highlighted = false; @@ -1288,53 +1366,170 @@ const selectTableNodeInDirection = ( } }; -const adjustFocusNodeInDirection = ( - tableObserver: TableObserver, - tableNode: TableNode, - x: number, - y: number, - direction: Direction, -): boolean => { - const isForward = direction === 'forward'; +type Corner = ['minColumn' | 'maxColumn', 'minRow' | 'maxRow']; +function getCorner( + rect: TableCellRectBoundary, + cellValue: TableMapValueType, +): Corner | null { + let colName: 'minColumn' | 'maxColumn'; + let rowName: 'minRow' | 'maxRow'; + if (cellValue.startColumn === rect.minColumn) { + colName = 'minColumn'; + } else if ( + cellValue.startColumn + cellValue.cell.__colSpan - 1 === + rect.maxColumn + ) { + colName = 'maxColumn'; + } else { + return null; + } + if (cellValue.startRow === rect.minRow) { + rowName = 'minRow'; + } else if ( + cellValue.startRow + cellValue.cell.__rowSpan - 1 === + rect.maxRow + ) { + rowName = 'maxRow'; + } else { + return null; + } + return [colName, rowName]; +} - switch (direction) { - case 'backward': - case 'forward': - if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) { - tableObserver.setFocusCellForSelection( - tableNode.getDOMCellFromCordsOrThrow( - x + (isForward ? 1 : -1), - y, - tableObserver.table, - ), - ); - } +function getCornerOrThrow( + rect: TableCellRectBoundary, + cellValue: TableMapValueType, +): Corner { + const corner = getCorner(rect, cellValue); + invariant( + corner !== null, + 'getCornerOrThrow: cell %s is not at a corner of rect', + cellValue.cell.getKey(), + ); + return corner; +} - return true; - case 'up': - if (y !== 0) { - tableObserver.setFocusCellForSelection( - tableNode.getDOMCellFromCordsOrThrow(x, y - 1, tableObserver.table), - ); +function oppositeCorner([colName, rowName]: Corner): Corner { + return [ + colName === 'minColumn' ? 'maxColumn' : 'minColumn', + rowName === 'minRow' ? 'maxRow' : 'minRow', + ]; +} - return true; - } else { - return false; - } - case 'down': - if (y !== tableObserver.table.rows - 1) { - tableObserver.setFocusCellForSelection( - tableNode.getDOMCellFromCordsOrThrow(x, y + 1, tableObserver.table), - ); +function cellAtCornerOrThrow( + tableMap: TableMapType, + rect: TableCellRectBoundary, + [colName, rowName]: Corner, +): TableMapValueType { + const rowNum = rect[rowName]; + const rowMap = tableMap[rowNum]; + invariant( + rowMap !== undefined, + 'cellAtCornerOrThrow: %s = %s missing in tableMap', + rowName, + String(rowNum), + ); + const colNum = rect[colName]; + const cell = rowMap[colNum]; + invariant( + cell !== undefined, + 'cellAtCornerOrThrow: %s = %s missing in tableMap', + colName, + String(colNum), + ); + return cell; +} - return true; - } else { - return false; - } - default: - return false; +function $extractRectCorners( + tableMap: TableMapType, + anchorCellValue: TableMapValueType, + newFocusCellValue: TableMapValueType, +) { + // We are sure that the focus now either contracts or expands the rect + // but both the anchor and focus might be moved to ensure a rectangle + // given a potentially ragged merge shape + const rect = $computeTableCellRectBoundary( + tableMap, + anchorCellValue, + newFocusCellValue, + ); + const anchorCorner = getCorner(rect, anchorCellValue); + if (anchorCorner) { + return [ + cellAtCornerOrThrow(tableMap, rect, anchorCorner), + cellAtCornerOrThrow(tableMap, rect, oppositeCorner(anchorCorner)), + ]; } -}; + const newFocusCorner = getCorner(rect, newFocusCellValue); + if (newFocusCorner) { + return [ + cellAtCornerOrThrow(tableMap, rect, oppositeCorner(newFocusCorner)), + cellAtCornerOrThrow(tableMap, rect, newFocusCorner), + ]; + } + // TODO this doesn't have to be arbitrary, use the closest corner instead + const newAnchorCorner: Corner = ['minColumn', 'minRow']; + return [ + cellAtCornerOrThrow(tableMap, rect, newAnchorCorner), + cellAtCornerOrThrow(tableMap, rect, oppositeCorner(newAnchorCorner)), + ]; +} + +function $adjustFocusInDirection( + tableObserver: TableObserver, + tableMap: TableMapType, + anchorCellValue: TableMapValueType, + focusCellValue: TableMapValueType, + direction: Direction, +): boolean { + const rect = $computeTableCellRectBoundary( + tableMap, + anchorCellValue, + focusCellValue, + ); + const spans = $computeTableCellRectSpans(tableMap, rect); + const {topSpan, leftSpan, bottomSpan, rightSpan} = spans; + const anchorCorner = getCornerOrThrow(rect, anchorCellValue); + const [focusColumn, focusRow] = oppositeCorner(anchorCorner); + let fCol = rect[focusColumn]; + let fRow = rect[focusRow]; + if (direction === 'forward') { + fCol += focusColumn === 'maxColumn' ? 1 : leftSpan; + } else if (direction === 'backward') { + fCol -= focusColumn === 'minColumn' ? 1 : rightSpan; + } else if (direction === 'down') { + fRow += focusRow === 'maxRow' ? 1 : topSpan; + } else if (direction === 'up') { + fRow -= focusRow === 'minRow' ? 1 : bottomSpan; + } + const targetRowMap = tableMap[fRow]; + if (targetRowMap === undefined) { + return false; + } + const newFocusCellValue = targetRowMap[fCol]; + if (newFocusCellValue === undefined) { + return false; + } + // We can be certain that anchorCellValue and newFocusCellValue are + // contained within the desired selection, but we are not certain if + // they need to be expanded or not to maintain a rectangular shape + const [finalAnchorCell, finalFocusCell] = $extractRectCorners( + tableMap, + anchorCellValue, + newFocusCellValue, + ); + const anchorDOM = $getObserverCellFromCellNodeOrThrow( + tableObserver, + finalAnchorCell.cell, + )!; + const focusDOM = $getObserverCellFromCellNodeOrThrow( + tableObserver, + finalFocusCell.cell, + ); + tableObserver.$setAnchorCellForSelection(anchorDOM); + tableObserver.$setFocusCellForSelection(focusDOM, true); + return true; +} function $isSelectionInTable( selection: null | BaseSelection, @@ -1545,8 +1740,8 @@ function $handleArrowKey( lastCellCoords.y, tableObserver.table, ); - tableObserver.setAnchorCellForSelection(firstCellDOM); - tableObserver.setFocusCellForSelection(lastCellDOM, true); + tableObserver.$setAnchorCellForSelection(firstCellDOM); + tableObserver.$setFocusCellForSelection(lastCellDOM, true); return true; } } @@ -1650,7 +1845,13 @@ function $handleArrowKey( if ( isExitingTableAnchor(anchorType, anchorOffset, anchorNode, direction) ) { - return $handleTableExit(event, anchorNode, tableNode, direction); + return $handleTableExit( + event, + anchorNode, + anchorCellNode, + tableNode, + direction, + ); } return false; @@ -1666,7 +1867,7 @@ function $handleArrowKey( if (anchor.type === 'element') { edgeSelectionRect = anchorDOM.getBoundingClientRect(); } else { - const domSelection = window.getSelection(); + const domSelection = getDOMSelection(getEditorWindow(editor)); if (domSelection === null || domSelection.rangeCount === 0) { return false; } @@ -1709,8 +1910,8 @@ function $handleArrowKey( cords.y, tableObserver.table, ); - tableObserver.setAnchorCellForSelection(cell); - tableObserver.setFocusCellForSelection(cell, true); + tableObserver.$setAnchorCellForSelection(cell); + tableObserver.$setFocusCellForSelection(cell, true); } else { return selectTableNodeInDirection( tableObserver, @@ -1751,7 +1952,7 @@ function $handleArrowKey( ) { return false; } - tableObserver.updateTableTableSelection(selection); + tableObserver.$updateTableTableSelection(selection); const grid = getTable(tableNodeFromSelection, tableElement); const cordsAnchor = tableNode.getCordsFromCellNode(anchorCellNode, grid); @@ -1760,17 +1961,21 @@ function $handleArrowKey( cordsAnchor.y, grid, ); - tableObserver.setAnchorCellForSelection(anchorCell); + tableObserver.$setAnchorCellForSelection(anchorCell); stopEvent(event); if (event.shiftKey) { - const cords = tableNode.getCordsFromCellNode(focusCellNode, grid); - return adjustFocusNodeInDirection( + const [tableMap, anchorValue, focusValue] = $computeTableMap( + tableNode, + anchorCellNode, + focusCellNode, + ); + return $adjustFocusInDirection( tableObserver, - tableNodeFromSelection, - cords.x, - cords.y, + tableMap, + anchorValue, + focusValue, direction, ); } else { @@ -1856,13 +2061,10 @@ function $isExitingTableTextAnchor( function $handleTableExit( event: KeyboardEvent, anchorNode: LexicalNode, + anchorCellNode: TableCellNode, tableNode: TableNode, direction: 'backward' | 'forward', -) { - const anchorCellNode = $findMatchingParent(anchorNode, $isTableCellNode); - if (!$isTableCellNode(anchorCellNode)) { - return false; - } +): boolean { const [tableMap, cellValue] = $computeTableMap( tableNode, anchorCellNode, @@ -1948,7 +2150,7 @@ function $getTableEdgeCursorPosition( } // TODO: Add support for nested tables - const domSelection = window.getSelection(); + const domSelection = getDOMSelection(getEditorWindow(editor)); if (!domSelection) { return undefined; } @@ -2009,3 +2211,19 @@ function $getTableEdgeCursorPosition( return undefined; } } + +export function $getObserverCellFromCellNodeOrThrow( + tableObserver: TableObserver, + tableCellNode: TableCellNode, +): TableDOMCell { + const {tableNode} = tableObserver.$lookup(); + const currentCords = tableNode.getCordsFromCellNode( + tableCellNode, + tableObserver.table, + ); + return tableNode.getDOMCellFromCordsOrThrow( + currentCords.x, + currentCords.y, + tableObserver.table, + ); +} diff --git a/packages/lexical-table/src/LexicalTableUtils.ts b/packages/lexical-table/src/LexicalTableUtils.ts index c2cce0126a1..e1c0c0884cd 100644 --- a/packages/lexical-table/src/LexicalTableUtils.ts +++ b/packages/lexical-table/src/LexicalTableUtils.ts @@ -788,12 +788,12 @@ export function $unmergeCell(): void { } export function $computeTableMap( - grid: TableNode, + tableNode: TableNode, cellA: TableCellNode, cellB: TableCellNode, ): [TableMapType, TableMapValueType, TableMapValueType] { const [tableMap, cellAValue, cellBValue] = $computeTableMapSkipCellCheck( - grid, + tableNode, cellA, cellB, ); @@ -803,10 +803,14 @@ export function $computeTableMap( } export function $computeTableMapSkipCellCheck( - grid: TableNode, + tableNode: TableNode, cellA: null | TableCellNode, cellB: null | TableCellNode, -): [TableMapType, TableMapValueType | null, TableMapValueType | null] { +): [ + tableMap: TableMapType, + cellAValue: TableMapValueType | null, + cellBValue: TableMapValueType | null, +] { const tableMap: TableMapType = []; let cellAValue: null | TableMapValueType = null; let cellBValue: null | TableMapValueType = null; @@ -817,7 +821,7 @@ export function $computeTableMapSkipCellCheck( } return row; } - const gridChildren = grid.getChildren(); + const gridChildren = tableNode.getChildren(); for (let rowIdx = 0; rowIdx < gridChildren.length; rowIdx++) { const row = gridChildren[rowIdx]; invariant( @@ -905,6 +909,118 @@ export function $getNodeTriplet( return [cell, row, grid]; } +export interface TableCellRectBoundary { + minColumn: number; + minRow: number; + maxColumn: number; + maxRow: number; +} + +export interface TableCellRectSpans { + topSpan: number; + leftSpan: number; + rightSpan: number; + bottomSpan: number; +} + +export function $computeTableCellRectSpans( + map: TableMapType, + boundary: TableCellRectBoundary, +): TableCellRectSpans { + const {minColumn, maxColumn, minRow, maxRow} = boundary; + let topSpan = 1; + let leftSpan = 1; + let rightSpan = 1; + let bottomSpan = 1; + const topRow = map[minRow]; + const bottomRow = map[maxRow]; + for (let col = minColumn; col <= maxColumn; col++) { + topSpan = Math.max(topSpan, topRow[col].cell.__rowSpan); + bottomSpan = Math.max(bottomSpan, bottomRow[col].cell.__rowSpan); + } + for (let row = minRow; row <= maxRow; row++) { + leftSpan = Math.max(leftSpan, map[row][minColumn].cell.__colSpan); + rightSpan = Math.max(rightSpan, map[row][maxColumn].cell.__colSpan); + } + return {bottomSpan, leftSpan, rightSpan, topSpan}; +} + +export function $computeTableCellRectBoundary( + map: TableMapType, + cellAMap: TableMapValueType, + cellBMap: TableMapValueType, +): TableCellRectBoundary { + let minColumn = Math.min(cellAMap.startColumn, cellBMap.startColumn); + let minRow = Math.min(cellAMap.startRow, cellBMap.startRow); + let maxColumn = Math.max( + cellAMap.startColumn + cellAMap.cell.__colSpan - 1, + cellBMap.startColumn + cellBMap.cell.__colSpan - 1, + ); + let maxRow = Math.max( + cellAMap.startRow + cellAMap.cell.__rowSpan - 1, + cellBMap.startRow + cellBMap.cell.__rowSpan - 1, + ); + let exploredMinColumn = minColumn; + let exploredMinRow = minRow; + let exploredMaxColumn = minColumn; + let exploredMaxRow = minRow; + function expandBoundary(mapValue: TableMapValueType): void { + const { + cell, + startColumn: cellStartColumn, + startRow: cellStartRow, + } = mapValue; + minColumn = Math.min(minColumn, cellStartColumn); + minRow = Math.min(minRow, cellStartRow); + maxColumn = Math.max(maxColumn, cellStartColumn + cell.__colSpan - 1); + maxRow = Math.max(maxRow, cellStartRow + cell.__rowSpan - 1); + } + while ( + minColumn < exploredMinColumn || + minRow < exploredMinRow || + maxColumn > exploredMaxColumn || + maxRow > exploredMaxRow + ) { + if (minColumn < exploredMinColumn) { + // Expand on the left + const rowDiff = exploredMaxRow - exploredMinRow; + const previousColumn = exploredMinColumn - 1; + for (let i = 0; i <= rowDiff; i++) { + expandBoundary(map[exploredMinRow + i][previousColumn]); + } + exploredMinColumn = previousColumn; + } + if (minRow < exploredMinRow) { + // Expand on top + const columnDiff = exploredMaxColumn - exploredMinColumn; + const previousRow = exploredMinRow - 1; + for (let i = 0; i <= columnDiff; i++) { + expandBoundary(map[previousRow][exploredMinColumn + i]); + } + exploredMinRow = previousRow; + } + if (maxColumn > exploredMaxColumn) { + // Expand on the right + const rowDiff = exploredMaxRow - exploredMinRow; + const nextColumn = exploredMaxColumn + 1; + for (let i = 0; i <= rowDiff; i++) { + expandBoundary(map[exploredMinRow + i][nextColumn]); + } + exploredMaxColumn = nextColumn; + } + if (maxRow > exploredMaxRow) { + // Expand on the bottom + const columnDiff = exploredMaxColumn - exploredMinColumn; + const nextRow = exploredMaxRow + 1; + for (let i = 0; i <= columnDiff; i++) { + expandBoundary(map[nextRow][exploredMinColumn + i]); + } + exploredMaxRow = nextRow; + } + } + return {maxColumn, maxRow, minColumn, minRow}; +} + export function $getTableCellNodeRect(tableCellNode: TableCellNode): { rowIndex: number; columnIndex: number; diff --git a/packages/lexical/src/LexicalEvents.ts b/packages/lexical/src/LexicalEvents.ts index b6c46fbdeeb..663fbb236a4 100644 --- a/packages/lexical/src/LexicalEvents.ts +++ b/packages/lexical/src/LexicalEvents.ts @@ -26,7 +26,6 @@ import { $getRoot, $getSelection, $isElementNode, - $isNodeSelection, $isRangeSelection, $isRootNode, $isTextNode, @@ -437,7 +436,7 @@ function onClick(event: PointerEvent, editor: LexicalEditor): void { domSelection.removeAllRanges(); selection.dirty = true; } else if (event.detail === 3 && !selection.isCollapsed()) { - // Tripple click causing selection to overflow into the nearest element. In that + // Triple click causing selection to overflow into the nearest element. In that // case visually it looks like a single element content is selected, focus node // is actually at the beginning of the next element (if present) and any manipulations // with selection (formatting) are affecting second element as well @@ -1089,7 +1088,8 @@ function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void { dispatchCommand(editor, REDO_COMMAND, undefined); } else { const prevSelection = editor._editorState._selection; - if ($isNodeSelection(prevSelection)) { + if (prevSelection !== null && !$isRangeSelection(prevSelection)) { + // Only RangeSelection can use the native cut/copy/select all if (isCopy(key, shiftKey, metaKey, ctrlKey)) { event.preventDefault(); dispatchCommand(editor, COPY_COMMAND, event); diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index dc3baa6fd4f..28b4f57e943 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -1615,6 +1615,13 @@ export function updateDOMBlockCursorElement( } } +/** + * Returns the selection for the given window, or the global window if null. + * Will return null if {@link CAN_USE_DOM} is false. + * + * @param targetWindow The window to get the selection from + * @returns a Selection or null + */ export function getDOMSelection(targetWindow: null | Window): null | Selection { return !CAN_USE_DOM ? null : (targetWindow || window).getSelection(); } diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 2898a73a9b7..5f1eae58210 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -179,6 +179,7 @@ export { $setCompositionKey, $setSelection, $splitNode, + getDOMSelection, getEditorPropertyFromDOMNode, getNearestEditorFromDOMNode, isBlockDomNode, diff --git a/packages/shared/viteModuleResolution.ts b/packages/shared/viteModuleResolution.ts index 572e1e52dfd..1764b008640 100644 --- a/packages/shared/viteModuleResolution.ts +++ b/packages/shared/viteModuleResolution.ts @@ -11,6 +11,7 @@ import type { NpmModuleExportEntry, PackageMetadata, } from '../../scripts/shared/PackageMetadata'; +import type {Alias} from 'vite'; import * as fs from 'node:fs'; import {createRequire} from 'node:module'; @@ -81,7 +82,7 @@ const distModuleResolution = (environment: 'development' | 'production') => { export default function moduleResolution( environment: 'source' | 'development' | 'production', -) { +): Alias[] { return environment === 'source' ? sourceModuleResolution() : distModuleResolution(environment); From 89af9942b8f80f0c1df35392ab7768b50b9173ba Mon Sep 17 00:00:00 2001 From: Hamza <40746210+hamza221@users.noreply.github.com> Date: Mon, 25 Nov 2024 09:07:31 +0800 Subject: [PATCH 3/4] [lexical-table] Bug Fix: get table-cell background selection color from a class (#6658) Signed-off-by: hamza221 Signed-off-by: Hamza Mahjoubi Co-authored-by: Ivaylo Pavlov Co-authored-by: Bob Ippolito --- examples/react-table/src/ExampleTheme.ts | 4 - examples/react-table/src/styles.css | 33 +++---- .../html/TablesHTMLCopyAndPaste.spec.mjs | 48 ++++------ .../__tests__/e2e/Indentation.spec.mjs | 15 ++-- .../__tests__/e2e/Tables.spec.mjs | 88 +++++++------------ .../src/themes/PlaygroundEditorTheme.css | 33 +++---- .../src/themes/PlaygroundEditorTheme.ts | 4 - .../lexical-table/src/LexicalTableCellNode.ts | 38 ++++---- .../src/LexicalTableSelectionHelpers.ts | 28 +++--- packages/lexical/src/LexicalEditor.ts | 4 - 10 files changed, 100 insertions(+), 195 deletions(-) diff --git a/examples/react-table/src/ExampleTheme.ts b/examples/react-table/src/ExampleTheme.ts index ca6919c8757..3033445c2df 100644 --- a/examples/react-table/src/ExampleTheme.ts +++ b/examples/react-table/src/ExampleTheme.ts @@ -34,13 +34,9 @@ export default { tableCellActionButton: 'ExampleEditorTheme__tableCellActionButton', tableCellActionButtonContainer: 'ExampleEditorTheme__tableCellActionButtonContainer', - tableCellEditing: 'ExampleEditorTheme__tableCellEditing', tableCellHeader: 'ExampleEditorTheme__tableCellHeader', - tableCellPrimarySelected: 'ExampleEditorTheme__tableCellPrimarySelected', tableCellResizer: 'ExampleEditorTheme__tableCellResizer', tableCellSelected: 'ExampleEditorTheme__tableCellSelected', - tableCellSortedIndicator: 'ExampleEditorTheme__tableCellSortedIndicator', - tableResizeRuler: 'ExampleEditorTheme__tableCellResizeRuler', tableSelected: 'ExampleEditorTheme__tableSelected', tableSelection: 'ExampleEditorTheme__tableSelection', text: { diff --git a/examples/react-table/src/styles.css b/examples/react-table/src/styles.css index c6832a104f4..ceade988694 100644 --- a/examples/react-table/src/styles.css +++ b/examples/react-table/src/styles.css @@ -474,16 +474,6 @@ i.justify-align { position: relative; outline: none; } -.ExampleEditorTheme__tableCellSortedIndicator { - display: block; - opacity: 0.5; - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 4px; - background-color: #999; -} .ExampleEditorTheme__tableCellResizer { position: absolute; right: -4px; @@ -498,21 +488,18 @@ i.justify-align { text-align: start; } .ExampleEditorTheme__tableCellSelected { - background-color: #c9dbf0; + caret-color: transparent; } -.ExampleEditorTheme__tableCellPrimarySelected { - border: 2px solid rgb(60, 132, 244); - display: block; - height: calc(100% - 2px); +.ExampleEditorTheme__tableCellSelected::after { position: absolute; - width: calc(100% - 2px); - left: -1px; - top: -1px; - z-index: 2; -} -.ExampleEditorTheme__tableCellEditing { - box-shadow: 0 0 5px rgba(0, 0, 0, 0.4); - border-radius: 3px; + left: 0; + right: 0; + bottom: 0; + top: 0; + background-color: rgb(172, 206, 247); + opacity: 0.6; + content: ''; + pointer-events: none; } .ExampleEditorTheme__tableAddColumns { position: absolute; diff --git a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs index 8ac28284f78..a2c9e0bf610 100644 --- a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs @@ -302,8 +302,7 @@ test.describe('HTML Tables CopyAndPaste', () => { + class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellHeader PlaygroundEditorTheme__tableCellSelected">

@@ -311,8 +310,7 @@ test.describe('HTML Tables CopyAndPaste', () => {

+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellHeader PlaygroundEditorTheme__tableCellSelected">

@@ -320,20 +318,17 @@ test.describe('HTML Tables CopyAndPaste', () => {

+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellHeader PlaygroundEditorTheme__tableCellSelected">


+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellHeader PlaygroundEditorTheme__tableCellSelected">


+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellHeader PlaygroundEditorTheme__tableCellSelected">

@@ -341,8 +336,7 @@ test.describe('HTML Tables CopyAndPaste', () => {

+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellSelected">

{

+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellSelected">


+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellSelected">


+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellHeader PlaygroundEditorTheme__tableCellSelected">


+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellSelected">


+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellSelected">


+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellSelected">


+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellHeader PlaygroundEditorTheme__tableCellSelected">


+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellSelected">


+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellSelected">


+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellSelected">


diff --git a/packages/lexical-playground/__tests__/e2e/Indentation.spec.mjs b/packages/lexical-playground/__tests__/e2e/Indentation.spec.mjs index 58a0b41af91..aee91bc356b 100644 --- a/packages/lexical-playground/__tests__/e2e/Indentation.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Indentation.spec.mjs @@ -113,8 +113,7 @@ test.describe('Identation', () => { + class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellHeader PlaygroundEditorTheme__tableCellSelected">

@@ -198,8 +197,7 @@ test.describe('Identation', () => { + class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellHeader PlaygroundEditorTheme__tableCellSelected">

{ + class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellHeader PlaygroundEditorTheme__tableCellSelected">

{ + class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellHeader PlaygroundEditorTheme__tableCellSelected">

{ + class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellHeader PlaygroundEditorTheme__tableCellSelected">

{ - +

a

- +

bb

@@ -772,12 +770,10 @@ test.describe.parallel('Tables', () => { - +

d

- +

e

@@ -873,12 +869,10 @@ test.describe.parallel('Tables', () => { - +

a

- +

bb

@@ -886,12 +880,10 @@ test.describe.parallel('Tables', () => { - +

d

- +

e

@@ -997,12 +989,10 @@ test.describe.parallel('Tables', () => { - +

a

- +

bb

@@ -1010,12 +1000,10 @@ test.describe.parallel('Tables', () => { - +

d

- +

e

@@ -1448,12 +1436,10 @@ test.describe.parallel('Tables', () => { - +


- +


@@ -1508,30 +1494,24 @@ test.describe.parallel('Tables', () => { - +


- +


- +


- +


- +


- +


@@ -2608,27 +2588,23 @@ test.describe.parallel('Tables', () => { + class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellHeader PlaygroundEditorTheme__tableCellSelected" + rowspan="2">


+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellHeader PlaygroundEditorTheme__tableCellSelected" + colspan="2">


+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellSelected">


+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellSelected">


@@ -3690,13 +3666,15 @@ test.describe.parallel('Tables', () => { + class="PlaygroundEditorTheme__tableCellSelected" + style="text-align: center">

a

+ class="PlaygroundEditorTheme__tableCellSelected" + style="text-align: center">

bb

@@ -3707,13 +3685,15 @@ test.describe.parallel('Tables', () => { + class="PlaygroundEditorTheme__tableCellSelected" + style="text-align: center">

d

+ class="PlaygroundEditorTheme__tableCellSelected" + style="text-align: center">

e

diff --git a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css index cbed93864d1..657814a0041 100644 --- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css +++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css @@ -151,16 +151,6 @@ position: relative; outline: none; } -.PlaygroundEditorTheme__tableCellSortedIndicator { - display: block; - opacity: 0.5; - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 4px; - background-color: #999; -} .PlaygroundEditorTheme__tableCellResizer { position: absolute; right: -4px; @@ -175,21 +165,18 @@ text-align: start; } .PlaygroundEditorTheme__tableCellSelected { - background-color: #c9dbf0; + caret-color: transparent; } -.PlaygroundEditorTheme__tableCellPrimarySelected { - border: 2px solid rgb(60, 132, 244); - display: block; - height: calc(100% - 2px); +.PlaygroundEditorTheme__tableCellSelected::after { position: absolute; - width: calc(100% - 2px); - left: -1px; - top: -1px; - z-index: 2; -} -.PlaygroundEditorTheme__tableCellEditing { - box-shadow: 0 0 5px rgba(0, 0, 0, 0.4); - border-radius: 3px; + left: 0; + right: 0; + bottom: 0; + top: 0; + background-color: rgb(172, 206, 247); + opacity: 0.6; + content: ''; + pointer-events: none; } .PlaygroundEditorTheme__tableAddColumns { position: absolute; diff --git a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts index e1c87638895..882bb879898 100644 --- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts +++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts @@ -95,13 +95,9 @@ const theme: EditorThemeClasses = { tableCellActionButton: 'PlaygroundEditorTheme__tableCellActionButton', tableCellActionButtonContainer: 'PlaygroundEditorTheme__tableCellActionButtonContainer', - tableCellEditing: 'PlaygroundEditorTheme__tableCellEditing', tableCellHeader: 'PlaygroundEditorTheme__tableCellHeader', - tableCellPrimarySelected: 'PlaygroundEditorTheme__tableCellPrimarySelected', tableCellResizer: 'PlaygroundEditorTheme__tableCellResizer', tableCellSelected: 'PlaygroundEditorTheme__tableCellSelected', - tableCellSortedIndicator: 'PlaygroundEditorTheme__tableCellSortedIndicator', - tableResizeRuler: 'PlaygroundEditorTheme__tableCellResizeRuler', tableRowStriping: 'PlaygroundEditorTheme__tableRowStriping', tableScrollableWrapper: 'PlaygroundEditorTheme__tableScrollableWrapper', tableSelected: 'PlaygroundEditorTheme__tableSelected', diff --git a/packages/lexical-table/src/LexicalTableCellNode.ts b/packages/lexical-table/src/LexicalTableCellNode.ts index 525a8bce82c..c43e7fe1c9e 100644 --- a/packages/lexical-table/src/LexicalTableCellNode.ts +++ b/packages/lexical-table/src/LexicalTableCellNode.ts @@ -122,10 +122,8 @@ export class TableCellNode extends ElementNode { this.__backgroundColor = null; } - createDOM(config: EditorConfig): HTMLElement { - const element = document.createElement( - this.getTag(), - ) as HTMLTableCellElement; + createDOM(config: EditorConfig): HTMLTableCellElement { + const element = document.createElement(this.getTag()); if (this.__width) { element.style.width = `${this.__width}px`; @@ -150,33 +148,27 @@ export class TableCellNode extends ElementNode { } exportDOM(editor: LexicalEditor): DOMExportOutput { - const {element} = super.exportDOM(editor); + const output = super.exportDOM(editor); - if (element) { - const element_ = element as HTMLTableCellElement; - element_.style.border = '1px solid black'; + if (output.element) { + const element = output.element as HTMLTableCellElement; + element.style.border = '1px solid black'; if (this.__colSpan > 1) { - element_.colSpan = this.__colSpan; + element.colSpan = this.__colSpan; } if (this.__rowSpan > 1) { - element_.rowSpan = this.__rowSpan; + element.rowSpan = this.__rowSpan; } - element_.style.width = `${this.getWidth() || COLUMN_WIDTH}px`; + element.style.width = `${this.getWidth() || COLUMN_WIDTH}px`; - element_.style.verticalAlign = 'top'; - element_.style.textAlign = 'start'; - - const backgroundColor = this.getBackgroundColor(); - if (backgroundColor !== null) { - element_.style.backgroundColor = backgroundColor; - } else if (this.hasHeader()) { - element_.style.backgroundColor = '#f2f3f5'; + element.style.verticalAlign = 'top'; + element.style.textAlign = 'start'; + if (this.__backgroundColor === null && this.hasHeader()) { + element.style.backgroundColor = '#f2f3f5'; } } - return { - element, - }; + return output; } exportJSON(): SerializedTableCellNode { @@ -211,7 +203,7 @@ export class TableCellNode extends ElementNode { return self; } - getTag(): string { + getTag(): 'th' | 'td' { return this.hasHeader() ? 'th' : 'td'; } diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index 2a1e4945037..4bb6d3944c6 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -27,7 +27,12 @@ import { $getClipboardDataFromSelection, copyToClipboard, } from '@lexical/clipboard'; -import {$findMatchingParent, objectKlassEquals} from '@lexical/utils'; +import { + $findMatchingParent, + addClassNamesToElement, + objectKlassEquals, + removeClassNamesFromElement, +} from '@lexical/utils'; import { $createParagraphNode, $createRangeSelectionFromDom, @@ -1553,24 +1558,15 @@ function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean) { } } -const BROWSER_BLUE_RGB = '172,206,247'; function $addHighlightToDOM(editor: LexicalEditor, cell: TableDOMCell): void { const element = cell.elem; + const editorThemeClasses = editor._config.theme; const node = $getNearestNodeFromDOMNode(element); invariant( $isTableCellNode(node), 'Expected to find LexicalNode from Table Cell DOMNode', ); - const backgroundColor = node.getBackgroundColor(); - if (backgroundColor === null) { - element.style.setProperty('background-color', `rgb(${BROWSER_BLUE_RGB})`); - } else { - element.style.setProperty( - 'background-image', - `linear-gradient(to right, rgba(${BROWSER_BLUE_RGB},0.85), rgba(${BROWSER_BLUE_RGB},0.85))`, - ); - } - element.style.setProperty('caret-color', 'transparent'); + addClassNamesToElement(element, editorThemeClasses.tableCellSelected); } function $removeHighlightFromDOM( @@ -1583,12 +1579,8 @@ function $removeHighlightFromDOM( $isTableCellNode(node), 'Expected to find LexicalNode from Table Cell DOMNode', ); - const backgroundColor = node.getBackgroundColor(); - if (backgroundColor === null) { - element.style.removeProperty('background-color'); - } - element.style.removeProperty('background-image'); - element.style.removeProperty('caret-color'); + const editorThemeClasses = editor._config.theme; + removeClassNamesFromElement(element, editorThemeClasses.tableCellSelected); } export function $findCellNode(node: LexicalNode): null | TableCellNode { diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 9223e571544..60722c6f32b 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -135,14 +135,10 @@ export type EditorThemeClasses = { tableAddRows?: EditorThemeClassName; tableCellActionButton?: EditorThemeClassName; tableCellActionButtonContainer?: EditorThemeClassName; - tableCellPrimarySelected?: EditorThemeClassName; tableCellSelected?: EditorThemeClassName; tableCell?: EditorThemeClassName; - tableCellEditing?: EditorThemeClassName; tableCellHeader?: EditorThemeClassName; tableCellResizer?: EditorThemeClassName; - tableCellSortedIndicator?: EditorThemeClassName; - tableResizeRuler?: EditorThemeClassName; tableRow?: EditorThemeClassName; tableScrollableWrapper?: EditorThemeClassName; tableSelected?: EditorThemeClassName; From 149949f8511bf9afac765c357bddf27239b10905 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 25 Nov 2024 16:34:11 +0100 Subject: [PATCH 4/4] [lexical-selection] Bug Fix: Wrong selection type in $setBlocksType (#6867) --- packages/lexical-selection/flow/LexicalSelection.js.flow | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lexical-selection/flow/LexicalSelection.js.flow b/packages/lexical-selection/flow/LexicalSelection.js.flow index d1d68d66376..bc70e777b53 100644 --- a/packages/lexical-selection/flow/LexicalSelection.js.flow +++ b/packages/lexical-selection/flow/LexicalSelection.js.flow @@ -51,7 +51,7 @@ declare export function $wrapNodes( wrappingElement?: ElementNode, ): void; declare export function $setBlocksType( - selection: RangeSelection, + selection: BaseSelection | null, createElement: () => ElementNode, ): void; declare export function $isAtNodeEnd(point: Point): boolean;