@lexical/table 0.48.0 → 0.48.1-nightly.20260720.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.
@@ -14,7 +14,6 @@ import {
14
14
  $isLineBreakNode,
15
15
  $isTextNode,
16
16
  addClassNamesToElement,
17
- type DOMConversionMap,
18
17
  type DOMConversionOutput,
19
18
  type DOMExportOutput,
20
19
  type EditorConfig,
@@ -68,17 +67,20 @@ export class TableCellNode extends ElementNode {
68
67
  /** @internal */
69
68
  __verticalAlign?: undefined | string;
70
69
 
71
- static getType(): string {
72
- return 'tablecell';
73
- }
74
-
75
- static clone(node: TableCellNode): TableCellNode {
76
- return new TableCellNode(
77
- node.__headerState,
78
- node.__colSpan,
79
- node.__width,
80
- node.__key,
81
- );
70
+ $config() {
71
+ return this.config('tablecell', {
72
+ extends: ElementNode,
73
+ importDOM: {
74
+ td: () => ({
75
+ conversion: $convertTableCellNodeElement,
76
+ priority: 0,
77
+ }),
78
+ th: () => ({
79
+ conversion: $convertTableCellNodeElement,
80
+ priority: 0,
81
+ }),
82
+ },
83
+ });
82
84
  }
83
85
 
84
86
  afterCloneFrom(node: this): void {
@@ -91,23 +93,6 @@ export class TableCellNode extends ElementNode {
91
93
  this.__width = node.__width;
92
94
  }
93
95
 
94
- static importDOM(): DOMConversionMap | null {
95
- return {
96
- td: (node: Node) => ({
97
- conversion: $convertTableCellNodeElement,
98
- priority: 0,
99
- }),
100
- th: (node: Node) => ({
101
- conversion: $convertTableCellNodeElement,
102
- priority: 0,
103
- }),
104
- };
105
- }
106
-
107
- static importJSON(serializedNode: SerializedTableCellNode): TableCellNode {
108
- return $createTableCellNode().updateFromJSON(serializedNode);
109
- }
110
-
111
96
  updateFromJSON(
112
97
  serializedNode: LexicalUpdateJSON<SerializedTableCellNode>,
113
98
  ): this {
@@ -6,12 +6,15 @@
6
6
  *
7
7
  */
8
8
 
9
+ import type {LexicalEditor} from 'lexical';
10
+
9
11
  import {effect, namedSignals} from '@lexical/extension';
10
12
  import {CoreImportExtension, DOMImportExtension} from '@lexical/html';
11
13
  import {
12
14
  $fullReconcile,
13
15
  configExtension,
14
16
  defineExtension,
17
+ isHTMLElement,
15
18
  mergeRegister,
16
19
  safeCast,
17
20
  } from 'lexical';
@@ -19,7 +22,11 @@ import {
19
22
  import {TableCellNode} from './LexicalTableCellNode';
20
23
  import {
21
24
  $isScrollableTablesActive,
25
+ attachStickyScrollbarListeners,
26
+ findStickyScrollbarElements,
22
27
  setScrollableTablesActive,
28
+ type StickyScrollbarElements,
29
+ syncStickyScrollbar,
23
30
  TableNode,
24
31
  } from './LexicalTableNode';
25
32
  import {
@@ -48,6 +55,19 @@ export interface TableConfig {
48
55
  * When `true` (default `true`), tables will be wrapped in a `<div>` to enable horizontal scrolling
49
56
  */
50
57
  hasHorizontalScroll: boolean;
58
+ /**
59
+ * When `true` (default `false`), a sticky scrollbar is rendered below each table that overflows horizontally.
60
+ * Requires `hasHorizontalScroll` to be `true`. The native scrollbar is hidden via inline
61
+ * `scrollbar-width: none` (Firefox/Chromium). Themed consumers providing a `tableScrollableWrapper`
62
+ * class should also add `::-webkit-scrollbar { display: none }` for Safari/WebKit.
63
+ * A themed scrollbar is expected to guarantee its own visible height (the
64
+ * playground does this with `::-webkit-scrollbar { height: ... }`); the
65
+ * unthemed fallback depends on classic native scrollbars, so on platforms
66
+ * where those render with no thickness (overlay scrollbars, and non-layout
67
+ * environments like jsdom) it is disabled at runtime and the wrapper's
68
+ * native scrollbar is restored.
69
+ */
70
+ hasStickyScrollbar: boolean;
51
71
  /**
52
72
  * When `true` (default `false`), nested tables will be allowed.
53
73
  *
@@ -56,6 +76,64 @@ export interface TableConfig {
56
76
  hasNestedTables: boolean;
57
77
  }
58
78
 
79
+ function registerStickyScrollbar(editor: LexicalEditor) {
80
+ const attached = new Map<
81
+ string,
82
+ {cleanup: () => void; parts: StickyScrollbarElements}
83
+ >();
84
+ const detachAll = () => {
85
+ for (const {cleanup} of attached.values()) {
86
+ cleanup();
87
+ }
88
+ attached.clear();
89
+ };
90
+ return mergeRegister(
91
+ detachAll,
92
+ editor.registerMutationListener(
93
+ TableNode,
94
+ nodeMutations => {
95
+ for (const [nodeKey, mutation] of nodeMutations) {
96
+ const prev = attached.get(nodeKey);
97
+ if (mutation === 'destroyed') {
98
+ if (prev) {
99
+ prev.cleanup();
100
+ attached.delete(nodeKey);
101
+ }
102
+ continue;
103
+ }
104
+ const dom = editor.getElementByKey(nodeKey);
105
+ const parts =
106
+ dom && isHTMLElement(dom) ? findStickyScrollbarElements(dom) : null;
107
+ if (
108
+ prev &&
109
+ parts &&
110
+ prev.parts.scrollable === parts.scrollable &&
111
+ prev.parts.scrollbar === parts.scrollbar &&
112
+ prev.parts.tableElement === parts.tableElement
113
+ ) {
114
+ // Same DOM: keep the listeners, but resync since the
115
+ // update may still change scrollability (e.g. a frozen
116
+ // rows toggle switches the wrapper to overflow-x: clip).
117
+ syncStickyScrollbar(parts.scrollable, parts.scrollbar);
118
+ continue;
119
+ }
120
+ if (prev) {
121
+ prev.cleanup();
122
+ attached.delete(nodeKey);
123
+ }
124
+ if (parts) {
125
+ attached.set(nodeKey, {
126
+ cleanup: attachStickyScrollbarListeners(parts),
127
+ parts,
128
+ });
129
+ }
130
+ }
131
+ },
132
+ {skipInitialization: false},
133
+ ),
134
+ );
135
+ }
136
+
59
137
  /**
60
138
  * Configures {@link TableNode}, {@link TableRowNode}, {@link TableCellNode} and
61
139
  * registers table behaviors (see {@link TableConfig})
@@ -69,6 +147,7 @@ export const TableExtension = /* @__PURE__ */ defineExtension({
69
147
  hasCellMerge: true,
70
148
  hasHorizontalScroll: true,
71
149
  hasNestedTables: false,
150
+ hasStickyScrollbar: false,
72
151
  hasTabHandler: true,
73
152
  }),
74
153
  dependencies: [
@@ -84,23 +163,44 @@ export const TableExtension = /* @__PURE__ */ defineExtension({
84
163
  nodes: () => [TableNode, TableRowNode, TableCellNode],
85
164
  register(editor, config, state) {
86
165
  const stores = state.getOutput();
166
+ let prevStickyScrollbar = false;
87
167
  return mergeRegister(
88
168
  effect(() => {
89
169
  const hasHorizontalScroll = stores.hasHorizontalScroll.value;
170
+ const hasStickyScrollbar =
171
+ stores.hasStickyScrollbar.value && hasHorizontalScroll;
90
172
  const hadHorizontalScroll = $isScrollableTablesActive(editor);
91
173
  if (hadHorizontalScroll !== hasHorizontalScroll) {
92
174
  setScrollableTablesActive(editor, hasHorizontalScroll);
175
+ }
176
+ if (
177
+ hadHorizontalScroll !== hasHorizontalScroll ||
178
+ prevStickyScrollbar !== hasStickyScrollbar
179
+ ) {
93
180
  // Re-render existing tables through the new scroll-wrapper config
94
181
  // without cloning every TableNode the way marking them dirty would. A
95
182
  // full reconcile marks no nodes dirty, so it's deferred (no
96
183
  // synchronous render from this effect) and produces no history entry.
97
184
  editor.update($fullReconcile);
98
185
  }
186
+ prevStickyScrollbar = hasStickyScrollbar;
99
187
  }),
100
188
  registerTablePlugin(editor, stores),
101
189
  effect(() =>
102
190
  registerTableSelectionObserver(editor, stores.hasTabHandler.value),
103
191
  ),
192
+ effect(() => {
193
+ if (
194
+ stores.hasStickyScrollbar.value &&
195
+ stores.hasHorizontalScroll.value
196
+ ) {
197
+ return editor.registerRootListener(rootElement => {
198
+ if (rootElement) {
199
+ return registerStickyScrollbar(editor);
200
+ }
201
+ });
202
+ }
203
+ }),
104
204
  effect(() =>
105
205
  stores.hasCellMerge.value
106
206
  ? undefined
@@ -6,8 +6,10 @@
6
6
  *
7
7
  */
8
8
 
9
+ import type {TableExtension} from './LexicalTableExtension';
9
10
  import type {TableDOMCell, TableDOMTable} from './LexicalTableObserver';
10
11
 
12
+ import {getPeerDependencyFromEditor} from '@lexical/extension';
11
13
  import invariant from '@lexical/internal/invariant';
12
14
  import {$descendantsMatching} from '@lexical/utils';
13
15
  import {
@@ -17,7 +19,6 @@ import {
17
19
  $getNearestNodeFromDOMNode,
18
20
  addClassNamesToElement,
19
21
  type BaseSelection,
20
- type DOMConversionMap,
21
22
  type DOMConversionOutput,
22
23
  type DOMExportOutput,
23
24
  type EditorConfig,
@@ -149,6 +150,177 @@ function alignTableElement(
149
150
  addClassNamesToElement(dom, ...addClasses);
150
151
  }
151
152
 
153
+ function $createScrollableWrapper(
154
+ tableElement: HTMLTableElement,
155
+ config: EditorConfig,
156
+ hideNativeScrollbar: boolean,
157
+ ): HTMLDivElement {
158
+ const wrapper = $getDocument().createElement('div');
159
+ const classes = config.theme.tableScrollableWrapper;
160
+ if (classes) {
161
+ addClassNamesToElement(wrapper, classes);
162
+ } else {
163
+ wrapper.style.overflowX = 'auto';
164
+ }
165
+ if (hideNativeScrollbar) {
166
+ wrapper.style.scrollbarWidth = 'none';
167
+ }
168
+ wrapper.appendChild(tableElement);
169
+ return wrapper;
170
+ }
171
+
172
+ function $createStickyScrollbar(config: EditorConfig): HTMLDivElement {
173
+ const doc = $getDocument();
174
+ const scrollbar = doc.createElement('div');
175
+ const classes = config.theme.tableStickyScrollbar;
176
+ if (classes) {
177
+ addClassNamesToElement(scrollbar, classes);
178
+ } else {
179
+ scrollbar.style.position = 'sticky';
180
+ scrollbar.style.bottom = '0';
181
+ scrollbar.style.overflowX = 'scroll';
182
+ scrollbar.style.overflowY = 'hidden';
183
+ }
184
+ scrollbar.style.display = 'none';
185
+ scrollbar.setAttribute('aria-hidden', 'true');
186
+ scrollbar.tabIndex = -1;
187
+ const spacer = doc.createElement('div');
188
+ spacer.style.height = '1px';
189
+ spacer.style.width = '0px';
190
+ scrollbar.appendChild(spacer);
191
+ return scrollbar;
192
+ }
193
+
194
+ export interface StickyScrollbarElements {
195
+ scrollable: HTMLDivElement;
196
+ scrollbar: HTMLDivElement;
197
+ tableElement: HTMLTableElement;
198
+ }
199
+
200
+ // Unthemed sticky scrollbars whose environment cannot render a persistent
201
+ // proxy scrollbar (overlay scrollbars reserve no height and show no idle
202
+ // thumb), permanently hidden in favor of the wrapper's native scrollbar.
203
+ const overlayStickyScrollbars = new WeakSet<HTMLDivElement>();
204
+
205
+ function measureScrollbarThickness(scrollbar: HTMLDivElement): number {
206
+ const prevDisplay = scrollbar.style.display;
207
+ scrollbar.style.display = '';
208
+ const thickness = scrollbar.offsetHeight - scrollbar.clientHeight;
209
+ scrollbar.style.display = prevDisplay;
210
+ return thickness;
211
+ }
212
+
213
+ export function attachStickyScrollbarListeners(
214
+ elements: StickyScrollbarElements,
215
+ ): () => void {
216
+ const {scrollable, scrollbar, tableElement} = elements;
217
+ // A themed scrollbar (theme.tableStickyScrollbar, detectable by its
218
+ // classes) is the integrator's responsibility to keep visible (e.g. via
219
+ // ::-webkit-scrollbar height or scrollbar-width). The unthemed fallback
220
+ // relies on classic native scrollbars for its height, so where the
221
+ // environment renders overlay scrollbars (no reserved thickness — e.g.
222
+ // macOS "show scrollbars when scrolling", or non-layout environments like
223
+ // jsdom) the proxy would be an invisible strip: keep it hidden and
224
+ // restore the wrapper's native scrollbar instead of presenting no scroll
225
+ // affordance at all.
226
+ if (
227
+ scrollbar.classList.length === 0 &&
228
+ measureScrollbarThickness(scrollbar) === 0
229
+ ) {
230
+ overlayStickyScrollbars.add(scrollbar);
231
+ scrollbar.style.display = 'none';
232
+ scrollable.style.scrollbarWidth = 'auto';
233
+ return () => {};
234
+ }
235
+ const onWrapperScroll = () => {
236
+ if (scrollbar.scrollLeft !== scrollable.scrollLeft) {
237
+ scrollbar.scrollLeft = scrollable.scrollLeft;
238
+ }
239
+ };
240
+ const onScrollbarScroll = () => {
241
+ if (scrollable.scrollLeft !== scrollbar.scrollLeft) {
242
+ scrollable.scrollLeft = scrollbar.scrollLeft;
243
+ }
244
+ };
245
+ scrollable.addEventListener('scroll', onWrapperScroll, {
246
+ passive: true,
247
+ });
248
+ scrollbar.addEventListener('scroll', onScrollbarScroll, {passive: true});
249
+ let resizeObserver: ResizeObserver | null = null;
250
+ if (typeof ResizeObserver !== 'undefined') {
251
+ resizeObserver = new ResizeObserver(() => {
252
+ syncStickyScrollbar(scrollable, scrollbar);
253
+ });
254
+ resizeObserver.observe(scrollable);
255
+ resizeObserver.observe(tableElement);
256
+ }
257
+ const cleanup = () => {
258
+ scrollable.removeEventListener('scroll', onWrapperScroll);
259
+ scrollbar.removeEventListener('scroll', onScrollbarScroll);
260
+ if (resizeObserver) {
261
+ resizeObserver.disconnect();
262
+ }
263
+ };
264
+ syncStickyScrollbar(scrollable, scrollbar);
265
+ return cleanup;
266
+ }
267
+
268
+ export function syncStickyScrollbar(
269
+ scrollable: HTMLDivElement,
270
+ scrollbar: HTMLDivElement,
271
+ ): void {
272
+ if (overlayStickyScrollbars.has(scrollbar)) {
273
+ return;
274
+ }
275
+ const spacer = scrollbar.firstElementChild;
276
+ if (!spacer || !isHTMLElement(spacer) || !scrollable.isConnected) {
277
+ return;
278
+ }
279
+ const view = scrollable.ownerDocument.defaultView;
280
+ if (!view) {
281
+ scrollbar.style.display = 'none';
282
+ return;
283
+ }
284
+ // All layout reads happen before any write so a sync forces at most one
285
+ // reflow, and both metrics come from the same element so their rounding
286
+ // can't disagree at fractional zoom levels.
287
+ const overflowX = view.getComputedStyle(scrollable).overflowX;
288
+ const isScrollable = overflowX === 'auto' || overflowX === 'scroll';
289
+ const scrollWidth = scrollable.scrollWidth;
290
+ const clientWidth = scrollable.clientWidth;
291
+ const spacerWidth = scrollWidth + 'px';
292
+ if (spacer.style.width !== spacerWidth) {
293
+ spacer.style.width = spacerWidth;
294
+ }
295
+ const hasOverflow = isScrollable && scrollWidth > clientWidth;
296
+ scrollbar.style.display = hasOverflow ? '' : 'none';
297
+ }
298
+
299
+ export function findStickyScrollbarElements(
300
+ dom: HTMLElement,
301
+ ): StickyScrollbarElements | null {
302
+ if (!dom.hasAttribute('data-lexical-sticky-scrollbar')) {
303
+ return null;
304
+ }
305
+ const firstChild = dom.firstElementChild;
306
+ if (!isHTMLDivElement(firstChild)) {
307
+ return null;
308
+ }
309
+ const tableElement = firstChild.querySelector(':scope > table');
310
+ if (!isHTMLTableElement(tableElement)) {
311
+ return null;
312
+ }
313
+ const scrollbar = firstChild.nextElementSibling;
314
+ if (!isHTMLDivElement(scrollbar)) {
315
+ return null;
316
+ }
317
+ return {
318
+ scrollable: firstChild,
319
+ scrollbar,
320
+ tableElement,
321
+ };
322
+ }
323
+
152
324
  const scrollableEditors = new WeakSet<LexicalEditor>();
153
325
 
154
326
  export function $isScrollableTablesActive(
@@ -157,6 +329,22 @@ export function $isScrollableTablesActive(
157
329
  return scrollableEditors.has(editor);
158
330
  }
159
331
 
332
+ export function $isStickyScrollbarActive(
333
+ editor: LexicalEditor = $getEditor(),
334
+ ): boolean {
335
+ const dep = getPeerDependencyFromEditor<typeof TableExtension>(
336
+ editor,
337
+ '@lexical/table/Table',
338
+ );
339
+ // peek() so a reconcile that runs inside a signals effect (e.g. via a
340
+ // discrete update or force-commit read) does not subscribe that effect to
341
+ // the table config; re-rendering on change is the TableExtension's job.
342
+ return dep
343
+ ? dep.output.hasStickyScrollbar.peek() &&
344
+ dep.output.hasHorizontalScroll.peek()
345
+ : false;
346
+ }
347
+
160
348
  export function setScrollableTablesActive(
161
349
  editor: LexicalEditor,
162
350
  active: boolean,
@@ -176,13 +364,25 @@ export function setScrollableTablesActive(
176
364
  /** @noInheritDoc */
177
365
  export class TableNode extends ElementNode {
178
366
  /** @internal */
179
- __rowStriping: boolean;
180
- __frozenColumnCount: number;
181
- __frozenRowCount: number;
182
- __colWidths?: readonly number[];
183
-
184
- static getType(): string {
185
- return 'table';
367
+ __rowStriping: boolean = false;
368
+ __frozenColumnCount: number = 0;
369
+ __frozenRowCount: number = 0;
370
+ // Initialized unconditionally (not `__colWidths?: ...`) so a freshly
371
+ // constructed instance always has the own property — `@lexical/yjs` derives a
372
+ // node's syncable properties from `Object.keys(new Klass())`, so an
373
+ // uninitialized optional field would silently drop out of collab sync.
374
+ __colWidths: readonly number[] | undefined = undefined;
375
+
376
+ $config() {
377
+ return this.config('table', {
378
+ extends: ElementNode,
379
+ importDOM: {
380
+ table: () => ({
381
+ conversion: $convertTableElement,
382
+ priority: 1,
383
+ }),
384
+ },
385
+ });
186
386
  }
187
387
 
188
388
  getColWidths(): readonly number[] | undefined {
@@ -198,10 +398,6 @@ export class TableNode extends ElementNode {
198
398
  return self;
199
399
  }
200
400
 
201
- static clone(node: TableNode): TableNode {
202
- return new TableNode(node.__key);
203
- }
204
-
205
401
  afterCloneFrom(prevNode: this) {
206
402
  super.afterCloneFrom(prevNode);
207
403
  this.__colWidths = prevNode.__colWidths;
@@ -210,19 +406,6 @@ export class TableNode extends ElementNode {
210
406
  this.__frozenRowCount = prevNode.__frozenRowCount;
211
407
  }
212
408
 
213
- static importDOM(): DOMConversionMap | null {
214
- return {
215
- table: (_node: Node) => ({
216
- conversion: $convertTableElement,
217
- priority: 1,
218
- }),
219
- };
220
- }
221
-
222
- static importJSON(serializedNode: SerializedTableNode): TableNode {
223
- return $createTableNode().updateFromJSON(serializedNode);
224
- }
225
-
226
409
  updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedTableNode>): this {
227
410
  return super
228
411
  .updateFromJSON(serializedNode)
@@ -232,14 +415,6 @@ export class TableNode extends ElementNode {
232
415
  .setColWidths(serializedNode.colWidths);
233
416
  }
234
417
 
235
- constructor(key?: NodeKey) {
236
- super(key);
237
- this.__rowStriping = false;
238
- this.__frozenColumnCount = 0;
239
- this.__frozenRowCount = 0;
240
- this.__colWidths = undefined;
241
- }
242
-
243
418
  exportJSON(): SerializedTableNode {
244
419
  return {
245
420
  ...super.exportJSON(),
@@ -285,16 +460,23 @@ export class TableNode extends ElementNode {
285
460
  addClassNamesToElement(tableElement, config.theme.table);
286
461
  this.updateTableElement(null, tableElement, config);
287
462
  if ($isScrollableTablesActive(editor)) {
288
- const wrapperElement = $getDocument().createElement('div');
289
- const classes = config.theme.tableScrollableWrapper;
290
- if (classes) {
291
- addClassNamesToElement(wrapperElement, classes);
292
- } else {
293
- wrapperElement.style.overflowX = 'auto';
463
+ const hasStickyScrollbar = $isStickyScrollbarActive(editor);
464
+ const scrollableWrapper = $createScrollableWrapper(
465
+ tableElement,
466
+ config,
467
+ hasStickyScrollbar,
468
+ );
469
+ this.updateTableWrapper(null, scrollableWrapper, tableElement, config);
470
+ if (hasStickyScrollbar) {
471
+ const stickyScrollbar = $createStickyScrollbar(config);
472
+ const outerWrapper = $getDocument().createElement('div');
473
+ outerWrapper.setAttribute('data-lexical-sticky-scrollbar', 'true');
474
+ outerWrapper.appendChild(scrollableWrapper);
475
+ outerWrapper.appendChild(stickyScrollbar);
476
+ setDOMUnmanaged(stickyScrollbar);
477
+ return outerWrapper;
294
478
  }
295
- wrapperElement.appendChild(tableElement);
296
- this.updateTableWrapper(null, wrapperElement, tableElement, config);
297
- return wrapperElement;
479
+ return scrollableWrapper;
298
480
  }
299
481
  return tableElement;
300
482
  }
@@ -352,7 +534,16 @@ export class TableNode extends ElementNode {
352
534
  return true;
353
535
  }
354
536
  if (isHTMLDivElement(dom)) {
355
- this.updateTableWrapper(prevNode, dom, tableElement, config);
537
+ const hasStickyDom = dom.hasAttribute('data-lexical-sticky-scrollbar');
538
+ if (hasStickyDom !== $isStickyScrollbarActive()) {
539
+ return true;
540
+ }
541
+ // The scrollable wrapper is the table's immediate parent in both
542
+ // layouts (the inner div in sticky mode, dom itself otherwise).
543
+ const scrollable = tableElement.parentElement;
544
+ if (isHTMLDivElement(scrollable)) {
545
+ this.updateTableWrapper(prevNode, scrollable, tableElement, config);
546
+ }
356
547
  }
357
548
  this.updateTableElement(prevNode, tableElement, config);
358
549
  return false;
@@ -12,7 +12,6 @@ import {
12
12
  $getDocument,
13
13
  addClassNamesToElement,
14
14
  type BaseSelection,
15
- type DOMConversionMap,
16
15
  type DOMConversionOutput,
17
16
  type EditorConfig,
18
17
  ElementNode,
@@ -38,12 +37,16 @@ export class TableRowNode extends ElementNode {
38
37
  /** @internal */
39
38
  __height?: number;
40
39
 
41
- static getType(): string {
42
- return 'tablerow';
43
- }
44
-
45
- static clone(node: TableRowNode): TableRowNode {
46
- return new TableRowNode(node.__height, node.__key);
40
+ $config() {
41
+ return this.config('tablerow', {
42
+ extends: ElementNode,
43
+ importDOM: {
44
+ tr: () => ({
45
+ conversion: $convertTableRowElement,
46
+ priority: 0,
47
+ }),
48
+ },
49
+ });
47
50
  }
48
51
 
49
52
  afterCloneFrom(prevNode: this): void {
@@ -51,19 +54,6 @@ export class TableRowNode extends ElementNode {
51
54
  this.__height = prevNode.__height;
52
55
  }
53
56
 
54
- static importDOM(): DOMConversionMap | null {
55
- return {
56
- tr: (node: Node) => ({
57
- conversion: $convertTableRowElement,
58
- priority: 0,
59
- }),
60
- };
61
- }
62
-
63
- static importJSON(serializedNode: SerializedTableRowNode): TableRowNode {
64
- return $createTableRowNode().updateFromJSON(serializedNode);
65
- }
66
-
67
57
  updateFromJSON(
68
58
  serializedNode: LexicalUpdateJSON<SerializedTableRowNode>,
69
59
  ): this {
@@ -72,7 +62,9 @@ export class TableRowNode extends ElementNode {
72
62
  .setHeight(serializedNode.height);
73
63
  }
74
64
 
75
- constructor(height?: number, key?: NodeKey) {
65
+ // `height` carries an explicit `undefined` default so the constructor reports
66
+ // zero required arguments and `$config` can synthesize the static `clone`.
67
+ constructor(height: number | undefined = undefined, key?: NodeKey) {
76
68
  super(key);
77
69
  this.__height = height;
78
70
  }
@@ -289,7 +289,7 @@ export class TableSelection implements BaseSelection {
289
289
  selection.insertNodes(nodes);
290
290
  }
291
291
 
292
- // TODO Deprecate this method. It's confusing when used with colspan|rowspan
292
+ /** @deprecated Use {@link $computeTableMap} and {@link $computeTableCellRectBoundary} directly. */
293
293
  getShape(): TableSelectionShape {
294
294
  const {anchorCell, focusCell} = $getCellNodes(this);
295
295
  const anchorCellNodeRect = $getTableCellNodeRect(anchorCell);
@@ -417,21 +417,27 @@ export class TableSelection implements BaseSelection {
417
417
  }
418
418
  }
419
419
 
420
+ /** Type guard for {@link TableSelection}. */
420
421
  export function $isTableSelection(x: unknown): x is TableSelection {
421
422
  return x instanceof TableSelection;
422
423
  }
423
424
 
425
+ /** @deprecated Use {@link $createTableSelectionFrom} instead. */
424
426
  export function $createTableSelection(): TableSelection {
425
- // TODO this is a suboptimal design, it doesn't make sense to have
426
- // a table selection that isn't associated with a table. This
427
- // constructor should have required arguments and in __DEV__ we
428
- // should check that they point to a table and are element points to
429
- // cell nodes of that table.
430
427
  const anchor = $createPoint('root', 0, 'element');
431
428
  const focus = $createPoint('root', 0, 'element');
432
429
  return new TableSelection('root', anchor, focus);
433
430
  }
434
431
 
432
+ /**
433
+ * Creates a {@link TableSelection} spanning from `anchorCell` to `focusCell`
434
+ * within `tableNode`. In `__DEV__` mode, validates that both cells belong to
435
+ * the given table and that the table is attached to the editor state.
436
+ *
437
+ * If the current selection is already a TableSelection, it clones and
438
+ * re-targets it (preserving identity for dirty-checking); otherwise it
439
+ * constructs a fresh one.
440
+ */
435
441
  export function $createTableSelectionFrom(
436
442
  tableNode: TableNode,
437
443
  anchorCell: TableCellNode,
@@ -463,7 +469,11 @@ export function $createTableSelectionFrom(
463
469
  const prevSelection = $getSelection();
464
470
  const nextSelection = $isTableSelection(prevSelection)
465
471
  ? prevSelection.clone()
466
- : $createTableSelection();
472
+ : new TableSelection(
473
+ 'root',
474
+ $createPoint('root', 0, 'element'),
475
+ $createPoint('root', 0, 'element'),
476
+ );
467
477
  nextSelection.set(
468
478
  tableNode.getKey(),
469
479
  anchorCell.getKey(),