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

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/README.md CHANGED
@@ -67,6 +67,42 @@ Without that, every remaining column inherited the previous `<col>`'s width and
67
67
  class. Column resize still patches widths onto the same elements, so the two
68
68
  paths do not fight.
69
69
 
70
+ Merge and split keep that contract too. A merged cell's `colwidth` is one
71
+ entry per column it covers — the concatenation of those columns' widths, filled
72
+ from other cells in the column and then from the stored colgroup, never folded
73
+ into a single number — and `scope` follows the new span: a header that covers
74
+ two columns is `scope="colgroup"`, and a body cell that still carried `scope`
75
+ from an earlier header conversion has it cleared. Split writes each entry back
76
+ onto the cell that covers that column.
77
+
78
+ Inserting a row or column copies the neighbour cell's alignment and background
79
+ so the new cells look like the ones the author was in. It does not copy `class`
80
+ (often a band the author wanted on one row), `scope` (owned by the header pass
81
+ above), or — on a new column — `colwidth`, because a new column is an unmeasured
82
+ `<col>` and stamping the neighbour's width would make the next resize fight it.
83
+ A body row inserted below a header copies from a body neighbour of the same
84
+ type, not from the header's chrome.
85
+
86
+ ## Nested tables
87
+
88
+ A cell may contain a table — `table_cell` is `block+`, and Insert table inside a
89
+ cell is how you make one. Two editing rules follow from that:
90
+
91
+ - **Column resize.** Dragging a column border targets the table whose border the
92
+ pointer is on. When the pointer sits on an outer column edge that crosses a
93
+ nested table, the outer grid wins, so parent column widths can be restored
94
+ without first leaving the inner table.
95
+ - **Paste.** A whole table pasted at a text caret inside a cell is inserted as a
96
+ nested table, and it keeps the table's own attributes — caption, colgroup,
97
+ header and footer row counts, `border`, `class`. That is true of external HTML
98
+ (a browser, Word, or Excel table, which arrives as a closed table) and of an
99
+ internal copy of every cell (which arrives as that same table node, open by
100
+ one). Loose cells pasted at a text caret are wrapped in a new table and nested
101
+ the same way; a copied rectangle is not a table, so it does not inherit a
102
+ caption it did not carry. A cell selection still maps pasted cells onto the
103
+ selected rectangle. In no case does a caret-paste rewrite `colspan` on cells
104
+ the author did not select.
105
+
70
106
  ## License
71
107
 
72
108
  Apache-2.0.
@@ -83,9 +119,9 @@ the ones you want in the element's `toolbar` attribute:
83
119
  | `rowProperties` | Row properties dialog |
84
120
  | `cellProperties` | Cell properties dialog |
85
121
  | `tableCaption` | Caption dialog |
86
- | `addRowBefore`, `addRowAfter`, `deleteRow` | Row commands. They keep `headerRows` / `footerRows` in step so `<thead>` and `<tfoot>` stay on the header and footer rows the author actually has. |
87
- | `addColumnBefore`, `addColumnAfter`, `deleteColumn` | Column commands |
88
- | `mergeCells`, `splitCell` | Cell merge and split |
122
+ | `addRowBefore`, `addRowAfter`, `deleteRow` | Row commands. They keep `headerRows` / `footerRows` in step so `<thead>` and `<tfoot>` stay on the header and footer rows the author actually has. A new row copies the neighbour cell's alignment, background, and `colwidth`. |
123
+ | `addColumnBefore`, `addColumnAfter`, `deleteColumn` | Column commands. A new column copies alignment and background, not `colwidth`. |
124
+ | `mergeCells`, `splitCell` | Cell merge and split. They keep per-column `colwidth` and rewrite `scope` for the new span. |
89
125
  | `toggleHeaderRow` | Promote or demote the header row |
90
126
  | `deleteTable` | Delete the whole table |
91
127
 
@@ -6,6 +6,13 @@
6
6
  * delete also reindex the stored colgroup here: the upstream commands only move
7
7
  * cells, and `colgroupFromCellWidths` never runs unless a cell already has
8
8
  * `colwidth`.
