2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
9 import type {LexicalEditor, NodeKey, TextFormatType} from 'lexical';
12 addClassNamesToElement,
13 removeClassNamesFromElement,
14 } from '@lexical/utils';
17 $createRangeSelection,
19 $getNearestNodeFromDOMNode,
25 SELECTION_CHANGE_COMMAND,
27 import invariant from 'lexical/shared/invariant';
29 import {$isTableCellNode} from './LexicalTableCellNode';
30 import {$isTableNode} from './LexicalTableNode';
32 $createTableSelection,
35 } from './LexicalTableSelection';
38 $updateDOMForSelection,
41 } from './LexicalTableSelectionHelpers';
43 export type TableDOMCell = {
46 hasBackgroundColor: boolean;
51 export type TableDOMRows = Array<Array<TableDOMCell | undefined> | undefined>;
53 export type TableDOMTable = {
54 domRows: TableDOMRows;
59 export class TableObserver {
62 listenersToRemove: Set<() => void>;
64 isHighlightingCells: boolean;
67 tableNodeKey: NodeKey;
68 anchorCell: TableDOMCell | null;
69 focusCell: TableDOMCell | null;
70 anchorCellNodeKey: NodeKey | null;
71 focusCellNodeKey: NodeKey | null;
72 editor: LexicalEditor;
73 tableSelection: TableSelection | null;
74 hasHijackedSelectionStyles: boolean;
77 constructor(editor: LexicalEditor, tableNodeKey: string) {
78 this.isHighlightingCells = false;
83 this.listenersToRemove = new Set();
84 this.tableNodeKey = tableNodeKey;
91 this.tableSelection = null;
92 this.anchorCellNodeKey = null;
93 this.focusCellNodeKey = null;
94 this.anchorCell = null;
95 this.focusCell = null;
96 this.hasHijackedSelectionStyles = false;
98 this.isSelecting = false;
101 getTable(): TableDOMTable {
106 Array.from(this.listenersToRemove).forEach((removeListener) =>
112 const observer = new MutationObserver((records) => {
113 this.editor.update(() => {
114 let gridNeedsRedraw = false;
116 for (let i = 0; i < records.length; i++) {
117 const record = records[i];
118 const target = record.target;
119 const nodeName = target.nodeName;
122 nodeName === 'TABLE' ||
123 nodeName === 'TBODY' ||
124 nodeName === 'THEAD' ||
127 gridNeedsRedraw = true;
132 if (!gridNeedsRedraw) {
136 const tableElement = this.editor.getElementByKey(this.tableNodeKey);
139 throw new Error('Expected to find TableElement in DOM');
142 this.table = getTable(tableElement);
145 this.editor.update(() => {
146 const tableElement = this.editor.getElementByKey(this.tableNodeKey);
149 throw new Error('Expected to find TableElement in DOM');
152 this.table = getTable(tableElement);
153 observer.observe(tableElement, {
162 const editor = this.editor;
163 this.isHighlightingCells = false;
168 this.tableSelection = null;
169 this.anchorCellNodeKey = null;
170 this.focusCellNodeKey = null;
171 this.anchorCell = null;
172 this.focusCell = null;
173 this.hasHijackedSelectionStyles = false;
175 this.enableHighlightStyle();
177 editor.update(() => {
178 const tableNode = $getNodeByKey(this.tableNodeKey);
180 if (!$isTableNode(tableNode)) {
181 throw new Error('Expected TableNode.');
184 const tableElement = editor.getElementByKey(this.tableNodeKey);
187 throw new Error('Expected to find TableElement in DOM');
190 const grid = getTable(tableElement);
191 $updateDOMForSelection(editor, grid, null);
193 editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
197 enableHighlightStyle() {
198 const editor = this.editor;
199 editor.update(() => {
200 const tableElement = editor.getElementByKey(this.tableNodeKey);
203 throw new Error('Expected to find TableElement in DOM');
206 removeClassNamesFromElement(
208 editor._config.theme.tableSelection,
210 tableElement.classList.remove('disable-selection');
211 this.hasHijackedSelectionStyles = false;
215 disableHighlightStyle() {
216 const editor = this.editor;
217 editor.update(() => {
218 const tableElement = editor.getElementByKey(this.tableNodeKey);
221 throw new Error('Expected to find TableElement in DOM');
224 addClassNamesToElement(tableElement, editor._config.theme.tableSelection);
225 this.hasHijackedSelectionStyles = true;
229 updateTableTableSelection(selection: TableSelection | null): void {
230 if (selection !== null && selection.tableKey === this.tableNodeKey) {
231 const editor = this.editor;
232 this.tableSelection = selection;
233 this.isHighlightingCells = true;
234 this.disableHighlightStyle();
235 $updateDOMForSelection(editor, this.table, this.tableSelection);
236 } else if (selection == null) {
237 this.clearHighlight();
239 this.tableNodeKey = selection.tableKey;
240 this.updateTableTableSelection(selection);
244 setFocusCellForSelection(cell: TableDOMCell, ignoreStart = false) {
245 const editor = this.editor;
246 editor.update(() => {
247 const tableNode = $getNodeByKey(this.tableNodeKey);
249 if (!$isTableNode(tableNode)) {
250 throw new Error('Expected TableNode.');
253 const tableElement = editor.getElementByKey(this.tableNodeKey);
256 throw new Error('Expected to find TableElement in DOM');
259 const cellX = cell.x;
260 const cellY = cell.y;
261 this.focusCell = cell;
263 if (this.anchorCell !== null) {
264 const domSelection = getDOMSelection(editor._window);
265 // Collapse the selection
267 domSelection.setBaseAndExtent(
268 this.anchorCell.elem,
277 !this.isHighlightingCells &&
278 (this.anchorX !== cellX || this.anchorY !== cellY || ignoreStart)
280 this.isHighlightingCells = true;
281 this.disableHighlightStyle();
282 } else if (cellX === this.focusX && cellY === this.focusY) {
289 if (this.isHighlightingCells) {
290 const focusTableCellNode = $getNearestNodeFromDOMNode(cell.elem);
293 this.tableSelection != null &&
294 this.anchorCellNodeKey != null &&
295 $isTableCellNode(focusTableCellNode) &&
296 tableNode.is($findTableNode(focusTableCellNode))
298 const focusNodeKey = focusTableCellNode.getKey();
300 this.tableSelection =
301 this.tableSelection.clone() || $createTableSelection();
303 this.focusCellNodeKey = focusNodeKey;
304 this.tableSelection.set(
306 this.anchorCellNodeKey,
307 this.focusCellNodeKey,
310 $setSelection(this.tableSelection);
312 editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
314 $updateDOMForSelection(editor, this.table, this.tableSelection);
320 setAnchorCellForSelection(cell: TableDOMCell) {
321 this.isHighlightingCells = false;
322 this.anchorCell = cell;
323 this.anchorX = cell.x;
324 this.anchorY = cell.y;
326 this.editor.update(() => {
327 const anchorTableCellNode = $getNearestNodeFromDOMNode(cell.elem);
329 if ($isTableCellNode(anchorTableCellNode)) {
330 const anchorNodeKey = anchorTableCellNode.getKey();
331 this.tableSelection =
332 this.tableSelection != null
333 ? this.tableSelection.clone()
334 : $createTableSelection();
335 this.anchorCellNodeKey = anchorNodeKey;
340 formatCells(type: TextFormatType) {
341 this.editor.update(() => {
342 const selection = $getSelection();
344 if (!$isTableSelection(selection)) {
345 invariant(false, 'Expected grid selection');
348 const formatSelection = $createRangeSelection();
350 const anchor = formatSelection.anchor;
351 const focus = formatSelection.focus;
353 selection.getNodes().forEach((cellNode) => {
354 if ($isTableCellNode(cellNode) && cellNode.getTextContentSize() !== 0) {
355 anchor.set(cellNode.getKey(), 0, 'element');
356 focus.set(cellNode.getKey(), cellNode.getChildrenSize(), 'element');
357 formatSelection.formatText(type);
361 $setSelection(selection);
363 this.editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
368 const editor = this.editor;
369 editor.update(() => {
370 const tableNode = $getNodeByKey(this.tableNodeKey);
372 if (!$isTableNode(tableNode)) {
373 throw new Error('Expected TableNode.');
376 const selection = $getSelection();
378 if (!$isTableSelection(selection)) {
379 invariant(false, 'Expected grid selection');
382 const selectedNodes = selection.getNodes().filter($isTableCellNode);
384 if (selectedNodes.length === this.table.columns * this.table.rows) {
385 tableNode.selectPrevious();
386 // Delete entire table
388 const rootNode = $getRoot();
389 rootNode.selectStart();
393 selectedNodes.forEach((cellNode) => {
394 if ($isElementNode(cellNode)) {
395 const paragraphNode = $createParagraphNode();
396 const textNode = $createTextNode();
397 paragraphNode.append(textNode);
398 cellNode.append(paragraphNode);
399 cellNode.getChildren().forEach((child) => {
400 if (child !== paragraphNode) {
407 $updateDOMForSelection(editor, this.table, null);
411 editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);