@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.
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { ElementNode, $getDocument, addClassNamesToElement, isHTMLElement, $isInlineElementOrDecoratorNode, $isTextNode, $isLineBreakNode, $createParagraphNode, $applyNodeReplacement, createCommand, $createTextNode, $getSelection, $isRangeSelection, $findMatchingParent, $isParagraphNode, $createPoint, $getNodeByKey, $isElementNode, $normalizeSelection__EXPERIMENTAL, isCurrentlyReadOnlyMode, TEXT_TYPE_TO_FORMAT, $getEditor, $setSelection, SELECTION_CHANGE_COMMAND, removeClassNamesFromElement, getDOMSelection, $createRangeSelection, $isRootNode, INSERT_PARAGRAPH_COMMAND, registerEventListener, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, COMMAND_PRIORITY_HIGH, KEY_ESCAPE_COMMAND, DELETE_WORD_COMMAND, DELETE_LINE_COMMAND, DELETE_CHARACTER_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, CUT_COMMAND, PASTE_COMMAND, getComposedEventTarget, isDOMNode, getActiveElement, COPY_COMMAND, FORMAT_TEXT_COMMAND, FORMAT_ELEMENT_COMMAND, CONTROLLED_TEXT_INSERTION_COMMAND, KEY_TAB_COMMAND, FOCUS_COMMAND, $getNearestNodeFromDOMNode, $isRootOrShadowRoot, getDOMSelectionRange, getDOMSelectionPoints, $getPreviousSelection, $caretFromPoint, $isExtendableTextPointCaret, $extendCaretToRange, $isSiblingCaret, $getSiblingCaret, $setPointFromCaret, $normalizeCaret, $createRangeSelectionFromDom, $isChildCaret, $getChildCaret, $getAdjacentChildCaret, IS_FIREFOX, $getNodeByKeyOrThrow, isDOMDocumentNode, isDOMShadowRoot, setDOMStyleFromCSS, setDOMUnmanaged, mergeRegister, COMMAND_PRIORITY_EDITOR, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, SELECT_ALL_COMMAND, COMMAND_PRIORITY_LOW, CLICK_COMMAND, $getRoot, isHTMLTableRowElement, IS_BOLD, IS_ITALIC, IS_UNDERLINE, IS_STRIKETHROUGH, defineExtension, $fullReconcile, configExtension, safeCast } from 'lexical';
10
- import { signal, effect, namedSignals } from '@lexical/extension';
10
+ import { getPeerDependencyFromEditor, signal, effect, namedSignals } from '@lexical/extension';
11
11
  import { defineImportRule, sel, ImportTextFormat, ImportTextStyle, contextValue, $propagateTextAlignToBlockChildren, CoreImportExtension, DOMImportExtension } from '@lexical/html';
12
12
  import { $descendantsMatching, objectKlassEquals, $insertFirst as $insertFirst$1, $insertNodeToNearestRoot, $dfs, $unwrapAndFilterDescendants } from '@lexical/utils';
13
13
  import { copyToClipboard, $getClipboardDataFromSelection } from '@lexical/clipboard';
