@lexical/table 0.44.1-nightly.20260518.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/{LexicalTable.dev.js → dist/LexicalTable.dev.js} +243 -33
  2. package/{LexicalTable.dev.mjs → dist/LexicalTable.dev.mjs} +239 -33
  3. package/{LexicalTable.mjs → dist/LexicalTable.mjs} +4 -0
  4. package/{LexicalTable.node.mjs → dist/LexicalTable.node.mjs} +4 -0
  5. package/dist/LexicalTable.prod.js +9 -0
  6. package/dist/LexicalTable.prod.mjs +9 -0
  7. package/dist/TableImportExtension.d.ts +38 -0
  8. package/{index.d.ts → dist/index.d.ts} +1 -0
  9. package/package.json +34 -18
  10. package/src/LexicalTableCellNode.ts +479 -0
  11. package/src/LexicalTableCommands.ts +27 -0
  12. package/src/LexicalTableExtension.ts +104 -0
  13. package/src/LexicalTableNode.ts +678 -0
  14. package/src/LexicalTableObserver.ts +575 -0
  15. package/src/LexicalTablePluginHelpers.ts +694 -0
  16. package/src/LexicalTableRowNode.ts +154 -0
  17. package/src/LexicalTableSelection.ts +460 -0
  18. package/src/LexicalTableSelectionHelpers.ts +2409 -0
  19. package/src/LexicalTableUtils.ts +1386 -0
  20. package/src/TableImportExtension.ts +302 -0
  21. package/src/constants.ts +13 -0
  22. package/src/index.ts +97 -0
  23. package/LexicalTable.prod.js +0 -9
  24. package/LexicalTable.prod.mjs +0 -9
  25. /package/{LexicalTable.js → dist/LexicalTable.js} +0 -0
  26. /package/{LexicalTable.js.flow → dist/LexicalTable.js.flow} +0 -0
  27. /package/{LexicalTableCellNode.d.ts → dist/LexicalTableCellNode.d.ts} +0 -0
  28. /package/{LexicalTableCommands.d.ts → dist/LexicalTableCommands.d.ts} +0 -0
  29. /package/{LexicalTableExtension.d.ts → dist/LexicalTableExtension.d.ts} +0 -0
  30. /package/{LexicalTableNode.d.ts → dist/LexicalTableNode.d.ts} +0 -0
  31. /package/{LexicalTableObserver.d.ts → dist/LexicalTableObserver.d.ts} +0 -0
  32. /package/{LexicalTablePluginHelpers.d.ts → dist/LexicalTablePluginHelpers.d.ts} +0 -0
  33. /package/{LexicalTableRowNode.d.ts → dist/LexicalTableRowNode.d.ts} +0 -0
  34. /package/{LexicalTableSelection.d.ts → dist/LexicalTableSelection.d.ts} +0 -0
  35. /package/{LexicalTableSelectionHelpers.d.ts → dist/LexicalTableSelectionHelpers.d.ts} +0 -0
  36. /package/{LexicalTableUtils.d.ts → dist/LexicalTableUtils.d.ts} +0 -0
  37. /package/{constants.d.ts → dist/constants.d.ts} +0 -0
