@openleaf-editor/core 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.
Files changed (101) hide show
  1. package/README.md +115 -0
  2. package/dist/autolink.d.ts +12 -0
  3. package/dist/autolink.d.ts.map +1 -0
  4. package/dist/autolink.js +136 -0
  5. package/dist/autolink.js.map +1 -0
  6. package/dist/command-helpers.d.ts +8 -0
  7. package/dist/command-helpers.d.ts.map +1 -0
  8. package/dist/command-helpers.js +36 -0
  9. package/dist/command-helpers.js.map +1 -0
  10. package/dist/commands.d.ts +43 -22
  11. package/dist/commands.d.ts.map +1 -1
  12. package/dist/commands.js +513 -115
  13. package/dist/commands.js.map +1 -1
  14. package/dist/css.d.ts +1 -123
  15. package/dist/css.d.ts.map +1 -1
  16. package/dist/css.js +1 -214
  17. package/dist/css.js.map +1 -1
  18. package/dist/disclosure.d.ts +21 -0
  19. package/dist/disclosure.d.ts.map +1 -0
  20. package/dist/disclosure.js +144 -0
  21. package/dist/disclosure.js.map +1 -0
  22. package/dist/elements.d.ts +2 -0
  23. package/dist/elements.d.ts.map +1 -0
  24. package/dist/elements.js +2 -0
  25. package/dist/elements.js.map +1 -0
  26. package/dist/embed.d.ts +2 -0
  27. package/dist/embed.d.ts.map +1 -0
  28. package/dist/embed.js +2 -0
  29. package/dist/embed.js.map +1 -0
  30. package/dist/errors.d.ts +38 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +40 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/extensions.d.ts +46 -5
  35. package/dist/extensions.d.ts.map +1 -1
  36. package/dist/extensions.js +292 -65
  37. package/dist/extensions.js.map +1 -1
  38. package/dist/formats.d.ts +44 -0
  39. package/dist/formats.d.ts.map +1 -0
  40. package/dist/formats.js +123 -0
  41. package/dist/formats.js.map +1 -0
  42. package/dist/html.d.ts +32 -2
  43. package/dist/html.d.ts.map +1 -1
  44. package/dist/html.js +185 -31
  45. package/dist/html.js.map +1 -1
  46. package/dist/index.d.ts +27 -6
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +44 -10
  49. package/dist/index.js.map +1 -1
  50. package/dist/insert-commands.d.ts +172 -0
  51. package/dist/insert-commands.d.ts.map +1 -0
  52. package/dist/insert-commands.js +560 -0
  53. package/dist/insert-commands.js.map +1 -0
  54. package/dist/isolating-selection.d.ts +41 -0
  55. package/dist/isolating-selection.d.ts.map +1 -0
  56. package/dist/isolating-selection.js +162 -0
  57. package/dist/isolating-selection.js.map +1 -0
  58. package/dist/keymap.d.ts.map +1 -1
  59. package/dist/keymap.js +6 -3
  60. package/dist/keymap.js.map +1 -1
  61. package/dist/noneditable.d.ts +14 -0
  62. package/dist/noneditable.d.ts.map +1 -0
  63. package/dist/noneditable.js +107 -0
  64. package/dist/noneditable.js.map +1 -0
  65. package/dist/plugins.d.ts +20 -2
  66. package/dist/plugins.d.ts.map +1 -1
  67. package/dist/plugins.js +27 -2
  68. package/dist/plugins.js.map +1 -1
  69. package/dist/preserve.d.ts +76 -0
  70. package/dist/preserve.d.ts.map +1 -1
  71. package/dist/preserve.js +308 -58
  72. package/dist/preserve.js.map +1 -1
  73. package/dist/schema.d.ts +15 -0
  74. package/dist/schema.d.ts.map +1 -1
  75. package/dist/schema.js +356 -76
  76. package/dist/schema.js.map +1 -1
  77. package/dist/structure.d.ts +76 -0
  78. package/dist/structure.d.ts.map +1 -0
  79. package/dist/structure.js +422 -0
  80. package/dist/structure.js.map +1 -0
  81. package/dist/tables.d.ts +60 -1
  82. package/dist/tables.d.ts.map +1 -1
  83. package/dist/tables.js +617 -35
  84. package/dist/tables.js.map +1 -1
  85. package/dist/testing.d.ts +3 -0
  86. package/dist/testing.d.ts.map +1 -0
  87. package/dist/testing.js +3 -0
  88. package/dist/testing.js.map +1 -0
  89. package/dist/tokens.d.ts +36 -0
  90. package/dist/tokens.d.ts.map +1 -0
  91. package/dist/tokens.js +116 -0
  92. package/dist/tokens.js.map +1 -0
  93. package/dist/url.d.ts +1 -25
  94. package/dist/url.d.ts.map +1 -1
  95. package/dist/url.js +1 -77
  96. package/dist/url.js.map +1 -1
  97. package/dist/visual-aids.d.ts +20 -0
  98. package/dist/visual-aids.d.ts.map +1 -0
  99. package/dist/visual-aids.js +149 -0
  100. package/dist/visual-aids.js.map +1 -0
  101. package/package.json +16 -1
package/dist/tables.js CHANGED
@@ -35,10 +35,156 @@
35
35
  * is what tells a screen reader which cells a header governs. Dropping it turns
