@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.
@@ -56,11 +56,20 @@ class TableCellNode extends lexical.ElementNode {
56
56
  __backgroundColor;
57
57
  /** @internal */
58
58
  __verticalAlign;
59
- static getType() {
60
- return 'tablecell';
61
- }
62
- static clone(node) {
63
- return new TableCellNode(node.__headerState, node.__colSpan, node.__width, node.__key);
59
+ $config() {
60
+ return this.config('tablecell', {
61
+ extends: lexical.ElementNode,
62
+ importDOM: {
63
+ td: () => ({
64
+ conversion: $convertTableCellNodeElement,
65
+ priority: 0
66
+ }),
67
+ th: () => ({
68
+ conversion: $convertTableCellNodeElement,
69
+ priority: 0
70
+ })
71
+ }
72
+ });
64
73
  }
65
74
  afterCloneFrom(node) {
66
75
  super.afterCloneFrom(node);
@@ -71,21 +80,6 @@ class TableCellNode extends lexical.ElementNode {
71
80
  this.__headerState = node.__headerState;
72
81
  this.__width = node.__width;
73
82
  }
74
- static importDOM() {
75
- return {
76
- td: node => ({
77
- conversion: $convertTableCellNodeElement,
78
- priority: 0
79
- }),
80
- th: node => ({
81
- conversion: $convertTableCellNodeElement,
82
- priority: 0
83
- })
84
- };
85
- }
86
- static importJSON(serializedNode) {
87
- return $createTableCellNode().updateFromJSON(serializedNode);
88
- }
89
83
  updateFromJSON(serializedNode) {
90
84
  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);
91
85
  }
@@ -383,31 +377,28 @@ function formatDevErrorMessage(message) {
383
377
  class TableRowNode extends lexical.ElementNode {
384
378
  /** @internal */
385
379
  __height;
386
- static getType() {
387
- return 'tablerow';
388
- }
389
- static clone(node) {
390
- return new TableRowNode(node.__height, node.__key);
380
+ $config() {
381
+ return this.config('tablerow', {
382
+ extends: lexical.ElementNode,
383
+ importDOM: {
384
+ tr: () => ({
385
+ conversion: $convertTableRowElement,
386
+ priority: 0
387
+ })
388
+ }
389
+ });
391
390
  }
392
391
  afterCloneFrom(prevNode) {
393
392
  super.afterCloneFrom(prevNode);
394
393
  this.__height = prevNode.__height;
395
394
  }
396
- static importDOM() {
397
- return {
398
- tr: node => ({
399
- conversion: $convertTableRowElement,
400
- priority: 0
401
- })
402
- };
403
- }
404
- static importJSON(serializedNode) {
405
- return $createTableRowNode().updateFromJSON(serializedNode);
406
- }
407
395
  updateFromJSON(serializedNode) {
408
396
  return super.updateFromJSON(serializedNode).setHeight(serializedNode.height);
409
397
  }
410
- constructor(height, key) {
398
+
399
+ // `height` carries an explicit `undefined` default so the constructor reports
400
+ // zero required arguments and `$config` can synthesize the static `clone`.
401
+ constructor(height = undefined, key) {
411
402
  super(key);
412
403
  this.__height = height;
413
404
  }
@@ -1391,6 +1382,11 @@ function $computeTableCellRectSpans(map, boundary) {
1391
1382
  topSpan
1392
1383
  };
1393
1384
  }
1385
+
1386
+ /**
1387
+ * Compute the bounding rectangle of two table cells in a table map,
1388
+ * expanding iteratively to include any merged cells that overlap the boundary.
1389
+ */
1394
1390
  function $computeTableCellRectBoundary(map, cellAMap, cellBMap) {
1395
1391
  // Initial boundaries based on the anchor and focus cells
1396
1392
  let minColumn = Math.min(cellAMap.startColumn, cellBMap.startColumn);
@@ -1899,7 +1895,7 @@ class TableSelection {
1899
1895
  selection.insertNodes(nodes);
1900
1896
  }
1901
1897
 
1902
- // TODO Deprecate this method. It's confusing when used with colspan|rowspan
1898
+ /** @deprecated Use {@link $computeTableMap} and {@link $computeTableCellRectBoundary} directly. */
1903
1899
  getShape() {
1904
1900
  const {
1905
1901
  anchorCell,
@@ -2011,19 +2007,28 @@ class TableSelection {
2011
2007
  return textContent;
2012
2008
  }
2013
2009
  }
2010
+
2011
+ /** Type guard for {@link TableSelection}. */
2014
2012
  function $isTableSelection(x) {
2015
2013
  return x instanceof TableSelection;
2016
2014
  }
2015
+
2016
+ /** @deprecated Use {@link $createTableSelectionFrom} instead. */
2017
2017
  function $createTableSelection() {
2018
- // TODO this is a suboptimal design, it doesn't make sense to have
2019
- // a table selection that isn't associated with a table. This
2020
- // constructor should have required arguments and in __DEV__ we
2021
- // should check that they point to a table and are element points to
2022
- // cell nodes of that table.
2023
2018
  const anchor = lexical.$createPoint('root', 0, 'element');
2024
2019
  const focus = lexical.$createPoint('root', 0, 'element');
2025
2020
  return new TableSelection('root', anchor, focus);
2026
2021
  }
2022
+
2023
+ /**
2024
+ * Creates a {@link TableSelection} spanning from `anchorCell` to `focusCell`
2025
+ * within `tableNode`. In `__DEV__` mode, validates that both cells belong to
2026
+ * the given table and that the table is attached to the editor state.
2027
+ *
2028
+ * If the current selection is already a TableSelection, it clones and
2029
+ * re-targets it (preserving identity for dirty-checking); otherwise it
2030
+ * constructs a fresh one.
2031
+ */
2027
2032
  function $createTableSelectionFrom(tableNode, anchorCell, focusCell) {
2028
2033
  const tableNodeKey = tableNode.getKey();
2029
2034
  const anchorCellKey = anchorCell.getKey();
@@ -2040,7 +2045,7 @@ function $createTableSelectionFrom(tableNode, anchorCell, focusCell) {
2040
2045
  } // TODO: Check for rectangular grid
2041
2046
  }
2042
2047
  const prevSelection = lexical.$getSelection();
2043
- const nextSelection = $isTableSelection(prevSelection) ? prevSelection.clone() : $createTableSelection();
2048
+ const nextSelection = $isTableSelection(prevSelection) ? prevSelection.clone() : new TableSelection('root', lexical.$createPoint('root', 0, 'element'), lexical.$createPoint('root', 0, 'element'));
2044
2049
  nextSelection.set(tableNode.getKey(), anchorCell.getKey(), focusCell.getKey());
2045
2050
  return nextSelection;
2046
2051
  }
@@ -2899,7 +2904,7 @@ function applyTableHandlers(tableNode, element, editor, hasTabHandler, tableObse
2899
2904
  }
2900
2905
  return false;
2901
2906
  }, lexical.COMMAND_PRIORITY_HIGH));
2902
- const deleteTextHandler = command => () => {
2907
+ const $deleteTextHandler = () => {
2903
2908
  const selection = lexical.$getSelection();
2904
2909
  if (!$isSelectionInTable(selection, tableNode)) {
2905
2910
  return false;
@@ -2907,34 +2912,11 @@ function applyTableHandlers(tableNode, element, editor, hasTabHandler, tableObse
2907
2912
  if ($isTableSelection(selection)) {
2908
2913
  tableObserver.$clearText();
2909
2914
  return true;
2910
- } else if (lexical.$isRangeSelection(selection)) {
2911
- const tableCellNode = $findParentTableCellNodeInTable(tableNode, selection.anchor.getNode());
2912
- if (!$isTableCellNode(tableCellNode)) {
2913
- return false;
2914
- }
2915
- const anchorNode = selection.anchor.getNode();
2916
- const focusNode = selection.focus.getNode();
2917
- const isAnchorInside = tableNode.isParentOf(anchorNode);
2918
- const isFocusInside = tableNode.isParentOf(focusNode);
2919
- const selectionContainsPartialTable = isAnchorInside && !isFocusInside || isFocusInside && !isAnchorInside;
2920
- if (selectionContainsPartialTable) {
2921
- tableObserver.$clearText();
2922
- return true;
2923
- }
2924
- const nearestElementNode = lexical.$findMatchingParent(selection.anchor.getNode(), n => lexical.$isElementNode(n));
2925
- const topLevelCellElementNode = nearestElementNode && lexical.$findMatchingParent(nearestElementNode, n => lexical.$isElementNode(n) && $isTableCellNode(n.getParent()));
2926
- if (!lexical.$isElementNode(topLevelCellElementNode) || !lexical.$isElementNode(nearestElementNode)) {
2927
- return false;
2928
- }
2929
- if (command === lexical.DELETE_LINE_COMMAND && topLevelCellElementNode.getPreviousSibling() === null) {
2930
- // TODO: Fix Delete Line in Table Cells.
2931
- return true;
2932
- }
2933
2915
  }
2934
2916
  return false;
2935
2917
  };
2936
2918
  for (const command of DELETE_TEXT_COMMANDS) {
2937
- tableObserver.listenersToRemove.add(editor.registerCommand(command, deleteTextHandler(command), lexical.COMMAND_PRIORITY_HIGH));
2919
+ tableObserver.listenersToRemove.add(editor.registerCommand(command, $deleteTextHandler, lexical.COMMAND_PRIORITY_HIGH));
2938
2920
  }
2939
2921
  const $deleteCellHandler = event => {
2940
2922
  const selection = lexical.$getSelection();
@@ -3047,11 +3029,6 @@ function applyTableHandlers(tableNode, element, editor, hasTabHandler, tableObse
3047
3029
  if ($isTableSelection(selection)) {
3048
3030
  tableObserver.$formatCells(payload);
3049
3031
  return true;
3050
- } else if (lexical.$isRangeSelection(selection)) {
3051
- const tableCellNode = lexical.$findMatchingParent(selection.anchor.getNode(), n => $isTableCellNode(n));
3052
- if (!$isTableCellNode(tableCellNode)) {
3053
- return false;
3054
- }
3055
3032
  }
3056
3033
  return false;
3057
3034
  }, lexical.COMMAND_PRIORITY_HIGH));
@@ -4197,10 +4174,167 @@ function alignTableElement(dom, config, formatType) {
4197
4174
  lexical.removeClassNamesFromElement(dom, ...removeClasses);
4198
4175
  lexical.addClassNamesToElement(dom, ...addClasses);
4199
4176
  }
4177
+ function $createScrollableWrapper(tableElement, config, hideNativeScrollbar) {
4178
+ const wrapper = lexical.$getDocument().createElement('div');
4179
+ const classes = config.theme.tableScrollableWrapper;
4180
+ if (classes) {
4181
+ lexical.addClassNamesToElement(wrapper, classes);
4182
+ } else {
4183
+ wrapper.style.overflowX = 'auto';
4184
+ }
4185
+ if (hideNativeScrollbar) {
4186
+ wrapper.style.scrollbarWidth = 'none';
4187
+ }
4188
+ wrapper.appendChild(tableElement);
4189
+ return wrapper;
4190
+ }
4191
+ function $createStickyScrollbar(config) {
4192
+ const doc = lexical.$getDocument();
4193
+ const scrollbar = doc.createElement('div');
4194
+ const classes = config.theme.tableStickyScrollbar;
4195
+ if (classes) {
4196
+ lexical.addClassNamesToElement(scrollbar, classes);
4197
+ } else {
4198
+ scrollbar.style.position = 'sticky';
4199
+ scrollbar.style.bottom = '0';
4200
+ scrollbar.style.overflowX = 'scroll';
4201
+ scrollbar.style.overflowY = 'hidden';
4202
+ }
4203
+ scrollbar.style.display = 'none';
4204
+ scrollbar.setAttribute('aria-hidden', 'true');
4205
+ scrollbar.tabIndex = -1;
4206
+ const spacer = doc.createElement('div');
4207
+ spacer.style.height = '1px';
4208
+ spacer.style.width = '0px';
4209
+ scrollbar.appendChild(spacer);
4210
+ return scrollbar;
4211
+ }
4212
+ // Unthemed sticky scrollbars whose environment cannot render a persistent
4213
+ // proxy scrollbar (overlay scrollbars reserve no height and show no idle
4214
+ // thumb), permanently hidden in favor of the wrapper's native scrollbar.
4215
+ const overlayStickyScrollbars = new WeakSet();
4216
+ function measureScrollbarThickness(scrollbar) {
4217
+ const prevDisplay = scrollbar.style.display;
4218
+ scrollbar.style.display = '';
4219
+ const thickness = scrollbar.offsetHeight - scrollbar.clientHeight;
4220
+ scrollbar.style.display = prevDisplay;
4221
+ return thickness;
4222
+ }
4223
+ function attachStickyScrollbarListeners(elements) {
4224
+ const {
4225
+ scrollable,
4226
+ scrollbar,
4227
+ tableElement
4228
+ } = elements;
4229
+ // A themed scrollbar (theme.tableStickyScrollbar, detectable by its
4230
+ // classes) is the integrator's responsibility to keep visible (e.g. via
4231
+ // ::-webkit-scrollbar height or scrollbar-width). The unthemed fallback
4232
+ // relies on classic native scrollbars for its height, so where the
4233
+ // environment renders overlay scrollbars (no reserved thickness — e.g.
4234
+ // macOS "show scrollbars when scrolling", or non-layout environments like
4235
+ // jsdom) the proxy would be an invisible strip: keep it hidden and
4236
+ // restore the wrapper's native scrollbar instead of presenting no scroll
4237
+ // affordance at all.
4238
+ if (scrollbar.classList.length === 0 && measureScrollbarThickness(scrollbar) === 0) {
4239
+ overlayStickyScrollbars.add(scrollbar);
4240
+ scrollbar.style.display = 'none';
4241
+ scrollable.style.scrollbarWidth = 'auto';
4242
+ return () => {};
4243
+ }
4244
+ const onWrapperScroll = () => {
4245
+ if (scrollbar.scrollLeft !== scrollable.scrollLeft) {
4246
+ scrollbar.scrollLeft = scrollable.scrollLeft;
4247
+ }
4248
+ };
4249
+ const onScrollbarScroll = () => {
4250
+ if (scrollable.scrollLeft !== scrollbar.scrollLeft) {
4251
+ scrollable.scrollLeft = scrollbar.scrollLeft;
4252
+ }
4253
+ };
4254
+ scrollable.addEventListener('scroll', onWrapperScroll, {
4255
+ passive: true
4256
+ });
4257
+ scrollbar.addEventListener('scroll', onScrollbarScroll, {
4258
+ passive: true
4259
+ });
4260
+ let resizeObserver = null;
4261
+ if (typeof ResizeObserver !== 'undefined') {
4262
+ resizeObserver = new ResizeObserver(() => {
4263
+ syncStickyScrollbar(scrollable, scrollbar);
4264
+ });
4265
+ resizeObserver.observe(scrollable);
4266
+ resizeObserver.observe(tableElement);
4267
+ }
4268
+ const cleanup = () => {
4269
+ scrollable.removeEventListener('scroll', onWrapperScroll);
4270
+ scrollbar.removeEventListener('scroll', onScrollbarScroll);
4271
+ if (resizeObserver) {
4272
+ resizeObserver.disconnect();
4273
+ }
4274
+ };
4275
+ syncStickyScrollbar(scrollable, scrollbar);
4276
+ return cleanup;
4277
+ }
4278
+ function syncStickyScrollbar(scrollable, scrollbar) {
4279
+ if (overlayStickyScrollbars.has(scrollbar)) {
4280
+ return;
4281
+ }
4282
+ const spacer = scrollbar.firstElementChild;
4283
+ if (!spacer || !lexical.isHTMLElement(spacer) || !scrollable.isConnected) {
4284
+ return;
4285
+ }
4286
+ const view = scrollable.ownerDocument.defaultView;
4287
+ if (!view) {
4288
+ scrollbar.style.display = 'none';
4289
+ return;
4290
+ }
4291
+ // All layout reads happen before any write so a sync forces at most one
4292
+ // reflow, and both metrics come from the same element so their rounding
4293
+ // can't disagree at fractional zoom levels.
4294
+ const overflowX = view.getComputedStyle(scrollable).overflowX;
4295
+ const isScrollable = overflowX === 'auto' || overflowX === 'scroll';
4296
+ const scrollWidth = scrollable.scrollWidth;
4297
+ const clientWidth = scrollable.clientWidth;
4298
+ const spacerWidth = scrollWidth + 'px';
4299
+ if (spacer.style.width !== spacerWidth) {
4300
+ spacer.style.width = spacerWidth;
4301
+ }
4302
+ const hasOverflow = isScrollable && scrollWidth > clientWidth;
4303
+ scrollbar.style.display = hasOverflow ? '' : 'none';
4304
+ }
4305
+ function findStickyScrollbarElements(dom) {
4306
+ if (!dom.hasAttribute('data-lexical-sticky-scrollbar')) {
4307
+ return null;
4308
+ }
4309
+ const firstChild = dom.firstElementChild;
4310
+ if (!isHTMLDivElement(firstChild)) {
4311
+ return null;
4312
+ }
4313
+ const tableElement = firstChild.querySelector(':scope > table');
4314
+ if (!isHTMLTableElement(tableElement)) {
4315
+ return null;
4316
+ }
4317
+ const scrollbar = firstChild.nextElementSibling;
4318
+ if (!isHTMLDivElement(scrollbar)) {
4319
+ return null;
4320
+ }
4321
+ return {
4322
+ scrollable: firstChild,
4323
+ scrollbar,
4324
+ tableElement
4325
+ };
4326
+ }
4200
4327
  const scrollableEditors = new WeakSet();
4201
4328
  function $isScrollableTablesActive(editor = lexical.$getEditor()) {
4202
4329
  return scrollableEditors.has(editor);
4203
4330
  }
4331
+ function $isStickyScrollbarActive(editor = lexical.$getEditor()) {
4332
+ const dep = extension.getPeerDependencyFromEditor(editor, '@lexical/table/Table');
4333
+ // peek() so a reconcile that runs inside a signals effect (e.g. via a
4334
+ // discrete update or force-commit read) does not subscribe that effect to
4335
+ // the table config; re-rendering on change is the TableExtension's job.
4336
+ return dep ? dep.output.hasStickyScrollbar.peek() && dep.output.hasHorizontalScroll.peek() : false;
4337
+ }
4204
4338
  function setScrollableTablesActive(editor, active) {
4205
4339
  if (active) {
4206
4340
  if (!editor._config.theme.tableScrollableWrapper) {
@@ -4215,12 +4349,24 @@ function setScrollableTablesActive(editor, active) {
4215
4349
  /** @noInheritDoc */
4216
4350
  class TableNode extends lexical.ElementNode {
4217
4351
  /** @internal */
4218
- __rowStriping;
4219
- __frozenColumnCount;
4220
- __frozenRowCount;
4221
- __colWidths;
4222
- static getType() {
4223
- return 'table';
4352
+ __rowStriping = false;
4353
+ __frozenColumnCount = 0;
4354
+ __frozenRowCount = 0;
4355
+ // Initialized unconditionally (not `__colWidths?: ...`) so a freshly
4356
+ // constructed instance always has the own property — `@lexical/yjs` derives a
4357
+ // node's syncable properties from `Object.keys(new Klass())`, so an
4358
+ // uninitialized optional field would silently drop out of collab sync.
4359
+ __colWidths = undefined;
4360
+ $config() {
4361
+ return this.config('table', {
4362
+ extends: lexical.ElementNode,
4363
+ importDOM: {
4364
+ table: () => ({
4365
+ conversion: $convertTableElement,
4366
+ priority: 1
4367
+ })
4368
+ }
4369
+ });
4224
4370
  }
4225
4371
  getColWidths() {
4226
4372
  const self = this.getLatest();
@@ -4232,9 +4378,6 @@ class TableNode extends lexical.ElementNode {
4232
4378
  self.__colWidths = colWidths !== undefined && __DEV__ ? Object.freeze(colWidths) : colWidths;
4233
4379
  return self;
4234
4380
  }
4235
- static clone(node) {
4236
- return new TableNode(node.__key);
4237
- }
4238
4381
  afterCloneFrom(prevNode) {
4239
4382
  super.afterCloneFrom(prevNode);
4240
4383
  this.__colWidths = prevNode.__colWidths;
@@ -4242,27 +4385,9 @@ class TableNode extends lexical.ElementNode {
4242
4385
  this.__frozenColumnCount = prevNode.__frozenColumnCount;
4243
4386
  this.__frozenRowCount = prevNode.__frozenRowCount;
4244
4387
  }
4245
- static importDOM() {
4246
- return {
4247
- table: _node => ({
4248
- conversion: $convertTableElement,
4249
- priority: 1
4250
- })
4251
- };
4252
- }
4253
- static importJSON(serializedNode) {
4254
- return $createTableNode().updateFromJSON(serializedNode);
4255
- }
4256
4388
  updateFromJSON(serializedNode) {
4257
4389
  return super.updateFromJSON(serializedNode).setRowStriping(serializedNode.rowStriping || false).setFrozenColumns(serializedNode.frozenColumnCount || 0).setFrozenRows(serializedNode.frozenRowCount || 0).setColWidths(serializedNode.colWidths);
4258
4390
  }
4259
- constructor(key) {
4260
- super(key);
4261
- this.__rowStriping = false;
4262
- this.__frozenColumnCount = 0;
4263
- this.__frozenRowCount = 0;
4264
- this.__colWidths = undefined;
4265
- }
4266
4391
  exportJSON() {
4267
4392
  return {
4268
4393
  ...super.exportJSON(),
@@ -4293,16 +4418,19 @@ class TableNode extends lexical.ElementNode {
4293
4418
  lexical.addClassNamesToElement(tableElement, config.theme.table);
4294
4419
  this.updateTableElement(null, tableElement, config);
4295
4420
  if ($isScrollableTablesActive(editor)) {
4296
- const wrapperElement = lexical.$getDocument().createElement('div');
4297
- const classes = config.theme.tableScrollableWrapper;
4298
- if (classes) {
4299
- lexical.addClassNamesToElement(wrapperElement, classes);
4300
- } else {
4301
- wrapperElement.style.overflowX = 'auto';
4302
- }
4303
- wrapperElement.appendChild(tableElement);
4304
- this.updateTableWrapper(null, wrapperElement, tableElement, config);
4305
- return wrapperElement;
4421
+ const hasStickyScrollbar = $isStickyScrollbarActive(editor);
4422
+ const scrollableWrapper = $createScrollableWrapper(tableElement, config, hasStickyScrollbar);
4423
+ this.updateTableWrapper(null, scrollableWrapper, tableElement, config);
4424
+ if (hasStickyScrollbar) {
4425
+ const stickyScrollbar = $createStickyScrollbar(config);
4426
+ const outerWrapper = lexical.$getDocument().createElement('div');
4427
+ outerWrapper.setAttribute('data-lexical-sticky-scrollbar', 'true');
4428
+ outerWrapper.appendChild(scrollableWrapper);
4429
+ outerWrapper.appendChild(stickyScrollbar);
4430
+ lexical.setDOMUnmanaged(stickyScrollbar);
4431
+ return outerWrapper;
4432
+ }
4433
+ return scrollableWrapper;
4306
4434
  }
4307
4435
  return tableElement;
4308
4436
  }
@@ -4334,7 +4462,16 @@ class TableNode extends lexical.ElementNode {
4334
4462
  return true;
4335
4463
  }
4336
4464
  if (isHTMLDivElement(dom)) {
4337
- this.updateTableWrapper(prevNode, dom, tableElement, config);
4465
+ const hasStickyDom = dom.hasAttribute('data-lexical-sticky-scrollbar');
4466
+ if (hasStickyDom !== $isStickyScrollbarActive()) {
4467
+ return true;
4468
+ }
4469
+ // The scrollable wrapper is the table's immediate parent in both
4470
+ // layouts (the inner div in sticky mode, dom itself otherwise).
4471
+ const scrollable = tableElement.parentElement;
4472
+ if (isHTMLDivElement(scrollable)) {
4473
+ this.updateTableWrapper(prevNode, scrollable, tableElement, config);
4474
+ }
4338
4475
  }
4339
4476
  this.updateTableElement(prevNode, tableElement, config);
4340
4477
  return false;
@@ -5157,6 +5294,51 @@ const TableImportRules = [TableRule, TableRowRule, TableCellRule];
5157
5294
  *
5158
5295
  */
5159
5296
 
5297
+ function registerStickyScrollbar(editor) {
5298
+ const attached = new Map();
5299
+ const detachAll = () => {
5300
+ for (const {
5301
+ cleanup
5302
+ } of attached.values()) {
5303
+ cleanup();
5304
+ }
5305
+ attached.clear();
5306
+ };
5307
+ return lexical.mergeRegister(detachAll, editor.registerMutationListener(TableNode, nodeMutations => {
5308
+ for (const [nodeKey, mutation] of nodeMutations) {
5309
+ const prev = attached.get(nodeKey);
5310
+ if (mutation === 'destroyed') {
5311
+ if (prev) {
5312
+ prev.cleanup();
5313
+ attached.delete(nodeKey);
5314
+ }
5315
+ continue;
5316
+ }
5317
+ const dom = editor.getElementByKey(nodeKey);
5318
+ const parts = dom && lexical.isHTMLElement(dom) ? findStickyScrollbarElements(dom) : null;
5319
+ if (prev && parts && prev.parts.scrollable === parts.scrollable && prev.parts.scrollbar === parts.scrollbar && prev.parts.tableElement === parts.tableElement) {
5320
+ // Same DOM: keep the listeners, but resync since the
5321
+ // update may still change scrollability (e.g. a frozen
5322
+ // rows toggle switches the wrapper to overflow-x: clip).
5323
+ syncStickyScrollbar(parts.scrollable, parts.scrollbar);
5324
+ continue;
5325
+ }
5326
+ if (prev) {
5327
+ prev.cleanup();
5328
+ attached.delete(nodeKey);
5329
+ }
5330
+ if (parts) {
5331
+ attached.set(nodeKey, {
5332
+ cleanup: attachStickyScrollbarListeners(parts),
5333
+ parts
5334
+ });
5335
+ }
5336
+ }
5337
+ }, {
5338
+ skipInitialization: false
5339
+ }));
5340
+ }
5341
+
5160
5342
  /**
5161
5343
  * Configures {@link TableNode}, {@link TableRowNode}, {@link TableCellNode} and
5162
5344
  * registers table behaviors (see {@link TableConfig})
@@ -5170,6 +5352,7 @@ const TableExtension = /* @__PURE__ */lexical.defineExtension({
5170
5352
  hasCellMerge: true,
5171
5353
  hasHorizontalScroll: true,
5172
5354
  hasNestedTables: false,
5355
+ hasStickyScrollbar: false,
5173
5356
  hasTabHandler: true
5174
5357
  }),
5175
5358
  dependencies: [
@@ -5183,18 +5366,31 @@ const TableExtension = /* @__PURE__ */lexical.defineExtension({
5183
5366
  nodes: () => [TableNode, TableRowNode, TableCellNode],
5184
5367
  register(editor, config, state) {
5185
5368
  const stores = state.getOutput();
5369
+ let prevStickyScrollbar = false;
5186
5370
  return lexical.mergeRegister(extension.effect(() => {
5187
5371
  const hasHorizontalScroll = stores.hasHorizontalScroll.value;
5372
+ const hasStickyScrollbar = stores.hasStickyScrollbar.value && hasHorizontalScroll;
5188
5373
  const hadHorizontalScroll = $isScrollableTablesActive(editor);
5189
5374
  if (hadHorizontalScroll !== hasHorizontalScroll) {
5190
5375
  setScrollableTablesActive(editor, hasHorizontalScroll);
5376
+ }
5377
+ if (hadHorizontalScroll !== hasHorizontalScroll || prevStickyScrollbar !== hasStickyScrollbar) {
5191
5378
  // Re-render existing tables through the new scroll-wrapper config
5192
5379
  // without cloning every TableNode the way marking them dirty would. A
5193
5380
  // full reconcile marks no nodes dirty, so it's deferred (no
5194
5381
  // synchronous render from this effect) and produces no history entry.
5195
5382
  editor.update(lexical.$fullReconcile);
5196
5383
  }
5197
- }), registerTablePlugin(editor, stores), extension.effect(() => registerTableSelectionObserver(editor, stores.hasTabHandler.value)), extension.effect(() => stores.hasCellMerge.value ? undefined : registerTableCellUnmergeTransform(editor)), extension.effect(() => stores.hasCellBackgroundColor.value ? undefined : editor.registerNodeTransform(TableCellNode, node => {
5384
+ prevStickyScrollbar = hasStickyScrollbar;
5385
+ }), registerTablePlugin(editor, stores), extension.effect(() => registerTableSelectionObserver(editor, stores.hasTabHandler.value)), extension.effect(() => {
5386
+ if (stores.hasStickyScrollbar.value && stores.hasHorizontalScroll.value) {
5387
+ return editor.registerRootListener(rootElement => {
5388
+ if (rootElement) {
5389
+ return registerStickyScrollbar(editor);
5390
+ }
5391
+ });
5392
+ }
5393
+ }), extension.effect(() => stores.hasCellMerge.value ? undefined : registerTableCellUnmergeTransform(editor)), extension.effect(() => stores.hasCellBackgroundColor.value ? undefined : editor.registerNodeTransform(TableCellNode, node => {
5198
5394
  if (node.getBackgroundColor() !== null) {
5199
5395
  node.setBackgroundColor(null);
5200
5396
  }
@@ -5216,6 +5412,7 @@ const TableImportExtension = /* @__PURE__ */lexical.defineExtension({
5216
5412
  name: '@lexical/table/Import'
5217
5413
  });
5218
5414
 
5415
+ exports.$computeTableCellRectBoundary = $computeTableCellRectBoundary;
5219
5416
  exports.$computeTableMap = $computeTableMap;
5220
5417
  exports.$computeTableMapSkipCellCheck = $computeTableMapSkipCellCheck;
5221
5418
  exports.$createTableCellNode = $createTableCellNode;
@@ -5250,6 +5447,7 @@ exports.$insertTableRowAtSelection = $insertTableRowAtSelection;
5250
5447
  exports.$insertTableRow__EXPERIMENTAL = $insertTableRow__EXPERIMENTAL;
5251
5448
  exports.$isScrollableTablesActive = $isScrollableTablesActive;
5252
5449
  exports.$isSimpleTable = $isSimpleTable;
5450
+ exports.$isStickyScrollbarActive = $isStickyScrollbarActive;
5253
5451
  exports.$isTableCellNode = $isTableCellNode;
5254
5452
  exports.$isTableNode = $isTableNode;
5255
5453
  exports.$isTableRowNode = $isTableRowNode;