9
+ *
10
+ * `mergeCells` and `splitCell` stay the stock commands. This file wraps them
11
+ * so that after they run, `colwidth` is one entry per covered column (the
12
+ * same array `colgroupFromCellWidths` already walks) and `applyCellScope`
13
+ * sees the new span. The colgroup sync plugin does the same repair for a
14
+ * stock `mergeCells` dispatched from elsewhere, so the two paths cannot
15
+ * disagree.
9
16
  */
10
17
  import type { Node, ResolvedPos } from 'prosemirror-model';
11
18
  import type { Command, EditorState } from 'prosemirror-state';
@@ -40,6 +47,8 @@ export declare const addRowAfter: Command;
40
47
  export declare const addRowBefore: Command;
41
48
  export declare const deleteRow: Command;
42
49
  export declare const toggleHeaderRow: Command;
50
+ export declare const mergeCells: Command;
51
+ export declare const splitCell: Command;
43
52
  export declare function selectedCellPositions(state: EditorState): number[];
44
53
  /**
45
54
  * Patch declarations onto a style attribute, validating every value written.
@@ -1 +1 @@
1
- {"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../src/commands.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../src/commands.ts"],"names":[],"mappings":"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"}
package/dist/commands.js CHANGED
@@ -6,10 +6,17 @@
6
6
  * delete also reindex the stored colgroup here: the upstream commands only move
7
7
  * cells, and `colgroupFromCellWidths` never runs unless a cell already has
8
8
  * `colwidth`.
9
+ *
10
+ * `mergeCells` and `splitCell` stay the stock commands. This file wraps them
11
+ * so that after they run, `colwidth` is one entry per covered column (the
12
+ * same array `colgroupFromCellWidths` already walks) and `applyCellScope`
13
+ * sees the new span. The colgroup sync plugin does the same repair for a
14
+ * stock `mergeCells` dispatched from elsewhere, so the two paths cannot
15
+ * disagree.
9
16
  */
10
17
  import { canInsert, isNodeActive, parseDeclarations, safeColor, safeTableStyleValue, serializeDeclarations, } from '@openleaf-editor/core';
11
18
  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';
19
+ import { addColumnAfter as addColumnAfterRaw, addColumnBefore as addColumnBeforeRaw, addRowAfter as addRowAfterRaw, addRowBefore as addRowBeforeRaw, CellSelection, deleteColumn as deleteColumnRaw, deleteRow as deleteRowRaw, mergeCells as mergeCellsRaw, splitCell as splitCellRaw, TableMap, toggleHeaderRow as toggleHeaderRowRaw, } from 'prosemirror-tables';
13
20
  export function inTable(state) {
14
21
  return isNodeActive(state, 'table');
15
22
  }
@@ -56,7 +63,39 @@ export function insertTable(rows = 3, cols = 3) {
56
63
  };
57
64
  }
58
65
  /**
59
- * Rewrite header cell `scope` for the table the selection is in.
66
+ * The `scope` this header should carry, given where it sits and how far it spans.
67
+ *
68
+ * Axis is the rule `applyCellScope` has always used: the first row, and every
69
+ * header that is not the row-opening cell, heads a column. `colspan` / `rowspan`
70
+ * then promote that axis to the HTML group tokens — a two-column header is
71
+ * `colgroup`, not `col`, which is what a screen reader needs to hear.
72
+ */
73
+ function expectedHeaderScope(rowIndex, cellIndex, colspan, rowspan) {
74
+ const axis = rowIndex === 0 || cellIndex > 0 ? 'col' : 'row';
75
+ if (axis === 'col' && colspan > 1)
76
+ return 'colgroup';
77
+ if (axis === 'row' && rowspan > 1)
78
+ return 'rowgroup';
79
+ return axis;
80
+ }
81
+ function scopeOf(node) {
82
+ const scope = node.attrs['scope'];
83
+ return scope === null || scope === undefined || scope === '' ? null : String(scope);
84
+ }
85
+ function scopeSpanMismatch(current, expected) {
86
+ return ((current === 'col' && expected === 'colgroup') ||
87
+ (current === 'colgroup' && expected === 'col') ||
88
+ (current === 'row' && expected === 'rowgroup') ||
89
+ (current === 'rowgroup' && expected === 'row'));
90
+ }
91
+ /**
92
+ * Rewrite header cell `scope` for a table.
93
+ *
94
+ * `fillMissing` is how insert and toggle-header give a new `<th>` the same
95
+ * `scope` `insertTable` uses. Merge and split already have a scope, just the
96
+ * wrong one for the new span, so the colgroup sync plugin calls this with
97
+ * `fillMissing: false` and only repairs contradictions — otherwise a keystroke
98
+ * inside a table would invent `scope` on headers the author had left bare.
60
99
  *
61
100
  * The selection is captured and restored around the rewrite. Every change here
62
101
  * is a `setNodeMarkup`, which replaces the cell node, and mapping a text
@@ -65,21 +104,23 @@ export function insertTable(rows = 3, cols = 3) {
65
104
  * out of the cell the author was editing, so the next table command found no
66
105
  * cell and did nothing at all.
67
106
  */
