@lexical/table 0.44.1-nightly.20260519.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,575 @@
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 invariant from '@lexical/internal/invariant';
10
+ import {
11
+ addClassNamesToElement,
12
+ removeClassNamesFromElement,
13
+ } from '@lexical/utils';
14
+ import {
15
+ $createParagraphNode,
16
+ $createRangeSelection,
17
+ $createTextNode,
18
+ $getEditor,
19
+ $getNodeByKey,
20
+ $getSelection,
21
+ $isElementNode,
22
+ $isParagraphNode,
23
+ $isRootNode,
24
+ $setSelection,
25
+ getDOMSelection,
26
+ INSERT_PARAGRAPH_COMMAND,
27
+ type LexicalEditor,
28
+ type NodeKey,
29
+ SELECTION_CHANGE_COMMAND,
30
+ type TextFormatType,
31
+ } from 'lexical';
32
+
33
+ import {$isTableCellNode, TableCellNode} from './LexicalTableCellNode';
34
+ import {$isTableNode, TableNode} from './LexicalTableNode';
35
+ import {$isTableRowNode} from './LexicalTableRowNode';
36
+ import {
37
+ $createTableSelectionFrom,
38
+ $isTableSelection,
39
+ type TableSelection,
40
+ } from './LexicalTableSelection';
41
+ import {
42
+ $getNearestTableCellInTableFromDOMNode,
43
+ $updateDOMForSelection,
44
+ getTable,
45
+ getTableElement,
46
+ HTMLTableElementWithWithTableSelectionState,
47
+ } from './LexicalTableSelectionHelpers';
48
+
49
+ export type TableDOMCell = {
50
+ elem: HTMLElement;
51
+ highlighted: boolean;
52
+ hasBackgroundColor: boolean;
53
+ x: number;
54
+ y: number;
55
+ };
56
+
57
+ export type TableDOMRows = Array<Array<TableDOMCell | undefined> | undefined>;
58
+
59
+ export type TableDOMTable = {
60
+ domRows: TableDOMRows;
61
+ columns: number;
62
+ rows: number;
63
+ };
64
+
65
+ export function $getTableAndElementByKey(
66
+ tableNodeKey: NodeKey,
67
+ editor: LexicalEditor = $getEditor(),
68
+ ): {
69
+ tableNode: TableNode;
70
+ tableElement: HTMLTableElementWithWithTableSelectionState;
71
+ } {
72
+ const tableNode = $getNodeByKey(tableNodeKey);
73
+ invariant(
74
+ $isTableNode(tableNode),
75
+ 'TableObserver: Expected tableNodeKey %s to be a TableNode',
76
+ tableNodeKey,
77
+ );
78
+ const tableElement = getTableElement(
79
+ tableNode,
80
+ editor.getElementByKey(tableNodeKey),
81
+ );
82
+ invariant(
83
+ tableElement !== null,
84
+ 'TableObserver: Expected to find TableElement in DOM for key %s',
85
+ tableNodeKey,
86
+ );
87
+ return {tableElement, tableNode};
88
+ }
89
+
90
+ export type TableNextFocus = {
91
+ tableKey: NodeKey;
92
+ focusCell: TableDOMCell;
93
+ override: boolean;
94
+ };
95
+
96
+ /**
97
+ * Tracks table selection state that sits across all tables.
98
+ */
99
+ export class TableObservers {
100
+ observers: Map<
101
+ NodeKey,
102
+ [TableObserver, HTMLTableElementWithWithTableSelectionState]
103
+ >;
104
+ nextFocus: TableNextFocus | null;
105
+ shouldCheckSelectionForTable: NodeKey | null;
106
+
107
+ constructor() {
108
+ this.observers = new Map<
109
+ NodeKey,
110
+ [TableObserver, HTMLTableElementWithWithTableSelectionState]
111
+ >();
112
+ this.nextFocus = null;
113
+ this.shouldCheckSelectionForTable = null;
114
+ }
115
+
116
+ /**
117
+ * @internal
118
+ * When handling mousemove events we track what the focus cell should be, but
119
+ * the DOM selection may end up somewhere else entirely. We don't have an elegant
120
+ * way to handle this after the DOM selection has been resolved in a
121
+ * SELECTION_CHANGE_COMMAND callback.
122
+ */
123
+ setNextFocus(nextFocus: TableNextFocus | null): void {
124
+ this.nextFocus = nextFocus;
125
+ }
126
+
127
+ /** @internal */
128
+ getAndClearNextFocus(): TableNextFocus | null {
129
+ const {nextFocus} = this;
130
+ if (nextFocus !== null) {
131
+ this.nextFocus = null;
132
+ }
133
+ return nextFocus;
134
+ }
135
+
136
+ /**
137
+ * @internal
138
+ * Firefox has a strange behavior where pressing the down arrow key from
139
+ * above the table will move the caret after the table and then lexical
140
+ * will select the last cell instead of the first.
141
+ * We do still want to let the browser handle caret movement but we will
142
+ * use this property to "tag" the update so that we can recheck the
143
+ * selection after the event is processed.
144
+ */
145
+ setShouldCheckSelectionForTable(tableKey: NodeKey): void {
146
+ this.shouldCheckSelectionForTable = tableKey;
147
+ }
148
+ /**
149
+ * @internal
150
+ */
151
+ getAndClearShouldCheckSelectionForTable(): NodeKey | null {
152
+ const {shouldCheckSelectionForTable} = this;
153
+ if (shouldCheckSelectionForTable) {
154
+ this.shouldCheckSelectionForTable = null;
155
+ return shouldCheckSelectionForTable;
156
+ }
157
+ return null;
158
+ }
159
+ }
160
+
161
+ export class TableObserver {
162
+ focusX: number;
163
+ focusY: number;
164
+ listenersToRemove: Set<() => void>;
165
+ table: TableDOMTable;
166
+ isHighlightingCells: boolean;
167
+ anchorX: number;
168
+ anchorY: number;
169
+ tableNodeKey: NodeKey;
170
+ anchorCell: TableDOMCell | null;
171
+ focusCell: TableDOMCell | null;
172
+ anchorCellNodeKey: NodeKey | null;
173
+ focusCellNodeKey: NodeKey | null;
174
+ editor: LexicalEditor;
175
+ tableSelection: TableSelection | null;
176
+ hasHijackedSelectionStyles: boolean;
177
+ isSelecting: boolean;
178
+ pointerType: string | null;
179
+ abortController: AbortController;
180
+ listenerOptions: {signal: AbortSignal};
181
+
182
+ constructor(editor: LexicalEditor, tableNodeKey: string) {
183
+ this.isHighlightingCells = false;
184
+ this.anchorX = -1;
185
+ this.anchorY = -1;
186
+ this.focusX = -1;
187
+ this.focusY = -1;
188
+ this.listenersToRemove = new Set();
189
+ this.tableNodeKey = tableNodeKey;
190
+ this.editor = editor;
191
+ this.table = {
192
+ columns: 0,
193
+ domRows: [],
194
+ rows: 0,
195
+ };
196
+ this.tableSelection = null;
197
+ this.anchorCellNodeKey = null;
198
+ this.focusCellNodeKey = null;
199
+ this.anchorCell = null;
200
+ this.focusCell = null;
201
+ this.hasHijackedSelectionStyles = false;
202
+ this.isSelecting = false;
203
+ this.pointerType = null;
204
+ this.abortController = new AbortController();
205
+ this.listenerOptions = {signal: this.abortController.signal};
206
+ this.trackTable();
207
+ }
208
+
209
+ getTable(): TableDOMTable {
210
+ return this.table;
211
+ }
212
+
213
+ removeListeners() {
214
+ this.abortController.abort('removeListeners');
215
+ Array.from(this.listenersToRemove).forEach(removeListener =>
216
+ removeListener(),
217
+ );
218
+ this.listenersToRemove.clear();
219
+ }
220
+
221
+ $lookup(): {
222
+ tableNode: TableNode;
223
+ tableElement: HTMLTableElementWithWithTableSelectionState;
224
+ } {
225
+ return $getTableAndElementByKey(this.tableNodeKey, this.editor);
226
+ }
227
+
228
+ trackTable() {
229
+ const observer = new MutationObserver(records => {
230
+ this.editor.getEditorState().read(
231
+ () => {
232
+ let gridNeedsRedraw = false;
233
+
234
+ for (let i = 0; i < records.length; i++) {
235
+ const record = records[i];
236
+ const target = record.target;
237
+ const nodeName = target.nodeName;
238
+
239
+ if (
240
+ nodeName === 'TABLE' ||
241
+ nodeName === 'TBODY' ||
242
+ nodeName === 'THEAD' ||
243
+ nodeName === 'TR'
244
+ ) {
245
+ gridNeedsRedraw = true;
246
+ break;
247
+ }
248
+ }
249
+
250
+ if (!gridNeedsRedraw) {
251
+ return;
252
+ }
253
+
254
+ const {tableNode, tableElement} = this.$lookup();
255
+ this.table = getTable(tableNode, tableElement);
256
+ },
257
+ {editor: this.editor},
258
+ );
259
+ });
260
+ this.editor.getEditorState().read(
261
+ () => {
262
+ const {tableNode, tableElement} = this.$lookup();
263
+ this.table = getTable(tableNode, tableElement);
264
+ observer.observe(tableElement, {
265
+ attributes: true,
266
+ childList: true,
267
+ subtree: true,
268
+ });
269
+ },
270
+ {editor: this.editor},
271
+ );
272
+ }
273
+
274
+ $clearHighlight(setEmptySelection: boolean = true): void {
275
+ const editor = this.editor;
276
+ this.isHighlightingCells = false;
277
+ this.anchorX = -1;
278
+ this.anchorY = -1;
279
+ this.focusX = -1;
280
+ this.focusY = -1;
281
+ this.tableSelection = null;
282
+ this.anchorCellNodeKey = null;
283
+ this.focusCellNodeKey = null;
284
+ this.anchorCell = null;
285
+ this.focusCell = null;
286
+ this.hasHijackedSelectionStyles = false;
287
+
288
+ this.$enableHighlightStyle();
289
+
290
+ const {tableNode, tableElement} = this.$lookup();
291
+ const grid = getTable(tableNode, tableElement);
292
+ $updateDOMForSelection(editor, grid, null);
293
+ if (setEmptySelection && $getSelection() !== null) {
294
+ $setSelection(null);
295
+ editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
296
+ }
297
+ }
298
+
299
+ $enableHighlightStyle() {
300
+ const editor = this.editor;
301
+ const {tableElement} = this.$lookup();
302
+
303
+ removeClassNamesFromElement(
304
+ tableElement,
305
+ editor._config.theme.tableSelection,
306
+ );
307
+ tableElement.classList.remove('disable-selection');
308
+ this.hasHijackedSelectionStyles = false;
309
+ }
310
+
311
+ $disableHighlightStyle() {
312
+ const {tableElement} = this.$lookup();
313
+ addClassNamesToElement(
314
+ tableElement,
315
+ this.editor._config.theme.tableSelection,
316
+ );
317
+ this.hasHijackedSelectionStyles = true;
318
+ }
319
+
320
+ $updateTableTableSelection(selection: TableSelection | null): void {
321
+ if (selection !== null) {
322
+ invariant(
323
+ selection.tableKey === this.tableNodeKey,
324
+ "TableObserver.$updateTableTableSelection: selection.tableKey !== this.tableNodeKey ('%s' !== '%s')",
325
+ selection.tableKey,
326
+ this.tableNodeKey,
327
+ );
328
+ const editor = this.editor;
329
+ this.tableSelection = selection;
330
+ this.isHighlightingCells = true;
331
+ this.$disableHighlightStyle();
332
+ this.updateDOMSelection();
333
+ $updateDOMForSelection(editor, this.table, this.tableSelection);
334
+ } else {
335
+ this.$clearHighlight();
336
+ }
337
+ }
338
+
339
+ /** @internal */
340
+ updateDOMSelection() {
341
+ if (this.anchorCell !== null && this.focusCell !== null) {
342
+ const domSelection = getDOMSelection(this.editor._window);
343
+ // We are not using a native selection for tables, and if we
344
+ // set one then the reconciler will undo it.
345
+ // TODO - it would make sense to have one so that native
346
+ // copy/paste worked. Right now we have to emulate with
347
+ // keyboard events but it won't fire if triggered from the menu
348
+ if (domSelection && domSelection.rangeCount > 0) {
349
+ domSelection.removeAllRanges();
350
+ }
351
+ }
352
+ }
353
+
354
+ $setFocusCellForSelection(cell: TableDOMCell, ignoreStart = false): boolean {
355
+ const editor = this.editor;
356
+ const {tableNode} = this.$lookup();
357
+
358
+ const cellX = cell.x;
359
+ const cellY = cell.y;
360
+ this.focusCell = cell;
361
+
362
+ // Enable highlighting if: ignoreStart is true, or anchor differs from focus,
363
+ // or we have valid tableSelection with anchor (for first drag after column switch)
364
+ if (!this.isHighlightingCells) {
365
+ const shouldEnable =
366
+ ignoreStart ||
367
+ this.anchorX !== cellX ||
368
+ this.anchorY !== cellY ||
369
+ (this.tableSelection != null && this.anchorCellNodeKey != null);
370
+ if (shouldEnable) {
371
+ this.isHighlightingCells = true;
372
+ this.$disableHighlightStyle();
373
+ }
374
+ }
375
+
376
+ // Skip if we're trying to select the same cell we already have selected
377
+ // But only if focusX/focusY are valid (not -1, which means not reset)
378
+ if (
379
+ this.focusX !== -1 &&
380
+ this.focusY !== -1 &&
381
+ cellX === this.focusX &&
382
+ cellY === this.focusY
383
+ ) {
384
+ return false;
385
+ }
386
+
387
+ this.focusX = cellX;
388
+ this.focusY = cellY;
389
+
390
+ if (this.isHighlightingCells) {
391
+ const focusTableCellNode = $getNearestTableCellInTableFromDOMNode(
392
+ tableNode,
393
+ cell.elem,
394
+ );
395
+
396
+ if (this.tableSelection != null && this.anchorCellNodeKey != null) {
397
+ let targetCellNode = focusTableCellNode;
398
+
399
+ // Fallback: use coordinates if DOM lookup failed (handles timing issues on first drag)
400
+ if (targetCellNode === null && ignoreStart) {
401
+ targetCellNode = tableNode.getCellNodeFromCords(
402
+ cellX,
403
+ cellY,
404
+ this.table,
405
+ );
406
+ }
407
+
408
+ if (targetCellNode !== null) {
409
+ const anchorTableCell = this.$getAnchorTableCellOrThrow();
410
+ this.focusCellNodeKey = targetCellNode.getKey();
411
+ this.tableSelection = $createTableSelectionFrom(
412
+ tableNode,
413
+ anchorTableCell,
414
+ targetCellNode,
415
+ );
416
+
417
+ $setSelection(this.tableSelection);
418
+ editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
419
+ $updateDOMForSelection(editor, this.table, this.tableSelection);
420
+ return true;
421
+ }
422
+ }
423
+ }
424
+ return false;
425
+ }
426
+
427
+ $getAnchorTableCell(): TableCellNode | null {
428
+ return this.anchorCellNodeKey
429
+ ? $getNodeByKey(this.anchorCellNodeKey)
430
+ : null;
431
+ }
432
+ $getAnchorTableCellOrThrow(): TableCellNode {
433
+ const anchorTableCell = this.$getAnchorTableCell();
434
+ invariant(
435
+ anchorTableCell !== null,
436
+ 'TableObserver anchorTableCell is null',
437
+ );
438
+ return anchorTableCell;
439
+ }
440
+
441
+ $getFocusTableCell(): TableCellNode | null {
442
+ return this.focusCellNodeKey ? $getNodeByKey(this.focusCellNodeKey) : null;
443
+ }
444
+
445
+ $getFocusTableCellOrThrow(): TableCellNode {
446
+ const focusTableCell = this.$getFocusTableCell();
447
+ invariant(focusTableCell !== null, 'TableObserver focusTableCell is null');
448
+ return focusTableCell;
449
+ }
450
+
451
+ $setAnchorCellForSelection(cell: TableDOMCell) {
452
+ this.isHighlightingCells = false;
453
+ this.anchorCell = cell;
454
+ this.anchorX = cell.x;
455
+ this.anchorY = cell.y;
456
+ // Reset focus state to prevent stale values from previous selections
457
+ this.focusX = -1;
458
+ this.focusY = -1;
459
+ this.focusCell = null;
460
+ this.focusCellNodeKey = null;
461
+
462
+ const {tableNode} = this.$lookup();
463
+ const anchorTableCellNode = $getNearestTableCellInTableFromDOMNode(
464
+ tableNode,
465
+ cell.elem,
466
+ );
467
+
468
+ if (anchorTableCellNode !== null) {
469
+ const anchorNodeKey = anchorTableCellNode.getKey();
470
+ if (this.tableSelection != null) {
471
+ this.tableSelection = this.tableSelection.clone();
472
+ this.tableSelection.set(
473
+ tableNode.getKey(),
474
+ anchorNodeKey,
475
+ anchorNodeKey,
476
+ );
477
+ } else {
478
+ this.tableSelection = $createTableSelectionFrom(
479
+ tableNode,
480
+ anchorTableCellNode,
481
+ anchorTableCellNode,
482
+ );
483
+ }
484
+ this.anchorCellNodeKey = anchorNodeKey;
485
+ }
486
+ }
487
+
488
+ $formatCells(type: TextFormatType) {
489
+ const selection = $getSelection();
490
+
491
+ invariant($isTableSelection(selection), 'Expected Table selection');
492
+
493
+ const formatSelection = $createRangeSelection();
494
+
495
+ const anchor = formatSelection.anchor;
496
+ const focus = formatSelection.focus;
497
+
498
+ const cellNodes = selection.getNodes().filter($isTableCellNode);
499
+ invariant(cellNodes.length > 0, 'No table cells present');
500
+ const paragraph = cellNodes[0].getFirstChild();
501
+ const alignFormatWith = $isParagraphNode(paragraph)
502
+ ? paragraph.getFormatFlags(type, null)
503
+ : null;
504
+
505
+ cellNodes.forEach((cellNode: TableCellNode) => {
506
+ anchor.set(cellNode.getKey(), 0, 'element');
507
+ focus.set(cellNode.getKey(), cellNode.getChildrenSize(), 'element');
508
+ formatSelection.formatText(type, alignFormatWith);
509
+ });
510
+
511
+ $setSelection(selection);
512
+
513
+ this.editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
514
+ }
515
+
516
+ $clearText() {
517
+ const {editor} = this;
518
+ const tableNode = $getNodeByKey(this.tableNodeKey);
519
+
520
+ if (!$isTableNode(tableNode)) {
521
+ throw new Error('Expected TableNode.');
522
+ }
523
+
524
+ const selection = $getSelection();
525
+
526
+ invariant($isTableSelection(selection), 'Expected TableSelection');
527
+
528
+ const selectedNodes = selection.getNodes().filter($isTableCellNode);
529
+
530
+ // Check if the entire table is selected by verifying first and last cells
531
+ const firstRow = tableNode.getFirstChild();
532
+ const lastRow = tableNode.getLastChild();
533
+
534
+ const isEntireTableSelected =
535
+ selectedNodes.length > 0 &&
536
+ firstRow !== null &&
537
+ lastRow !== null &&
538
+ $isTableRowNode(firstRow) &&
539
+ $isTableRowNode(lastRow) &&
540
+ selectedNodes[0] === firstRow.getFirstChild() &&
541
+ selectedNodes[selectedNodes.length - 1] === lastRow.getLastChild();
542
+
543
+ if (isEntireTableSelected) {
544
+ tableNode.selectPrevious();
545
+ const parent = tableNode.getParent();
546
+ // Delete entire table
547
+ tableNode.remove();
548
+ // Handle case when table was the only node
549
+ if ($isRootNode(parent) && parent.isEmpty()) {
550
+ editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined);
551
+ }
552
+ return;
553
+ }
554
+
555
+ selectedNodes.forEach(cellNode => {
556
+ if ($isElementNode(cellNode)) {
557
+ const paragraphNode = $createParagraphNode();
558
+ const textNode = $createTextNode();
559
+ paragraphNode.append(textNode);
560
+ cellNode.append(paragraphNode);
561
+ cellNode.getChildren().forEach(child => {
562
+ if (child !== paragraphNode) {
563
+ child.remove();
564
+ }
565
+ });
566
+ }
567
+ });
568
+
569
+ $updateDOMForSelection(editor, this.table, null);
570
+
571
+ $setSelection(null);
572
+
573
+ editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
574
+ }
575
+ }