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/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/Autocomplete.spec.mjs b/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs
index 1f1cf21bcae..39d3ef16e50 100644
--- a/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs
@@ -6,6 +6,14 @@
*
*/
+import {
+ decreaseFontSize,
+ increaseFontSize,
+ toggleBold,
+ toggleItalic,
+ toggleStrikethrough,
+ toggleUnderline,
+} from '../keyboardShortcuts/index.mjs';
import {
assertHTML,
focusEditor,
@@ -30,14 +38,12 @@ test.describe('Autocomplete', () => {
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
Sort by alpha
-
-
- betical (TAB)
-
+
+ betical (TAB)
-
`,
html`
@@ -45,8 +51,7 @@ test.describe('Autocomplete', () => {
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
+ class="PlaygroundEditorTheme__tableCellSelected"
+ style="text-align: center">
d
|
+ class="PlaygroundEditorTheme__tableCellSelected"
+ style="text-align: center">
e
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/App.tsx b/packages/lexical-playground/src/App.tsx
index 6f20d8c3841..aa4852cf87e 100644
--- a/packages/lexical-playground/src/App.tsx
+++ b/packages/lexical-playground/src/App.tsx
@@ -22,7 +22,6 @@ import {
import {isDevPlayground} from './appSettings';
import {FlashMessageContext} from './context/FlashMessageContext';
import {SettingsContext, useSettings} from './context/SettingsContext';
-import {SharedAutocompleteContext} from './context/SharedAutocompleteContext';
import {SharedHistoryContext} from './context/SharedHistoryContext';
import {ToolbarContext} from './context/ToolbarContext';
import Editor from './Editor';
@@ -211,24 +210,22 @@ function App(): JSX.Element {
-
-
-
-
-
-
-
- {isDevPlayground ? : null}
- {isDevPlayground ? : null}
- {isDevPlayground ? : null}
+
+
+
+
+
+
+ {isDevPlayground ? : null}
+ {isDevPlayground ? : null}
+ {isDevPlayground ? : null}
- {measureTypingPerf ? : null}
-
-
+ {measureTypingPerf ? : null}
+
diff --git a/packages/lexical-playground/src/context/SharedAutocompleteContext.tsx b/packages/lexical-playground/src/context/SharedAutocompleteContext.tsx
deleted file mode 100644
index 4f282709eea..00000000000
--- a/packages/lexical-playground/src/context/SharedAutocompleteContext.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * 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 * as React from 'react';
-import {
- createContext,
- ReactNode,
- useContext,
- useEffect,
- useMemo,
- useState,
-} from 'react';
-
-type Suggestion = null | string;
-type CallbackFn = (newSuggestion: Suggestion) => void;
-type SubscribeFn = (callbackFn: CallbackFn) => () => void;
-type PublishFn = (newSuggestion: Suggestion) => void;
-type ContextShape = [SubscribeFn, PublishFn];
-type HookShape = [suggestion: Suggestion, setSuggestion: PublishFn];
-
-const Context: React.Context = createContext([
- (_cb) => () => {
- return;
- },
- (_newSuggestion: Suggestion) => {
- return;
- },
-]);
-
-export const SharedAutocompleteContext = ({
- children,
-}: {
- children: ReactNode;
-}): JSX.Element => {
- const context: ContextShape = useMemo(() => {
- let suggestion: Suggestion | null = null;
- const listeners: Set = new Set();
- return [
- (cb: (newSuggestion: Suggestion) => void) => {
- cb(suggestion);
- listeners.add(cb);
- return () => {
- listeners.delete(cb);
- };
- },
- (newSuggestion: Suggestion) => {
- suggestion = newSuggestion;
- for (const listener of listeners) {
- listener(newSuggestion);
- }
- },
- ];
- }, []);
- return {children};
-};
-
-export const useSharedAutocompleteContext = (): HookShape => {
- const [subscribe, publish]: ContextShape = useContext(Context);
- const [suggestion, setSuggestion] = useState(null);
- useEffect(() => {
- return subscribe((newSuggestion: Suggestion) => {
- setSuggestion(newSuggestion);
- });
- }, [subscribe]);
- return [suggestion, publish];
-};
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/nodes/AutocompleteNode.tsx b/packages/lexical-playground/src/nodes/AutocompleteNode.tsx
index f3eb6bd715a..220add6396c 100644
--- a/packages/lexical-playground/src/nodes/AutocompleteNode.tsx
+++ b/packages/lexical-playground/src/nodes/AutocompleteNode.tsx
@@ -7,36 +7,26 @@
*/
import type {
+ DOMExportOutput,
EditorConfig,
- EditorThemeClassName,
LexicalEditor,
NodeKey,
- SerializedLexicalNode,
+ SerializedTextNode,
Spread,
} from 'lexical';
-import {DecoratorNode} from 'lexical';
-import * as React from 'react';
+import {TextNode} from 'lexical';
-import {useSharedAutocompleteContext} from '../context/SharedAutocompleteContext';
import {uuid as UUID} from '../plugins/AutocompletePlugin';
-declare global {
- interface Navigator {
- userAgentData?: {
- mobile: boolean;
- };
- }
-}
-
export type SerializedAutocompleteNode = Spread<
{
uuid: string;
},
- SerializedLexicalNode
+ SerializedTextNode
>;
-export class AutocompleteNode extends DecoratorNode {
+export class AutocompleteNode extends TextNode {
/**
* A unique uuid is generated for each session and assigned to the instance.
* This helps to:
@@ -48,7 +38,7 @@ export class AutocompleteNode extends DecoratorNode {
__uuid: string;
static clone(node: AutocompleteNode): AutocompleteNode {
- return new AutocompleteNode(node.__uuid, node.__key);
+ return new AutocompleteNode(node.__text, node.__uuid, node.__key);
}
static getType(): 'autocomplete' {
@@ -58,7 +48,14 @@ export class AutocompleteNode extends DecoratorNode {
static importJSON(
serializedNode: SerializedAutocompleteNode,
): AutocompleteNode {
- const node = $createAutocompleteNode(serializedNode.uuid);
+ const node = $createAutocompleteNode(
+ serializedNode.text,
+ serializedNode.uuid,
+ );
+ node.setFormat(serializedNode.format);
+ node.setDetail(serializedNode.detail);
+ node.setMode(serializedNode.mode);
+ node.setStyle(serializedNode.style);
return node;
}
@@ -71,8 +68,8 @@ export class AutocompleteNode extends DecoratorNode {
};
}
- constructor(uuid: string, key?: NodeKey) {
- super(key);
+ constructor(text: string, uuid: string, key?: NodeKey) {
+ super(text, key);
this.__uuid = uuid;
}
@@ -84,36 +81,23 @@ export class AutocompleteNode extends DecoratorNode {
return false;
}
- createDOM(config: EditorConfig): HTMLElement {
- return document.createElement('span');
+ exportDOM(_: LexicalEditor): DOMExportOutput {
+ return {element: null};
}
- decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element | null {
+ createDOM(config: EditorConfig): HTMLElement {
if (this.__uuid !== UUID) {
- return null;
+ return document.createElement('span');
}
- return ;
+ const dom = super.createDOM(config);
+ dom.classList.add(config.theme.autocomplete);
+ return dom;
}
}
-export function $createAutocompleteNode(uuid: string): AutocompleteNode {
- return new AutocompleteNode(uuid);
-}
-
-function AutocompleteComponent({
- className,
-}: {
- className: EditorThemeClassName;
-}): JSX.Element {
- const [suggestion] = useSharedAutocompleteContext();
- const userAgentData = window.navigator.userAgentData;
- const isMobile =
- userAgentData !== undefined
- ? userAgentData.mobile
- : window.innerWidth <= 800 && window.innerHeight <= 600;
- return (
-
- {suggestion} {isMobile ? '(SWIPE \u2B95)' : '(TAB)'}
-
- );
+export function $createAutocompleteNode(
+ text: string,
+ uuid: string,
+): AutocompleteNode {
+ return new AutocompleteNode(text, uuid);
}
diff --git a/packages/lexical-playground/src/plugins/AutocompletePlugin/index.tsx b/packages/lexical-playground/src/plugins/AutocompletePlugin/index.tsx
index 7e32e1f4fb1..fa7d5fe5690 100644
--- a/packages/lexical-playground/src/plugins/AutocompletePlugin/index.tsx
+++ b/packages/lexical-playground/src/plugins/AutocompletePlugin/index.tsx
@@ -6,7 +6,7 @@
*
*/
-import type {BaseSelection, NodeKey} from 'lexical';
+import type {BaseSelection, NodeKey, TextNode} from 'lexical';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {$isAtNodeEnd} from '@lexical/selection';
@@ -24,13 +24,21 @@ import {
} from 'lexical';
import {useCallback, useEffect} from 'react';
-import {useSharedAutocompleteContext} from '../../context/SharedAutocompleteContext';
+import {useToolbarState} from '../../context/ToolbarContext';
import {
$createAutocompleteNode,
AutocompleteNode,
} from '../../nodes/AutocompleteNode';
import {addSwipeRightListener} from '../../utils/swipe';
+declare global {
+ interface Navigator {
+ userAgentData?: {
+ mobile: boolean;
+ };
+ }
+}
+
type SearchPromise = {
dismiss: () => void;
promise: Promise;
@@ -76,16 +84,27 @@ function useQuery(): (searchText: string) => SearchPromise {
}, []);
}
+function formatSuggestionText(suggestion: string): string {
+ const userAgentData = window.navigator.userAgentData;
+ const isMobile =
+ userAgentData !== undefined
+ ? userAgentData.mobile
+ : window.innerWidth <= 800 && window.innerHeight <= 600;
+
+ return `${suggestion} ${isMobile ? '(SWIPE \u2B95)' : '(TAB)'}`;
+}
+
export default function AutocompletePlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext();
- const [, setSuggestion] = useSharedAutocompleteContext();
const query = useQuery();
+ const {toolbarState} = useToolbarState();
useEffect(() => {
let autocompleteNodeKey: null | NodeKey = null;
let lastMatch: null | string = null;
let lastSuggestion: null | string = null;
let searchPromise: null | SearchPromise = null;
+ let prevNodeFormat: number = 0;
function $clearSuggestion() {
const autocompleteNode =
autocompleteNodeKey !== null
@@ -101,7 +120,7 @@ export default function AutocompletePlugin(): JSX.Element | null {
}
lastMatch = null;
lastSuggestion = null;
- setSuggestion(null);
+ prevNodeFormat = 0;
}
function updateAsyncSuggestion(
refSearchPromise: SearchPromise,
@@ -124,12 +143,18 @@ export default function AutocompletePlugin(): JSX.Element | null {
return;
}
const selectionCopy = selection.clone();
- const node = $createAutocompleteNode(uuid);
+ const prevNode = selection.getNodes()[0] as TextNode;
+ prevNodeFormat = prevNode.getFormat();
+ const node = $createAutocompleteNode(
+ formatSuggestionText(newSuggestion),
+ uuid,
+ )
+ .setFormat(prevNodeFormat)
+ .setStyle(`font-size: ${toolbarState.fontSize}`);
autocompleteNodeKey = node.getKey();
selection.insertNodes([node]);
$setSelection(selectionCopy);
lastSuggestion = newSuggestion;
- setSuggestion(newSuggestion);
},
{tag: 'history-merge'},
);
@@ -175,7 +200,9 @@ export default function AutocompletePlugin(): JSX.Element | null {
if (autocompleteNode === null) {
return false;
}
- const textNode = $createTextNode(lastSuggestion);
+ const textNode = $createTextNode(lastSuggestion)
+ .setFormat(prevNodeFormat)
+ .setStyle(`font-size: ${toolbarState.fontSize}`);
autocompleteNode.replace(textNode);
textNode.selectNext();
$clearSuggestion();
@@ -224,7 +251,7 @@ export default function AutocompletePlugin(): JSX.Element | null {
: []),
unmountSuggestion,
);
- }, [editor, query, setSuggestion]);
+ }, [editor, query, toolbarState.fontSize]);
return null;
}
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..39c9cb2d695 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,
@@ -36,6 +37,12 @@ import {getSelectedNode} from '../../utils/getSelectedNode';
import {setFloatingElemPositionForLinkEditor} from '../../utils/setFloatingElemPositionForLinkEditor';
import {sanitizeUrl} from '../../utils/url';
+function preventDefault(
+ event: React.KeyboardEvent | React.MouseEvent,
+): void {
+ event.preventDefault();
+}
+
function FloatingLinkEditor({
editor,
isLink,
@@ -77,7 +84,7 @@ function FloatingLinkEditor({
}
}
const editorElem = editorRef.current;
- const nativeSelection = window.getSelection();
+ const nativeSelection = getDOMSelection(editor._window);
const activeElement = document.activeElement;
if (editorElem === null) {
@@ -182,19 +189,26 @@ function FloatingLinkEditor({
event: React.KeyboardEvent,
) => {
if (event.key === 'Enter') {
- event.preventDefault();
- handleLinkSubmission();
+ handleLinkSubmission(event);
} else if (event.key === 'Escape') {
event.preventDefault();
setIsLinkEditMode(false);
}
};
- const handleLinkSubmission = () => {
+ const handleLinkSubmission = (
+ event:
+ | React.KeyboardEvent
+ | React.MouseEvent,
+ ) => {
+ event.preventDefault();
if (lastSelection !== null) {
if (linkUrl !== '') {
- editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(editedLinkUrl));
editor.update(() => {
+ editor.dispatchCommand(
+ TOGGLE_LINK_COMMAND,
+ sanitizeUrl(editedLinkUrl),
+ );
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const parent = getSelectedNode(selection).getParent();
@@ -234,7 +248,7 @@ function FloatingLinkEditor({
className="link-cancel"
role="button"
tabIndex={0}
- onMouseDown={(event) => event.preventDefault()}
+ onMouseDown={preventDefault}
onClick={() => {
setIsLinkEditMode(false);
}}
@@ -244,7 +258,7 @@ function FloatingLinkEditor({
className="link-confirm"
role="button"
tabIndex={0}
- onMouseDown={(event) => event.preventDefault()}
+ onMouseDown={preventDefault}
onClick={handleLinkSubmission}
/>
@@ -261,8 +275,9 @@ function FloatingLinkEditor({
className="link-edit"
role="button"
tabIndex={0}
- onMouseDown={(event) => event.preventDefault()}
- onClick={() => {
+ onMouseDown={preventDefault}
+ onClick={(event) => {
+ event.preventDefault();
setEditedLinkUrl(linkUrl);
setIsLinkEditMode(true);
}}
@@ -271,7 +286,7 @@ function FloatingLinkEditor({
className="link-trash"
role="button"
tabIndex={0}
- onMouseDown={(event) => event.preventDefault()}
+ onMouseDown={preventDefault}
onClick={() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 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/TableCellResizer/index.tsx b/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx
index 7f586262645..23f8d1935ab 100644
--- a/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx
+++ b/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx
@@ -88,27 +88,27 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element {
useEffect(() => {
const onMouseMove = (event: MouseEvent) => {
- setTimeout(() => {
- const target = event.target;
-
- if (draggingDirection) {
- updateMouseCurrentPos({
- x: event.clientX,
- y: event.clientY,
- });
- return;
- }
- updateIsMouseDown(isMouseDownOnEvent(event));
- if (resizerRef.current && resizerRef.current.contains(target as Node)) {
- return;
- }
+ const target = event.target;
- if (targetRef.current !== target) {
- targetRef.current = target as HTMLElement;
- const cell = getDOMCellFromTarget(target as HTMLElement);
+ if (draggingDirection) {
+ updateMouseCurrentPos({
+ x: event.clientX,
+ y: event.clientY,
+ });
+ return;
+ }
+ updateIsMouseDown(isMouseDownOnEvent(event));
+ if (resizerRef.current && resizerRef.current.contains(target as Node)) {
+ return;
+ }
- if (cell && activeCell !== cell) {
- editor.update(() => {
+ if (targetRef.current !== target) {
+ targetRef.current = target as HTMLElement;
+ const cell = getDOMCellFromTarget(target as HTMLElement);
+
+ if (cell && activeCell !== cell) {
+ editor.getEditorState().read(
+ () => {
const tableCellNode = $getNearestNodeFromDOMNode(cell.elem);
if (!tableCellNode) {
throw new Error('TableCellResizer: Table cell node not found.');
@@ -128,24 +128,21 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element {
targetRef.current = target as HTMLElement;
tableRectRef.current = tableElement.getBoundingClientRect();
updateActiveCell(cell);
- });
- } else if (cell == null) {
- resetState();
- }
+ },
+ {editor},
+ );
+ } else if (cell == null) {
+ resetState();
}
- }, 0);
+ }
};
const onMouseDown = (event: MouseEvent) => {
- setTimeout(() => {
- updateIsMouseDown(true);
- }, 0);
+ updateIsMouseDown(true);
};
const onMouseUp = (event: MouseEvent) => {
- setTimeout(() => {
- updateIsMouseDown(false);
- }, 0);
+ updateIsMouseDown(false);
};
const removeRootListener = editor.registerRootListener(
diff --git a/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx b/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx
index 92a26ff0015..2116d21179a 100644
--- a/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx
+++ b/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx
@@ -66,40 +66,44 @@ function TableHoverActionsContainer({
let hoveredColumnNode: TableCellNode | null = null;
let tableDOMElement: HTMLElement | null = null;
- editor.update(() => {
- const maybeTableCell = $getNearestNodeFromDOMNode(tableDOMNode);
-
- if ($isTableCellNode(maybeTableCell)) {
- const table = $findMatchingParent(maybeTableCell, (node) =>
- $isTableNode(node),
- );
- if (!$isTableNode(table)) {
- return;
- }
-
- tableDOMElement = getTableElement(
- table,
- editor.getElementByKey(table.getKey()),
- );
-
- if (tableDOMElement) {
- const rowCount = table.getChildrenSize();
- const colCount = (
- (table as TableNode).getChildAtIndex(0) as TableRowNode
- )?.getChildrenSize();
-
- const rowIndex = $getTableRowIndexFromTableCellNode(maybeTableCell);
- const colIndex =
- $getTableColumnIndexFromTableCellNode(maybeTableCell);
+ editor.getEditorState().read(
+ () => {
+ const maybeTableCell = $getNearestNodeFromDOMNode(tableDOMNode);
+
+ if ($isTableCellNode(maybeTableCell)) {
+ const table = $findMatchingParent(maybeTableCell, (node) =>
+ $isTableNode(node),
+ );
+ if (!$isTableNode(table)) {
+ return;
+ }
- if (rowIndex === rowCount - 1) {
- hoveredRowNode = maybeTableCell;
- } else if (colIndex === colCount - 1) {
- hoveredColumnNode = maybeTableCell;
+ tableDOMElement = getTableElement(
+ table,
+ editor.getElementByKey(table.getKey()),
+ );
+
+ if (tableDOMElement) {
+ const rowCount = table.getChildrenSize();
+ const colCount = (
+ (table as TableNode).getChildAtIndex(0) as TableRowNode
+ )?.getChildrenSize();
+
+ const rowIndex =
+ $getTableRowIndexFromTableCellNode(maybeTableCell);
+ const colIndex =
+ $getTableColumnIndexFromTableCellNode(maybeTableCell);
+
+ if (rowIndex === rowCount - 1) {
+ hoveredRowNode = maybeTableCell;
+ } else if (colIndex === colCount - 1) {
+ hoveredColumnNode = maybeTableCell;
+ }
}
}
- }
- });
+ },
+ {editor},
+ );
if (tableDOMElement) {
const {
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/src/themes/PlaygroundEditorTheme.css b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css
index a5310aa492d..5a525ba4b6b 100644
--- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css
+++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css
@@ -169,16 +169,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;
@@ -193,21 +183,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-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-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;
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/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/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..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,
@@ -51,6 +56,7 @@ import {
FOCUS_COMMAND,
FORMAT_ELEMENT_COMMAND,
FORMAT_TEXT_COMMAND,
+ getDOMSelection,
INSERT_PARAGRAPH_COMMAND,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_LEFT_COMMAND,
@@ -63,7 +69,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 +81,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 +113,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 +158,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 +173,9 @@ export function applyTableHandlers(
);
const createMouseHandlers = () => {
+ if (tableObserver.isSelecting) {
+ return;
+ }
const onMouseUp = () => {
tableObserver.isSelecting = false;
editorWindow.removeEventListener('mouseup', onMouseUp);
@@ -135,57 +183,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 +302,7 @@ export function applyTableHandlers(
selection.tableKey === tableObserver.tableNodeKey &&
rootElement.contains(target)
) {
- tableObserver.clearHighlight();
+ tableObserver.$clearHighlight();
}
});
};
@@ -218,40 +313,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 +330,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 +355,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 +378,7 @@ export function applyTableHandlers(
(isFocusInside && !isAnchorInside);
if (selectionContainsPartialTable) {
- tableObserver.clearText();
+ tableObserver.$clearText();
return true;
}
@@ -342,17 +413,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 +459,7 @@ export function applyTableHandlers(
event.preventDefault();
event.stopPropagation();
}
- tableObserver.clearText();
+ tableObserver.$clearText();
return true;
}
@@ -398,21 +467,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 +519,7 @@ export function applyTableHandlers(
}
if ($isTableSelection(selection)) {
- tableObserver.formatCells(payload);
+ tableObserver.$formatCells(payload);
return true;
} else if ($isRangeSelection(selection)) {
@@ -499,19 +562,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 +611,7 @@ export function applyTableHandlers(
}
if ($isTableSelection(selection)) {
- tableObserver.clearHighlight();
+ tableObserver.$clearHighlight();
return false;
} else if ($isRangeSelection(selection)) {
@@ -625,20 +696,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 +823,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 +895,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 +940,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 +964,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 +1015,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 +1278,7 @@ export function $addHighlightStyleToTable(
editor: LexicalEditor,
tableSelection: TableObserver,
) {
- tableSelection.disableHighlightStyle();
+ tableSelection.$disableHighlightStyle();
$forEachTableCell(tableSelection.table, (cell) => {
cell.highlighted = true;
$addHighlightToDOM(editor, cell);
@@ -1206,7 +1289,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 +1371,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,
@@ -1358,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(
@@ -1388,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 {
@@ -1545,8 +1732,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 +1837,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 +1859,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 +1902,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 +1944,7 @@ function $handleArrowKey(
) {
return false;
}
- tableObserver.updateTableTableSelection(selection);
+ tableObserver.$updateTableTableSelection(selection);
const grid = getTable(tableNodeFromSelection, tableElement);
const cordsAnchor = tableNode.getCordsFromCellNode(anchorCellNode, grid);
@@ -1760,17 +1953,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 +2053,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 +2142,7 @@ function $getTableEdgeCursorPosition(
}
// TODO: Add support for nested tables
- const domSelection = window.getSelection();
+ const domSelection = getDOMSelection(getEditorWindow(editor));
if (!domSelection) {
return undefined;
}
@@ -2009,3 +2203,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-utils/src/index.ts b/packages/lexical-utils/src/index.ts
index f9dad4ea5fc..68bafe208d8 100644
--- a/packages/lexical-utils/src/index.ts
+++ b/packages/lexical-utils/src/index.ts
@@ -647,19 +647,38 @@ export function $insertFirst(parent: ElementNode, node: LexicalNode): void {
}
}
+let NEEDS_MANUAL_ZOOM = IS_FIREFOX || !CAN_USE_DOM ? false : undefined;
+function needsManualZoom(): boolean {
+ if (NEEDS_MANUAL_ZOOM === undefined) {
+ // If the browser implements standardized CSS zoom, then the client rect
+ // will be wider after zoom is applied
+ // https://chromestatus.com/feature/5198254868529152
+ // https://github.com/facebook/lexical/issues/6863
+ const div = document.createElement('div');
+ div.style.cssText =
+ 'position: absolute; opacity: 0; width: 100px; left: -1000px;';
+ document.body.appendChild(div);
+ const noZoom = div.getBoundingClientRect();
+ div.style.setProperty('zoom', '2');
+ NEEDS_MANUAL_ZOOM = div.getBoundingClientRect().width === noZoom.width;
+ document.body.removeChild(div);
+ }
+ return NEEDS_MANUAL_ZOOM;
+}
+
/**
* Calculates the zoom level of an element as a result of using
- * css zoom property.
+ * css zoom property. For browsers that implement standardized CSS
+ * zoom (Firefox, Chrome >= 128), this will always return 1.
* @param element
*/
export function calculateZoomLevel(element: Element | null): number {
- if (IS_FIREFOX) {
- return 1;
- }
let zoom = 1;
- while (element) {
- zoom *= Number(window.getComputedStyle(element).getPropertyValue('zoom'));
- element = element.parentElement;
+ if (needsManualZoom()) {
+ while (element) {
+ zoom *= Number(window.getComputedStyle(element).getPropertyValue('zoom'));
+ element = element.parentElement;
+ }
}
return zoom;
}
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-website/docs/concepts/nodes.md b/packages/lexical-website/docs/concepts/nodes.md
index aedb7bb43cb..baa60eb92c5 100644
--- a/packages/lexical-website/docs/concepts/nodes.md
+++ b/packages/lexical-website/docs/concepts/nodes.md
@@ -25,6 +25,23 @@ There is only ever a single `RootNode` in an `EditorState` and it is always at t
- To get the text content of the entire editor, you should use `rootNode.getTextContent()`.
- To avoid selection issues, Lexical forbids insertion of text nodes directly into a `RootNode`.
+#### Semantics and Use Cases
+
+The `RootNode` has specific characteristics and restrictions to maintain editor integrity:
+
+1. **Non-extensibility**
+ The `RootNode` cannot be subclassed or replaced with a custom implementation. It is designed as a fixed part of the editor architecture.
+
+2. **Exclusion from Mutation Listeners**
+ The `RootNode` does not participate in mutation listeners. Instead, use a root-level or update listener to observe changes at the document level.
+
+3. **Compatibility with Node Transforms**
+ While the `RootNode` is not "part of the document" in the traditional sense, it can still appear to be in some cases, such as during serialization or when applying node transforms.
+
+4. **Document-Level Metadata**
+ If you are attempting to use the `RootNode` for document-level metadata (e.g., undo/redo support), consider alternative designs. Currently, Lexical does not provide direct facilities for this use case, but solutions like creating a shadow root under the `RootNode` might work.
+
+By design, the `RootNode` serves as a container for the editor's content rather than an active part of the document's logical structure. This approach simplifies operations like serialization and keeps the focus on content nodes.
### [`LineBreakNode`](https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalLineBreakNode.ts)
diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts
index 174b18cb62c..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;
@@ -216,13 +212,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 +840,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.
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);
|