68
- function applyCellScope(tr) {
107
+ function applyCellScope(tr, tablePos, fillMissing = true) {
69
108
  const header = tr.doc.type.schema.nodes['table_header'];
70
109
  const cell = tr.doc.type.schema.nodes['table_cell'];
71
110
  if (!header || !cell)
72
111
  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;
112
+ let pos = tablePos ?? -1;
113
+ if (pos < 0) {
114
+ for (let depth = tr.selection.$from.depth; depth > 0; depth -= 1) {
115
+ if (tr.selection.$from.node(depth).type.spec['tableRole'] === 'table') {
116
+ pos = tr.selection.$from.before(depth);
117
+ break;
118
+ }
78
119
  }
79
120
  }
80
- if (tablePos < 0)
121
+ if (pos < 0)
81
122
  return tr;
82
- const table = tr.doc.nodeAt(tablePos);
123
+ const table = tr.doc.nodeAt(pos);
83
124
  if (!table)
84
125
  return tr;
85
126
  // Only a plain text selection needs restoring: CellSelection maps itself and
@@ -87,22 +128,28 @@ function applyCellScope(tr) {
87
128
  const restore = tr.selection instanceof TextSelection ? tr.selection.from : null;
88
129
  const stepsBefore = tr.steps.length;
89
130
  table.forEach((row, rowOffset, rowIndex) => {
90
- const rowPos = tablePos + 1 + rowOffset;
131
+ const rowPos = pos + 1 + rowOffset;
91
132
  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 });
133
+ const cellPos = rowPos + 1 + cellOffset;
134
+ if (cellNode.type === cell) {
135
+ if (cellNode.attrs['scope']) {
136
+ tr.setNodeMarkup(cellPos, undefined, { ...cellNode.attrs, scope: null });
137
+ }
95
138
  return;
96
139
  }
97
140
  if (cellNode.type !== header)
98
141
  return;
99
- const scope = cellNode.attrs['scope'];
100
- if (scope !== null && scope !== undefined && scope !== '')
142
+ const expected = expectedHeaderScope(rowIndex, cellIndex, cellNode.attrs['colspan'] || 1, cellNode.attrs['rowspan'] || 1);
143
+ const current = scopeOf(cellNode);
144
+ if (current === expected)
101
145
  return;
102
- tr.setNodeMarkup(pos, undefined, {
103
- ...cellNode.attrs,
104
- scope: rowIndex === 0 || cellIndex > 0 ? 'col' : 'row',
105
- });
146
+ // A set `scope` is author intent — a first-row cell can still be a row
147
+ // header — unless the span has outgrown the token (`col` on colspan=2).
148
+ if (current !== null && !scopeSpanMismatch(current, expected))
149
+ return;
150
+ if (current === null && !fillMissing)
151
+ return;
152
+ tr.setNodeMarkup(cellPos, undefined, { ...cellNode.attrs, scope: expected });
106
153
  });
107
154
  });
108
155
  // Mapped through only the steps this pass added, so the caret lands where it