36
36
  * a navigable table into a grid of unrelated values.
37
37
  */
38
+ import { DOMSerializer } from 'prosemirror-model';
39
+ import { Plugin, PluginKey } from 'prosemirror-state';
40
+ import { applyStyleAttribute, parseDeclarations, safeColor, serializeDeclarations, } from './css.js';
41
+ import { scrub, serializationTarget } from './preserve.js';
38
42
  /** Presentational attributes legacy CMS content puts on `<table>`. */
39
43
  const TABLE_LEGACY_ATTRS = ['border', 'cellpadding', 'cellspacing', 'width', 'align', 'summary', 'class'];
40
44
  /** Attributes legacy content puts on cells, plus the accessibility-critical ones. */
41
45
  const CELL_LEGACY_ATTRS = ['align', 'valign', 'width', 'height', 'class', 'scope', 'headers', 'abbr'];
46
+ const ROW_ATTRS = ['class', 'align', 'valign'];
47
+ /**
48
+ * Style properties the table schema models. Kept here rather than in
49
+ * `MODELLED_PROPERTIES` because those drive span-unwrapping; a span with
50
+ * `padding` is still an opaque atom, a cell with `padding` is a cell.
51
+ */
52
+ const TABLE_STYLE_PROPS = ['background-color', 'width', 'height'];
53
+ const ROW_STYLE_PROPS = ['background-color', 'height'];
54
+ const CELL_STYLE_PROPS = ['background-color', 'padding'];
55
+ const LENGTH = /^-?\d+(?:\.\d+)?(?:px|em|rem|%|pt|ex|ch)?$/i;
56
+ const VALIGN = new Set(['top', 'middle', 'bottom', 'baseline']);
57
+ function safeLength(value) {
58
+ const candidate = value.trim();
59
+ return LENGTH.test(candidate) ? candidate : null;
60
+ }
61
+ function safePadding(value) {
62
+ const parts = value.trim().split(/\s+/);
63
+ if (parts.length < 1 || parts.length > 4)
64
+ return null;
65
+ const safe = parts.map(safeLength);
66
+ return safe.every((part) => part !== null) ? safe.join(' ') : null;
67
+ }
68
+ function safeVAlign(value) {
69
+ if (!value)
70
+ return null;
71
+ const candidate = value.trim().toLowerCase();
72
+ return VALIGN.has(candidate) ? candidate : null;
73
+ }
74
+ /**
75
+ * The one validator for a table style declaration, on the way in or out.
76
+ *
77
+ * Exported because the property dialogs in `@openleaf-editor/plugins-table`
78
+ * write node attributes directly, which does not go through any parse rule. A
79
+ * dialog with its own idea of an acceptable padding would drift from this one,
80
+ * and `padding: 0;position:fixed;inset:0` is what that drift looks like: the
81
+ * value becomes two more declarations when the style attribute is serialized.
82
+ *
83
+ * Returns null for a property this schema does not model, so an unrecognised
84
+ * name is dropped rather than trusted.
85
+ */
86
+ export function safeTableStyleValue(property, value) {
87
+ if (!value)
88
+ return null;
89
+ if (property === 'background-color')
90
+ return safeColor(value);
91
+ if (property === 'padding')
92
+ return safePadding(value);
93
+ if (property === 'width' || property === 'height')
94
+ return safeLength(value);
95
+ return null;
96
+ }
97
+ function safeStyleValue(property, value) {
98
+ return safeTableStyleValue(property, value);
99
+ }
100
+ function readStyle(el, properties) {
101
+ const declarations = parseDeclarations(el.getAttribute('style'));
102
+ const bgcolor = safeColor(el.getAttribute('bgcolor'));
103
+ const out = new Map();
104
+ for (const name of properties) {
105
+ const safe = safeStyleValue(name, declarations.get(name));
106
+ /*
107
+ * `bgcolor` is the legacy spelling of `background-color`, read only when
108
+ * the declaration itself is absent.
109
+ *
110
+ * Resolved INSIDE the loop so that the result is in `properties` order
111
+ * whichever spelling it came from. Filling it in afterwards -- which is
112
+ * what this did -- appended the background last when it came from the
113
+ * attribute and emitted it first when it came from the declaration. A cell
114
+ * carrying both a `bgcolor` and a `padding` therefore serialized in one
115
+ * order on the first save and the other on the second: the round trip was
116
+ * not a fixed point, so the markup churned on every save, forever, with a
117
+ * real diff each time and nothing to show for it.
118
+ */
119
+ const value = safe ?? (name === 'background-color' ? bgcolor : null);
120
+ if (value)
121
+ out.set(name, value);
122
+ }
123
+ return serializeDeclarations(out);
124
+ }
125
+ /**
126
+ * Remove from a node's carried residue every declaration the node itself
127
+ * consumed, and nothing else.
128
+ *
129
+ * The counterpart to `readStyle`. Because `style` is always carried verbatim
130
+ * (see extensions.ts for why a composite attribute cannot be "claimed" by a
131
+ * spec), a cell that stored `background-color:red;border:1px solid red` holds
132
+ * the whole string in residue and the background in its own attribute. Emitting
133
+ * both would write the background twice.
134
+ *
135
+ * The test is `safeTableStyleValue(...) !== null`, not "is this property in the
136
+ * list", and the difference is the point: `readStyle` drops a value it cannot
137
+ * validate, so `width:calc(100% - 3px)` never reached the node's attribute and
138
+ * must therefore stay in the residue. Matching on the property name alone would
139
+ * delete it from both places and lose it.
140
+ *
141
+ * `bgcolor` and `vertical-align` go for the same reason one level up: both are
142
+ * folded into a modelled attribute on the way in, so leaving the original in the
143
+ * residue emits two spellings of one fact -- which is the state `<td bgcolor>`
144
+ * was actually in, coming back as `style="background-color:#f00" bgcolor="#f00"`.
145
+ * This is the trade `scrubModelledStyle` already makes for `<p align="center">`:
146
+ * stored content converges on the spelling that is still valid HTML.
147
+ */
148
+ function scrubTableStyle(properties, cell) {
149
+ return (carried) => {
150
+ const style = carried['style'];
151
+ if (style !== undefined) {
152
+ const declarations = parseDeclarations(style);
153
+ const before = declarations.size;
154
+ for (const name of properties) {
155
+ const value = declarations.get(name);
156
+ if (value !== undefined && safeTableStyleValue(name, value) !== null) {
157
+ declarations.delete(name);
158
+ }
159
+ }
160
+ if (cell) {
161
+ const valign = declarations.get('vertical-align');
162
+ if (valign !== undefined && safeVAlign(valign) !== null) {
163
+ declarations.delete('vertical-align');
164
+ }
165
+ }
166
+ // Nothing consumed means nothing to rewrite: leave the author's spelling
167
+ // alone, for the same reason `scrubModelledStyle` does.
168
+ if (declarations.size !== before) {
169
+ const rest = serializeDeclarations(declarations);
170
+ if (rest !== null)
171
+ carried['style'] = rest;
172
+ else
173
+ delete carried['style'];
174
+ }
175
+ }
176
+ const bgcolor = carried['bgcolor'];
177
+ if (bgcolor !== undefined && safeColor(bgcolor) !== null)
178
+ delete carried['bgcolor'];
179
+ };
180
+ }
181
+ /** The residue scrubs for the table node types, keyed by schema node name. */
182
+ export const CARRIED_STYLE_SCRUBS = {
183
+ table: scrubTableStyle(TABLE_STYLE_PROPS, false),
184
+ table_row: scrubTableStyle(ROW_STYLE_PROPS, false),
185
+ table_cell: scrubTableStyle(CELL_STYLE_PROPS, true),
186
+ table_header: scrubTableStyle(CELL_STYLE_PROPS, true),
187
+ };
42
188
  function readAttrs(el, names) {
43
189
  const out = {};
44
190
  for (const name of names)
@@ -56,19 +202,131 @@ function writeAttrs(attrs, names) {
56
202
  }
57
203
  const legacyDefaults = (names) => Object.fromEntries(names.map((name) => [name, { default: null }]));
58
204
  /**
59
- * Parse `colwidth` from an inline width style or attribute.
205
+ * Bounds for the cell span attributes, and why a parser has to impose them.
60
206
  *
61
- * `prosemirror-tables` stores column widths as an array of numbers on the cell
62
- * that starts the column, which is how its resizing plugin reads them.
63
- */
64
- function readColwidth(el) {
65
- const widthAttr = el.getAttribute('data-colwidth');
66
- if (widthAttr) {
67
- const parsed = widthAttr.split(',').map((n) => Number.parseInt(n, 10));
68
- if (parsed.every((n) => Number.isFinite(n)))
69
- return parsed;
207
+ * Both consumers of `colspan` scale linearly in it. `TableMap.get` allocates and
208
+ * fills `width * height` map cells, and `updateColumnsOnResize` -- installed for
209
+ * every table node view by `columnResizing()` in `plugins-table` -- appends one
210
+ * real `<col>` element per column. So a single stored `<td colspan="5000000">`
211
+ * was five million DOM elements built synchronously on first render, and a table
212
+ * asked to lay out half a billion pixels wide. That is a hung tab from a
213
+ * fifty-byte attribute, reachable through every entry point into the schema:
214
+ * `element.value`, `parseHtml`, a paste, an import, or content stored before this
215
+ * bound existed.
216
+ *
217
+ * Negative values were worse than large ones. `|| 1` catches `NaN` and `0` and
218
+ * nothing else, so `colspan="-5"` landed verbatim and `computeMap` then did
219
+ * `mapPos += colspan` with a negative operand, walking its write cursor backwards
220
+ * through the map it was filling.
221
+ *
222
+ * The numbers are HTML's own limits, so nothing an author could have written in a
223
+ * document is lost: a browser parsing the same markup clamps it identically.
224
+ *
225
+ * Clamping here rather than defending in the consumers is deliberate. The
226
+ * commands in `plugins-table`, the property dialogs, `fixTables` and the resize
227
+ * node view all read `node.attrs.colspan` directly, and every one of them would
228
+ * otherwise need a bound of its own.
229
+ */
230
+ const MAX_COLSPAN = 1000;
231
+ const MAX_ROWSPAN = 65534;
232
+ /**
233
+ * The cumulative bound, and why the per-cell one is not enough on its own.
234
+ *
235
+ * `MAX_COLSPAN` bounds one attribute. It does not bound their sum, and both
236
+ * consumers scale in the sum: a row of 5,000 `<td colspan="1000">` cells is
237
+ * about 125 KB of input and produces a five-million-column table -- measured,
238
+ * not estimated -- which is the same hung tab the per-cell clamp exists to
239
+ * prevent, reached by addition instead of by one large number.
240
+ *
241
+ * So a row gets a total as well, and each cell is clamped against what the row
242
+ * has left. A cell arriving with nothing left still claims one column rather
243
+ * than being dropped: losing a cell silently changes the document, and one
244
+ * column each is already harmless. That puts the worst case at
245
+ * `max(cells in the row, MAX_TABLE_COLUMNS)` columns -- linear in the input,
246
+ * because 5,000 cells cost 5,000 tags to write. Removing the amplification is
247
+ * the property that matters; a wide table is only ever as wide as its markup.
248
+ */
249
+ const MAX_TABLE_COLUMNS = 1000;
250
+ /**
251
+ * A column wider than this is not a layout.
252
+ *
253
+ * `updateColumnsOnResize` writes each entry straight into `col.style.width`, and
254
+ * sums them into the table's `minWidth`.
255
+ */
256
+ const MAX_COLWIDTH = 10000;
257
+ /**
258
+ * A span attribute, clamped to what HTML itself allows.
259
+ *
260
+ * `rowspan="0"` means "to the end of the section" in HTML, but
261
+ * `prosemirror-tables` requires at least 1 and the schema default is 1, so it
262
+ * normalizes up rather than being carried as a zero the cell map cannot use.
263
+ */
264
+ function readSpan(el, name, max) {
265
+ const parsed = Number.parseInt(el.getAttribute(name) ?? '1', 10);
266
+ if (!Number.isFinite(parsed) || parsed < 1)
267
+ return 1;
268
+ return Math.min(parsed, max);
269
+ }
270
+ /**
271
+ * Every cell in a row, clamped against the row's cumulative column budget.
272
+ *
273
+ * Computed once per row and memoised rather than per cell against its preceding
274
+ * siblings, which would be quadratic in exactly the row built to be wide. Keyed
275
+ * by element, so the answer does not depend on the order ProseMirror happens to
276
+ * visit the cells in, and recomputing it for the same row is a lookup.
277
+ */
278
+ const rowBudgets = new WeakMap();
279
+ /** Whether this element is a cell, so a stray child cannot spend the budget. */
280
+ function isCell(el) {
281
+ const name = el.nodeName.toLowerCase();
282
+ return name === 'td' || name === 'th';
283
+ }
284
+ function budgetedColspan(el) {
285
+ const row = el.parentElement;
286
+ if (row === null)
287
+ return readSpan(el, 'colspan', MAX_COLSPAN);
288
+ let budget = rowBudgets.get(row);
289
+ if (budget === undefined) {
290
+ budget = new Map();
291
+ let used = 0;
292
+ for (const cell of Array.from(row.children)) {
293
+ if (!isCell(cell))
294
+ continue;
295
+ const asked = readSpan(cell, 'colspan', MAX_COLSPAN);
296
+ // At least one: see the note on MAX_TABLE_COLUMNS about not dropping cells.
297
+ const granted = Math.max(1, Math.min(asked, MAX_TABLE_COLUMNS - used));
298
+ budget.set(cell, granted);
299
+ used += granted;
300
+ }
301
+ rowBudgets.set(row, budget);
70
302
  }
71
- return null;
303
+ return budget.get(el) ?? readSpan(el, 'colspan', MAX_COLSPAN);
304
+ }
305
+ /**
306
+ * Parse `colwidth` from the attribute the serializer writes.
307
+ *
308
+ * `prosemirror-tables` stores column widths as an array of numbers on the cell
309
+ * that starts the column, which is how its resizing plugin reads them -- one
310
+ * entry per column the cell covers, which is the invariant the commands in
311
+ * `plugins-table` already document and rely on.
312
+ *
313
+ * The digits-only test and the length check are `prosemirror-tables`' own rules
314
+ * for the same attribute, adopted rather than reinvented. They are stricter than
315
+ * the `Number.isFinite` test they replace in the two ways that matter: a negative
316
+ * width no longer survives to be written into `col.style.width`, and an array
317
+ * that does not match `colspan` is rejected instead of being indexed past its
318
+ * end. The ceiling is ours, for the same reason the span bounds exist.
319
+ */
320
+ function readColwidth(el, colspan) {
321
+ const attr = el.getAttribute('data-colwidth');
322
+ if (!attr || !/^\d+(,\d+)*$/.test(attr))
323
+ return null;
324
+ const parsed = attr.split(',').map((n) => Number.parseInt(n, 10));
325
+ if (parsed.length !== colspan)
326
+ return null;
327
+ if (parsed.some((n) => n < 1 || n > MAX_COLWIDTH))
328
+ return null;
329
+ return parsed;
72
330
  }
73
331
  const cellAttrs = {
74
332
  // These three names are required by prosemirror-tables.
@@ -76,15 +334,39 @@ const cellAttrs = {
76
334
  rowspan: { default: 1 },
77
335
  colwidth: { default: null },
78
336
  ...legacyDefaults(CELL_LEGACY_ATTRS),
337
+ style: { default: null },
79
338
  };
80
339
  function cellGetAttrs(dom) {
81
340
  const el = dom;
82
- return {
83
- colspan: Number.parseInt(el.getAttribute('colspan') ?? '1', 10) || 1,
84
- rowspan: Number.parseInt(el.getAttribute('rowspan') ?? '1', 10) || 1,
85
- colwidth: readColwidth(el),
341
+ // Against the row's remaining budget, not just the per-cell ceiling: the sum
342
+ // is what both consumers actually scale in. See MAX_TABLE_COLUMNS.
343
+ const colspan = budgetedColspan(el);
344
+ const attrs = {
345
+ colspan,
346
+ rowspan: readSpan(el, 'rowspan', MAX_ROWSPAN),
347
+ // Read against the clamped colspan, not the attribute: the array has to
348
+ // match the number of columns the cell actually claims.
349
+ colwidth: readColwidth(el, colspan),
86
350
  ...readAttrs(el, CELL_LEGACY_ATTRS),
351
+ style: readStyle(el, CELL_STYLE_PROPS),
87
352
  };
353
+ // Fold CSS vertical-align into the HTML attribute the commands already edit,
354
+ // so an inherited `style="vertical-align:middle"` is not a second, uneditable
355
+ // spelling of the same fact.
356
+ if (!attrs['valign']) {
357
+ const fromStyle = safeVAlign(parseDeclarations(el.getAttribute('style')).get('vertical-align'));
358
+ if (fromStyle)
359
+ attrs['valign'] = fromStyle;
360
+ }
361
+ return attrs;
362
+ }
363
+ function styledElement(tag, attrs, style) {
364
+ const el = serializationTarget().createElement(tag);
365
+ for (const [name, value] of Object.entries(attrs))
366
+ el.setAttribute(name, value);
367
+ if (style)
368
+ applyStyleAttribute(el, style);
369
+ return { dom: el, contentDOM: el };
88
370
  }
89
371
  function cellToDOM(tag) {
90
372
  return (node) => {
@@ -98,17 +380,204 @@ function cellToDOM(tag) {
98
380
  const colwidth = node.attrs['colwidth'];
99
381
  if (colwidth)
100
382
  attrs['data-colwidth'] = colwidth.join(',');
383
+ const style = node.attrs['style'];
384
+ if (style)
385
+ return styledElement(tag, attrs, style);
101
386
  return [tag, attrs, 0];
102
387
  };
103
388
  }
389
+ /**
390
+ * `<caption>` and `<colgroup>`/`<col>`: preserved as markup on the table node.
391
+ *
392
+ * These are the two parts of a table that are neither rows nor cells, and until
393
+ * now both were discarded on parse. For a caption that is not a cosmetic loss:
394
+ * a caption is a table's accessible name, so opening and saving an inherited
395
+ * document silently stripped the one element telling a screen-reader user what
396
+ * the table is. Column widths in a `<colgroup>` went the same way, taking the
397
+ * page's layout with them.
398
+ *
399
+ * They are attributes rather than child nodes, which is the compromise and is
400
+ * worth being precise about. The natural model is a `caption` node as the
401
+ * table's first child. It cannot work today: `prosemirror-tables` computes its
402
+ * cell map with `height = table.childCount` and reads `table.child(row)` as a
403
+ * row, so any non-row child shifts every coordinate the library derives, and
404
+ * cell selection, column resizing and the row/column commands all index into
405
+ * the wrong place. Storing markup on an attribute keeps the node's children
406
+ * exactly what that library requires while making the round-trip lossless.
407
+ *
408
+ * The cost is that the caption is not editable in place -- it renders, it
409
+ * survives, it cannot be typed into. That is a real limitation and it is the
410
+ * same bargain the preservation layer already strikes everywhere else: content
411
+ * kept intact and inert beats content silently deleted. Editing it needs the
412
+ * upstream indexing fix, at which point this becomes a node and the attribute
413
+ * migrates.
414
+ *
415
+ * Scrubbed on the way in with the preservation layer's own scrubber, so a
416
+ * `<caption onclick="...">` cannot ride in on a code path whose entire promise
417
+ * is to hand markup back unmodified.
418
+ */
419
+ const FURNITURE_TAGS = new Set(['caption', 'colgroup', 'col']);
420
+ /**
421
+ * Drop `contenteditable` from furniture before it is stored as a node attr.
422
+ *
423
+ * The editor stamps `contenteditable="false"` on a live caption so a caret
424
+ * cannot enter it. That marker is not author content. Clipboard HTML used to
425
+ * carry it (DOMSerializer does not run inside `serializeHtml`), parse then
426
+ * kept it via `scrub`, and from then on it was in the document permanently.
427
+ * Stripping here is how already-contaminated markup, and any future serializer
428
+ * hole, cannot persist it.
429
+ */
430
+ function dropFurnitureInertMarker(el) {
431
+ el.removeAttribute('contenteditable');
432
+ }
433
+ /** Serialized direct children of `el` matching `tags`, in document order. */
434
+ function readFurniture(el, tags) {
435
+ let html = '';
436
+ for (const child of Array.from(el.children)) {
437
+ if (!tags.includes(child.nodeName.toLowerCase()))
438
+ continue;
439
+ const clone = child.cloneNode(true);
440
+ dropFurnitureInertMarker(clone);
441
+ html += scrub(clone);
442
+ // HTML permits exactly one caption; a second is somebody else's bug and
443
+ // concatenating it would render two. Take the first and stop.
444
+ if (child.nodeName.toLowerCase() === 'caption')
445
+ break;
446
+ }
447
+ return html || null;
448
+ }
449
+ /**
450
+ * Rebuild stored furniture markup and append it to the table being built.
451
+ *
452
+ * `<template>` is the parsing context because a bare `<caption>` or `<col>` is
453
+ * illegal inside a `<div>` and would be silently discarded there -- the same
454
+ * reason the preservation layer uses one.
455
+ */
456
+ function appendFurniture(table, html, doc) {
457
+ const tpl = doc.createElement('template');
458
+ tpl.innerHTML = html;
459
+ for (const child of Array.from(tpl.content.children)) {
460
+ if (!FURNITURE_TAGS.has(child.nodeName.toLowerCase()))
461
+ continue;
462
+ // Never stamp `contenteditable` here. `toDOM` is shared by the editor, by
463
+ // `serializeHtml`, and by clipboard serialization (`DOMSerializer` is
464
+ // called with no `withSerializationDocument` wrap). Emitting the inert
465
+ // marker for "not serializeHtml" leaked it into clipboard HTML, then into
466
+ // stored content on paste. The editor applies the marker in a node view
467
+ // (`tableCaptionNodeView`) that serializers never run.
468
+ table.appendChild(child);
469
+ }
470
+ }
471
+ /**
472
+ * `<thead>` and `<tfoot>`: recorded as row COUNTS, restored on serialization.
473
+ *
474
+ * The wrappers were skipped on parse and never written back, so every save
475
+ * flattened a grouped table into one undifferentiated `<tbody>`. That is not a
476
+ * cosmetic loss. `<thead>` is what makes a header repeat at the top of each page
477
+ * when a long table is printed, it is the hook `position: sticky` header CSS and
478
+ * every `thead th` selector in a site's stylesheet attach to, and it is the
479
+ * structural cue that tells assistive technology which rows label the data.
480
+ * Opening and saving an inherited document changed how the page rendered and
481
+ * printed, in a way nothing in the editor showed.
482
+ *
483
+ * A count rather than a wrapper node, for the reason spelled out above
484
+ * `readFurniture`: `prosemirror-tables` computes its cell map with
485
+ * `height = table.childCount` and reads `table.child(row)` as a row, so a
486
+ * `thead` node between the table and its rows would shift every coordinate the
487
+ * library derives and break cell selection, column resizing and the row and
488
+ * column commands. The rows stay direct children; only the knowledge of where
489
+ * the groups were is stored beside them.
490
+ *
491
+ * Counted from the ends and only from the ends. A `<tfoot>` written before its
492
+ * `<tbody>` -- required by HTML 4, still common in old content -- leaves rows
493
+ * that are not trailing, so no footer is recorded and that table keeps today's
494
+ * behaviour. The alternative is taking the last N rows regardless and moving
495
+ * somebody's data into a `<tfoot>` it never belonged to, which is worse than the
496
+ * loss it would be fixing.
497
+ */
498
+ const tableSectionRows = new WeakMap();
499
+ /**
500
+ * Row counts for a `<table>` element this module rendered, or undefined.
501
+ *
502
+ * The serialization pass in html.ts needs to know how many of a table's rows
503
+ * came out of a `<thead>`, and `toDOM` cannot tell it in the output: ProseMirror
504
+ * allows exactly one `contentDOM`, so rows cannot flow into two sections. The
505
+ * count therefore travels beside the DOM rather than inside it.
506
+ *
507
+ * Out of band for the same reason `preservedElements` is (see preserve.ts): a
508
+ * `data-` attribute stripped after the fact cannot distinguish the attribute
509
+ * this code just added from the identical attribute in a customer's document,
510
+ * and deleting theirs is a worse bug than the one being fixed. A WeakMap cannot
511
+ * collide with content and needs no cleanup pass.
512
+ */
513
+ export function tableSectionRowCounts(el) {
514
+ return tableSectionRows.get(el);
515
+ }
516
+ /**
517
+ * Count the rows a table's own `<thead>` and `<tfoot>` contribute.
518
+ *
519
+ * Direct children only, at both levels. `querySelectorAll` would attribute a
520
+ * nested table's header to the outer table and lift the wrong rows on the way
521
+ * out -- a nested table is the case where being off by a row is silent and
522
+ * permanent.
523
+ *
524
+ * The rows are walked in the order the parser will see them, because that is the
525
+ * order they will be in on the node. A `<thead>` that is not first, or a
526
+ * `<tfoot>` that is not last, contributes nothing: the counts describe a leading
527
+ * and a trailing run, and anything else cannot be expressed as one.
528
+ */
529
+ function readSectionRows(el) {
530
+ const owners = [];
531
+ for (const child of Array.from(el.children)) {
532
+ const name = child.nodeName.toLowerCase();
533
+ if (name === 'tr') {
534
+ owners.push('tbody');
535
+ continue;
536
+ }
537
+ if (name !== 'thead' && name !== 'tbody' && name !== 'tfoot')
538
+ continue;
539
+ for (const row of Array.from(child.children)) {
540
+ if (row.nodeName.toLowerCase() === 'tr')
541
+ owners.push(name);
542
+ }
543
+ }
544
+ let header = 0;
545
+ while (header < owners.length && owners[header] === 'thead')
546
+ header += 1;
547
+ let footer = 0;
548
+ while (footer < owners.length - header && owners[owners.length - 1 - footer] === 'tfoot') {
549
+ footer += 1;
550
+ }
551
+ return { header, footer };
552
+ }
104
553
  export const table = {
105
554
  content: 'table_row+',
106
555
  tableRole: 'table',
107
556
  isolating: true,
108
557
  group: 'block',
109
- attrs: legacyDefaults(TABLE_LEGACY_ATTRS),
558
+ attrs: {
559
+ ...legacyDefaults(TABLE_LEGACY_ATTRS),
560
+ caption: { default: null },
561
+ colgroup: { default: null },
562
+ headerRows: { default: 0 },
563
+ footerRows: { default: 0 },
564
+ style: { default: null },
565
+ },
110
566
  parseDOM: [
111
- { tag: 'table', getAttrs: (dom) => readAttrs(dom, TABLE_LEGACY_ATTRS) },
567
+ {
568
+ tag: 'table',
569
+ getAttrs: (dom) => {
570
+ const sections = readSectionRows(dom);
571
+ return {
572
+ ...readAttrs(dom, TABLE_LEGACY_ATTRS),
573
+ caption: readFurniture(dom, ['caption']),
574
+ colgroup: readFurniture(dom, ['colgroup', 'col']),
575
+ headerRows: sections.header,
576
+ footerRows: sections.footer,
577
+ style: readStyle(dom, TABLE_STYLE_PROPS),
578
+ };
579
+ },
580
+ },
112
581
  /*
113
582
  * `tbody`, `thead` and `tfoot` are SKIPPED rather than ignored: the wrapper
114
583
  * itself carries nothing, but its rows are the entire content. `ignore`
@@ -117,40 +586,153 @@ export const table = {
117
586
  * These rules also have to exist because the preservation layer's catch-all
118
587
  * would otherwise claim a `<tbody>` as unrecognised markup and produce an
119
588
  * opaque atom that `table_row+` refuses to accept.
589
+ *
590
+ * Skipping loses which group each row was in, which is why `headerRows` and
591
+ * `footerRows` are counted separately in `getAttrs` and put back on the way
592
+ * out. Attributes ON the wrapper -- a `<thead class="sticky">` -- are still
593
+ * dropped; recording the grouping is the fidelity win worth having, and a
594
+ * second residue channel for an element that is not a node is not.
120
595
  */
121
596
  { tag: 'tbody', skip: true },
122
597
  { tag: 'thead', skip: true },
123
598
  { tag: 'tfoot', skip: true },
124
599
  /*
125
- * KNOWN LIMITATION, declared rather than hidden.
600
+ * `caption`, `colgroup` and `col` are ignored as CONTENT and captured as
601
+ * ATTRIBUTES instead -- see `readFurniture` for why they cannot be nodes.
126
602
  *
127
- * `<colgroup>`/`<col>` are dropped, and `<caption>` is dropped with its
128
- * text. Both should be preserved and neither can be today: a caption node
129
- * would have to be the table's first child, and `prosemirror-tables`
130
- * computes its cell map by treating every child of a table as a row, so a
131
- * leading caption breaks its indexing.
603
+ * The `ignore` rules are still load-bearing after that capture. Without
604
+ * them the preservation layer's catch-all claims a `<caption>` as an
605
+ * unrecognised block, and `table_row+` refuses to accept it, so the caption
606
+ * is dropped a second way by a different mechanism.
132
607
  *
133
- * Dropping a caption is a real accessibility regression -- a caption is a
134
- * table's accessible name -- so this is tracked as a bug to fix by adding a
135
- * caption node and contributing the indexing fix upstream, not as a design
136
- * decision. There is a fixture asserting the current behaviour so it cannot
137
- * regress further without someone noticing.
608
+ * They are scoped with `context: 'table/'`. `DOMParser.fromSchema` flattens
609
+ * every spec's parse rules into one list; a rule declared on `table` is
610
+ * otherwise global. Unscoped `ignore` also fired for an orphaned
611
+ * `<caption>` or `<col>` -- a fragment, a partial paste, a leftover after
612
+ * the table was deleted -- and `ignore` takes the element's text with it.
613
+ * Outside a table the preservation layer keeps the markup instead.
138
614
  */
139
- { tag: 'colgroup', ignore: true },
140
- { tag: 'col', ignore: true },
141
- { tag: 'caption', ignore: true },
615
+ { tag: 'colgroup', ignore: true, context: 'table/' },
616
+ { tag: 'col', ignore: true, context: 'table/' },
617
+ { tag: 'caption', ignore: true, context: 'table/' },
142
618
  ],
143
619
  toDOM(node) {
144
- return ['table', writeAttrs(node.attrs, TABLE_LEGACY_ATTRS), ['tbody', 0]];
620
+ const attrs = writeAttrs(node.attrs, TABLE_LEGACY_ATTRS);
621
+ const caption = node.attrs['caption'];
622
+ const colgroup = node.attrs['colgroup'];
623
+ const style = node.attrs['style'];
624
+ const header = node.attrs['headerRows'] || 0;
625
+ const footer = node.attrs['footerRows'] || 0;
626
+ // A table with sections has to take the element path even when it has no
627
+ // furniture: the row counts are keyed by the element, and the array path
628
+ // never produces one to key them by.
629
+ if (!caption && !colgroup && !style && !header && !footer) {
630
+ return ['table', attrs, ['tbody', 0]];
631
+ }
632
+ /*
633
+ * Furniture forces a real element rather than an output-spec array, because
634
+ * an array cannot express "these children, then the content hole inside a
635
+ * LATER child". `{ dom, contentDOM }` can, and ProseMirror accepts it from
636
+ * both the editor's node renderer and DOMSerializer.
637
+ */
638
+ const doc = serializationTarget();
639
+ const table = doc.createElement('table');
640
+ for (const [name, value] of Object.entries(attrs))
641
+ table.setAttribute(name, value);
642
+ if (style)
643
+ applyStyleAttribute(table, style);
644
+ // Document order is fixed by HTML: caption first, then colgroup, then rows.
645
+ // Emitting them in any other order produces markup browsers reshuffle, which
646
+ // would make the round-trip lossy again by a subtler route.
647
+ if (caption)
648
+ appendFurniture(table, caption, doc);
649
+ if (colgroup)
650
+ appendFurniture(table, colgroup, doc);
651
+ const tbody = doc.createElement('tbody');
652
+ table.appendChild(tbody);
653
+ /*
654
+ * Every row goes into the one `<tbody>`, including the header rows, and the
655
+ * split back into `<thead>`/`<tfoot>` happens after serialization in
656
+ * html.ts. It cannot happen here: a node has exactly one `contentDOM`, so
657
+ * there is no output spec that means "the first two rows here, the rest
658
+ * there". This is also why the editor shows a grouped table as one body --
659
+ * the grouping survives the save, it just is not visible while typing.
660
+ */
661
+ if (header || footer)
662
+ tableSectionRows.set(table, { header, footer });
663
+ return { dom: table, contentDOM: tbody };
145
664
  },
146
665
  };
666
+ /**
667
+ * Editor-only table node view: same DOM as `toDOM`, plus `contenteditable="false"`
668
+ * on a preserved caption.
669
+ *
670
+ * A caption sits inside the editable area but outside the node's `contentDOM`,
671
+ * so without the marker a caret can enter text ProseMirror will discard on its
672
+ * next redraw. Serializers (`serializeHtml`, clipboard `DOMSerializer`) never
673
+ * consult node views, so the marker cannot reach stored or clipboard HTML.
674
+ *
675
+ * `@openleaf-editor/plugins-table` replaces this with `CaptionedTableView`, which
676
+ * stamps the same attribute on the caption it rebuilds. Until that bundle loads,
677
+ * this is the view the core editor uses.
678
+ */
679
+ export function tableCaptionNodeView(node) {
680
+ const spec = node.type.spec.toDOM(node);
681
+ const rendered = Array.isArray(spec)
682
+ ? DOMSerializer.renderSpec(document, spec)
683
+ : spec;
684
+ const root = rendered.dom;
685
+ if (root instanceof Element) {
686
+ const first = root.firstElementChild;
687
+ if (first && first.nodeName === 'CAPTION')
688
+ first.setAttribute('contenteditable', 'false');
689
+ }
690
+ const view = { dom: rendered.dom };
691
+ if (rendered.contentDOM)
692
+ view.contentDOM = rendered.contentDOM;
693
+ return view;
694
+ }
695
+ const captionViewKey = new PluginKey('openleaf-table-caption-view');
696
+ /**
697
+ * Registers `tableCaptionNodeView` for editors that have not loaded
698
+ * `@openleaf-editor/plugins-table`.
699
+ *
700
+ * Must sit *before* that bundle's plugins in the editor stack.
701
+ * `EditorView.someProp` walks plugins from the end, so `columnResizing`'s
702
+ * `CaptionedTableView` still wins when both are present. Putting this view
703
+ * later is how it once shadowed column resize.
704
+ */
705
+ export function tableCaptionPlugin() {
706
+ return new Plugin({
707
+ key: captionViewKey,
708
+ props: {
709
+ nodeViews: {
710
+ table(node) {
711
+ return tableCaptionNodeView(node);
712
+ },
713
+ },
714
+ },
715
+ });
716
+ }
147
717
  export const table_row = {
148
718
  content: '(table_cell | table_header)*',
149
719
  tableRole: 'row',
150
- attrs: { class: { default: null }, align: { default: null } },
151
- parseDOM: [{ tag: 'tr', getAttrs: (dom) => readAttrs(dom, ['class', 'align']) }],
720
+ attrs: { ...legacyDefaults(ROW_ATTRS), style: { default: null } },
721
+ parseDOM: [
722
+ {
723
+ tag: 'tr',
724
+ getAttrs: (dom) => ({
725
+ ...readAttrs(dom, ROW_ATTRS),
726
+ style: readStyle(dom, ROW_STYLE_PROPS),
727
+ }),
728
+ },
729
+ ],
152
730
  toDOM(node) {
153
- return ['tr', writeAttrs(node.attrs, ['class', 'align']), 0];
731
+ const attrs = writeAttrs(node.attrs, ROW_ATTRS);
732
+ const style = node.attrs['style'];
733
+ if (style)
734
+ return styledElement('tr', attrs, style);
735
+ return ['tr', attrs, 0];
154
736
  },
155
737
  };
156
738
  export const table_cell = {