@atlaskit/editor-plugin-table 15.3.14 → 15.3.16

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.
@@ -0,0 +1,721 @@
1
+ import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
+ import debounce from 'lodash/debounce';
3
+ import throttle from 'lodash/throttle';
4
+ import { getParentOfTypeCount } from '@atlaskit/editor-common/nesting';
5
+ import { nodeVisibilityManager } from '@atlaskit/editor-common/node-visibility';
6
+ import { findOverflowScrollParent } from '@atlaskit/editor-common/ui';
7
+ import { findParentNodeClosestToPos } from '@atlaskit/editor-prosemirror/utils';
8
+ import { getPluginState } from '../pm-plugins/plugin-factory';
9
+ import { pluginKey as tablePluginKey } from '../pm-plugins/plugin-key';
10
+ import { updateStickyState } from '../pm-plugins/sticky-headers/commands';
11
+ import { syncStickyRowToTable, updateStickyMargins as updateTableMargin } from '../pm-plugins/table-resizing/utils/dom';
12
+ import { getTop, getTree } from '../pm-plugins/utils/dom';
13
+ import { supportedHeaderRow } from '../pm-plugins/utils/nodes';
14
+ import { TableCssClassName as ClassName, TableCssClassName } from '../types';
15
+ import { stickyHeaderBorderBottomWidth, stickyRowOffsetTop, tableControlsSpacing, tableScrollbarOffset } from '../ui/consts';
16
+ import TableNodeView from './TableNodeViewBase';
17
+ // limit scroll event calls
18
+ const HEADER_ROW_SCROLL_THROTTLE_TIMEOUT = 200;
19
+
20
+ // timeout for resetting the scroll class - if it's too long then users won't be able to click on the header cells,
21
+ // if too short it would trigger too many dom updates.
22
+ const HEADER_ROW_SCROLL_RESET_DEBOUNCE_TIMEOUT = 400;
23
+ export default class TableRowNativeStickyWithFallback extends TableNodeView {
24
+ constructor(node, view, getPos, eventDispatcher, api) {
25
+ var _api$limitedMode, _api$limitedMode$shar, _api$limitedMode$shar2;
26
+ super(node, view, getPos, eventDispatcher);
27
+ _defineProperty(this, "cleanup", () => {
28
+ if (this.isStickyHeaderEnabled) {
29
+ this.unsubscribe();
30
+ this.nodeVisibilityObserverCleanupFn && this.nodeVisibilityObserverCleanupFn();
31
+ const tree = getTree(this.dom);
32
+ if (tree) {
33
+ this.makeRowHeaderNotSticky(tree.table, true);
34
+ }
35
+ this.emitOff(false);
36
+ }
37
+ if (this.tableContainerObserver) {
38
+ this.tableContainerObserver.disconnect();
39
+ }
40
+ });
41
+ _defineProperty(this, "colControlsOffset", 0);
42
+ _defineProperty(this, "focused", false);
43
+ _defineProperty(this, "topPosEditorElement", 0);
44
+ _defineProperty(this, "sentinels", {});
45
+ _defineProperty(this, "sentinelData", {
46
+ top: {
47
+ isIntersecting: false,
48
+ boundingClientRect: null,
49
+ rootBounds: null
50
+ },
51
+ bottom: {
52
+ isIntersecting: false,
53
+ boundingClientRect: null,
54
+ rootBounds: null
55
+ }
56
+ });
57
+ _defineProperty(this, "listening", false);
58
+ _defineProperty(this, "padding", 0);
59
+ _defineProperty(this, "top", 0);
60
+ _defineProperty(this, "isNativeSticky", false);
61
+ /**
62
+ * Methods
63
+ */
64
+ _defineProperty(this, "headerRowMouseScrollEnd", debounce(() => {
65
+ this.dom.classList.remove('no-pointer-events');
66
+ }, HEADER_ROW_SCROLL_RESET_DEBOUNCE_TIMEOUT));
67
+ // When the header is sticky, the header row is set to position: fixed
68
+ // This prevents mouse wheel scrolling on the scroll-parent div when user's mouse is hovering the header row.
69
+ // This fix sets pointer-events: none on the header row briefly to avoid this behaviour
70
+ _defineProperty(this, "headerRowMouseScroll", throttle(() => {
71
+ if (this.isSticky) {
72
+ this.dom.classList.add('no-pointer-events');
73
+ this.headerRowMouseScrollEnd();
74
+ }
75
+ }, HEADER_ROW_SCROLL_THROTTLE_TIMEOUT));
76
+ this.isHeaderRow = supportedHeaderRow(node);
77
+ this.isSticky = false;
78
+ const {
79
+ pluginConfig
80
+ } = getPluginState(view.state);
81
+ this.isStickyHeaderEnabled = !!pluginConfig.stickyHeaders;
82
+ if (api !== null && api !== void 0 && (_api$limitedMode = api.limitedMode) !== null && _api$limitedMode !== void 0 && (_api$limitedMode$shar = _api$limitedMode.sharedState.currentState()) !== null && _api$limitedMode$shar !== void 0 && (_api$limitedMode$shar2 = _api$limitedMode$shar.limitedModePluginKey.getState(view.state)) !== null && _api$limitedMode$shar2 !== void 0 && _api$limitedMode$shar2.documentSizeBreachesThreshold) {
83
+ this.isStickyHeaderEnabled = false;
84
+ // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners
85
+ document.addEventListener('limited-mode-activated', this.cleanup);
86
+ }
87
+ const pos = this.getPos();
88
+ this.isInNestedTable = false;
89
+ if (pos) {
90
+ this.isInNestedTable = getParentOfTypeCount(view.state.schema.nodes.table)(view.state.doc.resolve(pos)) > 1;
91
+ }
92
+ if (this.isHeaderRow) {
93
+ this.dom.setAttribute('data-vc-nvs', 'true');
94
+ const {
95
+ observe
96
+ } = nodeVisibilityManager(view.dom);
97
+ this.nodeVisibilityObserverCleanupFn = observe({
98
+ element: this.contentDOM,
99
+ onFirstVisible: () => {
100
+ this.subscribeWhenRowVisible();
101
+ }
102
+ });
103
+ }
104
+ }
105
+ subscribeWhenRowVisible() {
106
+ if (this.listening) {
107
+ return;
108
+ }
109
+ this.dom.setAttribute('data-header-row', 'true');
110
+ if (this.isStickyHeaderEnabled) {
111
+ this.subscribe();
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Variables
117
+ */
118
+
119
+ /**
120
+ * Methods: Nodeview Lifecycle
121
+ */
122
+ // Ignored via go/ees005
123
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
124
+ update(node, ..._args) {
125
+ // do nothing if nodes were identical
126
+ if (node === this.node) {
127
+ return true;
128
+ }
129
+
130
+ // see if we're changing into a header row or
131
+ // changing away from one
132
+ const newNodeIsHeaderRow = supportedHeaderRow(node);
133
+ if (this.isHeaderRow !== newNodeIsHeaderRow) {
134
+ return false; // re-create nodeview
135
+ }
136
+
137
+ // node is different but no need to re-create nodeview
138
+ this.node = node;
139
+
140
+ // don't do anything if we're just a regular tr
141
+ if (!this.isHeaderRow) {
142
+ return true;
143
+ }
144
+
145
+ // something changed, sync widths
146
+ if (this.isStickyHeaderEnabled) {
147
+ const tbody = this.dom.parentElement;
148
+ const table = tbody && tbody.parentElement;
149
+ syncStickyRowToTable(table);
150
+ }
151
+ return true;
152
+ }
153
+ destroy() {
154
+ if (this.isStickyHeaderEnabled) {
155
+ this.unsubscribe();
156
+ this.overflowObserver && this.overflowObserver.disconnect();
157
+ this.nodeVisibilityObserverCleanupFn && this.nodeVisibilityObserverCleanupFn();
158
+ const tree = getTree(this.dom);
159
+ if (tree) {
160
+ this.makeRowHeaderNotSticky(tree.table, true);
161
+ }
162
+ this.emitOff(true);
163
+ }
164
+
165
+ // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners
166
+ document.removeEventListener('limited-mode-activated', this.cleanup);
167
+ if (this.tableContainerObserver) {
168
+ this.tableContainerObserver.disconnect();
169
+ }
170
+ }
171
+ ignoreMutation(mutationRecord) {
172
+ /* tableRows are not directly editable by the user
173
+ * so it should be safe to ignore mutations that we cause
174
+ * by updating styles and classnames on this DOM element
175
+ *
176
+ * Update: should not ignore mutations for row selection to avoid known issue with table selection highlight in firefox
177
+ * Related bug report: https://bugzilla.mozilla.org/show_bug.cgi?id=1289673
178
+ * */
179
+ const isTableSelection = mutationRecord.type === 'selection' && mutationRecord.target.nodeName === 'TR';
180
+ /**
181
+ * Update: should not ignore mutations when an node is added, as this interferes with
182
+ * prosemirrors handling of some language inputs in Safari (ie. Pinyin, Hiragana).
183
+ *
184
+ * In paticular, when a composition occurs at the start of the first node inside a table cell, if the resulting mutation
185
+ * from the composition end is ignored than prosemirror will end up with; invalid table markup nesting and a misplaced
186
+ * selection and insertion.
187
+ */
188
+ const isNodeInsertion = mutationRecord.type === 'childList' && mutationRecord.target.nodeName === 'TR' && mutationRecord.addedNodes.length;
189
+ if (isTableSelection || isNodeInsertion) {
190
+ return false;
191
+ }
192
+ return true;
193
+ }
194
+ subscribe() {
195
+ // Ignored via go/ees005
196
+ // eslint-disable-next-line @atlaskit/editor/no-as-casting
197
+ this.editorScrollableElement = findOverflowScrollParent(this.view.dom) || window;
198
+ if (this.editorScrollableElement) {
199
+ this.initObservers();
200
+ this.topPosEditorElement = getTop(this.editorScrollableElement);
201
+ }
202
+ this.eventDispatcher.on('widthPlugin', this.updateStickyHeaderWidth.bind(this));
203
+
204
+ // Ignored via go/ees005
205
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
206
+ this.eventDispatcher.on(tablePluginKey.key, this.onTablePluginState.bind(this));
207
+ this.listening = true;
208
+
209
+ // Ignored via go/ees005
210
+ // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners
211
+ this.dom.addEventListener('wheel', this.headerRowMouseScroll.bind(this), {
212
+ passive: true
213
+ });
214
+ // Ignored via go/ees005
215
+ // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners
216
+ this.dom.addEventListener('touchmove', this.headerRowMouseScroll.bind(this), {
217
+ passive: true
218
+ });
219
+ }
220
+ unsubscribe() {
221
+ if (!this.listening) {
222
+ return;
223
+ }
224
+ if (this.intersectionObserver) {
225
+ this.intersectionObserver.disconnect();
226
+ // ED-16211 Once intersection observer is disconnected, we need to remove the isObserved from the sentinels
227
+ // Otherwise when newer intersection observer is created it will not observe because it thinks its already being observed
228
+ [this.sentinels.top, this.sentinels.bottom].forEach(el => {
229
+ if (el) {
230
+ delete el.dataset.isObserved;
231
+ }
232
+ });
233
+ }
234
+ if (this.resizeObserver) {
235
+ this.resizeObserver.disconnect();
236
+ }
237
+ this.eventDispatcher.off('widthPlugin', this.updateStickyHeaderWidth);
238
+ // Ignored via go/ees005
239
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
240
+ this.eventDispatcher.off(tablePluginKey.key, this.onTablePluginState);
241
+ this.listening = false;
242
+
243
+ // Ignored via go/ees005
244
+ // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners
245
+ this.dom.removeEventListener('wheel', this.headerRowMouseScroll);
246
+ // Ignored via go/ees005
247
+ // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners
248
+ this.dom.removeEventListener('touchmove', this.headerRowMouseScroll);
249
+ }
250
+ initOverflowObserver() {
251
+ const tableWrapper = this.dom.closest(`.${ClassName.TABLE_NODE_WRAPPER}`);
252
+ if (!tableWrapper) {
253
+ return;
254
+ }
255
+ const options = {
256
+ root: tableWrapper,
257
+ rootMargin: '0px',
258
+ threshold: 1
259
+ };
260
+ this.overflowObserver = new IntersectionObserver((entries, observer) => {
261
+ entries.forEach(entry => {
262
+ if (entry.isIntersecting) {
263
+ // eslint-disable-next-line @atlaskit/editor/no-as-casting
264
+ observer.root.classList.add(ClassName.TABLE_NODE_WRAPPER_NO_OVERFLOW);
265
+ this.dom.classList.add(ClassName.NATIVE_STICKY);
266
+ this.isNativeSticky = true;
267
+ } else {
268
+ // eslint-disable-next-line @atlaskit/editor/no-as-casting
269
+ observer.root.classList.remove(ClassName.TABLE_NODE_WRAPPER_NO_OVERFLOW);
270
+ this.dom.classList.remove(ClassName.NATIVE_STICKY);
271
+ this.isNativeSticky = false;
272
+ }
273
+ });
274
+ }, options);
275
+ }
276
+ // initialize intersection observer to track if table is within scroll area
277
+ initObservers() {
278
+ if (!this.dom || this.dom.dataset.isObserved) {
279
+ return;
280
+ }
281
+ this.dom.dataset.isObserved = 'true';
282
+ this.createIntersectionObserver();
283
+ this.createResizeObserver();
284
+ if (!this.intersectionObserver || !this.resizeObserver) {
285
+ return;
286
+ }
287
+ if (this.isHeaderRow && !this.isInNestedTable) {
288
+ var _this$overflowObserve;
289
+ this.initOverflowObserver();
290
+ // eslint-disable-next-line @atlaskit/editor/no-as-casting
291
+ (_this$overflowObserve = this.overflowObserver) === null || _this$overflowObserve === void 0 ? void 0 : _this$overflowObserve.observe(this.dom.closest('table'));
292
+ }
293
+ this.resizeObserver.observe(this.dom);
294
+ if (this.editorScrollableElement) {
295
+ // Ignored via go/ees005
296
+ // eslint-disable-next-line @atlaskit/editor/no-as-casting
297
+ this.resizeObserver.observe(this.editorScrollableElement);
298
+ }
299
+ window.requestAnimationFrame(() => {
300
+ const getTableContainer = () => {
301
+ var _getTree;
302
+ return (_getTree = getTree(this.dom)) === null || _getTree === void 0 ? void 0 : _getTree.wrapper.closest(`.${TableCssClassName.NODEVIEW_WRAPPER}`);
303
+ };
304
+
305
+ // we expect tree to be defined after animation frame
306
+ let tableContainer = getTableContainer();
307
+ if (tableContainer) {
308
+ const getSentinelTop = () => tableContainer &&
309
+ // Ignored via go/ees005
310
+ // eslint-disable-next-line @atlaskit/editor/no-as-casting
311
+ tableContainer.getElementsByClassName(ClassName.TABLE_STICKY_SENTINEL_TOP).item(0);
312
+ const getSentinelBottom = () => {
313
+ // Multiple bottom sentinels may be found if there are nested tables.
314
+ // We need to make sure we get the last one which will belong to the parent table.
315
+
316
+ const bottomSentinels = tableContainer && tableContainer.getElementsByClassName(ClassName.TABLE_STICKY_SENTINEL_BOTTOM);
317
+ return (
318
+ // eslint-disable-next-line @atlaskit/editor/no-as-casting
319
+ bottomSentinels && bottomSentinels.item(bottomSentinels.length - 1)
320
+ );
321
+ };
322
+ const sentinelsInDom = () => getSentinelTop() !== null && getSentinelBottom() !== null;
323
+ const observeStickySentinels = () => {
324
+ this.sentinels.top = getSentinelTop();
325
+ this.sentinels.bottom = getSentinelBottom();
326
+ [this.sentinels.top, this.sentinels.bottom].forEach(el => {
327
+ // skip if already observed for another row on this table
328
+ if (el && !el.dataset.isObserved) {
329
+ el.dataset.isObserved = 'true';
330
+
331
+ // Ignored via go/ees005
332
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
333
+ this.intersectionObserver.observe(el);
334
+ }
335
+ });
336
+ };
337
+ const isInitialProsemirrorToDomRender = tableContainer.hasAttribute('data-prosemirror-initial-toDOM-render');
338
+
339
+ // Sentinels may be in the DOM but they're part of the prosemirror placeholder structure which is replaced with the fully rendered React node.
340
+ if (sentinelsInDom() && !isInitialProsemirrorToDomRender) {
341
+ // great - DOM ready, observe as normal
342
+ observeStickySentinels();
343
+ } else {
344
+ // concurrent loading issue - here TableRow is too eager trying to
345
+ // observe sentinels before they are in the DOM, use MutationObserver
346
+ // to wait for sentinels to be added to the parent Table node DOM
347
+ // then attach the IntersectionObserver
348
+ this.tableContainerObserver = new MutationObserver(() => {
349
+ // Check if the tableContainer is still connected to the DOM. It can become disconnected when the placholder
350
+ // prosemirror node is replaced with the fully rendered React node (see _handleTableRef).
351
+
352
+ if (!tableContainer || !tableContainer.isConnected) {
353
+ tableContainer = getTableContainer();
354
+ }
355
+ if (sentinelsInDom()) {
356
+ var _this$tableContainerO;
357
+ observeStickySentinels();
358
+ (_this$tableContainerO = this.tableContainerObserver) === null || _this$tableContainerO === void 0 ? void 0 : _this$tableContainerO.disconnect();
359
+ }
360
+ });
361
+ const mutatingNode = tableContainer;
362
+ if (mutatingNode && this.tableContainerObserver) {
363
+ this.tableContainerObserver.observe(mutatingNode, {
364
+ subtree: true,
365
+ childList: true
366
+ });
367
+ }
368
+ }
369
+ }
370
+ });
371
+ }
372
+
373
+ // updating bottom sentinel position if sticky header height changes
374
+ // to allocate for new header height
375
+ createResizeObserver() {
376
+ this.resizeObserver = new ResizeObserver(entries => {
377
+ const tree = getTree(this.dom);
378
+ if (!tree) {
379
+ return;
380
+ }
381
+ const {
382
+ table
383
+ } = tree;
384
+ entries.forEach(entry => {
385
+ var _this$editorScrollabl;
386
+ // On resize of the parent scroll element we need to adjust the width
387
+ // of the sticky header
388
+ // Ignored via go/ees005
389
+ // eslint-disable-next-line @atlaskit/editor/no-as-casting
390
+ if (entry.target.className === ((_this$editorScrollabl = this.editorScrollableElement) === null || _this$editorScrollabl === void 0 ? void 0 : _this$editorScrollabl.className)) {
391
+ this.updateStickyHeaderWidth();
392
+ } else {
393
+ const newHeight = entry.contentRect ? entry.contentRect.height :
394
+ // Ignored via go/ees005
395
+ // eslint-disable-next-line @atlaskit/editor/no-as-casting
396
+ entry.target.offsetHeight;
397
+ if (this.sentinels.bottom &&
398
+ // When the table header is sticky, it would be taller by a 1px (border-bottom),
399
+ // So we adding this check to allow a 1px difference.
400
+ Math.abs(newHeight - (this.stickyRowHeight || 0)) > stickyHeaderBorderBottomWidth) {
401
+ this.stickyRowHeight = newHeight;
402
+ this.sentinels.bottom.style.bottom = `${tableScrollbarOffset + stickyRowOffsetTop + newHeight}px`;
403
+ updateTableMargin(table);
404
+ }
405
+ }
406
+ });
407
+ });
408
+ }
409
+ createIntersectionObserver() {
410
+ this.intersectionObserver = new IntersectionObserver((entries, _) => {
411
+ var _this$editorScrollabl2, _this$editorScrollabl3;
412
+ // IMPORTANT: please try and avoid using entry.rootBounds it's terribly inconsistent. For example; sometimes it may return
413
+ // 0 height. In safari it will multiply all values by the window scale factor, however chrome & firfox won't.
414
+ // This is why i just get the scroll view bounding rect here and use it, and fallback to the entry.rootBounds if needed.
415
+ const rootBounds = (_this$editorScrollabl2 = this.editorScrollableElement) === null || _this$editorScrollabl2 === void 0 ? void 0 : (_this$editorScrollabl3 = _this$editorScrollabl2.getBoundingClientRect) === null || _this$editorScrollabl3 === void 0 ? void 0 : _this$editorScrollabl3.call(_this$editorScrollabl2);
416
+ entries.forEach(entry => {
417
+ const {
418
+ target,
419
+ isIntersecting,
420
+ boundingClientRect
421
+ } = entry;
422
+ // This observer only every looks at the top/bottom sentinels, we can assume if it's not one then it's the other.
423
+ const targetKey = target.classList.contains(ClassName.TABLE_STICKY_SENTINEL_TOP) ? 'top' : 'bottom';
424
+ // Cache the latest sentinel information
425
+ this.sentinelData[targetKey] = {
426
+ isIntersecting,
427
+ boundingClientRect,
428
+ rootBounds: rootBounds !== null && rootBounds !== void 0 ? rootBounds : entry.rootBounds
429
+ };
430
+ // Keep the other sentinels rootBounds in sync with this latest one
431
+ this.sentinelData[targetKey === 'top' ? 'bottom' : targetKey].rootBounds = rootBounds !== null && rootBounds !== void 0 ? rootBounds : entry.rootBounds;
432
+ });
433
+ this.refreshStickyState();
434
+ }, {
435
+ threshold: 0,
436
+ root: this.editorScrollableElement
437
+ });
438
+ }
439
+ refreshStickyState() {
440
+ const tree = getTree(this.dom);
441
+ if (!tree) {
442
+ return;
443
+ }
444
+ const {
445
+ table
446
+ } = tree;
447
+ const shouldStick = this.shouldSticky();
448
+ if (this.isSticky !== shouldStick) {
449
+ if (shouldStick && !this.isNativeSticky) {
450
+ var _this$sentinelData$to;
451
+ // The rootRect is kept in sync across sentinels so it doesn't matter which one we use.
452
+ const rootRect = (_this$sentinelData$to = this.sentinelData.top.rootBounds) !== null && _this$sentinelData$to !== void 0 ? _this$sentinelData$to : this.sentinelData.bottom.rootBounds;
453
+ this.makeHeaderRowSticky(tree, rootRect === null || rootRect === void 0 ? void 0 : rootRect.top);
454
+ } else {
455
+ this.makeRowHeaderNotSticky(table);
456
+ }
457
+ }
458
+ }
459
+ shouldSticky() {
460
+ if (
461
+ // is Safari
462
+ navigator.userAgent.includes('AppleWebKit') && !navigator.userAgent.includes('Chrome')) {
463
+ const pos = this.getPos();
464
+ if (typeof pos === 'number') {
465
+ const $tableRowPos = this.view.state.doc.resolve(pos);
466
+
467
+ // layout -> layout column -> table -> table row
468
+ if ($tableRowPos.depth >= 3) {
469
+ var _findParentNodeCloses;
470
+ const isInsideLayout = (_findParentNodeCloses = findParentNodeClosestToPos($tableRowPos, node => {
471
+ return node.type.name === 'layoutColumn';
472
+ })) === null || _findParentNodeCloses === void 0 ? void 0 : _findParentNodeCloses.node;
473
+ if (isInsideLayout) {
474
+ return false;
475
+ }
476
+ }
477
+ }
478
+ }
479
+ return this.isHeaderSticky();
480
+ }
481
+ isHeaderSticky() {
482
+ var _sentinelTop$rootBoun;
483
+ /*
484
+ # Overview
485
+ I'm going to list all the view states associated with the sentinels and when they should trigger sticky headers.
486
+ The format of the states are; {top|bottom}:{in|above|below}
487
+ ie sentinel:view-position -- both "above" and "below" are equal to out of the viewport
488
+ For example; "top:in" means top sentinel is within the viewport. "bottom:above" means the bottom sentinel is
489
+ above and out of the viewport
490
+ This will hopefully simplify things and make it easier to determine when sticky should/shouldn't be triggered.
491
+ # States
492
+ top:in / bottom:in - NOT sticky
493
+ top:in / bottom:above - NOT sticky - NOTE: This is an inversion clause
494
+ top:in / bottom:below - NOT sticky
495
+ top:above / bottom:in - STICKY
496
+ top:above / bottom:above - NOT sticky
497
+ top:above / bottom:below - STICKY
498
+ top:below / bottom:in - NOT sticky - NOTE: This is an inversion clause
499
+ top:below / bottom:above - NOT sticky - NOTE: This is an inversion clause
500
+ top:below / bottom:below - NOT sticky
501
+ # Summary
502
+ The only time the header should be sticky is when the top sentinel is above the view and the bottom sentinel
503
+ is in or below it.
504
+ */
505
+
506
+ const {
507
+ top: sentinelTop,
508
+ bottom: sentinelBottom
509
+ } = this.sentinelData;
510
+ // The rootRect is kept in sync across sentinels so it doesn't matter which one we use.
511
+ const rootBounds = (_sentinelTop$rootBoun = sentinelTop.rootBounds) !== null && _sentinelTop$rootBoun !== void 0 ? _sentinelTop$rootBoun : sentinelBottom.rootBounds;
512
+ if (!rootBounds || !sentinelTop.boundingClientRect || !sentinelBottom.boundingClientRect) {
513
+ return false;
514
+ }
515
+
516
+ // Normalize the sentinels to y points
517
+ // Since the sentinels are actually rects 1px high we want make sure we normalise the inner most values closest to the table.
518
+ const sentinelTopY = sentinelTop.boundingClientRect.bottom;
519
+ const sentinelBottomY = sentinelBottom.boundingClientRect.top;
520
+
521
+ // If header row height is more than 50% of viewport height don't do this
522
+ const isRowHeaderTooLarge = this.stickyRowHeight && this.stickyRowHeight > window.innerHeight * 0.5;
523
+ const isTopSentinelAboveScrollArea = !sentinelTop.isIntersecting && sentinelTopY <= rootBounds.top;
524
+ const isBottomSentinelInOrBelowScrollArea = sentinelBottom.isIntersecting || sentinelBottomY > rootBounds.bottom;
525
+
526
+ // This makes sure that the top sentinel is actually above the bottom sentinel, and that they havn't inverted.
527
+ const isTopSentinelAboveBottomSentinel = sentinelTopY < sentinelBottomY;
528
+ return isTopSentinelAboveScrollArea && isBottomSentinelInOrBelowScrollArea && isTopSentinelAboveBottomSentinel && !isRowHeaderTooLarge;
529
+ }
530
+
531
+ /* receive external events */
532
+
533
+ onTablePluginState(state) {
534
+ const tableRef = state.tableRef;
535
+ const tree = getTree(this.dom);
536
+ if (!tree) {
537
+ return;
538
+ }
539
+
540
+ // when header rows are toggled off - mark sentinels as unobserved
541
+ if (!state.isHeaderRowEnabled) {
542
+ [this.sentinels.top, this.sentinels.bottom].forEach(el => {
543
+ if (el) {
544
+ delete el.dataset.isObserved;
545
+ }
546
+ });
547
+ }
548
+ const isCurrentTableSelected = tableRef === tree.table;
549
+
550
+ // If current table selected and header row is toggled off, turn off sticky header
551
+ if (isCurrentTableSelected && !state.isHeaderRowEnabled && tree) {
552
+ this.makeRowHeaderNotSticky(tree.table);
553
+ }
554
+ this.focused = isCurrentTableSelected;
555
+ const {
556
+ wrapper
557
+ } = tree;
558
+ const tableContainer = wrapper.parentElement;
559
+ const tableContentWrapper = tableContainer === null || tableContainer === void 0 ? void 0 : tableContainer.parentElement;
560
+ const parentContainer = tableContentWrapper && tableContentWrapper.parentElement;
561
+ const isTableInsideLayout = parentContainer && parentContainer.getAttribute('data-layout-content');
562
+ if (tableContentWrapper) {
563
+ if (isCurrentTableSelected) {
564
+ this.colControlsOffset = tableControlsSpacing;
565
+
566
+ // move table a little out of the way
567
+ // to provide spacing for table controls
568
+ if (isTableInsideLayout) {
569
+ tableContentWrapper.style.paddingLeft = '11px';
570
+ }
571
+ } else {
572
+ this.colControlsOffset = 0;
573
+ if (isTableInsideLayout) {
574
+ tableContentWrapper.style.removeProperty('padding-left');
575
+ }
576
+ }
577
+ }
578
+
579
+ // run after table style changes have been committed
580
+ setTimeout(() => {
581
+ syncStickyRowToTable(tree.table);
582
+ }, 0);
583
+ }
584
+ updateStickyHeaderWidth() {
585
+ // table width might have changed, sync that back to sticky row
586
+ const tree = getTree(this.dom);
587
+ if (!tree) {
588
+ return;
589
+ }
590
+ syncStickyRowToTable(tree.table);
591
+ }
592
+
593
+ /**
594
+ * Manually refire the intersection observers.
595
+ * Useful when the header may have detached from the table.
596
+ */
597
+ refireIntersectionObservers() {
598
+ if (this.isSticky) {
599
+ [this.sentinels.top, this.sentinels.bottom].forEach(el => {
600
+ if (el && this.intersectionObserver) {
601
+ this.intersectionObserver.unobserve(el);
602
+ this.intersectionObserver.observe(el);
603
+ }
604
+ });
605
+ }
606
+ }
607
+ makeHeaderRowSticky(tree, scrollTop) {
608
+ var _tbody$firstChild;
609
+ // If header row height is more than 50% of viewport height don't do this
610
+ if (this.isSticky || this.stickyRowHeight && this.stickyRowHeight > window.innerHeight / 2 || this.isInNestedTable) {
611
+ return;
612
+ }
613
+ const {
614
+ table,
615
+ wrapper
616
+ } = tree;
617
+
618
+ // TODO: ED-16035 - Make sure sticky header is only applied to first row
619
+ const tbody = this.dom.parentElement;
620
+ const isFirstHeader = tbody === null || tbody === void 0 ? void 0 : (_tbody$firstChild = tbody.firstChild) === null || _tbody$firstChild === void 0 ? void 0 : _tbody$firstChild.isEqualNode(this.dom);
621
+ if (!isFirstHeader) {
622
+ return;
623
+ }
624
+ const currentTableTop = this.getCurrentTableTop(tree);
625
+ if (!scrollTop) {
626
+ scrollTop = getTop(this.editorScrollableElement);
627
+ }
628
+ const domTop = currentTableTop > 0 ? scrollTop : scrollTop + currentTableTop;
629
+ if (!this.isSticky) {
630
+ var _this$editorScrollabl4;
631
+ syncStickyRowToTable(table);
632
+ this.dom.classList.add('sticky');
633
+ table.classList.add(ClassName.TABLE_STICKY);
634
+ this.isSticky = true;
635
+
636
+ /**
637
+ * The logic below is not desirable, but acts as a fail safe for scenarios where the sticky header
638
+ * detaches from the table. This typically happens during a fast scroll by the user which causes
639
+ * the intersection observer logic to not fire as expected.
640
+ */
641
+ // Ignored via go/ees005
642
+ // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners
643
+ (_this$editorScrollabl4 = this.editorScrollableElement) === null || _this$editorScrollabl4 === void 0 ? void 0 : _this$editorScrollabl4.addEventListener('scrollend', this.refireIntersectionObservers, {
644
+ passive: true,
645
+ once: true
646
+ });
647
+ const fastScrollThresholdMs = 500;
648
+ setTimeout(() => {
649
+ this.refireIntersectionObservers();
650
+ }, fastScrollThresholdMs);
651
+ }
652
+ this.dom.style.top = `0px`;
653
+ updateTableMargin(table);
654
+ this.dom.scrollLeft = wrapper.scrollLeft;
655
+ this.emitOn(domTop, this.colControlsOffset);
656
+ }
657
+ makeRowHeaderNotSticky(table, isEditorDestroyed = false) {
658
+ if (!this.isSticky || !table || !this.dom) {
659
+ return;
660
+ }
661
+ this.dom.style.removeProperty('width');
662
+ this.dom.classList.remove('sticky');
663
+ table.classList.remove(ClassName.TABLE_STICKY);
664
+ this.isSticky = false;
665
+ this.dom.style.top = '';
666
+ table.style.removeProperty('margin-top');
667
+ this.emitOff(isEditorDestroyed);
668
+ }
669
+ getWrapperoffset(inverse = false) {
670
+ const focusValue = inverse ? !this.focused : this.focused;
671
+ return focusValue ? 0 : tableControlsSpacing;
672
+ }
673
+ getWrapperRefTop(wrapper) {
674
+ return Math.round(getTop(wrapper)) + this.getWrapperoffset();
675
+ }
676
+ getScrolledTableTop(wrapper) {
677
+ return this.getWrapperRefTop(wrapper) - this.topPosEditorElement;
678
+ }
679
+ getCurrentTableTop(tree) {
680
+ return this.getScrolledTableTop(tree.wrapper) + tree.table.clientHeight;
681
+ }
682
+
683
+ /* emit external events */
684
+
685
+ emitOn(top, padding) {
686
+ if (top === this.top && padding === this.padding) {
687
+ return;
688
+ }
689
+ this.top = top;
690
+ this.padding = padding;
691
+ // Ignored via go/ees005
692
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
693
+ const pos = this.getPos();
694
+ if (Number.isFinite(pos)) {
695
+ updateStickyState({
696
+ pos,
697
+ top,
698
+ sticky: true,
699
+ padding
700
+ })(this.view.state, this.view.dispatch, this.view);
701
+ }
702
+ }
703
+ emitOff(isEditorDestroyed) {
704
+ if (this.top === 0 && this.padding === 0) {
705
+ return;
706
+ }
707
+ this.top = 0;
708
+ this.padding = 0;
709
+ // Ignored via go/ees005
710
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
711
+ const pos = this.getPos();
712
+ if (!isEditorDestroyed && Number.isFinite(pos)) {
713
+ updateStickyState({
714
+ pos,
715
+ sticky: false,
716
+ top: this.top,
717
+ padding: this.padding
718
+ })(this.view.state, this.view.dispatch, this.view);
719
+ }
720
+ }
721
+ }