@@ -0,0 +1,2409 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+
9
+ import type {TableCellNode} from './LexicalTableCellNode';
10
+ import type {
11
+ TableDOMCell,
12
+ TableDOMRows,
13
+ TableObservers,
14
+ } from './LexicalTableObserver';
15
+ import type {
16
+ TableMapType,
17
+ TableMapValueType,
18
+ TableSelection,
19
+ } from './LexicalTableSelection';
20
+ import type {
21
+ BaseSelection,
22
+ CaretDirection,
23
+ ChildCaret,
24
+ EditorState,
25
+ ElementNode,
26
+ LexicalCommand,
27
+ LexicalEditor,
28
+ LexicalNode,
29
+ PointCaret,
30
+ RangeSelection,
31
+ SiblingCaret,
32
+ } from 'lexical';
33
+
34
+ import {
35
+ $getClipboardDataFromSelection,
36
+ copyToClipboard,
37
+ } from '@lexical/clipboard';
38
+ import invariant from '@lexical/internal/invariant';
39
+ import {
40
+ $findMatchingParent,
41
+ addClassNamesToElement,
42
+ objectKlassEquals,
43
+ removeClassNamesFromElement,
44
+ } from '@lexical/utils';
45
+ import {
46
+ $caretFromPoint,
47
+ $createParagraphNode,
48
+ $createRangeSelectionFromDom,
49
+ $createTextNode,
50
+ $extendCaretToRange,
51
+ $getAdjacentChildCaret,
52
+ $getChildCaret,
53
+ $getNearestNodeFromDOMNode,
54
+ $getNodeByKeyOrThrow,
55
+ $getPreviousSelection,
56
+ $getSelection,
57
+ $getSiblingCaret,
58
+ $isChildCaret,
59
+ $isElementNode,
60
+ $isExtendableTextPointCaret,
61
+ $isRangeSelection,
62
+ $isRootOrShadowRoot,
63
+ $isSiblingCaret,
64
+ $isTextNode,
65
+ $normalizeCaret,
66
+ $setPointFromCaret,
67
+ $setSelection,
68
+ COMMAND_PRIORITY_HIGH,
69
+ CONTROLLED_TEXT_INSERTION_COMMAND,
70
+ CUT_COMMAND,
71
+ DELETE_CHARACTER_COMMAND,
72
+ DELETE_LINE_COMMAND,
73
+ DELETE_WORD_COMMAND,
74
+ FOCUS_COMMAND,
75
+ FORMAT_ELEMENT_COMMAND,
76
+ FORMAT_TEXT_COMMAND,
77
+ getDOMSelection,
78
+ INSERT_PARAGRAPH_COMMAND,
79
+ IS_FIREFOX,
80
+ isDOMNode,
81
+ isHTMLElement,
82
+ KEY_ARROW_DOWN_COMMAND,
83
+ KEY_ARROW_LEFT_COMMAND,
84
+ KEY_ARROW_RIGHT_COMMAND,
85
+ KEY_ARROW_UP_COMMAND,
86
+ KEY_BACKSPACE_COMMAND,
87
+ KEY_DELETE_COMMAND,
88
+ KEY_ESCAPE_COMMAND,
89
+ KEY_TAB_COMMAND,
90
+ SELECTION_CHANGE_COMMAND,
91
+ } from 'lexical';
92
+
93
+ import {$isTableCellNode} from './LexicalTableCellNode';
94
+ import {
95
+ $getElementForTableNode,
96
+ $isScrollableTablesActive,
97
+ $isTableNode,
98
+ TableNode,
99
+ } from './LexicalTableNode';
100
+ import {TableDOMTable, TableObserver} from './LexicalTableObserver';
101
+ import {$isTableRowNode} from './LexicalTableRowNode';
102
+ import {$isTableSelection} from './LexicalTableSelection';
103
+ import {
104
+ $computeTableCellRectBoundary,
105
+ $computeTableCellRectSpans,
106
+ $computeTableMap,
107
+ $getNodeTriplet,
108
+ TableCellRectBoundary,
109
+ } from './LexicalTableUtils';
110
+
111
+ const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection';
112
+
113
+ const isPointerDownOnEvent = (event: PointerEvent) => {
114
+ return (event.buttons & 1) === 1;
115
+ };
116
+
117
+ export function isHTMLTableElement(el: unknown): el is HTMLTableElement {
118
+ return isHTMLElement(el) && el.nodeName === 'TABLE';
119
+ }
120
+
121
+ export function getTableElement<T extends HTMLElement | null>(
122
+ tableNode: TableNode,
123
+ dom: T,
124
+ ): HTMLTableElementWithWithTableSelectionState | (T & null) {
125
+ if (!dom) {
126
+ return dom as T & null;
127
+ }
128
+ const element: null | HTMLTableElementWithWithTableSelectionState =
129
+ isHTMLTableElement(dom) ? dom : dom.querySelector('table');
130
+ invariant(
131
+ isHTMLTableElement(element),
132
+ 'getTableElement: Expecting table in DOM node for %s of type %s with key %s, not %s',
133
+ tableNode.constructor.name,
134
+ tableNode.getType(),
135
+ tableNode.getKey(),
136
+ dom.nodeName,
137
+ );
138
+ return element;
139
+ }
140
+
141
+ export function getEditorWindow(editor: LexicalEditor): Window | null {
142
+ return editor._window;
143
+ }
144
+
145
+ export function $findParentTableCellNodeInTable(
146
+ tableNode: LexicalNode,
147
+ node: LexicalNode | null,
148
+ ): TableCellNode | null {
149
+ for (
150
+ let currentNode = node, lastTableCellNode: TableCellNode | null = null;
151
+ currentNode !== null;
152
+ currentNode = currentNode.getParent()
153
+ ) {
154
+ if (tableNode.is(currentNode)) {
155
+ return lastTableCellNode;
156
+ } else if ($isTableCellNode(currentNode)) {
157
+ lastTableCellNode = currentNode;
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+
163
+ const ARROW_KEY_COMMANDS_WITH_DIRECTION = [
164
+ [KEY_ARROW_DOWN_COMMAND, 'down'],
165
+ [KEY_ARROW_UP_COMMAND, 'up'],
166
+ [KEY_ARROW_LEFT_COMMAND, 'backward'],
167
+ [KEY_ARROW_RIGHT_COMMAND, 'forward'],
168
+ ] as const;
169
+ const DELETE_TEXT_COMMANDS = [
170
+ DELETE_WORD_COMMAND,
171
+ DELETE_LINE_COMMAND,
172
+ DELETE_CHARACTER_COMMAND,
173
+ ] as const;
174
+ const DELETE_KEY_COMMANDS = [
175
+ KEY_BACKSPACE_COMMAND,
176
+ KEY_DELETE_COMMAND,
177
+ ] as const;
178
+
179
+ export function registerTableWindowHandlers(
180
+ editor: LexicalEditor,
181
+ tableObservers: TableObservers,
182
+ ) {
183
+ // Use registerRootListener so the pointerdown handler is (re)attached
184
+ // whenever the root element is set. This is required for the Extension API,
185
+ // where register() runs before the ContentEditable mounts and getRootElement()
186
+ // is still null.
187
+ return editor.registerRootListener(rootElement => {
188
+ if (rootElement === null) {
189
+ return;
190
+ }
191
+ const editorWindow = editor._window;
192
+ if (editorWindow === null) {
193
+ return;
194
+ }
195
+
196
+ const pointerDownCallback = (event: PointerEvent) => {
197
+ const target = event.target;
198
+ if (
199
+ event.button !== 0 ||
200
+ !isDOMNode(target) ||
201
+ !rootElement.contains(target)
202
+ ) {
203
+ return;
204
+ }
205
+ const selectionInfo = getTableObserverFromCellNode(target);
206
+
207
+ editor.update(() => {
208
+ // Clear highlights from all tables (even one we're actively clicking on)
209
+ const selection = $getSelection();
210
+ if ($isTableSelection(selection)) {
211
+ for (const [observer] of tableObservers.observers.values()) {
212
+ observer.$clearHighlight(false);
213
+ }
214
+ $setSelection(null);
215
+ editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
216
+ }
217
+ if (!selectionInfo) {
218
+ return;
219
+ }
220
+ const {tableObserver, tableElement, cellElement} = selectionInfo;
221
+ $handleTableClick(
222
+ editor,
223
+ event,
224
+ cellElement,
225
+ tableElement,
226
+ tableObserver,
227
+ tableObservers,
228
+ );
229
+ });
230
+ };
231
+
232
+ editorWindow.addEventListener('pointerdown', pointerDownCallback);
233
+ return () => {
234
+ editorWindow.removeEventListener('pointerdown', pointerDownCallback);
235
+ };
236
+ });
237
+ }
238
+
239
+ function $handleTableClick(
240
+ editor: LexicalEditor,
241
+ event: PointerEvent,
242
+ selectedDOMCell: TableDOMCell,
243
+ tableElement: HTMLTableElementWithWithTableSelectionState,
244
+ tableObserver: TableObserver,
245
+ tableObservers: TableObservers,
246
+ ) {
247
+ const editorWindow = editor._window;
248
+ if (!editorWindow) {
249
+ return;
250
+ }
251
+ const createPointerHandlers = (startingCell: TableDOMCell | null) => {
252
+ if (tableObserver.isSelecting) {
253
+ return;
254
+ }
255
+ tableObserver.isSelecting = true;
256
+
257
+ // Set anchor immediately if starting cell provided (handles direct drag without click)
258
+ if (startingCell !== null && tableObserver.anchorCell === null) {
259
+ editor.update(() => {
260
+ tableObserver.$setAnchorCellForSelection(startingCell);
261
+ });
262
+ }
263
+
264
+ const onPointerUp = () => {
265
+ tableObserver.isSelecting = false;
266
+ editorWindow.removeEventListener('pointerup', onPointerUp);
267
+ editorWindow.removeEventListener('pointermove', onPointerMove);
268
+ };
269
+
270
+ const onPointerMove = (moveEvent: PointerEvent) => {
271
+ if (!isPointerDownOnEvent(moveEvent) && tableObserver.isSelecting) {
272
+ tableObserver.isSelecting = false;
273
+ editorWindow.removeEventListener('pointerup', onPointerUp);
274
+ editorWindow.removeEventListener('pointermove', onPointerMove);
275
+ return;
276
+ }
277
+ if (!isDOMNode(moveEvent.target)) {
278
+ return;
279
+ }
280
+ let focusCell: null | TableDOMCell = null;
281
+ // In firefox the moveEvent.target may be captured so we must always
282
+ // consult the coordinates #7245
283
+ const override = !(IS_FIREFOX || tableElement.contains(moveEvent.target));
284
+ if (override) {
285
+ focusCell = getDOMCellInTableFromTarget(tableElement, moveEvent.target);
286
+ } else {
287
+ for (const el of document.elementsFromPoint(
288
+ moveEvent.clientX,
289
+ moveEvent.clientY,
290
+ )) {
291
+ focusCell = getDOMCellInTableFromTarget(tableElement, el);
292
+ if (focusCell) {
293
+ break;
294
+ }
295
+ }
296
+ }
297
+ if (focusCell) {
298
+ const anchorCell = focusCell;
299
+ // Fallback: set anchor if still missing (handles race conditions)
300
+ if (tableObserver.anchorCell === null) {
301
+ editor.update(() => {
302
+ tableObserver.$setAnchorCellForSelection(anchorCell);
303
+ });
304
+ }
305
+ if (
306
+ tableObserver.focusCell === null ||
307
+ focusCell.elem !== tableObserver.focusCell.elem
308
+ ) {
309
+ tableObservers.setNextFocus({
310
+ focusCell,
311
+ override,
312
+ tableKey: tableObserver.tableNodeKey,
313
+ });
314
+ editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
315
+ }
316
+ }
317
+ };
318
+ editorWindow.addEventListener(
319
+ 'pointerup',
320
+ onPointerUp,
321
+ tableObserver.listenerOptions,
322
+ );
323
+ editorWindow.addEventListener(
324
+ 'pointermove',
325
+ onPointerMove,
326
+ tableObserver.listenerOptions,
327
+ );
328
+ };
329
+
330
+ tableObserver.pointerType = event.pointerType;
331
+ const tableNode = $getNodeByKeyOrThrow<TableNode>(tableObserver.tableNodeKey);
332
+ const prevSelection = $getPreviousSelection();
333
+ // We can't trust Firefox to do the right thing with the selection and
334
+ // we don't have a proper state machine to do this "correctly" but
335
+ // if we go ahead and make the table selection now it will work
336
+ if (
337
+ IS_FIREFOX &&
338
+ event.shiftKey &&
339
+ $isSelectionInTable(prevSelection, tableNode) &&
340
+ ($isRangeSelection(prevSelection) || $isTableSelection(prevSelection))
341
+ ) {
342
+ const prevAnchorNode = prevSelection.anchor.getNode();
343
+ const prevAnchorCell = $findParentTableCellNodeInTable(
344
+ tableNode,
345
+ prevSelection.anchor.getNode(),
346
+ );
347
+ if (prevAnchorCell) {
348
+ tableObserver.$setAnchorCellForSelection(
349
+ $getObserverCellFromCellNodeOrThrow(tableObserver, prevAnchorCell),
350
+ );
351
+ tableObserver.$setFocusCellForSelection(selectedDOMCell);
352
+ stopEvent(event);
353
+ } else {
354
+ const newSelection = tableNode.isBefore(prevAnchorNode)
355
+ ? tableNode.selectStart()
356
+ : tableNode.selectEnd();
357
+ newSelection.anchor.set(
358
+ prevSelection.anchor.key,
359
+ prevSelection.anchor.offset,
360
+ prevSelection.anchor.type,
361
+ );
362
+ }
363
+ } else {
364
+ // Only set anchor cell for selection if this is not a simple touch tap
365
+ // Touch taps should not initiate table selection mode
366
+ if (event.pointerType !== 'touch') {
367
+ tableObserver.$setAnchorCellForSelection(selectedDOMCell);
368
+ }
369
+ }
370
+
371
+ // Pass the target cell to createPointerHandlers so it can be used as anchor
372
+ // if user drags directly without clicking first
373
+ createPointerHandlers(selectedDOMCell);
374
+ }
375
+
376
+ export function applyTableHandlers(
377
+ tableNode: TableNode,
378
+ element: HTMLElement,
379
+ editor: LexicalEditor,
380
+ hasTabHandler: boolean,
381
+ tableObservers: TableObservers,
382
+ ): TableObserver {
383
+ const rootElement = editor.getRootElement();
384
+ const editorWindow = getEditorWindow(editor);
385
+ invariant(
386
+ rootElement !== null && editorWindow !== null,
387
+ 'applyTableHandlers: editor has no root element set',
388
+ );
389
+
390
+ const tableObserver = new TableObserver(editor, tableNode.getKey());
391
+
392
+ const tableElement = getTableElement(tableNode, element);
393
+ attachTableObserverToTableElement(tableElement, tableObserver);
394
+ tableObserver.listenersToRemove.add(() =>
395
+ detachTableObserverFromTableElement(tableElement, tableObserver),
396
+ );
397
+
398
+ const onTripleClick = (event: MouseEvent) => {
399
+ if (event.detail >= 3 && isDOMNode(event.target)) {
400
+ const targetCell = getDOMCellFromTarget(event.target);
401
+ if (targetCell !== null) {
402
+ event.preventDefault();
403
+ }
404
+ }
405
+ };
406
+ tableElement.addEventListener(
407
+ 'mousedown',
408
+ onTripleClick,
409
+ tableObserver.listenerOptions,
410
+ );
411
+ tableObserver.listenersToRemove.add(() => {
412
+ tableElement.removeEventListener('mousedown', onTripleClick);
413
+ });
414
+
415
+ for (const [command, direction] of ARROW_KEY_COMMANDS_WITH_DIRECTION) {
416
+ tableObserver.listenersToRemove.add(
417
+ editor.registerCommand(
418
+ command,
419
+ event =>
420
+ $handleArrowKey(
421
+ editor,
422
+ event,
423
+ direction,
424
+ tableNode,
425
+ tableObserver,
426
+ tableObservers,
427
+ ),
428
+ COMMAND_PRIORITY_HIGH,
429
+ ),
430
+ );
431
+ }
432
+
433
+ tableObserver.listenersToRemove.add(
434
+ editor.registerCommand(
435
+ KEY_ESCAPE_COMMAND,
436
+ event => {
437
+ const selection = $getSelection();
438
+ if ($isTableSelection(selection)) {
439
+ const focusCellNode = $findParentTableCellNodeInTable(
440
+ tableNode,
441
+ selection.focus.getNode(),
442
+ );
443
+ if (focusCellNode !== null) {
444
+ stopEvent(event);
445
+ focusCellNode.selectEnd();
446
+ return true;
447
+ }
448
+ }
449
+
450
+ return false;
451
+ },
452
+ COMMAND_PRIORITY_HIGH,
453
+ ),
454
+ );
455
+
456
+ const deleteTextHandler = (command: LexicalCommand<boolean>) => () => {
457
+ const selection = $getSelection();
458
+
459
+ if (!$isSelectionInTable(selection, tableNode)) {
460
+ return false;
461
+ }
462
+
463
+ if ($isTableSelection(selection)) {
464
+ tableObserver.$clearText();
465
+
466
+ return true;
467
+ } else if ($isRangeSelection(selection)) {
468
+ const tableCellNode = $findParentTableCellNodeInTable(
469
+ tableNode,
470
+ selection.anchor.getNode(),
471
+ );
472
+
473
+ if (!$isTableCellNode(tableCellNode)) {
474
+ return false;
475
+ }
476
+
477
+ const anchorNode = selection.anchor.getNode();
478
+ const focusNode = selection.focus.getNode();
479
+ const isAnchorInside = tableNode.isParentOf(anchorNode);
480
+ const isFocusInside = tableNode.isParentOf(focusNode);
481
+
482
+ const selectionContainsPartialTable =
483
+ (isAnchorInside && !isFocusInside) ||
484
+ (isFocusInside && !isAnchorInside);
485
+
486
+ if (selectionContainsPartialTable) {
487
+ tableObserver.$clearText();
488
+ return true;
489
+ }
490
+
491
+ const nearestElementNode = $findMatchingParent(
492
+ selection.anchor.getNode(),
493
+ n => $isElementNode(n),
494
+ );
495
+
496
+ const topLevelCellElementNode =
497
+ nearestElementNode &&
498
+ $findMatchingParent(
499
+ nearestElementNode,
500
+ n => $isElementNode(n) && $isTableCellNode(n.getParent()),
501
+ );
502
+
503
+ if (
504
+ !$isElementNode(topLevelCellElementNode) ||
505
+ !$isElementNode(nearestElementNode)
506
+ ) {
507
+ return false;
508
+ }
509
+
510
+ if (
511
+ command === DELETE_LINE_COMMAND &&
512
+ topLevelCellElementNode.getPreviousSibling() === null
513
+ ) {
514
+ // TODO: Fix Delete Line in Table Cells.
515
+ return true;
516
+ }
517
+ }
518
+
519
+ return false;
520
+ };
521
+
522
+ for (const command of DELETE_TEXT_COMMANDS) {
523
+ tableObserver.listenersToRemove.add(
524
+ editor.registerCommand(
525
+ command,
526
+ deleteTextHandler(command),
527
+ COMMAND_PRIORITY_HIGH,
528
+ ),
529
+ );
530
+ }
531
+
532
+ const $deleteCellHandler = (
533
+ event: KeyboardEvent | ClipboardEvent | null,
534
+ ): boolean => {
535
+ const selection = $getSelection();
536
+ if (!($isTableSelection(selection) || $isRangeSelection(selection))) {
537
+ return false;
538
+ }
539
+
540
+ // If the selection is inside the table but should remove the whole table
541
+ // we expand the selection so that both the anchor and focus are outside
542
+ // the table and the editor's command listener will handle the delete
543
+ const isAnchorInside = tableNode.isParentOf(selection.anchor.getNode());
544
+ const isFocusInside = tableNode.isParentOf(selection.focus.getNode());
545
+ if (isAnchorInside !== isFocusInside) {
546
+ const tablePoint = isAnchorInside ? 'anchor' : 'focus';
547
+ const outerPoint = isAnchorInside ? 'focus' : 'anchor';
548
+ // Preserve the outer point
549
+ const {key, offset, type} = selection[outerPoint];
550
+ // Expand the selection around the table
551
+ const newSelection =
552
+ tableNode[
553
+ selection[tablePoint].isBefore(selection[outerPoint])
554
+ ? 'selectPrevious'
555
+ : 'selectNext'
556
+ ]();
557
+ // Restore the outer point of the selection
558
+ newSelection[outerPoint].set(key, offset, type);
559
+ // Let the base implementation handle the rest
560
+ return false;
561
+ }
562
+
563
+ if (!$isSelectionInTable(selection, tableNode)) {
564
+ return false;
565
+ }
566
+
567
+ if ($isTableSelection(selection)) {
568
+ if (event) {
569
+ event.preventDefault();
570
+ event.stopPropagation();
571
+ }
572
+ tableObserver.$clearText();
573
+
574
+ return true;
575
+ }
576
+
577
+ return false;
578
+ };
579
+
580
+ for (const command of DELETE_KEY_COMMANDS) {
581
+ tableObserver.listenersToRemove.add(
582
+ editor.registerCommand(
583
+ command,
584
+ $deleteCellHandler,
585
+ COMMAND_PRIORITY_HIGH,
586
+ ),
587
+ );
588
+ }
589
+
590
+ tableObserver.listenersToRemove.add(
591
+ editor.registerCommand(
592
+ CUT_COMMAND,
593
+ event => {
594
+ const selection = $getSelection();
595
+ if (selection) {
596
+ if (!($isTableSelection(selection) || $isRangeSelection(selection))) {
597
+ return false;
598
+ }
599
+ // Copying to the clipboard is async so we must capture the data
600
+ // before we delete it
601
+ void copyToClipboard(
602
+ editor,
603
+ objectKlassEquals(event, ClipboardEvent) ? event : null,
604
+ $getClipboardDataFromSelection(selection),
605
+ );
606
+ const intercepted = $deleteCellHandler(event);
607
+ if ($isRangeSelection(selection)) {
608
+ selection.removeText();
609
+ return true;
610
+ }
611
+ return intercepted;
612
+ }
613
+ return false;
614
+ },
615
+ COMMAND_PRIORITY_HIGH,
616
+ ),
617
+ );
618
+
619
+ tableObserver.listenersToRemove.add(
620
+ editor.registerCommand(
621
+ FORMAT_TEXT_COMMAND,
622
+ payload => {
623
+ const selection = $getSelection();
624
+
625
+ if (!$isSelectionInTable(selection, tableNode)) {
626
+ return false;
627
+ }
628
+
629
+ if ($isTableSelection(selection)) {
630
+ tableObserver.$formatCells(payload);
631
+
632
+ return true;
633
+ } else if ($isRangeSelection(selection)) {
634
+ const tableCellNode = $findMatchingParent(
635
+ selection.anchor.getNode(),
636
+ n => $isTableCellNode(n),
637
+ );
638
+
639
+ if (!$isTableCellNode(tableCellNode)) {
640
+ return false;
641
+ }
642
+ }
643
+
644
+ return false;
645
+ },
646
+ COMMAND_PRIORITY_HIGH,
647
+ ),
648
+ );
649
+
650
+ tableObserver.listenersToRemove.add(
651
+ editor.registerCommand(
652
+ FORMAT_ELEMENT_COMMAND,
653
+ formatType => {
654
+ const selection = $getSelection();
655
+ if (
656
+ !$isTableSelection(selection) ||
657
+ !$isSelectionInTable(selection, tableNode)
658
+ ) {
659
+ return false;
660
+ }
661
+
662
+ const anchorNode = selection.anchor.getNode();
663
+ const focusNode = selection.focus.getNode();
664
+ if (!$isTableCellNode(anchorNode) || !$isTableCellNode(focusNode)) {
665
+ return false;
666
+ }
667
+
668
+ // Align the table if the entire table is selected
669
+ if ($isFullTableSelection(selection, tableNode)) {
670
+ tableNode.setFormat(formatType);
671
+ return true;
672
+ }
673
+
674
+ const [tableMap, anchorCell, focusCell] = $computeTableMap(
675
+ tableNode,
676
+ anchorNode,
677
+ focusNode,
678
+ );
679
+ const maxRow = Math.max(
680
+ anchorCell.startRow + anchorCell.cell.__rowSpan - 1,
681
+ focusCell.startRow + focusCell.cell.__rowSpan - 1,
682
+ );
683
+ const maxColumn = Math.max(
684
+ anchorCell.startColumn + anchorCell.cell.__colSpan - 1,
685
+ focusCell.startColumn + focusCell.cell.__colSpan - 1,
686
+ );
687
+ const minRow = Math.min(anchorCell.startRow, focusCell.startRow);
688
+ const minColumn = Math.min(
689
+ anchorCell.startColumn,
690
+ focusCell.startColumn,
691
+ );
692
+ const visited = new Set<TableCellNode>();
693
+ for (let i = minRow; i <= maxRow; i++) {
694
+ for (let j = minColumn; j <= maxColumn; j++) {
695
+ const cell = tableMap[i][j].cell;
696
+ if (visited.has(cell)) {
697
+ continue;
698
+ }
699
+ visited.add(cell);
700
+ cell.setFormat(formatType);
701
+
702
+ const cellChildren = cell.getChildren();
703
+ for (let k = 0; k < cellChildren.length; k++) {
704
+ const child = cellChildren[k];
705
+ if ($isElementNode(child) && !child.isInline()) {
706
+ child.setFormat(formatType);
707
+ }
708
+ }
709
+ }
710
+ }
711
+ return true;
712
+ },
713
+ COMMAND_PRIORITY_HIGH,
714
+ ),
715
+ );
716
+
717
+ tableObserver.listenersToRemove.add(
718
+ editor.registerCommand(
719
+ CONTROLLED_TEXT_INSERTION_COMMAND,
720
+ payload => {
721
+ const selection = $getSelection();
722
+
723
+ if (!$isSelectionInTable(selection, tableNode)) {
724
+ return false;
725
+ }
726
+
727
+ if ($isTableSelection(selection)) {
728
+ tableObserver.$clearHighlight();
729
+
730
+ return false;
731
+ } else if ($isRangeSelection(selection)) {
732
+ const tableCellNode = $findMatchingParent(
733
+ selection.anchor.getNode(),
734
+ n => $isTableCellNode(n),
735
+ );
736
+
737
+ if (!$isTableCellNode(tableCellNode)) {
738
+ return false;
739
+ }
740
+
741
+ if (typeof payload === 'string') {
742
+ const edgePosition = $getTableEdgeCursorPosition(
743
+ editor,
744
+ selection,
745
+ tableNode,
746
+ );
747
+ if (edgePosition) {
748
+ $insertParagraphAtTableEdge(edgePosition, tableNode, [
749
+ $createTextNode(payload),
750
+ ]);
751
+ return true;
752
+ }
753
+ }
754
+ }
755
+
756
+ return false;
757
+ },
758
+ COMMAND_PRIORITY_HIGH,
759
+ ),
760
+ );
761
+
762
+ if (hasTabHandler) {
763
+ tableObserver.listenersToRemove.add(
764
+ editor.registerCommand(
765
+ KEY_TAB_COMMAND,
766
+ event => {
767
+ const selection = $getSelection();
768
+ if (
769
+ !$isRangeSelection(selection) ||
770
+ !selection.isCollapsed() ||
771
+ !$isSelectionInTable(selection, tableNode)
772
+ ) {
773
+ return false;
774
+ }
775
+
776
+ const tableCellNode = $findCellNode(selection.anchor.getNode());
777
+ if (
778
+ tableCellNode === null ||
779
+ !tableNode.is($findTableNode(tableCellNode))
780
+ ) {
781
+ return false;
782
+ }
783
+
784
+ stopEvent(event);
785
+ $selectAdjacentCell(
786
+ tableCellNode,
787
+ event.shiftKey ? 'previous' : 'next',
788
+ );
789
+
790
+ return true;
791
+ },
792
+ COMMAND_PRIORITY_HIGH,
793
+ ),
794
+ );
795
+ }
796
+
797
+ tableObserver.listenersToRemove.add(
798
+ editor.registerCommand(
799
+ FOCUS_COMMAND,
800
+ payload => {
801
+ return tableNode.isSelected();
802
+ },
803
+ COMMAND_PRIORITY_HIGH,
804
+ ),
805
+ );
806
+
807
+ tableObserver.listenersToRemove.add(
808
+ editor.registerCommand(
809
+ INSERT_PARAGRAPH_COMMAND,
810
+ () => {
811
+ const selection = $getSelection();
812
+ if (
813
+ !$isRangeSelection(selection) ||
814
+ !selection.isCollapsed() ||
815
+ !$isSelectionInTable(selection, tableNode)
816
+ ) {
817
+ return false;
818
+ }
819
+ const edgePosition = $getTableEdgeCursorPosition(
820
+ editor,
821
+ selection,
822
+ tableNode,
823
+ );
824
+ if (edgePosition) {
825
+ $insertParagraphAtTableEdge(edgePosition, tableNode);
826
+ return true;
827
+ }
828
+ return false;
829
+ },
830
+ COMMAND_PRIORITY_HIGH,
831
+ ),
832
+ );
833
+
834
+ return tableObserver;
835
+ }
836
+
837
+ /** @internal */
838
+ export function $handleTableSelectionChangeCommand(
839
+ tableObservers: TableObservers,
840
+ editor: LexicalEditor,
841
+ ) {
842
+ const selection = $getSelection();
843
+ const prevSelection = $getPreviousSelection();
844
+
845
+ const nextFocus = tableObservers.getAndClearNextFocus();
846
+ if (nextFocus !== null) {
847
+ const {tableKey, focusCell} = nextFocus;
848
+ const observerAndTable = tableObservers.observers.get(tableKey);
849
+ invariant(
850
+ !!observerAndTable,
851
+ 'tableObserver not found for tableKey: %s',
852
+ tableKey,
853
+ );
854
+ const [tableObserver] = observerAndTable;
855
+ if (
856
+ $isTableSelection(selection) &&
857
+ selection.tableKey === tableObserver.tableNodeKey
858
+ ) {
859
+ if (
860
+ focusCell.x === tableObserver.focusX &&
861
+ focusCell.y === tableObserver.focusY
862
+ ) {
863
+ // The selection is already the correct table selection
864
+ return false;
865
+ } else {
866
+ tableObserver.$setFocusCellForSelection(focusCell);
867
+ return true;
868
+ }
869
+ } else if (
870
+ tableObserver.anchorCell !== null &&
871
+ tableObserver.anchorCellNodeKey !== null &&
872
+ focusCell.elem !== tableObserver.anchorCell.elem &&
873
+ tableObserver.tableSelection !== null
874
+ ) {
875
+ // The selection has crossed cells
876
+ // If we have an anchor cell set and tableSelection initialized,
877
+ // we have all the necessary state to create the selection.
878
+ // The presence of nextFocus means we're dragging, so process it.
879
+ // Use ignoreStart=true to ensure isHighlightingCells is set correctly
880
+ // on the first drag attempt, especially when switching columns.
881
+ tableObserver.$setFocusCellForSelection(focusCell, true);
882
+ return true;
883
+ }
884
+ }
885
+ const shouldCheckSelectionForTable =
886
+ tableObservers.getAndClearShouldCheckSelectionForTable();
887
+ // If they pressed the down arrow with the selection outside of the
888
+ // table, and then the selection ends up in the table but not in the
889
+ // first cell, then move the selection to the first cell.
890
+ if (
891
+ !!shouldCheckSelectionForTable &&
892
+ $isRangeSelection(prevSelection) &&
893
+ $isRangeSelection(selection) &&
894
+ selection.isCollapsed()
895
+ ) {
896
+ const tableNode = $getNodeByKeyOrThrow<TableNode>(
897
+ shouldCheckSelectionForTable,
898
+ );
899
+ const anchor = selection.anchor.getNode();
900
+ const firstRow = tableNode.getFirstChild();
901
+ const anchorCell = $findCellNode(anchor);
902
+ if (anchorCell !== null && $isTableRowNode(firstRow)) {
903
+ const firstCell = firstRow.getFirstChild();
904
+ if (
905
+ $isTableCellNode(firstCell) &&
906
+ tableNode.is(
907
+ $findMatchingParent(
908
+ anchorCell,
909
+ node => node.is(tableNode) || node.is(firstCell),
910
+ ),
911
+ )
912
+ ) {
913
+ // The selection moved to the table, but not in the first cell
914
+ firstCell.selectStart();
915
+ return true;
916
+ }
917
+ }
918
+ }
919
+
920
+ if ($isTableSelection(selection)) {
921
+ $fixTableSelectionForSelectedTable(editor, selection);
922
+ }
923
+
924
+ if ($isRangeSelection(selection)) {
925
+ $fixRangeSelectionForSelectedTable(selection, tableObservers);
926
+ }
927
+
928
+ // Generic selection logic that runs across every table observer when the selection changes.
929
+ // Note: the selection might have changed in the code above, which re-dispatches the selection change command
930
+ // and gets handled here on the second pass. This should be refactored.
931
+ const tableNodesAndObservers = Array.from(
932
+ tableObservers.observers.entries(),
933
+ ).map(([tableKey, [tableObserver]]) => ({
934
+ tableNode: $getNodeByKeyOrThrow<TableNode>(tableKey),
935
+ tableObserver,
936
+ }));
937
+ for (const {tableNode, tableObserver} of tableNodesAndObservers) {
938
+ $syncTableSelectionState(editor, tableNode, tableObserver);
939
+ }
940
+
941
+ return false;
942
+ }
943
+
944
+ /**
945
+ * Handles cases where range selections cross into, out of, or within tables.
946
+ */
947
+ function $fixRangeSelectionForSelectedTable(
948
+ selection: RangeSelection,
949
+ tableObservers: TableObservers,
950
+ ) {
951
+ const prevSelection = $getPreviousSelection();
952
+ const {anchor, focus} = selection;
953
+ const anchorNode = anchor.getNode();
954
+ const focusNode = focus.getNode();
955
+ // Using explicit comparison with table node to ensure it's not a nested table
956
+ // as in that case we'll leave selection resolving to that table
957
+ const anchorCellNode = $findCellNode(anchorNode);
958
+ const focusCellNode = $findCellNode(focusNode);
959
+ const anchorCellTable = anchorCellNode
960
+ ? $findTableNode(anchorCellNode)
961
+ : null;
962
+ const focusCellTable = focusCellNode ? $findTableNode(focusCellNode) : null;
963
+ const isBackward = selection.isBackward();
964
+
965
+ const isSameTable =
966
+ anchorCellNode &&
967
+ focusCellNode &&
968
+ anchorCellTable &&
969
+ focusCellTable &&
970
+ anchorCellTable.is(focusCellTable);
971
+
972
+ // The focus should be moved (to cover the whole focus table) if it is moved outside of the anchor's table.
973
+ // For example, when dragging from outside a table into it.
974
+ const shouldMoveFocus =
975
+ focusCellTable &&
976
+ (!anchorCellTable || anchorCellTable.isParentOf(focusCellTable));
977
+ // The anchor should be moved (to cover the whole anchor table) if the focus is moved outside of the anchor table.
978
+ // For example, when dragging from inside a table out of it.
979
+ const shouldMoveAnchor =
980
+ anchorCellTable &&
981
+ (!focusCellTable || focusCellTable.isParentOf(anchorCellTable));
982
+
983
+ if (shouldMoveFocus) {
984
+ // Select the whole focus table.
985
+ const newSelection = selection.clone();
986
+ const [tableMap] = $computeTableMap(
987
+ focusCellTable,
988
+ focusCellNode!,
989
+ focusCellNode!,
990
+ );
991
+ const firstCell = tableMap[0][0].cell;
992
+ const lastCell = tableMap[tableMap.length - 1].at(-1)!.cell;
993
+ newSelection.focus.set(
994
+ isBackward ? firstCell.getKey() : lastCell.getKey(),
995
+ isBackward ? 0 : lastCell.getChildrenSize(),
996
+ 'element',
997
+ );
998
+ $setSelection(newSelection);
999
+ } else if (shouldMoveAnchor) {
1000
+ // Select the whole anchor table.
1001
+ const newSelection = selection.clone();
1002
+ const [tableMap] = $computeTableMap(
1003
+ anchorCellTable,
1004
+ anchorCellNode!,
1005
+ anchorCellNode!,
1006
+ );
1007
+ const firstCell = tableMap[0][0].cell;
1008
+ const lastCell = tableMap[tableMap.length - 1].at(-1)!.cell;
1009
+ newSelection.anchor.set(
1010
+ isBackward ? lastCell.getKey() : firstCell.getKey(),
1011
+ isBackward ? lastCell.getChildrenSize() : 0,
1012
+ 'element',
1013
+ );
1014
+ $setSelection(newSelection);
1015
+ } else if (isSameTable) {
1016
+ // Handle case when selection spans across multiple cells but still
1017
+ // has range selection, then we convert it into table selection
1018
+ // For example, this fires when dragging up from first cell, outside of the table, or when clicking a cell
1019
+ // then shift-clicking another cell.
1020
+ const observerInfo = tableObservers.observers.get(anchorCellTable.getKey());
1021
+ invariant(
1022
+ !!observerInfo,
1023
+ 'tableObserver not found for tableKey: %s',
1024
+ anchorCellTable.getKey(),
1025
+ );
1026
+ const [tableObserver] = observerInfo;
1027
+ if (!anchorCellNode.is(focusCellNode)) {
1028
+ tableObserver.$setAnchorCellForSelection(
1029
+ $getObserverCellFromCellNodeOrThrow(tableObserver, anchorCellNode),
1030
+ );
1031
+ tableObserver.$setFocusCellForSelection(
1032
+ $getObserverCellFromCellNodeOrThrow(tableObserver, focusCellNode),
1033
+ true,
1034
+ );
1035
+ }
1036
+
1037
+ // Handle case when the pointer type is touch and the current and
1038
+ // previous selection are collapsed, and the previous anchor and current
1039
+ // focus cell nodes are different, then we convert it into table selection
1040
+ // However, only do this if the table observer is actively selecting (user dragging)
1041
+ // to prevent unwanted selections when simply tapping between cells on mobile
1042
+ if (
1043
+ tableObserver.pointerType === 'touch' &&
1044
+ tableObserver.isSelecting &&
1045
+ selection.isCollapsed() &&
1046
+ $isRangeSelection(prevSelection) &&
1047
+ prevSelection.isCollapsed()
1048
+ ) {
1049
+ const prevAnchorCellNode = $findCellNode(prevSelection.anchor.getNode());
1050
+ if (prevAnchorCellNode && !prevAnchorCellNode.is(focusCellNode)) {
1051
+ tableObserver.$setAnchorCellForSelection(
1052
+ $getObserverCellFromCellNodeOrThrow(
1053
+ tableObserver,
1054
+ prevAnchorCellNode,
1055
+ ),
1056
+ );
1057
+ tableObserver.$setFocusCellForSelection(
1058
+ $getObserverCellFromCellNodeOrThrow(tableObserver, focusCellNode),
1059
+ true,
1060
+ );
1061
+ tableObserver.pointerType = null;
1062
+ }
1063
+ }
1064
+ }
1065
+ }
1066
+
1067
+ /**
1068
+ * Ensures that a TableSelection is automatically changed to a RangeSelection when the selection goes outside of the table.
1069
+ */
1070
+ function $fixTableSelectionForSelectedTable(
1071
+ editor: LexicalEditor,
1072
+ selection: TableSelection,
1073
+ ) {
1074
+ const editorWindow = getEditorWindow(editor);
1075
+ const prevSelection = $getPreviousSelection();
1076
+ if (!selection.is(prevSelection)) {
1077
+ return;
1078
+ }
1079
+ const tableNode = $getNodeByKeyOrThrow<TableNode>(selection.tableKey);
1080
+ // if selection goes outside of the table we need to change it to Range selection
1081
+ const domSelection = getDOMSelection(editorWindow);
1082
+ if (domSelection && domSelection.anchorNode && domSelection.focusNode) {
1083
+ const focusNode = $getNearestNodeFromDOMNode(domSelection.focusNode);
1084
+ const isFocusOutside = focusNode && !tableNode.isParentOf(focusNode);
1085
+
1086
+ const anchorNode = $getNearestNodeFromDOMNode(domSelection.anchorNode);
1087
+ const isAnchorInside = anchorNode && tableNode.isParentOf(anchorNode);
1088
+
1089
+ if (isFocusOutside && isAnchorInside && domSelection.rangeCount > 0) {
1090
+ const newSelection = $createRangeSelectionFromDom(domSelection, editor);
1091
+ if (newSelection) {
1092
+ newSelection.anchor.set(
1093
+ tableNode.getKey(),
1094
+ selection.isBackward() ? tableNode.getChildrenSize() : 0,
1095
+ 'element',
1096
+ );
1097
+ domSelection.removeAllRanges();
1098
+ $setSelection(newSelection);
1099
+ }
1100
+ }
1101
+ }
1102
+ }
1103
+
1104
+ // Handle keeping the table observer/DOM in sync with the selection.
1105
+ function $syncTableSelectionState(
1106
+ editor: LexicalEditor,
1107
+ tableNode: TableNode,
1108
+ tableObserver: TableObserver,
1109
+ ) {
1110
+ const selection = $getSelection();
1111
+ const prevSelection = $getPreviousSelection();
1112
+ if (
1113
+ selection &&
1114
+ !selection.is(prevSelection) &&
1115
+ ($isTableSelection(selection) || $isTableSelection(prevSelection)) &&
1116
+ tableObserver.tableSelection &&
1117
+ !tableObserver.tableSelection.is(prevSelection)
1118
+ ) {
1119
+ if (
1120
+ $isTableSelection(selection) &&
1121
+ selection.tableKey === tableObserver.tableNodeKey
1122
+ ) {
1123
+ tableObserver.$updateTableTableSelection(selection);
1124
+ } else if (
1125
+ !$isTableSelection(selection) &&
1126
+ $isTableSelection(prevSelection) &&
1127
+ prevSelection.tableKey === tableObserver.tableNodeKey
1128
+ ) {
1129
+ tableObserver.$updateTableTableSelection(null);
1130
+ }
1131
+ }
1132
+
1133
+ if (tableObserver.hasHijackedSelectionStyles && !tableNode.isSelected()) {
1134
+ $removeHighlightStyleToTable(editor, tableObserver);
1135
+ } else if (
1136
+ !tableObserver.hasHijackedSelectionStyles &&
1137
+ tableNode.isSelected()
1138
+ ) {
1139
+ $addHighlightStyleToTable(editor, tableObserver);
1140
+ }
1141
+ }
1142
+
1143
+ export type HTMLTableElementWithWithTableSelectionState = HTMLTableElement & {
1144
+ [LEXICAL_ELEMENT_KEY]?: TableObserver | undefined;
1145
+ };
1146
+
1147
+ export function detachTableObserverFromTableElement(
1148
+ tableElement: HTMLTableElementWithWithTableSelectionState,
1149
+ tableObserver: TableObserver,
1150
+ ) {
1151
+ if (getTableObserverFromTableElement(tableElement) === tableObserver) {
1152
+ delete tableElement[LEXICAL_ELEMENT_KEY];
1153
+ }
1154
+ }
1155
+
1156
+ export function attachTableObserverToTableElement(
1157
+ tableElement: HTMLTableElementWithWithTableSelectionState,
1158
+ tableObserver: TableObserver,
1159
+ ) {
1160
+ invariant(
1161
+ getTableObserverFromTableElement(tableElement) === null,
1162
+ 'tableElement already has an attached TableObserver',
1163
+ );
1164
+ tableElement[LEXICAL_ELEMENT_KEY] = tableObserver;
1165
+ }
1166
+
1167
+ export function getTableObserverFromTableElement(
1168
+ tableElement: HTMLTableElementWithWithTableSelectionState,
1169
+ ): TableObserver | null {
1170
+ return tableElement[LEXICAL_ELEMENT_KEY] || null;
1171
+ }
1172
+
1173
+ function getTableObserverFromCellNode(node: null | Node): {
1174
+ tableObserver: TableObserver;
1175
+ tableElement: HTMLTableElementWithWithTableSelectionState;
1176
+ cellElement: TableDOMCell;
1177
+ } | null {
1178
+ const cellNode = getDOMCellFromTarget(node);
1179
+ if (cellNode === null) {
1180
+ return null;
1181
+ }
1182
+ let currentNode: ParentNode | Node | null = cellNode.elem;
1183
+ while (currentNode != null) {
1184
+ const nodeName = currentNode.nodeName;
1185
+ if (
1186
+ nodeName === 'TABLE' &&
1187
+ LEXICAL_ELEMENT_KEY in currentNode &&
1188
+ !!currentNode[LEXICAL_ELEMENT_KEY]
1189
+ ) {
1190
+ return {
1191
+ cellElement: cellNode,
1192
+ tableElement:
1193
+ currentNode as HTMLTableElementWithWithTableSelectionState,
1194
+ tableObserver: currentNode[LEXICAL_ELEMENT_KEY] as TableObserver,
1195
+ };
1196
+ }
1197
+ currentNode = currentNode.parentNode;
1198
+ }
1199
+ return null;
1200
+ }
1201
+
1202
+ export function getDOMCellFromTarget(node: null | Node): TableDOMCell | null {
1203
+ let currentNode: ParentNode | Node | null = node;
1204
+
1205
+ while (currentNode != null) {
1206
+ const nodeName = currentNode.nodeName;
1207
+
1208
+ if (nodeName === 'TD' || nodeName === 'TH') {
1209
+ // @ts-expect-error: internal field
1210
+ const cell = currentNode._cell;
1211
+
1212
+ if (cell === undefined) {
1213
+ return null;
1214
+ }
1215
+
1216
+ return cell;
1217
+ }
1218
+
1219
+ currentNode = currentNode.parentNode;
1220
+ }
1221
+
1222
+ return null;
1223
+ }
1224
+
1225
+ export function getDOMCellInTableFromTarget(
1226
+ table: HTMLTableElementWithWithTableSelectionState,
1227
+ node: null | Node,
1228
+ ): TableDOMCell | null {
1229
+ if (!table.contains(node)) {
1230
+ return null;
1231
+ }
1232
+ let cell: null | TableDOMCell = null;
1233
+ for (
1234
+ let currentNode: ParentNode | Node | null = node;
1235
+ currentNode != null;
1236
+ currentNode = currentNode.parentNode
1237
+ ) {
1238
+ if (currentNode === table) {
1239
+ return cell;
1240
+ }
1241
+ const nodeName = currentNode.nodeName;
1242
+ if (nodeName === 'TD' || nodeName === 'TH') {
1243
+ // @ts-expect-error: internal field
1244
+ cell = currentNode._cell || null;
1245
+ }
1246
+ }
1247
+ return null;
1248
+ }
1249
+
1250
+ export function doesTargetContainText(node: Node): boolean {
1251
+ const currentNode: ParentNode | Node | null = node;
1252
+
1253
+ if (currentNode !== null) {
1254
+ const nodeName = currentNode.nodeName;
1255
+
1256
+ if (nodeName === 'SPAN') {
1257
+ return true;
1258
+ }
1259
+ }
1260
+ return false;
1261
+ }
1262
+
1263
+ export function getTable(
1264
+ tableNode: TableNode,
1265
+ dom: HTMLElement,
1266
+ ): TableDOMTable {
1267
+ const tableElement = getTableElement(tableNode, dom);
1268
+ const domRows: TableDOMRows = [];
1269
+ const grid = {
1270
+ columns: 0,
1271
+ domRows,
1272
+ rows: 0,
1273
+ };
1274
+ let currentNode = tableElement.querySelector('tr') as ChildNode | null;
1275
+ let x = 0;
1276
+ let y = 0;
1277
+ domRows.length = 0;
1278
+
1279
+ while (currentNode != null) {
1280
+ const nodeMame = currentNode.nodeName;
1281
+
1282
+ if (nodeMame === 'TD' || nodeMame === 'TH') {
1283
+ const elem = currentNode as HTMLElement;
1284
+ const cell = {
1285
+ elem,
1286
+ hasBackgroundColor: elem.style.backgroundColor !== '',
1287
+ highlighted: false,
1288
+ x,
1289
+ y,
1290
+ };
1291
+
1292
+ // @ts-expect-error: internal field
1293
+ currentNode._cell = cell;
1294
+
1295
+ let row = domRows[y];
1296
+ if (row === undefined) {
1297
+ row = domRows[y] = [];
1298
+ }
1299
+
1300
+ row[x] = cell;
1301
+ } else {
1302
+ const child = currentNode.firstChild;
1303
+
1304
+ if (child != null) {
1305
+ currentNode = child;
1306
+ continue;
1307
+ }
1308
+ }
1309
+
1310
+ const sibling = currentNode.nextSibling;
1311
+
1312
+ if (sibling != null) {
1313
+ x++;
1314
+ currentNode = sibling;
1315
+ continue;
1316
+ }
1317
+
1318
+ const parent = currentNode.parentNode;
1319
+
1320
+ if (parent != null) {
1321
+ const parentSibling = parent.nextSibling;
1322
+
1323
+ if (parentSibling == null) {
1324
+ break;
1325
+ }
1326
+
1327
+ y++;
1328
+ x = 0;
1329
+ currentNode = parentSibling;
1330
+ }
1331
+ }
1332
+
1333
+ grid.columns = x + 1;
1334
+ grid.rows = y + 1;
1335
+
1336
+ return grid;
1337
+ }
1338
+
1339
+ export function $updateDOMForSelection(
1340
+ editor: LexicalEditor,
1341
+ table: TableDOMTable,
1342
+ selection: TableSelection | RangeSelection | null,
1343
+ ) {
1344
+ const selectedCellNodes = new Set(selection ? selection.getNodes() : []);
1345
+ $forEachTableCell(table, (cell, lexicalNode) => {
1346
+ const elem = cell.elem;
1347
+
1348
+ if (selectedCellNodes.has(lexicalNode)) {
1349
+ cell.highlighted = true;
1350
+ $addHighlightToDOM(editor, cell);
1351
+ } else {
1352
+ cell.highlighted = false;
1353
+ $removeHighlightFromDOM(editor, cell);
1354
+ if (!elem.getAttribute('style')) {
1355
+ elem.removeAttribute('style');
1356
+ }
1357
+ }
1358
+ });
1359
+ }
1360
+
1361
+ export function $forEachTableCell(
1362
+ grid: TableDOMTable,
1363
+ cb: (
1364
+ cell: TableDOMCell,
1365
+ lexicalNode: LexicalNode,
1366
+ cords: {
1367
+ x: number;
1368
+ y: number;
1369
+ },
1370
+ ) => void,
1371
+ ) {
1372
+ const {domRows} = grid;
1373
+
1374
+ for (let y = 0; y < domRows.length; y++) {
1375
+ const row = domRows[y];
1376
+ if (!row) {
1377
+ continue;
1378
+ }
1379
+
1380
+ for (let x = 0; x < row.length; x++) {
1381
+ const cell = row[x];
1382
+ if (!cell) {
1383
+ continue;
1384
+ }
1385
+ const lexicalNode = $getNearestNodeFromDOMNode(cell.elem);
1386
+
1387
+ if (lexicalNode !== null) {
1388
+ cb(cell, lexicalNode, {
1389
+ x,
1390
+ y,
1391
+ });
1392
+ }
1393
+ }
1394
+ }
1395
+ }
1396
+
1397
+ export function $addHighlightStyleToTable(
1398
+ editor: LexicalEditor,
1399
+ tableSelection: TableObserver,
1400
+ ) {
1401
+ tableSelection.$disableHighlightStyle();
1402
+ $forEachTableCell(tableSelection.table, cell => {
1403
+ cell.highlighted = true;
1404
+ $addHighlightToDOM(editor, cell);
1405
+ });
1406
+ }
1407
+
1408
+ export function $removeHighlightStyleToTable(
1409
+ editor: LexicalEditor,
1410
+ tableObserver: TableObserver,
1411
+ ) {
1412
+ tableObserver.$enableHighlightStyle();
1413
+ $forEachTableCell(tableObserver.table, cell => {
1414
+ const elem = cell.elem;
1415
+ cell.highlighted = false;
1416
+ $removeHighlightFromDOM(editor, cell);
1417
+
1418
+ if (!elem.getAttribute('style')) {
1419
+ elem.removeAttribute('style');
1420
+ }
1421
+ });
1422
+ }
1423
+
1424
+ function $selectAdjacentCell(
1425
+ tableCellNode: TableCellNode,
1426
+ direction: 'next' | 'previous',
1427
+ ) {
1428
+ const siblingMethod =
1429
+ direction === 'next' ? 'getNextSibling' : 'getPreviousSibling';
1430
+ const childMethod = direction === 'next' ? 'getFirstChild' : 'getLastChild';
1431
+ const sibling = tableCellNode[siblingMethod]();
1432
+ if ($isElementNode(sibling)) {
1433
+ return sibling.selectEnd();
1434
+ }
1435
+ const parentRow = $findMatchingParent(tableCellNode, $isTableRowNode);
1436
+ invariant(parentRow !== null, 'selectAdjacentCell: Cell not in table row');
1437
+ for (
1438
+ let nextRow = parentRow[siblingMethod]();
1439
+ $isTableRowNode(nextRow);
1440
+ nextRow = nextRow[siblingMethod]()
1441
+ ) {
1442
+ const child = nextRow[childMethod]();
1443
+ if ($isElementNode(child)) {
1444
+ return child.selectEnd();
1445
+ }
1446
+ }
1447
+ const parentTable = $findMatchingParent(parentRow, $isTableNode);
1448
+ invariant(parentTable !== null, 'selectAdjacentCell: Row not in table');
1449
+ return direction === 'next'
1450
+ ? parentTable.selectNext()
1451
+ : parentTable.selectPrevious();
1452
+ }
1453
+
1454
+ type Direction = 'backward' | 'forward' | 'up' | 'down';
1455
+
1456
+ const selectTableNodeInDirection = (
1457
+ tableObserver: TableObserver,
1458
+ tableNode: TableNode,
1459
+ x: number,
1460
+ y: number,
1461
+ direction: Direction,
1462
+ ): boolean => {
1463
+ const isForward = direction === 'forward';
1464
+
1465
+ switch (direction) {
1466
+ case 'backward':
1467
+ case 'forward':
1468
+ if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
1469
+ selectTableCellNode(
1470
+ tableNode.getCellNodeFromCordsOrThrow(
1471
+ x + (isForward ? 1 : -1),
1472
+ y,
1473
+ tableObserver.table,
1474
+ ),
1475
+ isForward,
1476
+ );
1477
+ } else {
1478
+ if (y !== (isForward ? tableObserver.table.rows - 1 : 0)) {
1479
+ selectTableCellNode(
1480
+ tableNode.getCellNodeFromCordsOrThrow(
1481
+ isForward ? 0 : tableObserver.table.columns - 1,
1482
+ y + (isForward ? 1 : -1),
1483
+ tableObserver.table,
1484
+ ),
1485
+ isForward,
1486
+ );
1487
+ } else if (!isForward) {
1488
+ tableNode.selectPrevious();
1489
+ } else {
1490
+ tableNode.selectNext();
1491
+ }
1492
+ }
1493
+
1494
+ return true;
1495
+
1496
+ case 'up':
1497
+ if (y !== 0) {
1498
+ selectTableCellNode(
1499
+ tableNode.getCellNodeFromCordsOrThrow(x, y - 1, tableObserver.table),
1500
+ false,
1501
+ );
1502
+ } else {
1503
+ tableNode.selectPrevious();
1504
+ }
1505
+
1506
+ return true;
1507
+
1508
+ case 'down':
1509
+ if (y !== tableObserver.table.rows - 1) {
1510
+ selectTableCellNode(
1511
+ tableNode.getCellNodeFromCordsOrThrow(x, y + 1, tableObserver.table),
1512
+ true,
1513
+ );
1514
+ } else {
1515
+ tableNode.selectNext();
1516
+ }
1517
+
1518
+ return true;
1519
+ default:
1520
+ return false;
1521
+ }
1522
+ };
1523
+
1524
+ type Corner = ['minColumn' | 'maxColumn', 'minRow' | 'maxRow'];
1525
+ function getCorner(
1526
+ rect: TableCellRectBoundary,
1527
+ cellValue: TableMapValueType,
1528
+ ): Corner | null {
1529
+ let colName: 'minColumn' | 'maxColumn';
1530
+ let rowName: 'minRow' | 'maxRow';
1531
+ if (cellValue.startColumn === rect.minColumn) {
1532
+ colName = 'minColumn';
1533
+ } else if (
1534
+ cellValue.startColumn + cellValue.cell.__colSpan - 1 ===
1535
+ rect.maxColumn
1536
+ ) {
1537
+ colName = 'maxColumn';
1538
+ } else {
1539
+ return null;
1540
+ }
1541
+ if (cellValue.startRow === rect.minRow) {
1542
+ rowName = 'minRow';
1543
+ } else if (
1544
+ cellValue.startRow + cellValue.cell.__rowSpan - 1 ===
1545
+ rect.maxRow
1546
+ ) {
1547
+ rowName = 'maxRow';
1548
+ } else {
1549
+ return null;
1550
+ }
1551
+ return [colName, rowName];
1552
+ }
1553
+
1554
+ function getCornerOrThrow(
1555
+ rect: TableCellRectBoundary,
1556
+ cellValue: TableMapValueType,
1557
+ ): Corner {
1558
+ const corner = getCorner(rect, cellValue);
1559
+ invariant(
1560
+ corner !== null,
1561
+ 'getCornerOrThrow: cell %s is not at a corner of rect',
1562
+ cellValue.cell.getKey(),
1563
+ );
1564
+ return corner;
1565
+ }
1566
+
1567
+ function oppositeCorner([colName, rowName]: Corner): Corner {
1568
+ return [
1569
+ colName === 'minColumn' ? 'maxColumn' : 'minColumn',
1570
+ rowName === 'minRow' ? 'maxRow' : 'minRow',
1571
+ ];
1572
+ }
1573
+
1574
+ function cellAtCornerOrThrow(
1575
+ tableMap: TableMapType,
1576
+ rect: TableCellRectBoundary,
1577
+ [colName, rowName]: Corner,
1578
+ ): TableMapValueType {
1579
+ const rowNum = rect[rowName];
1580
+ const rowMap = tableMap[rowNum];
1581
+ invariant(
1582
+ rowMap !== undefined,
1583
+ 'cellAtCornerOrThrow: %s = %s missing in tableMap',
1584
+ rowName,
1585
+ String(rowNum),
1586
+ );
1587
+ const colNum = rect[colName];
1588
+ const cell = rowMap[colNum];
1589
+ invariant(
1590
+ cell !== undefined,
1591
+ 'cellAtCornerOrThrow: %s = %s missing in tableMap',
1592
+ colName,
1593
+ String(colNum),
1594
+ );
1595
+ return cell;
1596
+ }
1597
+
1598
+ function $extractRectCorners(
1599
+ tableMap: TableMapType,
1600
+ anchorCellValue: TableMapValueType,
1601
+ newFocusCellValue: TableMapValueType,
1602
+ ) {
1603
+ // We are sure that the focus now either contracts or expands the rect
1604
+ // but both the anchor and focus might be moved to ensure a rectangle
1605
+ // given a potentially ragged merge shape
1606
+ const rect = $computeTableCellRectBoundary(
1607
+ tableMap,
1608
+ anchorCellValue,
1609
+ newFocusCellValue,
1610
+ );
1611
+ const anchorCorner = getCorner(rect, anchorCellValue);
1612
+ if (anchorCorner) {
1613
+ return [
1614
+ cellAtCornerOrThrow(tableMap, rect, anchorCorner),
1615
+ cellAtCornerOrThrow(tableMap, rect, oppositeCorner(anchorCorner)),
1616
+ ];
1617
+ }
1618
+ const newFocusCorner = getCorner(rect, newFocusCellValue);
1619
+ if (newFocusCorner) {
1620
+ return [
1621
+ cellAtCornerOrThrow(tableMap, rect, oppositeCorner(newFocusCorner)),
1622
+ cellAtCornerOrThrow(tableMap, rect, newFocusCorner),
1623
+ ];
1624
+ }
1625
+ // TODO this doesn't have to be arbitrary, use the closest corner instead
1626
+ const newAnchorCorner: Corner = ['minColumn', 'minRow'];
1627
+ return [
1628
+ cellAtCornerOrThrow(tableMap, rect, newAnchorCorner),
1629
+ cellAtCornerOrThrow(tableMap, rect, oppositeCorner(newAnchorCorner)),
1630
+ ];
1631
+ }
1632
+
1633
+ function $adjustFocusInDirection(
1634
+ tableObserver: TableObserver,
1635
+ tableMap: TableMapType,
1636
+ anchorCellValue: TableMapValueType,
1637
+ focusCellValue: TableMapValueType,
1638
+ direction: Direction,
1639
+ ): boolean {
1640
+ const rect = $computeTableCellRectBoundary(
1641
+ tableMap,
1642
+ anchorCellValue,
1643
+ focusCellValue,
1644
+ );
1645
+ const spans = $computeTableCellRectSpans(tableMap, rect);
1646
+ const {topSpan, leftSpan, bottomSpan, rightSpan} = spans;
1647
+ const anchorCorner = getCornerOrThrow(rect, anchorCellValue);
1648
+ const [focusColumn, focusRow] = oppositeCorner(anchorCorner);
1649
+ let fCol = rect[focusColumn];
1650
+ let fRow = rect[focusRow];
1651
+ if (direction === 'forward') {
1652
+ fCol += focusColumn === 'maxColumn' ? 1 : leftSpan;
1653
+ } else if (direction === 'backward') {
1654
+ fCol -= focusColumn === 'minColumn' ? 1 : rightSpan;
1655
+ } else if (direction === 'down') {
1656
+ fRow += focusRow === 'maxRow' ? 1 : topSpan;
1657
+ } else if (direction === 'up') {
1658
+ fRow -= focusRow === 'minRow' ? 1 : bottomSpan;
1659
+ }
1660
+ const targetRowMap = tableMap[fRow];
1661
+ if (targetRowMap === undefined) {
1662
+ return false;
1663
+ }
1664
+ const newFocusCellValue = targetRowMap[fCol];
1665
+ if (newFocusCellValue === undefined) {
1666
+ return false;
1667
+ }
1668
+ // We can be certain that anchorCellValue and newFocusCellValue are
1669
+ // contained within the desired selection, but we are not certain if
1670
+ // they need to be expanded or not to maintain a rectangular shape
1671
+ const [finalAnchorCell, finalFocusCell] = $extractRectCorners(
1672
+ tableMap,
1673
+ anchorCellValue,
1674
+ newFocusCellValue,
1675
+ );
1676
+ const anchorDOM = $getObserverCellFromCellNodeOrThrow(
1677
+ tableObserver,
1678
+ finalAnchorCell.cell,
1679
+ )!;
1680
+ const focusDOM = $getObserverCellFromCellNodeOrThrow(
1681
+ tableObserver,
1682
+ finalFocusCell.cell,
1683
+ );
1684
+ tableObserver.$setAnchorCellForSelection(anchorDOM);
1685
+ tableObserver.$setFocusCellForSelection(focusDOM, true);
1686
+ return true;
1687
+ }
1688
+
1689
+ function $isSelectionInTable(
1690
+ selection: null | BaseSelection,
1691
+ tableNode: TableNode,
1692
+ ): boolean {
1693
+ if ($isRangeSelection(selection) || $isTableSelection(selection)) {
1694
+ // TODO this should probably return false if there's an unrelated
1695
+ // shadow root between the node and the table (e.g. another table,
1696
+ // collapsible, etc.)
1697
+ const isAnchorInside = tableNode.isParentOf(selection.anchor.getNode());
1698
+ const isFocusInside = tableNode.isParentOf(selection.focus.getNode());
1699
+
1700
+ return isAnchorInside && isFocusInside;
1701
+ }
1702
+
1703
+ return false;
1704
+ }
1705
+
1706
+ function $isFullTableSelection(
1707
+ selection: null | BaseSelection,
1708
+ tableNode: TableNode,
1709
+ ): boolean {
1710
+ if ($isTableSelection(selection)) {
1711
+ const anchorNode = selection.anchor.getNode() as TableCellNode;
1712
+ const focusNode = selection.focus.getNode() as TableCellNode;
1713
+ if (tableNode && anchorNode && focusNode) {
1714
+ const [map] = $computeTableMap(tableNode, anchorNode, focusNode);
1715
+ return (
1716
+ anchorNode.getKey() === map[0][0].cell.getKey() &&
1717
+ focusNode.getKey() === map[map.length - 1].at(-1)!.cell.getKey()
1718
+ );
1719
+ }
1720
+ }
1721
+ return false;
1722
+ }
1723
+
1724
+ function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean) {
1725
+ if (fromStart) {
1726
+ tableCell.selectStart();
1727
+ } else {
1728
+ tableCell.selectEnd();
1729
+ }
1730
+ }
1731
+
1732
+ function $addHighlightToDOM(editor: LexicalEditor, cell: TableDOMCell): void {
1733
+ const element = cell.elem;
1734
+ const editorThemeClasses = editor._config.theme;
1735
+ const node = $getNearestNodeFromDOMNode(element);
1736
+ invariant(
1737
+ $isTableCellNode(node),
1738
+ 'Expected to find LexicalNode from Table Cell DOMNode',
1739
+ );
1740
+ addClassNamesToElement(element, editorThemeClasses.tableCellSelected);
1741
+ }
1742
+
1743
+ function $removeHighlightFromDOM(
1744
+ editor: LexicalEditor,
1745
+ cell: TableDOMCell,
1746
+ ): void {
1747
+ const element = cell.elem;
1748
+ const node = $getNearestNodeFromDOMNode(element);
1749
+ invariant(
1750
+ $isTableCellNode(node),
1751
+ 'Expected to find LexicalNode from Table Cell DOMNode',
1752
+ );
1753
+ const editorThemeClasses = editor._config.theme;
1754
+ removeClassNamesFromElement(element, editorThemeClasses.tableCellSelected);
1755
+ }
1756
+
1757
+ export function $findCellNode(node: LexicalNode): null | TableCellNode {
1758
+ const cellNode = $findMatchingParent(node, $isTableCellNode);
1759
+ return $isTableCellNode(cellNode) ? cellNode : null;
1760
+ }
1761
+
1762
+ export function $findTableNode(node: LexicalNode): null | TableNode {
1763
+ const tableNode = $findMatchingParent(node, $isTableNode);
1764
+ return $isTableNode(tableNode) ? tableNode : null;
1765
+ }
1766
+
1767
+ function $getBlockParentIfFirstNode(node: LexicalNode): ElementNode | null {
1768
+ for (
1769
+ let prevNode = node, currentNode: LexicalNode | null = node;
1770
+ currentNode !== null;
1771
+ prevNode = currentNode, currentNode = currentNode.getParent()
1772
+ ) {
1773
+ if ($isElementNode(currentNode)) {
1774
+ if (
1775
+ currentNode !== prevNode &&
1776
+ currentNode.getFirstChild() !== prevNode
1777
+ ) {
1778
+ // Not the first child or the initial node
1779
+ return null;
1780
+ } else if (!currentNode.isInline()) {
1781
+ return currentNode;
1782
+ }
1783
+ }
1784
+ }
1785
+ return null;
1786
+ }
1787
+
1788
+ function $handleHorizontalArrowKeyRangeSelection(
1789
+ editor: LexicalEditor,
1790
+ event: KeyboardEvent,
1791
+ selection: RangeSelection,
1792
+ alter: 'extend' | 'move',
1793
+ isBackward: boolean,
1794
+ tableNode: TableNode,
1795
+ tableObserver: TableObserver,
1796
+ ): boolean {
1797
+ const initialFocus = $caretFromPoint(
1798
+ selection.focus,
1799
+ isBackward ? 'previous' : 'next',
1800
+ );
1801
+ if ($isExtendableTextPointCaret(initialFocus)) {
1802
+ return false;
1803
+ }
1804
+ let lastCaret = initialFocus;
1805
+ // TableCellNode is the only shadow root we are interested in piercing so
1806
+ // we find the last internal caret and then check its parent
1807
+ for (const nextCaret of $extendCaretToRange(initialFocus).iterNodeCarets(
1808
+ 'shadowRoot',
1809
+ )) {
1810
+ if (!($isSiblingCaret(nextCaret) && $isElementNode(nextCaret.origin))) {
1811
+ return false;
1812
+ }
1813
+ lastCaret = nextCaret;
1814
+ }
1815
+ const lastCaretParent = lastCaret.getParentAtCaret();
1816
+ if (!$isTableCellNode(lastCaretParent)) {
1817
+ return false;
1818
+ }
1819
+ const anchorCell = lastCaretParent;
1820
+ const focusCaret = $findNextTableCell(
1821
+ $getSiblingCaret(anchorCell, lastCaret.direction),
1822
+ );
1823
+ const anchorCellTable = $findMatchingParent(anchorCell, $isTableNode);
1824
+ if (!(anchorCellTable && anchorCellTable.is(tableNode))) {
1825
+ return false;
1826
+ }
1827
+ const anchorCellDOM = editor.getElementByKey(anchorCell.getKey());
1828
+ const anchorDOMCell = getDOMCellFromTarget(anchorCellDOM);
1829
+ if (!anchorCellDOM || !anchorDOMCell) {
1830
+ return false;
1831
+ }
1832
+
1833
+ const anchorCellTableElement = $getElementForTableNode(
1834
+ editor,
1835
+ anchorCellTable,
1836
+ );
1837
+ tableObserver.table = anchorCellTableElement;
1838
+ if (!focusCaret) {
1839
+ if (alter === 'extend') {
1840
+ // extend the selection from a range inside the cell to a table selection of the cell
1841
+ tableObserver.$setAnchorCellForSelection(anchorDOMCell);
1842
+ tableObserver.$setFocusCellForSelection(anchorDOMCell, true);
1843
+ } else {
1844
+ // exit the table
1845
+ const outerFocusCaret = $getTableExitCaret(
1846
+ $getSiblingCaret(anchorCellTable, initialFocus.direction),
1847
+ );
1848
+ $setPointFromCaret(selection.anchor, outerFocusCaret);
1849
+ $setPointFromCaret(selection.focus, outerFocusCaret);
1850
+ }
1851
+ } else if (alter === 'extend') {
1852
+ const focusDOMCell = getDOMCellFromTarget(
1853
+ editor.getElementByKey(focusCaret.origin.getKey()),
1854
+ );
1855
+ if (!focusDOMCell) {
1856
+ return false;
1857
+ }
1858
+ tableObserver.$setAnchorCellForSelection(anchorDOMCell);
1859
+ tableObserver.$setFocusCellForSelection(focusDOMCell, true);
1860
+ } else {
1861
+ // alter === 'move'
1862
+ const innerFocusCaret = $normalizeCaret(focusCaret);
1863
+ $setPointFromCaret(selection.anchor, innerFocusCaret);
1864
+ $setPointFromCaret(selection.focus, innerFocusCaret);
1865
+ }
1866
+ stopEvent(event);
1867
+ return true;
1868
+ }
1869
+
1870
+ function $getTableExitCaret<D extends CaretDirection>(
1871
+ initialCaret: SiblingCaret<TableNode, D>,
1872
+ ): PointCaret<D> {
1873
+ const adjacent = $getAdjacentChildCaret(initialCaret);
1874
+ return $isChildCaret(adjacent) ? $normalizeCaret(adjacent) : initialCaret;
1875
+ }
1876
+
1877
+ function $findNextTableCell<D extends CaretDirection>(
1878
+ initialCaret: SiblingCaret<TableCellNode, D>,
1879
+ ): null | ChildCaret<TableCellNode, D> {
1880
+ for (const nextCaret of $extendCaretToRange(initialCaret).iterNodeCarets(
1881
+ 'root',
1882
+ )) {
1883
+ const {origin} = nextCaret;
1884
+ if ($isTableCellNode(origin)) {
1885
+ // not sure why ts isn't narrowing here (even if the guard is on nextCaret.origin)
1886
+ // but returning a new caret is fine
1887
+ if ($isChildCaret(nextCaret)) {
1888
+ return $getChildCaret(origin, initialCaret.direction);
1889
+ }
1890
+ } else if (!$isTableRowNode(origin)) {
1891
+ break;
1892
+ }
1893
+ }
1894
+ return null;
1895
+ }
1896
+
1897
+ function $handleArrowKey(
1898
+ editor: LexicalEditor,
1899
+ event: KeyboardEvent,
1900
+ direction: Direction,
1901
+ tableNode: TableNode,
1902
+ tableObserver: TableObserver,
1903
+ tableObservers: TableObservers,
1904
+ ): boolean {
1905
+ if (
1906
+ (direction === 'up' || direction === 'down') &&
1907
+ isTypeaheadMenuInView(editor)
1908
+ ) {
1909
+ return false;
1910
+ }
1911
+
1912
+ const selection = $getSelection();
1913
+
1914
+ // Handle arrow key into a table (including from a table into a nested table)
1915
+ if (!$isSelectionInTable(selection, tableNode)) {
1916
+ if ($isRangeSelection(selection)) {
1917
+ if (direction === 'backward') {
1918
+ if (selection.focus.offset > 0) {
1919
+ return false;
1920
+ }
1921
+ const parentNode = $getBlockParentIfFirstNode(
1922
+ selection.focus.getNode(),
1923
+ );
1924
+ if (!parentNode) {
1925
+ return false;
1926
+ }
1927
+ const siblingNode = parentNode.getPreviousSibling();
1928
+ if (!$isTableNode(siblingNode)) {
1929
+ return false;
1930
+ }
1931
+ stopEvent(event);
1932
+ if (event.shiftKey) {
1933
+ selection.focus.set(
1934
+ siblingNode.getParentOrThrow().getKey(),
1935
+ siblingNode.getIndexWithinParent(),
1936
+ 'element',
1937
+ );
1938
+ } else {
1939
+ siblingNode.selectEnd();
1940
+ }
1941
+ return true;
1942
+ } else if (
1943
+ event.shiftKey &&
1944
+ (direction === 'up' || direction === 'down')
1945
+ ) {
1946
+ const focusNode = selection.focus.getNode();
1947
+ const isTableUnselect =
1948
+ !selection.isCollapsed() &&
1949
+ ((direction === 'up' && !selection.isBackward()) ||
1950
+ (direction === 'down' && selection.isBackward()));
1951
+ if (isTableUnselect) {
1952
+ let focusParentNode = $findMatchingParent(focusNode, n =>
1953
+ $isTableNode(n),
1954
+ );
1955
+ if ($isTableCellNode(focusParentNode)) {
1956
+ focusParentNode = $findMatchingParent(
1957
+ focusParentNode,
1958
+ $isTableNode,
1959
+ );
1960
+ }
1961
+ if (focusParentNode !== tableNode) {
1962
+ return false;
1963
+ }
1964
+ if (!focusParentNode) {
1965
+ return false;
1966
+ }
1967
+ const sibling =
1968
+ direction === 'down'
1969
+ ? focusParentNode.getNextSibling()
1970
+ : focusParentNode.getPreviousSibling();
1971
+ if (!sibling) {
1972
+ return false;
1973
+ }
1974
+ let newOffset = 0;
1975
+ if (direction === 'up') {
1976
+ if ($isElementNode(sibling)) {
1977
+ newOffset = sibling.getChildrenSize();
1978
+ }
1979
+ }
1980
+ let newFocusNode = sibling;
1981
+ if (direction === 'up') {
1982
+ if ($isElementNode(sibling)) {
1983
+ const lastCell = sibling.getLastChild();
1984
+ newFocusNode = lastCell ? lastCell : sibling;
1985
+ newOffset = $isTextNode(newFocusNode)
1986
+ ? newFocusNode.getTextContentSize()
1987
+ : 0;
1988
+ }
1989
+ }
1990
+ const newSelection = selection.clone();
1991
+
1992
+ newSelection.focus.set(
1993
+ newFocusNode.getKey(),
1994
+ newOffset,
1995
+ $isTextNode(newFocusNode) ? 'text' : 'element',
1996
+ );
1997
+ $setSelection(newSelection);
1998
+ stopEvent(event);
1999
+ return true;
2000
+ } else if ($isRootOrShadowRoot(focusNode)) {
2001
+ const selectedNode =
2002
+ direction === 'up'
2003
+ ? selection.getNodes()[selection.getNodes().length - 1]
2004
+ : selection.getNodes()[0];
2005
+ if (selectedNode) {
2006
+ const tableCellNode = $findParentTableCellNodeInTable(
2007
+ tableNode,
2008
+ selectedNode,
2009
+ );
2010
+ if (tableCellNode !== null) {
2011
+ const firstDescendant = tableNode.getFirstDescendant();
2012
+ const lastDescendant = tableNode.getLastDescendant();
2013
+ if (!firstDescendant || !lastDescendant) {
2014
+ return false;
2015
+ }
2016
+ const [firstCellNode] = $getNodeTriplet(firstDescendant);
2017
+ const [lastCellNode] = $getNodeTriplet(lastDescendant);
2018
+ const firstCellCoords = tableNode.getCordsFromCellNode(
2019
+ firstCellNode,
2020
+ tableObserver.table,
2021
+ );
2022
+ const lastCellCoords = tableNode.getCordsFromCellNode(
2023
+ lastCellNode,
2024
+ tableObserver.table,
2025
+ );
2026
+ const firstCellDOM = tableNode.getDOMCellFromCordsOrThrow(
2027
+ firstCellCoords.x,
2028
+ firstCellCoords.y,
2029
+ tableObserver.table,
2030
+ );
2031
+ const lastCellDOM = tableNode.getDOMCellFromCordsOrThrow(
2032
+ lastCellCoords.x,
2033
+ lastCellCoords.y,
2034
+ tableObserver.table,
2035
+ );
2036
+ tableObserver.$setAnchorCellForSelection(firstCellDOM);
2037
+ tableObserver.$setFocusCellForSelection(lastCellDOM, true);
2038
+ return true;
2039
+ }
2040
+ }
2041
+ return false;
2042
+ } else {
2043
+ let focusParentNode = $findMatchingParent(
2044
+ focusNode,
2045
+ n => $isElementNode(n) && !n.isInline(),
2046
+ );
2047
+ if ($isTableCellNode(focusParentNode)) {
2048
+ focusParentNode = $findMatchingParent(
2049
+ focusParentNode,
2050
+ $isTableNode,
2051
+ );
2052
+ }
2053
+ if (!focusParentNode) {
2054
+ return false;
2055
+ }
2056
+ const sibling =
2057
+ direction === 'down'
2058
+ ? focusParentNode.getNextSibling()
2059
+ : focusParentNode.getPreviousSibling();
2060
+ if (
2061
+ $isTableNode(sibling) &&
2062
+ tableObserver.tableNodeKey === sibling.getKey()
2063
+ ) {
2064
+ const firstDescendant = sibling.getFirstDescendant();
2065
+ const lastDescendant = sibling.getLastDescendant();
2066
+ if (!firstDescendant || !lastDescendant) {
2067
+ return false;
2068
+ }
2069
+ const [firstCellNode] = $getNodeTriplet(firstDescendant);
2070
+ const [lastCellNode] = $getNodeTriplet(lastDescendant);
2071
+ const newSelection = selection.clone();
2072
+ newSelection.focus.set(
2073
+ (direction === 'up' ? firstCellNode : lastCellNode).getKey(),
2074
+ direction === 'up' ? 0 : lastCellNode.getChildrenSize(),
2075
+ 'element',
2076
+ );
2077
+ stopEvent(event);
2078
+ $setSelection(newSelection);
2079
+ return true;
2080
+ }
2081
+ }
2082
+ }
2083
+ }
2084
+ if (direction === 'down' && $isScrollableTablesActive(editor)) {
2085
+ // Enable Firefox workaround
2086
+ tableObservers.setShouldCheckSelectionForTable(tableNode.getKey());
2087
+ }
2088
+ return false;
2089
+ }
2090
+
2091
+ if ($isRangeSelection(selection)) {
2092
+ if (direction === 'backward' || direction === 'forward') {
2093
+ const alter = event.shiftKey ? 'extend' : 'move';
2094
+ return $handleHorizontalArrowKeyRangeSelection(
2095
+ editor,
2096
+ event,
2097
+ selection,
2098
+ alter,
2099
+ direction === 'backward',
2100
+ tableNode,
2101
+ tableObserver,
2102
+ );
2103
+ }
2104
+
2105
+ if (selection.isCollapsed()) {
2106
+ const {anchor, focus} = selection;
2107
+ const anchorCellNode = $findMatchingParent(
2108
+ anchor.getNode(),
2109
+ $isTableCellNode,
2110
+ );
2111
+ const focusCellNode = $findMatchingParent(
2112
+ focus.getNode(),
2113
+ $isTableCellNode,
2114
+ );
2115
+ if (
2116
+ !$isTableCellNode(anchorCellNode) ||
2117
+ !anchorCellNode.is(focusCellNode)
2118
+ ) {
2119
+ return false;
2120
+ }
2121
+ const anchorCellTable = $findTableNode(anchorCellNode);
2122
+ if (anchorCellTable !== tableNode && anchorCellTable != null) {
2123
+ const anchorCellTableElement = getTableElement(
2124
+ anchorCellTable,
2125
+ editor.getElementByKey(anchorCellTable.getKey()),
2126
+ );
2127
+ if (anchorCellTableElement != null) {
2128
+ tableObserver.table = getTable(
2129
+ anchorCellTable,
2130
+ anchorCellTableElement,
2131
+ );
2132
+ return $handleArrowKey(
2133
+ editor,
2134
+ event,
2135
+ direction,
2136
+ anchorCellTable,
2137
+ tableObserver,
2138
+ tableObservers,
2139
+ );
2140
+ }
2141
+ }
2142
+
2143
+ const anchorCellDom = editor.getElementByKey(anchorCellNode.__key);
2144
+ const anchorDOM = editor.getElementByKey(anchor.key);
2145
+ if (anchorDOM == null || anchorCellDom == null) {
2146
+ return false;
2147
+ }
2148
+
2149
+ let edgeSelectionRect;
2150
+ if (anchor.type === 'element') {
2151
+ edgeSelectionRect = anchorDOM.getBoundingClientRect();
2152
+ } else {
2153
+ const domSelection = getDOMSelection(getEditorWindow(editor));
2154
+ if (domSelection === null || domSelection.rangeCount === 0) {
2155
+ return false;
2156
+ }
2157
+
2158
+ const range = domSelection.getRangeAt(0);
2159
+ edgeSelectionRect = range.getBoundingClientRect();
2160
+ }
2161
+
2162
+ const edgeChild =
2163
+ direction === 'up'
2164
+ ? anchorCellNode.getFirstChild()
2165
+ : anchorCellNode.getLastChild();
2166
+ if (edgeChild == null) {
2167
+ return false;
2168
+ }
2169
+
2170
+ const edgeChildDOM = editor.getElementByKey(edgeChild.__key);
2171
+
2172
+ if (edgeChildDOM == null) {
2173
+ return false;
2174
+ }
2175
+
2176
+ const edgeRect = edgeChildDOM.getBoundingClientRect();
2177
+ const isExiting =
2178
+ direction === 'up'
2179
+ ? edgeRect.top > edgeSelectionRect.top - edgeSelectionRect.height
2180
+ : edgeSelectionRect.bottom + edgeSelectionRect.height >
2181
+ edgeRect.bottom;
2182
+
2183
+ if (isExiting) {
2184
+ stopEvent(event);
2185
+
2186
+ const cords = tableNode.getCordsFromCellNode(
2187
+ anchorCellNode,
2188
+ tableObserver.table,
2189
+ );
2190
+
2191
+ if (event.shiftKey) {
2192
+ const cell = tableNode.getDOMCellFromCordsOrThrow(
2193
+ cords.x,
2194
+ cords.y,
2195
+ tableObserver.table,
2196
+ );
2197
+ tableObserver.$setAnchorCellForSelection(cell);
2198
+ tableObserver.$setFocusCellForSelection(cell, true);
2199
+ } else {
2200
+ return selectTableNodeInDirection(
2201
+ tableObserver,
2202
+ tableNode,
2203
+ cords.x,
2204
+ cords.y,
2205
+ direction,
2206
+ );
2207
+ }
2208
+
2209
+ return true;
2210
+ }
2211
+ }
2212
+ } else if ($isTableSelection(selection)) {
2213
+ const {anchor, focus, tableKey} = selection;
2214
+ if (tableKey !== tableNode.getKey()) {
2215
+ return false;
2216
+ }
2217
+ const anchorCellNode = $findMatchingParent(
2218
+ anchor.getNode(),
2219
+ $isTableCellNode,
2220
+ );
2221
+ const focusCellNode = $findMatchingParent(
2222
+ focus.getNode(),
2223
+ $isTableCellNode,
2224
+ );
2225
+
2226
+ const [tableNodeFromSelection] = selection.getNodes();
2227
+ invariant(
2228
+ $isTableNode(tableNodeFromSelection),
2229
+ '$handleArrowKey: TableSelection.getNodes()[0] expected to be TableNode',
2230
+ );
2231
+ const tableElement = getTableElement(
2232
+ tableNodeFromSelection,
2233
+ editor.getElementByKey(tableNodeFromSelection.getKey()),
2234
+ );
2235
+ if (
2236
+ !$isTableCellNode(anchorCellNode) ||
2237
+ !$isTableCellNode(focusCellNode) ||
2238
+ !$isTableNode(tableNodeFromSelection) ||
2239
+ tableElement == null
2240
+ ) {
2241
+ return false;
2242
+ }
2243
+ tableObserver.$updateTableTableSelection(selection);
2244
+
2245
+ const grid = getTable(tableNodeFromSelection, tableElement);
2246
+ const cordsAnchor = tableNode.getCordsFromCellNode(anchorCellNode, grid);
2247
+ const anchorCell = tableNode.getDOMCellFromCordsOrThrow(
2248
+ cordsAnchor.x,
2249
+ cordsAnchor.y,
2250
+ grid,
2251
+ );
2252
+ tableObserver.$setAnchorCellForSelection(anchorCell);
2253
+
2254
+ stopEvent(event);
2255
+
2256
+ if (event.shiftKey) {
2257
+ const [tableMap, anchorValue, focusValue] = $computeTableMap(
2258
+ tableNode,
2259
+ anchorCellNode,
2260
+ focusCellNode,
2261
+ );
2262
+ return $adjustFocusInDirection(
2263
+ tableObserver,
2264
+ tableMap,
2265
+ anchorValue,
2266
+ focusValue,
2267
+ direction,
2268
+ );
2269
+ } else {
2270
+ focusCellNode.selectEnd();
2271
+ }
2272
+
2273
+ return true;
2274
+ }
2275
+
2276
+ return false;
2277
+ }
2278
+
2279
+ function stopEvent(event: Event) {
2280
+ event.preventDefault();
2281
+ event.stopImmediatePropagation();
2282
+ event.stopPropagation();
2283
+ }
2284
+
2285
+ function isTypeaheadMenuInView(editor: LexicalEditor) {
2286
+ // There is no inbuilt way to check if the component picker is in view
2287
+ // but we can check if the root DOM element has the aria-controls attribute "typeahead-menu".
2288
+ const root = editor.getRootElement();
2289
+ if (!root) {
2290
+ return false;
2291
+ }
2292
+ return (
2293
+ root.hasAttribute('aria-controls') &&
2294
+ root.getAttribute('aria-controls') === 'typeahead-menu'
2295
+ );
2296
+ }
2297
+
2298
+ function $insertParagraphAtTableEdge(
2299
+ edgePosition: 'first' | 'last',
2300
+ tableNode: TableNode,
2301
+ children?: LexicalNode[],
2302
+ ) {
2303
+ const paragraphNode = $createParagraphNode();
2304
+ if (edgePosition === 'first') {
2305
+ tableNode.insertBefore(paragraphNode);
2306
+ } else {
2307
+ tableNode.insertAfter(paragraphNode);
2308
+ }
2309
+ paragraphNode.append(...(children || []));
2310
+ paragraphNode.selectEnd();
2311
+ }
2312
+
2313
+ function $getTableEdgeCursorPosition(
2314
+ editor: LexicalEditor,
2315
+ selection: RangeSelection,
2316
+ tableNode: TableNode,
2317
+ ) {
2318
+ const tableNodeParent = tableNode.getParent();
2319
+ if (!tableNodeParent) {
2320
+ return undefined;
2321
+ }
2322
+
2323
+ // TODO: Add support for nested tables
2324
+ const domSelection = getDOMSelection(getEditorWindow(editor));
2325
+ if (!domSelection) {
2326
+ return undefined;
2327
+ }
2328
+ const domAnchorNode = domSelection.anchorNode;
2329
+ const tableNodeParentDOM = editor.getElementByKey(tableNodeParent.getKey());
2330
+ const tableElement = getTableElement(
2331
+ tableNode,
2332
+ editor.getElementByKey(tableNode.getKey()),
2333
+ );
2334
+ // We are only interested in the scenario where the
2335
+ // native selection anchor is:
2336
+ // - at or inside the table's parent DOM
2337
+ // - and NOT at or inside the table DOM
2338
+ // It may be adjacent to the table DOM (e.g. in a wrapper)
2339
+ if (
2340
+ !domAnchorNode ||
2341
+ !tableNodeParentDOM ||
2342
+ !tableElement ||
2343
+ !tableNodeParentDOM.contains(domAnchorNode) ||
2344
+ tableElement.contains(domAnchorNode)
2345
+ ) {
2346
+ return undefined;
2347
+ }
2348
+
2349
+ const anchorCellNode = $findMatchingParent(selection.anchor.getNode(), n =>
2350
+ $isTableCellNode(n),
2351
+ ) as TableCellNode | null;
2352
+ if (!anchorCellNode) {
2353
+ return undefined;
2354
+ }
2355
+
2356
+ const parentTable = $findMatchingParent(anchorCellNode, n => $isTableNode(n));
2357
+ if (!$isTableNode(parentTable) || !parentTable.is(tableNode)) {
2358
+ return undefined;
2359
+ }
2360
+
2361
+ const [tableMap, cellValue] = $computeTableMap(
2362
+ tableNode,
2363
+ anchorCellNode,
2364
+ anchorCellNode,
2365
+ );
2366
+ const firstCell = tableMap[0][0];
2367
+ const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
2368
+ const {startRow, startColumn} = cellValue;
2369
+
2370
+ const isAtFirstCell =
2371
+ startRow === firstCell.startRow && startColumn === firstCell.startColumn;
2372
+ const isAtLastCell =
2373
+ startRow === lastCell.startRow && startColumn === lastCell.startColumn;
2374
+
2375
+ if (isAtFirstCell) {
2376
+ return 'first';
2377
+ } else if (isAtLastCell) {
2378
+ return 'last';
2379
+ } else {
2380
+ return undefined;
2381
+ }
2382
+ }
2383
+
2384
+ export function $getObserverCellFromCellNodeOrThrow(
2385
+ tableObserver: TableObserver,
2386
+ tableCellNode: TableCellNode,
2387
+ ): TableDOMCell {
2388
+ const {tableNode} = tableObserver.$lookup();
2389
+ const currentCords = tableNode.getCordsFromCellNode(
2390
+ tableCellNode,
2391
+ tableObserver.table,
2392
+ );
2393
+ return tableNode.getDOMCellFromCordsOrThrow(
2394
+ currentCords.x,
2395
+ currentCords.y,
2396
+ tableObserver.table,
2397
+ );
2398
+ }
2399
+
2400
+ export function $getNearestTableCellInTableFromDOMNode(
2401
+ tableNode: TableNode,
2402
+ startingDOM: Node,
2403
+ editorState?: EditorState,
2404
+ ) {
2405
+ return $findParentTableCellNodeInTable(
2406
+ tableNode,
2407
+ $getNearestNodeFromDOMNode(startingDOM, editorState),
2408
+ );
2409
+ }