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