@@ -189,13 +236,225 @@ function applyColgroupColumnChange(tr, range, kind) {
189
236
  return;
190
237
  tr.setNodeMarkup(pos, undefined, { ...table.attrs, colgroup: next });
191
238
  }
192
- export const addColumnAfter = withColgroupColumns('after', withCellScope(addColumnAfterRaw));
193
- export const addColumnBefore = withColgroupColumns('before', withCellScope(addColumnBeforeRaw));
239
+ /**
240
+ * Visual cell attributes Word/Excel authors expect a new row or column to
241
+ * inherit from the cell they were in.
242
+ *
243
+ * Copied: `align`, `valign`, `style` (background and padding), `bgcolor`, and
244
+ * — on a new *row* only — `colwidth`, so every cell in a column still carries
245
+ * the same stored widths the next resize will read.
246
+ *
247
+ * Not copied, on purpose:
248
+ * - `class` — often a band (`odd`/`even`) the author wanted on one row
249
+ * - `scope` / `headers` / `abbr` — identity of the source cell; `applyCellScope`
250
+ * owns `scope`
251
+ * - `colwidth` on a new *column* — `withColgroupColumns` inserts a bare `<col>`
252
+ * so later columns keep their own widths; stamping the neighbour's colwidth
253
+ * onto the new cells would make the sync plugin fight that bare column
254
+ * - header vs body type — stock `addRow` / `addColumn` already copy the type,
255
+ * and `maintainTableSections` refuses to insert into `<thead>` / `<tfoot>`
256
+ * - header `style` onto a body cell — the neighbour lookup requires matching
257
+ * type, so a body row inserted below a header copies from the body row on
258
+ * the other side, not from the header chrome
259
+ */
260
+ const COPIED_CELL_ATTRS = ['align', 'valign', 'style', 'bgcolor'];
261
+ function withCopiedCellFormatting(command) {
262
+ return (state, dispatch, view) => {
263
+ if (!dispatch)
264
+ return command(state, undefined, view);
265
+ const found = findTable(state.selection.$from);
266
+ const before = found ? { pos: found.pos, node: found.node } : null;
267
+ return command(state, (tr) => {
268
+ if (before)
269
+ copyFormattingOntoInsertedCells(tr, before);
270
+ dispatch(tr);
271
+ }, view);
272
+ };
273
+ }
274
+ function copyFormattingOntoInsertedCells(tr, before) {
275
+ const pos = tr.mapping.map(before.pos);
276
+ const after = tr.doc.nodeAt(pos);
277
+ if (!after || after.type.spec['tableRole'] !== 'table')
278
+ return;
279
+ const insertedRow = insertedRowIndex(before.node, after);
280
+ const insertedCol = insertedRow === null ? insertedColumnIndex(before.node, after) : null;
281
+ if (insertedRow === null && insertedCol === null)
282
+ return;
283
+ const map = TableMap.get(after);
284
+ after.forEach((row, rowOffset, rowIndex) => {
285
+ const rowPos = pos + 1 + rowOffset;
286
+ let col = 0;
287
+ row.forEach((cell, cellOffset) => {
288
+ const colspan = cell.attrs['colspan'] || 1;
289
+ const hit = insertedRow !== null ? rowIndex === insertedRow : col === insertedCol;
290
+ if (hit &&
291
+ !cell.textContent &&
292
+ colspan === 1 &&
293
+ (cell.attrs['rowspan'] || 1) === 1) {
294
+ const source = neighbourCell(after, map, rowIndex, col, insertedRow !== null ? 'row' : 'col', cell.type);
295
+ if (source) {
296
+ tr.setNodeMarkup(rowPos + 1 + cellOffset, undefined, {
297
+ ...cell.attrs,
298
+ ...copiedCellAttrs(source, insertedRow !== null),
299
+ });
300
+ }
301
+ }
302
+ col += colspan;
303
+ });
304
+ });
305
+ }
306
+ function insertedRowIndex(before, after) {
307
+ if (after.childCount !== before.childCount + 1)
308
+ return null;
309
+ const text = (row) => row.textContent;
310
+ for (let at = 0; at <= before.childCount; at += 1) {
311
+ let matches = true;
312
+ for (let i = 0; i < before.childCount; i += 1) {
313
+ const afterIndex = i < at ? i : i + 1;
314
+ if (text(before.child(i)) !== text(after.child(afterIndex))) {
315
+ matches = false;
316
+ break;
317
+ }
318
+ }
319
+ if (matches)
320
+ return at;
321
+ }
322
+ return null;
323
+ }
324
+ function insertedColumnIndex(before, after) {
325
+ const beforeMap = TableMap.get(before);
326
+ const afterMap = TableMap.get(after);
327
+ if (afterMap.width !== beforeMap.width + 1 || afterMap.height !== beforeMap.height) {
328
+ return null;
329
+ }
330
+ const textAt = (table, map, row, col) => table.nodeAt(map.map[row * map.width + col] ?? 0)?.textContent ?? '';
331
+ for (let at = 0; at <= beforeMap.width; at += 1) {
332
+ let matches = true;
333
+ outer: for (let row = 0; row < beforeMap.height; row += 1) {
334
+ for (let col = 0; col < beforeMap.width; col += 1) {
335
+ const afterCol = col < at ? col : col + 1;
336
+ if (textAt(before, beforeMap, row, col) !== textAt(after, afterMap, row, afterCol)) {
337
+ matches = false;
338
+ break outer;
339
+ }
340
+ }
341
+ }
342
+ if (matches)
343
+ return at;
344
+ }
345
+ return null;
346
+ }
347
+ function neighbourCell(table, map, row, col, axis, type) {
348
+ const prefer = axis === 'row' ? (row > 0 ? -1 : 1) : col > 0 ? -1 : 1;
349
+ const limit = axis === 'row' ? map.height : map.width;
350
+ for (const dir of [prefer, -prefer]) {
351
+ const at = (axis === 'row' ? row : col) + dir;
352
+ if (at < 0 || at >= limit)
353
+ continue;
354
+ const index = axis === 'row' ? at * map.width + col : row * map.width + at;
355
+ const node = table.nodeAt(map.map[index] ?? 0);
356
+ if (node && node.type === type)
357
+ return node;
358
+ }
359
+ return null;
360
+ }
361
+ function copiedCellAttrs(source, includeColwidth) {
362
+ const next = {};
363
+ for (const key of COPIED_CELL_ATTRS) {
364
+ if (source.attrs[key] != null)
365
+ next[key] = source.attrs[key];
366
+ }
367
+ if (includeColwidth &&
368
+ source.attrs['colwidth'] != null &&
369
+ (source.attrs['colspan'] || 1) === 1) {
370
+ // A spanning neighbour holds one width per column it covers. Copying that
371
+ // array onto a colspan=1 cell would disagree with the schema's length check.
372
+ next['colwidth'] = source.attrs['colwidth'];
373
+ }
374
+ return next;
375
+ }
376
+ /**
377
+ * Snapshot each column's width before a merge or split, then write those
378
+ * widths back onto the cells the command produced.
379
+ *
380
+ * Stock `mergeCells` grows `colwidth` with `addColSpan`, which inserts `0` for
381
+ * every newly covered column rather than taking the other cell's width. Stock
382
+ * `splitCell` then treats `0` as "no width". The array is one entry per
383
+ * covered column — the same invariant `colgroupFromCellWidths` already
384
+ * documents — so "sum" here means concatenate, not fold into a single number.
385
+ * Folding would make the next resize fight the colgroup: the spanning cell
386
+ * would report one width for every column it covers.
387
+ *
388
+ * Missing entries are filled from another cell in the same column, then from
389
+ * the stored colgroup. A column nobody knows a width for stays unknown: we do
390
+ * not invent one, and we do not divide a neighbour's width across the hole
391
+ * (100 and unknown is not 50/50). An odd pair such as 100 and 101 stays
392
+ * `[100, 101]` so a later split restores the same integers.
393
+ */
394
+ function withPreservedColumnWidths(command) {
395
+ return (state, dispatch, view) => {
396
+ if (!dispatch)
397
+ return command(state, undefined, view);
398
+ const found = findTable(state.selection.$from);
399
+ const snapshot = found ? { pos: found.pos, widths: columnWidths(found.node) } : null;
400
+ return command(state, (tr) => {
401
+ if (snapshot) {
402
+ const pos = tr.mapping.map(snapshot.pos);
403
+ const table = tr.doc.nodeAt(pos);
404
+ if (table?.type.spec['tableRole'] === 'table') {
405
+ writeColwidthOntoCells(tr, pos, table, snapshot.widths);
406
+ }
407
+ }
408
+ dispatch(tr);
409
+ }, view);
410
+ };
411
+ }
412
+ function writeColwidthOntoCells(tr, tablePos, table, widths, spanningOnly = false) {
413
+ const map = TableMap.get(table);
414
+ const seen = new Set();
415
+ for (let i = 0; i < map.map.length; i += 1) {
416
+ const rel = map.map[i] ?? 0;
417
+ if (seen.has(rel))
418
+ continue;
419
+ seen.add(rel);
420
+ const cell = table.nodeAt(rel);
421
+ if (!cell)
422
+ continue;
423
+ const colspan = cell.attrs['colspan'] || 1;
424
+ if (spanningOnly && colspan < 2)
425
+ continue;
426
+ const col = i % map.width;
427
+ const next = colwidthForSpan(widths, col, col + colspan);
428
+ const current = cell.attrs['colwidth'];
429
+ // Incomplete knowledge (`null`) is not a reason to wipe a partial array
430
+ // stock merge left behind. Only write when every covered column is known.
431
+ if (next === null || sameColwidth(current, next))
432
+ continue;
433
+ tr.setNodeMarkup(tablePos + 1 + rel, undefined, { ...cell.attrs, colwidth: next });
434
+ }
435
+ }
436
+ function colwidthForSpan(widths, left, right) {
437
+ const slice = widths.slice(left, right);
438
+ if (slice.length === 0 || slice.some((width) => width == null))
439
+ return null;
440
+ return slice;
441
+ }
442
+ function sameColwidth(a, b) {
443
+ if (a == null && b == null)
444
+ return true;
445
+ if (a == null || b == null)
446
+ return false;
447
+ return a.length === b.length && a.every((width, i) => width === b[i]);
448
+ }
449
+ export const addColumnAfter = withColgroupColumns('after', withCellScope(withCopiedCellFormatting(addColumnAfterRaw)));
450
+ export const addColumnBefore = withColgroupColumns('before', withCellScope(withCopiedCellFormatting(addColumnBeforeRaw)));
194
451
  export const deleteColumn = withColgroupColumns('delete', deleteColumnRaw);
