@openleaf-editor/plugins-table 0.1.0-beta.1 → 0.1.0-beta.3

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,754 @@
1
+ /**
2
+ * Table commands that are not already in `prosemirror-tables`.
3
+ *
4
+ * Property edits, caption/colgroup, nested insert and vertical alignment live
5
+ * here so a test can apply them without standing up a toolbar. Column insert and
6
+ * delete also reindex the stored colgroup here: the upstream commands only move
7
+ * cells, and `colgroupFromCellWidths` never runs unless a cell already has
8
+ * `colwidth`.
9
+ */
10
+ import { canInsert, isNodeActive, parseDeclarations, safeColor, safeTableStyleValue, serializeDeclarations, } from '@openleaf-editor/core';
11
+ import { Plugin, TextSelection } from 'prosemirror-state';
12
+ import { addColumnAfter as addColumnAfterRaw, addColumnBefore as addColumnBeforeRaw, addRowAfter as addRowAfterRaw, addRowBefore as addRowBeforeRaw, CellSelection, deleteColumn as deleteColumnRaw, deleteRow as deleteRowRaw, TableMap, toggleHeaderRow as toggleHeaderRowRaw, } from 'prosemirror-tables';
13
+ export function inTable(state) {
14
+ return isNodeActive(state, 'table');
15
+ }
16
+ export function findRole($pos, role) {
17
+ for (let depth = $pos.depth; depth > 0; depth -= 1) {
18
+ const node = $pos.node(depth);
19
+ const found = node.type.spec['tableRole'];
20
+ if (role === 'cell') {
21
+ if (found !== 'cell' && found !== 'header_cell')
22
+ continue;
23
+ }
24
+ else if (found !== role)
25
+ continue;
26
+ return { node, pos: $pos.before(depth), depth };
27
+ }
28
+ return null;
29
+ }
30
+ export function findTable($pos) {
31
+ return findRole($pos, 'table');
32
+ }
33
+ export function findRow($pos) {
34
+ return findRole($pos, 'row');
35
+ }
36
+ export function findCell($pos) {
37
+ return findRole($pos, 'cell');
38
+ }
39
+ export function insertTable(rows = 3, cols = 3) {
40
+ return (state, dispatch) => {
41
+ if (!canInsert(state, 'table'))
42
+ return false;
43
+ if (dispatch) {
44
+ const cell = state.schema.nodes['table_cell'];
45
+ const header = state.schema.nodes['table_header'];
46
+ const row = state.schema.nodes['table_row'];
47
+ const tableType = state.schema.nodes['table'];
48
+ if (!cell || !header || !row || !tableType)
49
+ return false;
50
+ const headerCells = Array.from({ length: cols }, () => header.createAndFill({ scope: 'col' })).filter((n) => n !== null);
51
+ const bodyRows = Array.from({ length: Math.max(0, rows - 1) }, () => row.create(null, Array.from({ length: cols }, () => cell.createAndFill()).filter((n) => n !== null)));
52
+ const table = tableType.create(null, [row.create(null, headerCells), ...bodyRows]);
53
+ dispatch(state.tr.replaceSelectionWith(table).scrollIntoView());
54
+ }
55
+ return true;
56
+ };
57
+ }
58
+ /**
59
+ * Rewrite header cell `scope` for the table the selection is in.
60
+ *
61
+ * The selection is captured and restored around the rewrite. Every change here
62
+ * is a `setNodeMarkup`, which replaces the cell node, and mapping a text
63
+ * selection through a replacement can drag it to the node boundary. The symptom
64
+ * was not a broken caret but a broken command: inserting a row moved the caret
65
+ * out of the cell the author was editing, so the next table command found no
66
+ * cell and did nothing at all.
67
+ */
68
+ function applyCellScope(tr) {
69
+ const header = tr.doc.type.schema.nodes['table_header'];
70
+ const cell = tr.doc.type.schema.nodes['table_cell'];
71
+ if (!header || !cell)
72
+ return tr;
73
+ let tablePos = -1;
74
+ for (let depth = tr.selection.$from.depth; depth > 0; depth -= 1) {
75
+ if (tr.selection.$from.node(depth).type.spec['tableRole'] === 'table') {
76
+ tablePos = tr.selection.$from.before(depth);
77
+ break;
78
+ }
79
+ }
80
+ if (tablePos < 0)
81
+ return tr;
82
+ const table = tr.doc.nodeAt(tablePos);
83
+ if (!table)
84
+ return tr;
85
+ // Only a plain text selection needs restoring: CellSelection maps itself and
86
+ // stays a CellSelection, which is what multi-cell commands act on.
87
+ const restore = tr.selection instanceof TextSelection ? tr.selection.from : null;
88
+ const stepsBefore = tr.steps.length;
89
+ table.forEach((row, rowOffset, rowIndex) => {
90
+ const rowPos = tablePos + 1 + rowOffset;
91
+ row.forEach((cellNode, cellOffset, cellIndex) => {
92
+ const pos = rowPos + 1 + cellOffset;
93
+ if (cellNode.type === cell && cellNode.attrs['scope']) {
94
+ tr.setNodeMarkup(pos, undefined, { ...cellNode.attrs, scope: null });
95
+ return;
96
+ }
97
+ if (cellNode.type !== header)
98
+ return;
99
+ const scope = cellNode.attrs['scope'];
100
+ if (scope !== null && scope !== undefined && scope !== '')
101
+ return;
102
+ tr.setNodeMarkup(pos, undefined, {
103
+ ...cellNode.attrs,
104
+ scope: rowIndex === 0 || cellIndex > 0 ? 'col' : 'row',
105
+ });
106
+ });
107
+ });
108
+ // Mapped through only the steps this pass added, so the caret lands where it
109
+ // was rather than where the last replacement pushed it.
110
+ if (restore !== null && tr.steps.length > stepsBefore) {
111
+ const at = tr.mapping.slice(stepsBefore).map(restore);
112
+ tr.setSelection(TextSelection.near(tr.doc.resolve(at)));
113
+ }
114
+ return tr;
115
+ }
116
+ export function withCellScope(command) {
117
+ return (state, dispatch, view) => {
118
+ if (!dispatch)
119
+ return command(state, undefined, view);
120
+ return command(state, (tr) => {
121
+ dispatch(applyCellScope(tr));
122
+ }, view);
123
+ };
124
+ }
125
+ /**
126
+ * Columns the selection covers, as a half-open range on the table map.
127
+ *
128
+ * `prosemirror-tables` uses the same rect for insert-after (`right`) and
129
+ * insert-before (`left`). Reading it here, before the command rewrites the
130
+ * cells, is what lets the colgroup patch aim at the same index.
131
+ */
132
+ function selectedColumnRange(state) {
133
+ const table = findTable(state.selection.$from);
134
+ if (!table)
135
+ return null;
136
+ const map = TableMap.get(table.node);
137
+ const tableStart = table.pos + 1;
138
+ const sel = state.selection;
139
+ const rect = sel instanceof CellSelection
140
+ ? map.rectBetween(sel.$anchorCell.pos - tableStart, sel.$headCell.pos - tableStart)
141
+ : (() => {
142
+ const cell = findCell(sel.$from);
143
+ return cell ? map.findCell(cell.pos - tableStart) : null;
144
+ })();
145
+ if (!rect)
146
+ return null;
147
+ return { tablePos: table.pos, left: rect.left, right: rect.right };
148
+ }
149
+ /**
150
+ * Reindex the stored colgroup the same way the column command reindexes cells.
151
+ *
152
+ * `colgroupFromCellWidths` only runs when a cell carries `colwidth`. Tables
153
+ * whose widths live only on inherited `<col>` elements never hit that path, so
154
+ * insert/delete used to leave the furniture describing the previous columns:
155
+ * every remaining column inherited the previous column's width and class.
156
+ */
157
+ function withColgroupColumns(kind, command) {
158
+ return (state, dispatch, view) => {
159
+ if (!dispatch)
160
+ return command(state, undefined, view);
161
+ const range = selectedColumnRange(state);
162
+ return command(state, (tr) => {
163
+ if (range)
164
+ applyColgroupColumnChange(tr, range, kind);
165
+ dispatch(tr);
166
+ }, view);
167
+ };
168
+ }
169
+ function applyColgroupColumnChange(tr, range, kind) {
170
+ const pos = tr.mapping.map(range.tablePos);
171
+ const table = tr.doc.nodeAt(pos);
172
+ if (!table || table.type.spec['tableRole'] !== 'table')
173
+ return;
174
+ const stored = table.attrs['colgroup'];
175
+ if (!stored)
176
+ return;
177
+ let next = stored;
178
+ if (kind === 'delete') {
179
+ for (let col = range.right - 1; col >= range.left; col -= 1) {
180
+ next = colgroupHtmlDeleteColumn(next, col) ?? next;
181
+ }
182
+ }
183
+ else {
184
+ next = colgroupHtmlInsertColumn(next, kind === 'before' ? range.left : range.right) ?? next;
185
+ }
186
+ const width = TableMap.get(table).width;
187
+ next = colgroupHtmlMatchWidth(next, width) ?? next;
188
+ if (next === stored)
189
+ return;
190
+ tr.setNodeMarkup(pos, undefined, { ...table.attrs, colgroup: next });
191
+ }
192
+ export const addColumnAfter = withColgroupColumns('after', withCellScope(addColumnAfterRaw));
193
+ export const addColumnBefore = withColgroupColumns('before', withCellScope(addColumnBeforeRaw));
194
+ export const deleteColumn = withColgroupColumns('delete', deleteColumnRaw);
195
+ export const addRowAfter = withCellScope(maintainTableSections(addRowAfterRaw, 'after'));
196
+ export const addRowBefore = withCellScope(maintainTableSections(addRowBeforeRaw, 'before'));
197
+ export const deleteRow = withCellScope(maintainTableSections(deleteRowRaw, 'delete'));
198
+ export const toggleHeaderRow = withCellScope(toggleHeaderRowRaw);
199
+ /**
200
+ * Keep `headerRows` / `footerRows` attached to the rows that actually live in
201
+ * those sections.
202
+ *
203
+ * The counts are how serialize rebuilds `<thead>` and `<tfoot>` (see html.ts).
204
+ * Upstream row commands never touch them, so deleting the header row left
205
+ * `headerRows: 1` and the first data row was serialized as `<thead>`. Inserting
206
+ * above the header left the empty row in `<thead>` and demoted the real header
207
+ * into `<tbody>`. The same shift happens at `<tfoot>`.
208
+ *
209
+ * Deletes decrement the count for the section the removed rows belonged to.
210
+ * Inserts that would land inside a section are redirected to the nearest body
211
+ * slot instead: the author asked for a row, not a new header or footer.
212
+ */
213
+ function maintainTableSections(command, kind) {
214
+ return (state, dispatch, view) => {
215
+ const table = findTable(state.selection.$from);
216
+ if (!table)
217
+ return command(state, dispatch, view);
218
+ const headerRows = table.node.attrs['headerRows'] || 0;
219
+ const footerRows = table.node.attrs['footerRows'] || 0;
220
+ const rowCount = table.node.childCount;
221
+ const indices = selectedRowIndices(state, table.node, table.pos);
222
+ if (kind === 'delete') {
223
+ if (!dispatch)
224
+ return command(state, undefined, view);
225
+ return command(state, (tr) => {
226
+ const mapped = tr.mapping.mapResult(table.pos);
227
+ if (mapped.deleted) {
228
+ dispatch(tr);
229
+ return;
230
+ }
231
+ const next = tr.doc.nodeAt(mapped.pos);
232
+ if (next?.type.spec['tableRole'] === 'table') {
233
+ let header = headerRows;
234
+ let footer = footerRows;
235
+ for (const index of indices) {
236
+ if (index < headerRows)
237
+ header -= 1;
238
+ else if (index >= rowCount - footerRows)
239
+ footer -= 1;
240
+ }
241
+ tr.setNodeMarkup(mapped.pos, undefined, {
242
+ ...next.attrs,
243
+ headerRows: Math.max(0, header),
244
+ footerRows: Math.max(0, footer),
245
+ });
246
+ }
247
+ dispatch(tr);
248
+ }, view);
249
+ }
250
+ const rowIndex = indices[0];
251
+ if (rowIndex === undefined)
252
+ return command(state, dispatch, view);
253
+ const natural = kind === 'before' ? rowIndex : rowIndex + 1;
254
+ let insertAt = natural;
255
+ if (insertAt < headerRows)
256
+ insertAt = headerRows;
257
+ if (insertAt > rowCount - footerRows)
258
+ insertAt = rowCount - footerRows;
259
+ if (insertAt === natural)
260
+ return command(state, dispatch, view);
261
+ if (!dispatch)
262
+ return command(state, undefined, view);
263
+ const working = stateWithRowSelection(state, table.node, table.pos, insertAt, rowCount);
264
+ const redirected = insertAt >= rowCount ? addRowAfterRaw : addRowBeforeRaw;
265
+ return redirected(working, dispatch);
266
+ };
267
+ }
268
+ function selectedRowIndices(state, table, tablePos) {
269
+ const found = new Set();
270
+ const addFrom = ($pos) => {
271
+ const row = findRow($pos);
272
+ if (!row)
273
+ return;
274
+ const index = rowIndexAt(table, tablePos, row.pos);
275
+ if (index >= 0)
276
+ found.add(index);
277
+ };
278
+ const { selection } = state;
279
+ if (selection instanceof CellSelection) {
280
+ selection.forEachCell((_node, pos) => {
281
+ addFrom(state.doc.resolve(pos + 1));
282
+ });
283
+ }
284
+ else {
285
+ addFrom(selection.$from);
286
+ }
287
+ return [...found].sort((a, b) => a - b);
288
+ }
289
+ function rowIndexAt(table, tablePos, rowPos) {
290
+ let offset = tablePos + 1;
291
+ for (let i = 0; i < table.childCount; i += 1) {
292
+ if (offset === rowPos)
293
+ return i;
294
+ offset += table.child(i).nodeSize;
295
+ }
296
+ return -1;
297
+ }
298
+ function stateWithRowSelection(state, table, tablePos, insertAt, rowCount) {
299
+ const rowIndex = insertAt >= rowCount ? rowCount - 1 : insertAt;
300
+ let pos = tablePos + 1;
301
+ for (let i = 0; i < rowIndex; i += 1)
302
+ pos += table.child(i).nodeSize;
303
+ const cellPos = pos + 1;
304
+ return state.apply(state.tr.setSelection(TextSelection.near(state.doc.resolve(cellPos + 1))));
305
+ }
306
+ export function selectedCellPositions(state) {
307
+ const { selection } = state;
308
+ if (selection instanceof CellSelection) {
309
+ const positions = [];
310
+ selection.forEachCell((_node, pos) => {
311
+ positions.push(pos);
312
+ });
313
+ return positions;
314
+ }
315
+ const cell = findCell(selection.$from);
316
+ return cell ? [cell.pos] : [];
317
+ }
318
+ /**
319
+ * Patch declarations onto a style attribute, validating every value written.
320
+ *
321
+ * The validation is here rather than in each caller because this is the choke
322
+ * point: a value reaches the stored style attribute only through this function,
323
+ * and `serializeDeclarations` joins on `;`, so an unchecked value carrying one
324
+ * becomes extra declarations. `padding: 0;position:fixed;inset:0` is a
325
+ * page-covering overlay, written from a property dialog and saved.
326
+ *
327
+ * `safeTableStyleValue` is core's own parse-path validator, so a dialog cannot
328
+ * disagree with the schema about what an acceptable value is.
329
+ */
330
+ export function mergeStyle(existing, patch) {
331
+ const declarations = parseDeclarations(existing);
332
+ for (const [name, value] of Object.entries(patch)) {
333
+ const safe = value ? safeTableStyleValue(name, value) : null;
334
+ if (!safe)
335
+ declarations.delete(name);
336
+ else
337
+ declarations.set(name, safe);
338
+ }
339
+ return serializeDeclarations(declarations);
340
+ }
341
+ /** A style value the schema will keep, or null. For a dialog's commit step. */
342
+ export function styleValueOrNull(property, value) {
343
+ const trimmed = emptyToNull(value);
344
+ return trimmed ? safeTableStyleValue(property, trimmed) : null;
345
+ }
346
+ export function setTableAttrs(attrs) {
347
+ return (state, dispatch) => {
348
+ const table = findTable(state.selection.$from);
349
+ if (!table)
350
+ return false;
351
+ if (dispatch) {
352
+ dispatch(state.tr.setNodeMarkup(table.pos, undefined, { ...table.node.attrs, ...attrs }));
353
+ }
354
+ return true;
355
+ };
356
+ }
357
+ export function setRowAttrs(attrs) {
358
+ return (state, dispatch) => {
359
+ const row = findRow(state.selection.$from);
360
+ if (!row)
361
+ return false;
362
+ if (dispatch) {
363
+ dispatch(state.tr.setNodeMarkup(row.pos, undefined, { ...row.node.attrs, ...attrs }));
364
+ }
365
+ return true;
366
+ };
367
+ }
368
+ export function setCellAttrs(attrs) {
369
+ return (state, dispatch) => {
370
+ const positions = selectedCellPositions(state);
371
+ if (positions.length === 0)
372
+ return false;
373
+ if (dispatch) {
374
+ const tr = state.tr;
375
+ for (const pos of positions) {
376
+ const node = tr.doc.nodeAt(pos);
377
+ if (!node)
378
+ continue;
379
+ const next = { ...node.attrs, ...attrs };
380
+ tr.setNodeMarkup(pos, undefined, next);
381
+ }
382
+ dispatch(tr);
383
+ }
384
+ return true;
385
+ };
386
+ }
387
+ export function setCellVerticalAlign(value) {
388
+ return setCellAttrs({ valign: value });
389
+ }
390
+ export function captionTextFromHtml(html) {
391
+ if (!html || typeof document === 'undefined')
392
+ return html?.replace(/<[^>]+>/g, '') ?? '';
393
+ const tpl = document.createElement('template');
394
+ tpl.innerHTML = html;
395
+ return tpl.content.querySelector('caption')?.textContent ?? '';
396
+ }
397
+ export function captionHtmlFromText(text, previous) {
398
+ const trimmed = text.trim();
399
+ if (!trimmed)
400
+ return null;
401
+ if (previous && captionTextFromHtml(previous) === trimmed)
402
+ return previous;
403
+ if (typeof document === 'undefined') {
404
+ return `<caption>${escapeText(trimmed)}</caption>`;
405
+ }
406
+ const caption = document.createElement('caption');
407
+ caption.textContent = trimmed;
408
+ return caption.outerHTML;
409
+ }
410
+ export function setTableCaption(text) {
411
+ return (state, dispatch) => {
412
+ const table = findTable(state.selection.$from);
413
+ if (!table)
414
+ return false;
415
+ const caption = captionHtmlFromText(text, table.node.attrs['caption']);
416
+ return setTableAttrs({ caption })(state, dispatch);
417
+ };
418
+ }
419
+ export function colgroupHtmlFromWidths(widths) {
420
+ if (widths.every((width) => !width))
421
+ return null;
422
+ if (typeof document === 'undefined') {
423
+ const cols = widths.map((width) => (width ? `<col width="${escapeAttr(width)}">` : '<col>')).join('');
424
+ return `<colgroup>${cols}</colgroup>`;
425
+ }
426
+ const group = document.createElement('colgroup');
427
+ for (const width of widths) {
428
+ const col = document.createElement('col');
429
+ if (width)
430
+ col.setAttribute('width', width);
431
+ group.appendChild(col);
432
+ }
433
+ return group.outerHTML;
434
+ }
435
+ /** The `<col>` elements of a stored colgroup, or null when there is no DOM. */
436
+ function colsOf(html) {
437
+ if (!html)
438
+ return null;
439
+ if (typeof document === 'undefined')
440
+ return null;
441
+ const tpl = document.createElement('template');
442
+ tpl.innerHTML = html;
443
+ const group = tpl.content.querySelector('colgroup');
444
+ if (!group)
445
+ return null;
446
+ return { group, cols: [...group.querySelectorAll('col')] };
447
+ }
448
+ /** `span` as a count of columns covered, defaulting to 1. */
449
+ function spanOf(col) {
450
+ const raw = Number(col.getAttribute('span') ?? '1');
451
+ return Number.isInteger(raw) && raw > 0 ? raw : 1;
452
+ }
453
+ /**
454
+ * Column widths, one per column.
455
+ *
456
+ * `span` is honoured: `<col span="2" width="120">` sets two columns to 120, not
457
+ * one. Reading the elements positionally would report the second column as
458
+ * having no width, and then saving would write that back.
459
+ */
460
+ export function widthsFromColgroup(html, columns) {
461
+ const parsed = colsOf(html);
462
+ if (!parsed)
463
+ return Array.from({ length: columns }, () => '');
464
+ return widthsFromCols(parsed.cols, columns);
465
+ }
466
+ /** The same reading, from `<col>` elements somebody has already parsed. */
467
+ function widthsFromCols(cols, columns) {
468
+ const widths = Array.from({ length: columns }, () => '');
469
+ let column = 0;
470
+ for (const col of cols) {
471
+ const width = col.getAttribute('width') ?? '';
472
+ for (let i = 0; i < spanOf(col); i += 1) {
473
+ if (column < columns)
474
+ widths[column] = width;
475
+ column += 1;
476
+ }
477
+ }
478
+ return widths;
479
+ }
480
+ /**
481
+ * Write widths into an existing colgroup rather than replacing it.
482
+ *
483
+ * Inherited markup carries more than widths -- `<colgroup class="layout">`,
484
+ * `<col span="2">`, whatever else a previous CMS wrote -- and the table
485
+ * properties dialog saves the whole table, so rebuilding the colgroup from
486
+ * widths alone dropped all of it on a save that changed nothing else.
487
+ *
488
+ * An unchanged set of widths returns the stored markup untouched, so saving the
489
+ * dialog is genuinely a no-op. When a width does change, the existing elements
490
+ * are patched: a spanned `<col>` keeps its span while its columns still agree,
491
+ * and is split into one `<col>` per column -- carrying its other attributes --
492
+ * only when they no longer do.
493
+ */
494
+ export function colgroupHtmlWithWidths(existing, widths) {
495
+ const parsed = colsOf(existing);
496
+ if (!parsed)
497
+ return colgroupHtmlFromWidths(widths);
498
+ const wanted = widths.map((width) => width ?? '');
499
+ // From the elements just parsed, not from the string again: this ran twice per
500
+ // table per transaction, and an HTML parse is not a cheap way to read an
501
+ // attribute you are already holding.
502
+ const current = widthsFromCols(parsed.cols, wanted.length);
503
+ if (current.every((width, i) => width === wanted[i]))
504
+ return existing ?? null;
505
+ let column = 0;
506
+ for (const col of parsed.cols) {
507
+ const span = spanOf(col);
508
+ const covered = wanted.slice(column, column + span);
509
+ column += span;
510
+ if (covered.length === 0)
511
+ continue;
512
+ if (covered.every((width) => width === covered[0])) {
513
+ setWidth(col, covered[0] ?? '');
514
+ continue;
515
+ }
516
+ // The columns this element covers no longer share a width, so it has to
517
+ // become one element per column. Its other attributes come along; `span`
518
+ // cannot, because each replacement now covers exactly one column.
519
+ const parent = col.parentNode;
520
+ if (!parent)
521
+ continue;
522
+ for (const width of covered) {
523
+ const clone = col.cloneNode(false);
524
+ clone.removeAttribute('span');
525
+ setWidth(clone, width);
526
+ parent.insertBefore(clone, col);
527
+ }
528
+ parent.removeChild(col);
529
+ }
530
+ // More columns than the stored colgroup described. Bare `<col>` for each, so
531
+ // the widths that follow land on the right column.
532
+ for (; column < wanted.length; column += 1) {
533
+ const col = parsed.group.ownerDocument.createElement('col');
534
+ setWidth(col, wanted[column] ?? '');
535
+ parsed.group.appendChild(col);
536
+ }
537
+ return parsed.group.outerHTML;
538
+ }
539
+ /**
540
+ * Insert a bare `<col>` so columns after `at` keep the `<col>` they already had.
541
+ *
542
+ * A spanned element that covers `at` is split around the insertion rather than
543
+ * widened: the new column must not inherit the neighbour's class or width, which
544
+ * is the same shift insert-without-a-patch produced for unspanned columns.
545
+ */
546
+ export function colgroupHtmlInsertColumn(existing, at) {
547
+ const parsed = colsOf(existing);
548
+ if (!parsed)
549
+ return existing ?? null;
550
+ let column = 0;
551
+ for (const col of parsed.cols) {
552
+ const span = spanOf(col);
553
+ if (at <= column) {
554
+ parsed.group.insertBefore(parsed.group.ownerDocument.createElement('col'), col);
555
+ return parsed.group.outerHTML;
556
+ }
557
+ if (at < column + span) {
558
+ const left = at - column;
559
+ const right = span - left;
560
+ const parent = col.parentNode;
561
+ if (!parent)
562
+ break;
563
+ if (left > 0) {
564
+ const before = col.cloneNode(false);
565
+ setSpan(before, left);
566
+ parent.insertBefore(before, col);
567
+ }
568
+ parent.insertBefore(parsed.group.ownerDocument.createElement('col'), col);
569
+ setSpan(col, right);
570
+ return parsed.group.outerHTML;
571
+ }
572
+ column += span;
573
+ }
574
+ parsed.group.appendChild(parsed.group.ownerDocument.createElement('col'));
575
+ return parsed.group.outerHTML;
576
+ }
577
+ /**
578
+ * Drop the `<col>` covering column `at`, or decrement its `span` when it covers
579
+ * more than one column.
580
+ */
581
+ export function colgroupHtmlDeleteColumn(existing, at) {
582
+ const parsed = colsOf(existing);
583
+ if (!parsed)
584
+ return existing ?? null;
585
+ let column = 0;
586
+ for (const col of parsed.cols) {
587
+ const span = spanOf(col);
588
+ if (at >= column && at < column + span) {
589
+ if (span <= 1)
590
+ col.remove();
591
+ else
592
+ setSpan(col, span - 1);
593
+ return parsed.group.outerHTML;
594
+ }
595
+ column += span;
596
+ }
597
+ return existing ?? null;
598
+ }
599
+ /** Pad or trim so the colgroup describes exactly `columns` columns. */
600
+ function colgroupHtmlMatchWidth(existing, columns) {
601
+ const parsed = colsOf(existing);
602
+ if (!parsed)
603
+ return existing ?? null;
604
+ let coverage = 0;
605
+ for (const col of [...parsed.group.querySelectorAll('col')])
606
+ coverage += spanOf(col);
607
+ while (coverage < columns) {
608
+ parsed.group.appendChild(parsed.group.ownerDocument.createElement('col'));
609
+ coverage += 1;
610
+ }
611
+ while (coverage > columns) {
612
+ const cols = [...parsed.group.querySelectorAll('col')];
613
+ const last = cols[cols.length - 1];
614
+ if (!last)
615
+ break;
616
+ const span = spanOf(last);
617
+ if (span <= 1)
618
+ last.remove();
619
+ else
620
+ setSpan(last, span - 1);
621
+ coverage -= 1;
622
+ }
623
+ return parsed.group.outerHTML;
624
+ }
625
+ function setSpan(col, span) {
626
+ if (span <= 1)
627
+ col.removeAttribute('span');
628
+ else
629
+ col.setAttribute('span', String(span));
630
+ }
631
+ function setWidth(col, width) {
632
+ if (width)
633
+ col.setAttribute('width', width);
634
+ else
635
+ col.removeAttribute('width');
636
+ }
637
+ export function setTableColgroup(widths) {
638
+ return setTableAttrs({ colgroup: colgroupHtmlFromWidths(widths) });
639
+ }
640
+ function colgroupFromCellWidths(table) {
641
+ const map = TableMap.get(table);
642
+ const widths = [];
643
+ let any = false;
644
+ // A cell spanning several columns appears in the map once per column it
645
+ // covers, and its `colwidth` holds one entry per covered column. `offset`
646
+ // tracks how far into that run this column is: reading entry 0 every time
647
+ // wrote the first column's width to every <col> the cell spans, so resizing a
648
+ // later column of a merged cell corrupted the stored colgroup.
649
+ let previous = -1;
650
+ let offset = 0;
651
+ for (let col = 0; col < map.width; col += 1) {
652
+ const pos = map.map[col] ?? 0;
653
+ if (pos === previous)
654
+ offset += 1;
655
+ else {
656
+ previous = pos;
657
+ offset = 0;
658
+ }
659
+ const cell = table.nodeAt(pos);
660
+ const colwidth = cell?.attrs['colwidth'];
661
+ const width = colwidth?.[offset];
662
+ if (width) {
663
+ widths.push(String(width));
664
+ any = true;
665
+ }
666
+ else {
667
+ widths.push('');
668
+ }
669
+ }
670
+ // Patched onto whatever the table already stored, for the same reason the
671
+ // properties dialog patches: a column resize must not cost an inherited
672
+ // colgroup its class or its other attributes.
673
+ return any ? colgroupHtmlWithWidths(table.attrs['colgroup'], widths) : null;
674
+ }
675
+ /**
676
+ * What `colgroupFromCellWidths` last said about a given table node.
677
+ *
678
+ * The sync plugin below runs on every `docChanged` transaction and asks this
679
+ * question of every table in the document -- so typing a character in a
680
+ * paragraph rebuilt the `TableMap`, walked every cell and parsed the stored
681
+ * colgroup HTML, for tables the transaction had not touched. With one resized
682
+ * 100x20 table that was 1.8 ms of jsdom on the keystroke path.
683
+ *
684
+ * A node is a legitimate cache key because ProseMirror nodes are immutable and
685
+ * persistent: an edit produces new nodes along the path it touched and reuses
686
+ * every other node by identity, so an unchanged table IS the same object, and a
687
+ * changed one cannot be. The answer is a pure function of the node -- its
688
+ * `TableMap`, its cells' `colwidth`, its own `colgroup` attribute -- so an entry
689
+ * cannot go stale without the key changing with it. Weak, so a node the document
690
+ * has moved past is collectable.
691
+ */
692
+ const colgroupForNode = new WeakMap();
693
+ function cachedColgroupFromCellWidths(table) {
694
+ const known = colgroupForNode.get(table);
695
+ // `null` is a real answer -- "this table has no resized columns" -- so the
696
+ // miss test is `undefined`, not falsiness.
697
+ if (known !== undefined)
698
+ return known;
699
+ const computed = colgroupFromCellWidths(table);
700
+ colgroupForNode.set(table, computed);
701
+ return computed;
702
+ }
703
+ /**
704
+ * Keep `<colgroup>` in lockstep with column resizing.
705
+ *
706
+ * `prosemirror-tables` writes `colwidth` onto cells. That is enough for the
707
+ * editor; it is not enough for stored HTML, whose column widths live on
708
+ * `<col>`. Updating the furniture attribute after a resize is what makes
709
+ * colgroup a first-class editing feature rather than a round-trip souvenir.
710
+ *
711
+ * Authored colgroups that are not the product of a resize are left alone:
712
+ * inventing `<col>` elements for a table that never had widths would change
713
+ * markup we had no reason to touch.
714
+ */
715
+ export function colgroupSyncPlugin() {
716
+ return new Plugin({
717
+ appendTransaction(transactions, _old, state) {
718
+ if (!transactions.some((tr) => tr.docChanged))
719
+ return null;
720
+ let tr = null;
721
+ state.doc.descendants((node, pos) => {
722
+ if (node.type.spec['tableRole'] !== 'table')
723
+ return true;
724
+ // Cached on the node: a transaction elsewhere in the document reuses
725
+ // every table node it did not touch, so an untouched table answers from
726
+ // the map instead of rebuilding its TableMap and reparsing its colgroup.
727
+ const next = cachedColgroupFromCellWidths(node);
728
+ if (next === null)
729
+ return false;
730
+ if (next === node.attrs['colgroup'])
731
+ return false;
732
+ tr ??= state.tr;
733
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, colgroup: next });
734
+ return false;
735
+ });
736
+ return tr;
737
+ },
738
+ });
739
+ }
740
+ export function emptyToNull(value) {
741
+ const trimmed = value?.trim() ?? '';
742
+ return trimmed === '' ? null : trimmed;
743
+ }
744
+ export function colorOrNull(value) {
745
+ const trimmed = emptyToNull(value);
746
+ return trimmed ? safeColor(trimmed) : null;
747
+ }
748
+ function escapeText(value) {
749
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
750
+ }
751
+ function escapeAttr(value) {
752
+ return escapeText(value).replace(/"/g, '&quot;');
753
+ }
754
+ //# sourceMappingURL=commands.js.map