@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.
- package/README.md +115 -0
- package/dist/autolink.d.ts +12 -0
- package/dist/autolink.d.ts.map +1 -0
- package/dist/autolink.js +136 -0
- package/dist/autolink.js.map +1 -0
- package/dist/command-helpers.d.ts +8 -0
- package/dist/command-helpers.d.ts.map +1 -0
- package/dist/command-helpers.js +36 -0
- package/dist/command-helpers.js.map +1 -0
- package/dist/commands.d.ts +43 -22
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +513 -115
- package/dist/commands.js.map +1 -1
- package/dist/css.d.ts +1 -123
- package/dist/css.d.ts.map +1 -1
- package/dist/css.js +1 -214
- package/dist/css.js.map +1 -1
- package/dist/disclosure.d.ts +21 -0
- package/dist/disclosure.d.ts.map +1 -0
- package/dist/disclosure.js +144 -0
- package/dist/disclosure.js.map +1 -0
- package/dist/elements.d.ts +2 -0
- package/dist/elements.d.ts.map +1 -0
- package/dist/elements.js +2 -0
- package/dist/elements.js.map +1 -0
- package/dist/embed.d.ts +2 -0
- package/dist/embed.d.ts.map +1 -0
- package/dist/embed.js +2 -0
- package/dist/embed.js.map +1 -0
- package/dist/errors.d.ts +38 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +40 -0
- package/dist/errors.js.map +1 -0
- package/dist/extensions.d.ts +46 -5
- package/dist/extensions.d.ts.map +1 -1
- package/dist/extensions.js +292 -65
- package/dist/extensions.js.map +1 -1
- package/dist/formats.d.ts +44 -0
- package/dist/formats.d.ts.map +1 -0
- package/dist/formats.js +123 -0
- package/dist/formats.js.map +1 -0
- package/dist/html.d.ts +32 -2
- package/dist/html.d.ts.map +1 -1
- package/dist/html.js +185 -31
- package/dist/html.js.map +1 -1
- package/dist/index.d.ts +27 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -10
- package/dist/index.js.map +1 -1
- package/dist/insert-commands.d.ts +172 -0
- package/dist/insert-commands.d.ts.map +1 -0
- package/dist/insert-commands.js +560 -0
- package/dist/insert-commands.js.map +1 -0
- package/dist/isolating-selection.d.ts +41 -0
- package/dist/isolating-selection.d.ts.map +1 -0
- package/dist/isolating-selection.js +162 -0
- package/dist/isolating-selection.js.map +1 -0
- package/dist/keymap.d.ts.map +1 -1
- package/dist/keymap.js +6 -3
- package/dist/keymap.js.map +1 -1
- package/dist/noneditable.d.ts +14 -0
- package/dist/noneditable.d.ts.map +1 -0
- package/dist/noneditable.js +107 -0
- package/dist/noneditable.js.map +1 -0
- package/dist/plugins.d.ts +20 -2
- package/dist/plugins.d.ts.map +1 -1
- package/dist/plugins.js +27 -2
- package/dist/plugins.js.map +1 -1
- package/dist/preserve.d.ts +76 -0
- package/dist/preserve.d.ts.map +1 -1
- package/dist/preserve.js +308 -58
- package/dist/preserve.js.map +1 -1
- package/dist/schema.d.ts +15 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +356 -76
- package/dist/schema.js.map +1 -1
- package/dist/structure.d.ts +76 -0
- package/dist/structure.d.ts.map +1 -0
- package/dist/structure.js +422 -0
- package/dist/structure.js.map +1 -0
- package/dist/tables.d.ts +60 -1
- package/dist/tables.d.ts.map +1 -1
- package/dist/tables.js +617 -35
- package/dist/tables.js.map +1 -1
- package/dist/testing.d.ts +3 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +3 -0
- package/dist/testing.js.map +1 -0
- package/dist/tokens.d.ts +36 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +116 -0
- package/dist/tokens.js.map +1 -0
- package/dist/url.d.ts +1 -25
- package/dist/url.d.ts.map +1 -1
- package/dist/url.js +1 -77
- package/dist/url.js.map +1 -1
- package/dist/visual-aids.d.ts +20 -0
- package/dist/visual-aids.d.ts.map +1 -0
- package/dist/visual-aids.js +149 -0
- package/dist/visual-aids.js.map +1 -0
- 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
|
-
*
|
|
205
|
+
* Bounds for the cell span attributes, and why a parser has to impose them.
|
|
60
206
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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:
|
|
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
|
-
{
|
|
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
|
-
*
|
|
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
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
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
|
-
|
|
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: {
|
|
151
|
-
parseDOM: [
|
|
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
|
-
|
|
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 = {
|