195
- export const addRowAfter = withCellScope(maintainTableSections(addRowAfterRaw, 'after'));
196
- export const addRowBefore = withCellScope(maintainTableSections(addRowBeforeRaw, 'before'));
452
+ export const addRowAfter = withCellScope(withCopiedCellFormatting(maintainTableSections(addRowAfterRaw, 'after')));
453
+ export const addRowBefore = withCellScope(withCopiedCellFormatting(maintainTableSections(addRowBeforeRaw, 'before')));
197
454
  export const deleteRow = withCellScope(maintainTableSections(deleteRowRaw, 'delete'));
198
455
  export const toggleHeaderRow = withCellScope(toggleHeaderRowRaw);
456
+ export const mergeCells = withCellScope(withPreservedColumnWidths(mergeCellsRaw));
457
+ export const splitCell = withCellScope(withPreservedColumnWidths(splitCellRaw));
199
458
  /**
200
459
  * Keep `headerRows` / `footerRows` attached to the rows that actually live in
201
460
  * those sections.
@@ -637,40 +896,57 @@ function setWidth(col, width) {
637
896
  export function setTableColgroup(widths) {
638
897
  return setTableAttrs({ colgroup: colgroupHtmlFromWidths(widths) });
639
898
  }
640
- function colgroupFromCellWidths(table) {
899
+ /**
900
+ * One width per column, from cells first, then the stored colgroup.
901
+ *
902
+ * The first row is not enough: merging it is exactly when its `colwidth`
903
+ * becomes `[100, 0]`, and the other rows still hold the second column's 200.
904
+ * Falling through to the colgroup covers the case where the width never lived
905
+ * on a cell at all — inherited `<col width>` furniture — so a later resize
906
+ * does not invent zeros that then blank those `<col>`s.
907
+ */
908
+ function columnWidths(table) {
641
909
  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('');
910
+ const widths = Array.from({ length: map.width }, () => null);
911
+ for (let row = 0; row < map.height; row += 1) {
912
+ let previous = -1;
913
+ let offset = 0;
914
+ for (let col = 0; col < map.width; col += 1) {
915
+ const pos = map.map[row * map.width + col] ?? 0;
916
+ if (pos === previous)
917
+ offset += 1;
918
+ else {
919
+ previous = pos;
920
+ offset = 0;
921
+ }
922
+ if (widths[col] != null)
923
+ continue;
924
+ const cell = table.nodeAt(pos);
925
+ const width = cell?.attrs['colwidth']?.[offset];
926
+ if (typeof width === 'number' && width > 0)
927
+ widths[col] = width;
668
928
  }
669
929
  }
930
+ const fromGroup = widthsFromColgroup(table.attrs['colgroup'], map.width);
931
+ for (let col = 0; col < map.width; col += 1) {
932
+ if (widths[col] != null)
933
+ continue;
934
+ const raw = fromGroup[col];
935
+ if (!raw)
936
+ continue;
937
+ const parsed = Number.parseInt(raw, 10);
938
+ if (parsed > 0)
939
+ widths[col] = parsed;
940
+ }
941
+ return widths;
942
+ }
943
+ function colgroupFromWidths(table, widths) {
944
+ if (widths.every((width) => width == null))
945
+ return null;
670
946
  // Patched onto whatever the table already stored, for the same reason the
671
947
  // properties dialog patches: a column resize must not cost an inherited
672
948
  // colgroup its class or its other attributes.
673
- return any ? colgroupHtmlWithWidths(table.attrs['colgroup'], widths) : null;
949
+ return colgroupHtmlWithWidths(table.attrs['colgroup'], widths.map((width) => (width != null ? String(width) : '')));
674
950
  }