@@ -54,11 +54,20 @@ class TableCellNode extends ElementNode {
54
54
  __backgroundColor;
55
55
  /** @internal */
56
56
  __verticalAlign;
57
- static getType() {
58
- return 'tablecell';
59
- }
60
- static clone(node) {
61
- return new TableCellNode(node.__headerState, node.__colSpan, node.__width, node.__key);
57
+ $config() {
58
+ return this.config('tablecell', {
59
+ extends: ElementNode,
60
+ importDOM: {
61
+ td: () => ({
62
+ conversion: $convertTableCellNodeElement,
63
+ priority: 0
64
+ }),
65
+ th: () => ({
66
+ conversion: $convertTableCellNodeElement,
67
+ priority: 0
68
+ })
69
+ }
70
+ });
62
71
  }
63
72
  afterCloneFrom(node) {
64
73
  super.afterCloneFrom(node);
@@ -69,21 +78,6 @@ class TableCellNode extends ElementNode {
69
78
  this.__headerState = node.__headerState;
70
79
  this.__width = node.__width;
71
80
  }
72
- static importDOM() {
73
- return {
74
- td: node => ({
75
- conversion: $convertTableCellNodeElement,
76
- priority: 0
77
- }),
78
- th: node => ({
79
- conversion: $convertTableCellNodeElement,
80
- priority: 0
81
- })
82
- };
83
- }
84
- static importJSON(serializedNode) {
85
- return $createTableCellNode().updateFromJSON(serializedNode);
86
- }
87
81
  updateFromJSON(serializedNode) {
88
82
  return super.updateFromJSON(serializedNode).setHeaderStyles(serializedNode.headerState).setColSpan(serializedNode.colSpan || 1).setRowSpan(serializedNode.rowSpan || 1).setWidth(serializedNode.width || undefined).setBackgroundColor(serializedNode.backgroundColor || null).setVerticalAlign(serializedNode.verticalAlign || undefined);
89
83
  }
@@ -381,31 +375,28 @@ function formatDevErrorMessage(message) {
381
375
  class TableRowNode extends ElementNode {
382
376
  /** @internal */
383
377
  __height;
384
- static getType() {
385
- return 'tablerow';
386
- }
387
- static clone(node) {
388
- return new TableRowNode(node.__height, node.__key);
378
+ $config() {
379
+ return this.config('tablerow', {
380
+ extends: ElementNode,
381
+ importDOM: {
382
+ tr: () => ({
383
+ conversion: $convertTableRowElement,
384
+ priority: 0
385
+ })
386
+ }
387
+ });
389
388
  }
390
389
  afterCloneFrom(prevNode) {
391
390
  super.afterCloneFrom(prevNode);
392
391
  this.__height = prevNode.__height;
393
392
  }
394
- static importDOM() {
395
- return {
396
- tr: node => ({
397
- conversion: $convertTableRowElement,
398
- priority: 0
399
- })
400
- };
401
- }
402
- static importJSON(serializedNode) {
403
- return $createTableRowNode().updateFromJSON(serializedNode);
404
- }
405
393
  updateFromJSON(serializedNode) {
406
394
  return super.updateFromJSON(serializedNode).setHeight(serializedNode.height);
407
395
  }
408
- constructor(height, key) {
396
+
397
+ // `height` carries an explicit `undefined` default so the constructor reports
398
+ // zero required arguments and `$config` can synthesize the static `clone`.
399
+ constructor(height = undefined, key) {
409
400
  super(key);
410
401
  this.__height = height;
411
402
  }
@@ -1389,6 +1380,11 @@ function $computeTableCellRectSpans(map, boundary) {
1389
1380
  topSpan
1390
1381
  };
1391
1382
  }
1383
+
1384
+ /**
1385
+ * Compute the bounding rectangle of two table cells in a table map,
1386
+ * expanding iteratively to include any merged cells that overlap the boundary.
1387
+ */
1392
1388
  function $computeTableCellRectBoundary(map, cellAMap, cellBMap) {
1393
1389
  // Initial boundaries based on the anchor and focus cells
1394
1390
  let minColumn = Math.min(cellAMap.startColumn, cellBMap.startColumn);
@@ -1897,7 +1893,7 @@ class TableSelection {
1897
1893
  selection.insertNodes(nodes);
1898
1894
  }
1899
1895
 
1900
- // TODO Deprecate this method. It's confusing when used with colspan|rowspan
1896
+ /** @deprecated Use {@link $computeTableMap} and {@link $computeTableCellRectBoundary} directly. */
1901
1897
  getShape() {
1902
1898
  const {
1903
1899
  anchorCell,
@@ -2009,19 +2005,28 @@ class TableSelection {
2009
2005
  return textContent;
2010
2006
  }
2011
2007
  }
2008
+
2009
+ /** Type guard for {@link TableSelection}. */
2012
2010
  function $isTableSelection(x) {
2013
2011
  return x instanceof TableSelection;
2014
2012
  }
2013
+
2014
+ /** @deprecated Use {@link $createTableSelectionFrom} instead. */
2015
2015
  function $createTableSelection() {
2016
- // TODO this is a suboptimal design, it doesn't make sense to have
2017
- // a table selection that isn't associated with a table. This
2018
- // constructor should have required arguments and in __DEV__ we
2019
- // should check that they point to a table and are element points to
2020
- // cell nodes of that table.
2021
2016
  const anchor = $createPoint('root', 0, 'element');
2022
2017
  const focus = $createPoint('root', 0, 'element');
2023
2018
  return new TableSelection('root', anchor, focus);
2024
2019
  }
2020
+
2021
+ /**
2022
+ * Creates a {@link TableSelection} spanning from `anchorCell` to `focusCell`
2023
+ * within `tableNode`. In `__DEV__` mode, validates that both cells belong to
2024
+ * the given table and that the table is attached to the editor state.
2025
+ *
2026
+ * If the current selection is already a TableSelection, it clones and
2027
+ * re-targets it (preserving identity for dirty-checking); otherwise it
2028
+ * constructs a fresh one.
2029
+ */
2025
2030
  function $createTableSelectionFrom(tableNode, anchorCell, focusCell) {
2026
2031
  const tableNodeKey = tableNode.getKey();
2027
2032
  const anchorCellKey = anchorCell.getKey();
@@ -2038,7 +2043,7 @@ function $createTableSelectionFrom(tableNode, anchorCell, focusCell) {
2038
2043
  } // TODO: Check for rectangular grid
2039
2044
  }
2040
2045
  const prevSelection = $getSelection();
2041
- const nextSelection = $isTableSelection(prevSelection) ? prevSelection.clone() : $createTableSelection();
2046
+ const nextSelection = $isTableSelection(prevSelection) ? prevSelection.clone() : new TableSelection('root', $createPoint('root', 0, 'element'), $createPoint('root', 0, 'element'));
2042
2047
  nextSelection.set(tableNode.getKey(), anchorCell.getKey(), focusCell.getKey());
2043
2048
  return nextSelection;
2044
2049
  }
@@ -2897,7 +2902,7 @@ function applyTableHandlers(tableNode, element, editor, hasTabHandler, tableObse
2897
2902
  }
2898
2903
  return false;
2899
2904
  }, COMMAND_PRIORITY_HIGH));
2900
- const deleteTextHandler = command => () => {
2905
+ const $deleteTextHandler = () => {
2901
2906
  const selection = $getSelection();
2902
2907
  if (!$isSelectionInTable(selection, tableNode)) {
2903
2908
  return false;
@@ -2905,34 +2910,11 @@ function applyTableHandlers(tableNode, element, editor, hasTabHandler, tableObse
2905
2910
  if ($isTableSelection(selection)) {
2906
2911
  tableObserver.$clearText();
2907
2912
  return true;
2908
- } else if ($isRangeSelection(selection)) {
2909
- const tableCellNode = $findParentTableCellNodeInTable(tableNode, selection.anchor.getNode());
2910
- if (!$isTableCellNode(tableCellNode)) {
2911
- return false;
2912
- }
2913
- const anchorNode = selection.anchor.getNode();
2914
- const focusNode = selection.focus.getNode();
2915
- const isAnchorInside = tableNode.isParentOf(anchorNode);
2916
- const isFocusInside = tableNode.isParentOf(focusNode);
2917
- const selectionContainsPartialTable = isAnchorInside && !isFocusInside || isFocusInside && !isAnchorInside;
2918
- if (selectionContainsPartialTable) {
2919
- tableObserver.$clearText();
2920
- return true;
2921
- }
2922
- const nearestElementNode = $findMatchingParent(selection.anchor.getNode(), n => $isElementNode(n));
2923
- const topLevelCellElementNode = nearestElementNode && $findMatchingParent(nearestElementNode, n => $isElementNode(n) && $isTableCellNode(n.getParent()));
2924
- if (!$isElementNode(topLevelCellElementNode) || !$isElementNode(nearestElementNode)) {
2925
- return false;
2926
- }
2927
- if (command === DELETE_LINE_COMMAND && topLevelCellElementNode.getPreviousSibling() === null) {
2928
- // TODO: Fix Delete Line in Table Cells.
2929
- return true;
2930
- }
2931
2913
  }
2932
2914
  return false;
2933
2915
  };
2934
2916
  for (const command of DELETE_TEXT_COMMANDS) {
2935
- tableObserver.listenersToRemove.add(editor.registerCommand(command, deleteTextHandler(command), COMMAND_PRIORITY_HIGH));
2917
+ tableObserver.listenersToRemove.add(editor.registerCommand(command, $deleteTextHandler, COMMAND_PRIORITY_HIGH));
2936
2918
  }
2937
2919
  const $deleteCellHandler = event => {
2938
2920
  const selection = $getSelection();
@@ -3045,11 +3027,6 @@ function applyTableHandlers(tableNode, element, editor, hasTabHandler, tableObse
3045
3027
  if ($isTableSelection(selection)) {
3046
3028
  tableObserver.$formatCells(payload);
3047
3029
  return true;
3048
- } else if ($isRangeSelection(selection)) {
3049
- const tableCellNode = $findMatchingParent(selection.anchor.getNode(), n => $isTableCellNode(n));
3050
- if (!$isTableCellNode(tableCellNode)) {
3051
- return false;
3052
- }
3053
3030
  }
3054
3031
  return false;
3055
3032
  }, COMMAND_PRIORITY_HIGH));
@@ -4195,10 +4172,167 @@ function alignTableElement(dom, config, formatType) {
4195
4172
  removeClassNamesFromElement(dom, ...removeClasses);
4196
4173
  addClassNamesToElement(dom, ...addClasses);
4197
4174
  }
4175
+ function $createScrollableWrapper(tableElement, config, hideNativeScrollbar) {
4176
+ const wrapper = $getDocument().createElement('div');
4177
+ const classes = config.theme.tableScrollableWrapper;
4178
+ if (classes) {
4179
+ addClassNamesToElement(wrapper, classes);
4180
+ } else {
4181
+ wrapper.style.overflowX = 'auto';
4182
+ }
4183
+ if (hideNativeScrollbar) {
4184
+ wrapper.style.scrollbarWidth = 'none';
4185
+ }
4186
+ wrapper.appendChild(tableElement);
4187
+ return wrapper;
4188
+ }
4189
+ function $createStickyScrollbar(config) {
4190
+ const doc = $getDocument();
4191
+ const scrollbar = doc.createElement('div');
4192
+ const classes = config.theme.tableStickyScrollbar;
4193
+ if (classes) {
4194
+ addClassNamesToElement(scrollbar, classes);
4195
+ } else {
4196
+ scrollbar.style.position = 'sticky';
4197
+ scrollbar.style.bottom = '0';
4198
+ scrollbar.style.overflowX = 'scroll';
4199
+ scrollbar.style.overflowY = 'hidden';
4200
+ }
4201
+ scrollbar.style.display = 'none';
4202
+ scrollbar.setAttribute('aria-hidden', 'true');
4203
+ scrollbar.tabIndex = -1;
4204
+ const spacer = doc.createElement('div');
4205
+ spacer.style.height = '1px';
4206
+ spacer.style.width = '0px';
4207
+ scrollbar.appendChild(spacer);
4208
+ return scrollbar;
4209
+ }
4210
+ // Unthemed sticky scrollbars whose environment cannot render a persistent
4211
+ // proxy scrollbar (overlay scrollbars reserve no height and show no idle
4212
+ // thumb), permanently hidden in favor of the wrapper's native scrollbar.
4213
+ const overlayStickyScrollbars = new WeakSet();
4214
+ function measureScrollbarThickness(scrollbar) {
4215
+ const prevDisplay = scrollbar.style.display;
4216
+ scrollbar.style.display = '';
4217
+ const thickness = scrollbar.offsetHeight - scrollbar.clientHeight;
4218
+ scrollbar.style.display = prevDisplay;
4219
+ return thickness;
4220
+ }
4221
+ function attachStickyScrollbarListeners(elements) {
4222
+ const {
4223
+ scrollable,
4224
+ scrollbar,
4225
+ tableElement
4226
+ } = elements;
4227
+ // A themed scrollbar (theme.tableStickyScrollbar, detectable by its
4228
+ // classes) is the integrator's responsibility to keep visible (e.g. via
4229
+ // ::-webkit-scrollbar height or scrollbar-width). The unthemed fallback
4230
+ // relies on classic native scrollbars for its height, so where the
4231
+ // environment renders overlay scrollbars (no reserved thickness — e.g.
4232
+ // macOS "show scrollbars when scrolling", or non-layout environments like
4233
+ // jsdom) the proxy would be an invisible strip: keep it hidden and
4234
+ // restore the wrapper's native scrollbar instead of presenting no scroll
4235
+ // affordance at all.
4236
+ if (scrollbar.classList.length === 0 && measureScrollbarThickness(scrollbar) === 0) {
4237
+ overlayStickyScrollbars.add(scrollbar);
4238
+ scrollbar.style.display = 'none';
4239
+ scrollable.style.scrollbarWidth = 'auto';
4240
+ return () => {};
4241
+ }
4242
+ const onWrapperScroll = () => {
4243
+ if (scrollbar.scrollLeft !== scrollable.scrollLeft) {
4244
+ scrollbar.scrollLeft = scrollable.scrollLeft;
4245
+ }
4246
+ };
4247
+ const onScrollbarScroll = () => {
4248
+ if (scrollable.scrollLeft !== scrollbar.scrollLeft) {
4249
+ scrollable.scrollLeft = scrollbar.scrollLeft;
4250
+ }
4251
+ };
4252
+ scrollable.addEventListener('scroll', onWrapperScroll, {
4253
+ passive: true
4254
+ });
4255
+ scrollbar.addEventListener('scroll', onScrollbarScroll, {
4256
+ passive: true
4257
+ });
4258
+ let resizeObserver = null;
4259
+ if (typeof ResizeObserver !== 'undefined') {
4260
+ resizeObserver = new ResizeObserver(() => {
4261
+ syncStickyScrollbar(scrollable, scrollbar);
4262
+ });
4263
+ resizeObserver.observe(scrollable);
4264
+ resizeObserver.observe(tableElement);
4265
+ }
4266
+ const cleanup = () => {
4267
+ scrollable.removeEventListener('scroll', onWrapperScroll);
4268
+ scrollbar.removeEventListener('scroll', onScrollbarScroll);
4269
+ if (resizeObserver) {
4270
+ resizeObserver.disconnect();
4271
+ }
4272
+ };
4273
+ syncStickyScrollbar(scrollable, scrollbar);
4274
+ return cleanup;
4275
+ }
4276
+ function syncStickyScrollbar(scrollable, scrollbar) {
4277
+ if (overlayStickyScrollbars.has(scrollbar)) {
4278
+ return;
4279
+ }
4280
+ const spacer = scrollbar.firstElementChild;
4281
+ if (!spacer || !isHTMLElement(spacer) || !scrollable.isConnected) {
4282
+ return;
4283
+ }
4284
+ const view = scrollable.ownerDocument.defaultView;
4285
+ if (!view) {
4286
+ scrollbar.style.display = 'none';
4287
+ return;
4288
+ }
4289
+ // All layout reads happen before any write so a sync forces at most one
4290
+ // reflow, and both metrics come from the same element so their rounding
4291
+ // can't disagree at fractional zoom levels.
4292
+ const overflowX = view.getComputedStyle(scrollable).overflowX;
4293
+ const isScrollable = overflowX === 'auto' || overflowX === 'scroll';
4294
+ const scrollWidth = scrollable.scrollWidth;
4295
+ const clientWidth = scrollable.clientWidth;
4296
+ const spacerWidth = scrollWidth + 'px';
4297
+ if (spacer.style.width !== spacerWidth) {
4298
+ spacer.style.width = spacerWidth;
4299
+ }
4300
+ const hasOverflow = isScrollable && scrollWidth > clientWidth;
4301
+ scrollbar.style.display = hasOverflow ? '' : 'none';
4302
+ }
4303
+ function findStickyScrollbarElements(dom) {
4304
+ if (!dom.hasAttribute('data-lexical-sticky-scrollbar')) {
4305
+ return null;
4306
+ }
4307
+ const firstChild = dom.firstElementChild;
4308
+ if (!isHTMLDivElement(firstChild)) {
4309
+ return null;
4310
+ }
4311
+ const tableElement = firstChild.querySelector(':scope > table');
4312
+ if (!isHTMLTableElement(tableElement)) {
4313
+ return null;
4314
+ }
4315
+ const scrollbar = firstChild.nextElementSibling;
4316
+ if (!isHTMLDivElement(scrollbar)) {
4317
+ return null;
4318
+ }
4319
+ return {
4320
+ scrollable: firstChild,
4321
+ scrollbar,
4322
+ tableElement
4323
+ };
4324
+ }
4198
4325
  const scrollableEditors = new WeakSet();
4199
4326
  function $isScrollableTablesActive(editor = $getEditor()) {
4200
4327
  return scrollableEditors.has(editor);
4201
4328
  }
4329
+ function $isStickyScrollbarActive(editor = $getEditor()) {
4330
+ const dep = getPeerDependencyFromEditor(editor, '@lexical/table/Table');
4331
+ // peek() so a reconcile that runs inside a signals effect (e.g. via a
4332
+ // discrete update or force-commit read) does not subscribe that effect to
4333
+ // the table config; re-rendering on change is the TableExtension's job.
4334
+ return dep ? dep.output.hasStickyScrollbar.peek() && dep.output.hasHorizontalScroll.peek() : false;
4335
+ }
4202
4336
  function setScrollableTablesActive(editor, active) {
4203
4337
  if (active) {
4204
4338
  if (!editor._config.theme.tableScrollableWrapper) {
@@ -4213,12 +4347,24 @@ function setScrollableTablesActive(editor, active) {
4213
4347
  /** @noInheritDoc */
4214
4348
  class TableNode extends ElementNode {
4215
4349
  /** @internal */
4216
- __rowStriping;
4217
- __frozenColumnCount;
4218
- __frozenRowCount;
4219
- __colWidths;
4220
- static getType() {
4221
- return 'table';
4350
+ __rowStriping = false;
4351
+ __frozenColumnCount = 0;
4352
+ __frozenRowCount = 0;
4353
+ // Initialized unconditionally (not `__colWidths?: ...`) so a freshly
4354
+ // constructed instance always has the own property — `@lexical/yjs` derives a
4355
+ // node's syncable properties from `Object.keys(new Klass())`, so an
4356
+ // uninitialized optional field would silently drop out of collab sync.
4357
+ __colWidths = undefined;
4358
+ $config() {
4359
+ return this.config('table', {
4360
+ extends: ElementNode,
4361
+ importDOM: {
4362
+ table: () => ({
4363
+ conversion: $convertTableElement,
4364
+ priority: 1
4365
+ })
4366
+ }
4367
+ });
4222
4368
  }
4223
4369
  getColWidths() {
4224
4370
  const self = this.getLatest();
@@ -4230,9 +4376,6 @@ class TableNode extends ElementNode {
4230
4376
  self.__colWidths = colWidths !== undefined && __DEV__ ? Object.freeze(colWidths) : colWidths;
4231
4377
  return self;
4232
4378
  }
4233
- static clone(node) {
4234
- return new TableNode(node.__key);
4235
- }
4236
4379
  afterCloneFrom(prevNode) {
4237
4380
  super.afterCloneFrom(prevNode);
4238
4381
  this.__colWidths = prevNode.__colWidths;
@@ -4240,27 +4383,9 @@ class TableNode extends ElementNode {
4240
4383
  this.__frozenColumnCount = prevNode.__frozenColumnCount;
4241
4384
  this.__frozenRowCount = prevNode.__frozenRowCount;
4242
4385
  }
4243
- static importDOM() {
4244
- return {
4245
- table: _node => ({
4246
- conversion: $convertTableElement,
4247
- priority: 1
4248
- })
4249
- };
4250
- }
4251
- static importJSON(serializedNode) {
4252
- return $createTableNode().updateFromJSON(serializedNode);
4253
- }
4254
4386
  updateFromJSON(serializedNode) {
4255
4387
  return super.updateFromJSON(serializedNode).setRowStriping(serializedNode.rowStriping || false).setFrozenColumns(serializedNode.frozenColumnCount || 0).setFrozenRows(serializedNode.frozenRowCount || 0).setColWidths(serializedNode.colWidths);
4256
4388
  }
4257
- constructor(key) {
4258
- super(key);
4259
- this.__rowStriping = false;
4260
- this.__frozenColumnCount = 0;
4261
- this.__frozenRowCount = 0;
4262
- this.__colWidths = undefined;
4263
- }
4264
4389
  exportJSON() {
4265
4390
  return {
4266
4391
  ...super.exportJSON(),
@@ -4291,16 +4416,19 @@ class TableNode extends ElementNode {
4291
4416
  addClassNamesToElement(tableElement, config.theme.table);
4292
4417
  this.updateTableElement(null, tableElement, config);
4293
4418
  if ($isScrollableTablesActive(editor)) {
4294
- const wrapperElement = $getDocument().createElement('div');
4295
- const classes = config.theme.tableScrollableWrapper;
4296
- if (classes) {
4297
- addClassNamesToElement(wrapperElement, classes);
4298
- } else {
4299
- wrapperElement.style.overflowX = 'auto';
4300
- }
4301
- wrapperElement.appendChild(tableElement);
4302
- this.updateTableWrapper(null, wrapperElement, tableElement, config);
4303
- return wrapperElement;
4419
+ const hasStickyScrollbar = $isStickyScrollbarActive(editor);
4420
+ const scrollableWrapper = $createScrollableWrapper(tableElement, config, hasStickyScrollbar);
4421
+ this.updateTableWrapper(null, scrollableWrapper, tableElement, config);
4422
+ if (hasStickyScrollbar) {
4423
+ const stickyScrollbar = $createStickyScrollbar(config);
4424
+ const outerWrapper = $getDocument().createElement('div');
4425
+ outerWrapper.setAttribute('data-lexical-sticky-scrollbar', 'true');
4426
+ outerWrapper.appendChild(scrollableWrapper);
4427
+ outerWrapper.appendChild(stickyScrollbar);
4428
+ setDOMUnmanaged(stickyScrollbar);
4429
+ return outerWrapper;
4430
+ }
4431
+ return scrollableWrapper;
4304
4432
  }
4305
4433
  return tableElement;
4306
4434
  }
@@ -4332,7 +4460,16 @@ class TableNode extends ElementNode {
4332
4460
  return true;
4333
4461
  }
4334
4462
  if (isHTMLDivElement(dom)) {
4335
- this.updateTableWrapper(prevNode, dom, tableElement, config);
4463
+ const hasStickyDom = dom.hasAttribute('data-lexical-sticky-scrollbar');
4464
+ if (hasStickyDom !== $isStickyScrollbarActive()) {
4465
+ return true;
4466
+ }
4467
+ // The scrollable wrapper is the table's immediate parent in both
4468
+ // layouts (the inner div in sticky mode, dom itself otherwise).
4469
+ const scrollable = tableElement.parentElement;
4470
+ if (isHTMLDivElement(scrollable)) {
4471
+ this.updateTableWrapper(prevNode, scrollable, tableElement, config);
4472
+ }
4336
4473
  }
4337
4474
  this.updateTableElement(prevNode, tableElement, config);
4338
4475
  return false;
@@ -5155,6 +5292,51 @@ const TableImportRules = [TableRule, TableRowRule, TableCellRule];
5155
5292
  *
5156
5293
  */
5157
5294
 
5295
+ function registerStickyScrollbar(editor) {
5296
+ const attached = new Map();
5297
+ const detachAll = () => {
5298
+ for (const {
5299
+ cleanup
5300
+ } of attached.values()) {
5301
+ cleanup();
5302
+ }
5303
+ attached.clear();
5304
+ };
5305
+ return mergeRegister(detachAll, editor.registerMutationListener(TableNode, nodeMutations => {
5306
+ for (const [nodeKey, mutation] of nodeMutations) {
5307
+ const prev = attached.get(nodeKey);
5308
+ if (mutation === 'destroyed') {
5309
+ if (prev) {
5310
+ prev.cleanup();
5311
+ attached.delete(nodeKey);
5312
+ }
5313
+ continue;
5314
+ }
5315
+ const dom = editor.getElementByKey(nodeKey);
5316
+ const parts = dom && isHTMLElement(dom) ? findStickyScrollbarElements(dom) : null;
5317
+ if (prev && parts && prev.parts.scrollable === parts.scrollable && prev.parts.scrollbar === parts.scrollbar && prev.parts.tableElement === parts.tableElement) {
5318
+ // Same DOM: keep the listeners, but resync since the
5319
+ // update may still change scrollability (e.g. a frozen
5320
+ // rows toggle switches the wrapper to overflow-x: clip).
5321
+ syncStickyScrollbar(parts.scrollable, parts.scrollbar);
5322
+ continue;
5323
+ }
5324
+ if (prev) {
5325
+ prev.cleanup();
5326
+ attached.delete(nodeKey);
5327
+ }
5328
+ if (parts) {
5329
+ attached.set(nodeKey, {
5330
+ cleanup: attachStickyScrollbarListeners(parts),
5331
+ parts
5332
+ });
5333
+ }
5334
+ }
5335
+ }, {
5336
+ skipInitialization: false
5337
+ }));
5338
+ }
5339
+
5158
5340
  /**
5159
5341
  * Configures {@link TableNode}, {@link TableRowNode}, {@link TableCellNode} and
5160
5342
  * registers table behaviors (see {@link TableConfig})
@@ -5168,6 +5350,7 @@ const TableExtension = /* @__PURE__ */defineExtension({
5168
5350
  hasCellMerge: true,
5169
5351
  hasHorizontalScroll: true,
5170
5352
  hasNestedTables: false,
5353
+ hasStickyScrollbar: false,
5171
5354
  hasTabHandler: true
5172
5355
  }),
5173
5356
  dependencies: [
@@ -5181,18 +5364,31 @@ const TableExtension = /* @__PURE__ */defineExtension({
5181
5364
  nodes: () => [TableNode, TableRowNode, TableCellNode],
5182
5365
  register(editor, config, state) {
5183
5366
  const stores = state.getOutput();
5367
+ let prevStickyScrollbar = false;
5184
5368
  return mergeRegister(effect(() => {
5185
5369
  const hasHorizontalScroll = stores.hasHorizontalScroll.value;
5370
+ const hasStickyScrollbar = stores.hasStickyScrollbar.value && hasHorizontalScroll;
5186
5371
  const hadHorizontalScroll = $isScrollableTablesActive(editor);
5187
5372
  if (hadHorizontalScroll !== hasHorizontalScroll) {
5188
5373
  setScrollableTablesActive(editor, hasHorizontalScroll);
5374
+ }
5375
+ if (hadHorizontalScroll !== hasHorizontalScroll || prevStickyScrollbar !== hasStickyScrollbar) {
5189
5376
  // Re-render existing tables through the new scroll-wrapper config
5190
5377
  // without cloning every TableNode the way marking them dirty would. A
5191
5378
  // full reconcile marks no nodes dirty, so it's deferred (no
5192
5379
  // synchronous render from this effect) and produces no history entry.
5193
5380
  editor.update($fullReconcile);
5194
5381
  }
5195
- }), registerTablePlugin(editor, stores), effect(() => registerTableSelectionObserver(editor, stores.hasTabHandler.value)), effect(() => stores.hasCellMerge.value ? undefined : registerTableCellUnmergeTransform(editor)), effect(() => stores.hasCellBackgroundColor.value ? undefined : editor.registerNodeTransform(TableCellNode, node => {
5382
+ prevStickyScrollbar = hasStickyScrollbar;
5383
+ }), registerTablePlugin(editor, stores), effect(() => registerTableSelectionObserver(editor, stores.hasTabHandler.value)), effect(() => {
5384
+ if (stores.hasStickyScrollbar.value && stores.hasHorizontalScroll.value) {
5385
+ return editor.registerRootListener(rootElement => {
5386
+ if (rootElement) {
5387
+ return registerStickyScrollbar(editor);
5388
+ }
5389
+ });
5390
+ }
5391
+ }), effect(() => stores.hasCellMerge.value ? undefined : registerTableCellUnmergeTransform(editor)), effect(() => stores.hasCellBackgroundColor.value ? undefined : editor.registerNodeTransform(TableCellNode, node => {
5196
5392
  if (node.getBackgroundColor() !== null) {
5197
5393
  node.setBackgroundColor(null);
5198
5394
  }
@@ -5214,4 +5410,4 @@ const TableImportExtension = /* @__PURE__ */defineExtension({
5214
5410
  name: '@lexical/table/Import'
5215
5411
  });
5216
5412
 
5217
- export { $computeTableMap, $computeTableMapSkipCellCheck, $createTableCellNode, $createTableNode, $createTableNodeWithDimensions, $createTableRowNode, $createTableSelection, $createTableSelectionFrom, $deleteTableColumn, $deleteTableColumnAtSelection, $deleteTableColumn__EXPERIMENTAL, $deleteTableRowAtSelection, $deleteTableRow__EXPERIMENTAL, $findCellNode, $findTableNode, $getElementForTableNode, $getNodeTriplet, $getTableAndElementByKey, $getTableCellNodeFromLexicalNode, $getTableCellNodeRect, $getTableColumnIndexFromTableCellNode, $getTableNodeFromLexicalNodeOrThrow, $getTableRowIndexFromTableCellNode, $getTableRowNodeFromTableCellNodeOrThrow, $insertTableColumn, $insertTableColumnAtNode, $insertTableColumnAtSelection, $insertTableColumn__EXPERIMENTAL, $insertTableRow, $insertTableRowAtNode, $insertTableRowAtSelection, $insertTableRow__EXPERIMENTAL, $isScrollableTablesActive, $isSimpleTable, $isTableCellNode, $isTableNode, $isTableRowNode, $isTableSelection, $mergeCells, $moveTableColumn, $moveTableRow, $removeTableRowAtIndex, $setTableColumnIsHeader, $setTableRowIsHeader, $unmergeCell, $unmergeCellNode, INSERT_TABLE_COMMAND, TableCellHeaderStates, TableCellNode, TableExtension, TableImportExtension, TableImportRules, TableNode, TableObserver, TableRowNode, TableRowSchema, TableSchema, applyTableHandlers, getDOMCellFromTarget, getTableElement, getTableObserverFromTableElement, registerTableCellUnmergeTransform, registerTablePlugin, registerTableSelectionObserver, setScrollableTablesActive };
5413
+ export { $computeTableCellRectBoundary, $computeTableMap, $computeTableMapSkipCellCheck, $createTableCellNode, $createTableNode, $createTableNodeWithDimensions, $createTableRowNode, $createTableSelection, $createTableSelectionFrom, $deleteTableColumn, $deleteTableColumnAtSelection, $deleteTableColumn__EXPERIMENTAL, $deleteTableRowAtSelection, $deleteTableRow__EXPERIMENTAL, $findCellNode, $findTableNode, $getElementForTableNode, $getNodeTriplet, $getTableAndElementByKey, $getTableCellNodeFromLexicalNode, $getTableCellNodeRect, $getTableColumnIndexFromTableCellNode, $getTableNodeFromLexicalNodeOrThrow, $getTableRowIndexFromTableCellNode, $getTableRowNodeFromTableCellNodeOrThrow, $insertTableColumn, $insertTableColumnAtNode, $insertTableColumnAtSelection, $insertTableColumn__EXPERIMENTAL, $insertTableRow, $insertTableRowAtNode, $insertTableRowAtSelection, $insertTableRow__EXPERIMENTAL, $isScrollableTablesActive, $isSimpleTable, $isStickyScrollbarActive, $isTableCellNode, $isTableNode, $isTableRowNode, $isTableSelection, $mergeCells, $moveTableColumn, $moveTableRow, $removeTableRowAtIndex, $setTableColumnIsHeader, $setTableRowIsHeader, $unmergeCell, $unmergeCellNode, INSERT_TABLE_COMMAND, TableCellHeaderStates, TableCellNode, TableExtension, TableImportExtension, TableImportRules, TableNode, TableObserver, TableRowNode, TableRowSchema, TableSchema, applyTableHandlers, getDOMCellFromTarget, getTableElement, getTableObserverFromTableElement, registerTableCellUnmergeTransform, registerTablePlugin, registerTableSelectionObserver, setScrollableTablesActive };
@@ -47,8 +47,6 @@ declare export class TableCellNode extends ElementNode {
47
47
  __headerState: TableCellHeaderState;
48
48
  __width?: number;
49
49
  __backgroundColor: null | string;
50
- static getType(): string;
51
- static clone(node: TableCellNode): TableCellNode;
52
50
  constructor(
53
51
  headerState?: TableCellHeaderState,
54
52
  colSpan?: number,
@@ -97,8 +95,6 @@ export type TableMapValueType = {
97
95
  export type TableMapType = TableMapValueType[][];
98
96
 
99
97
  declare export class TableNode extends ElementNode {
100
- static getType(): string;
101
- static clone(node: TableNode): TableNode;
102
98
  constructor(key?: NodeKey): void;
103
99
  createDOM(config: EditorConfig): HTMLElement;
104
100
  insertNewAfter(selection: RangeSelection): null | ParagraphNode | TableNode;
@@ -122,6 +118,9 @@ declare export function $isTableNode(
122
118
  declare export function $isScrollableTablesActive(
123
119
  editor?: LexicalEditor
124
120
  ): boolean;
121
+ declare export function $isStickyScrollbarActive(
122
+ editor?: LexicalEditor
123
+ ): boolean;
125
124
  declare export function setScrollableTablesActive(
126
125
  editor: LexicalEditor,
127
126
  active: boolean,
@@ -139,8 +138,6 @@ declare export function $getTableAndElementByKey(
139
138
  */
140
139
 
141
140
  declare export class TableRowNode extends ElementNode {
142
- static getType(): string;
143
- static clone(node: TableRowNode): TableRowNode;
144
141
  constructor(height?: ?number, key?: NodeKey): void;
145
142
  createDOM(config: EditorConfig): HTMLElement;
146
143
  setHeight(height: number): ?number;
@@ -467,6 +464,8 @@ export type TableConfig = {
467
464
  hasCellBackgroundColor: boolean;
468
465
  hasTabHandler: boolean;
469
466
  hasHorizontalScroll: boolean;
467
+ hasStickyScrollbar: boolean;
468
+ hasNestedTables: boolean;
470
469
  };
471
470
 
472
471
 
@@ -9,6 +9,7 @@
9
9
  import * as modDev from './LexicalTable.dev.mjs';
10
10
  import * as modProd from './LexicalTable.prod.mjs';
11
11
  const mod = process.env.NODE_ENV !== 'production' ? modDev : modProd;
12
+ export const $computeTableCellRectBoundary = mod.$computeTableCellRectBoundary;
12
13
  export const $computeTableMap = mod.$computeTableMap;
13
14
  export const $computeTableMapSkipCellCheck = mod.$computeTableMapSkipCellCheck;
14
15
  export const $createTableCellNode = mod.$createTableCellNode;
@@ -43,6 +44,7 @@ export const $insertTableRowAtSelection = mod.$insertTableRowAtSelection;
43
44
  export const $insertTableRow__EXPERIMENTAL = mod.$insertTableRow__EXPERIMENTAL;
44
45
  export const $isScrollableTablesActive = mod.$isScrollableTablesActive;
45
46
  export const $isSimpleTable = mod.$isSimpleTable;
47
+ export const $isStickyScrollbarActive = mod.$isStickyScrollbarActive;
46
48
  export const $isTableCellNode = mod.$isTableCellNode;
47
49
  export const $isTableNode = mod.$isTableNode;
48
50
  export const $isTableRowNode = mod.$isTableRowNode;
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  const mod = await (process.env.NODE_ENV !== 'production' ? import('./LexicalTable.dev.mjs') : import('./LexicalTable.prod.mjs'));
10
+ export const $computeTableCellRectBoundary = mod.$computeTableCellRectBoundary;
10
11
  export const $computeTableMap = mod.$computeTableMap;
11
12
  export const $computeTableMapSkipCellCheck = mod.$computeTableMapSkipCellCheck;
12
13
  export const $createTableCellNode = mod.$createTableCellNode;
@@ -41,6 +42,7 @@ export const $insertTableRowAtSelection = mod.$insertTableRowAtSelection;
41
42
  export const $insertTableRow__EXPERIMENTAL = mod.$insertTableRow__EXPERIMENTAL;
42
43
  export const $isScrollableTablesActive = mod.$isScrollableTablesActive;
43
44
  export const $isSimpleTable = mod.$isSimpleTable;
45
+ export const $isStickyScrollbarActive = mod.$isStickyScrollbarActive;
44
46
  export const $isTableCellNode = mod.$isTableCellNode;
45
47
  export const $isTableNode = mod.$isTableNode;
46
48
  export const $isTableRowNode = mod.$isTableRowNode;