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