675
951
  /**
676
952
  * What `colgroupFromCellWidths` last said about a given table node.
@@ -690,16 +966,6 @@ function colgroupFromCellWidths(table) {
690
966
  * has moved past is collectable.
691
967
  */
692
968
  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
969
  /**
704
970
  * Keep `<colgroup>` in lockstep with column resizing.
705
971
  *
@@ -724,13 +990,27 @@ export function colgroupSyncPlugin() {
724
990
  // Cached on the node: a transaction elsewhere in the document reuses
725
991
  // every table node it did not touch, so an untouched table answers from
726
992
  // 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'])
993
+ // `null` is a real answer -- "this table has no resized columns" -- so
994
+ // the miss test is `undefined`, not falsiness.
995
+ const known = colgroupForNode.get(node);
996
+ if (known !== undefined)
731
997
  return false;
732
- tr ??= state.tr;
733
- tr.setNodeMarkup(pos, undefined, { ...node.attrs, colgroup: next });
998
+ // One walk for widths, then the existing write helpers no-op when the
999
+ // cell already agrees. A separate "does this need repair?" pass was a
1000
+ // second copy of the same loop.
1001
+ const widths = columnWidths(node);
1002
+ const next = colgroupFromWidths(node, widths);
1003
+ colgroupForNode.set(node, next);
1004
+ const working = tr ?? state.tr;
1005
+ const stepsBefore = working.steps.length;
1006
+ writeColwidthOntoCells(working, pos, node, widths, true);
1007
+ applyCellScope(working, pos, false);
1008
+ const current = working.doc.nodeAt(pos) ?? node;
1009
+ if (next !== null && next !== current.attrs['colgroup']) {
1010
+ working.setNodeMarkup(pos, undefined, { ...current.attrs, colgroup: next });
1011
+ }
1012
+ if (working.steps.length > stepsBefore)
1013
+ tr = working;
734
1014
  return false;
735
1015
  });
736
1016
  return tr;