@quillmark/svelte 0.27.0 → 0.28.0

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 (95) hide show
  1. package/NOTICE +4 -4
  2. package/README.md +26 -8
  3. package/THEMING.md +10 -8
  4. package/dist/core/address.d.ts +35 -64
  5. package/dist/core/address.js +35 -55
  6. package/dist/core/bloom.d.ts +4 -10
  7. package/dist/core/bloom.js +14 -27
  8. package/dist/core/codec/blocks.d.ts +41 -43
  9. package/dist/core/codec/blocks.js +97 -130
  10. package/dist/core/codec/code.d.ts +7 -10
  11. package/dist/core/codec/code.js +37 -46
  12. package/dist/core/codec/decode.d.ts +19 -23
  13. package/dist/core/codec/decode.js +76 -111
  14. package/dist/core/codec/encode.d.ts +11 -15
  15. package/dist/core/codec/encode.js +60 -67
  16. package/dist/core/codec/field.d.ts +62 -95
  17. package/dist/core/codec/field.js +107 -179
  18. package/dist/core/codec/islands.d.ts +33 -41
  19. package/dist/core/codec/islands.js +76 -86
  20. package/dist/core/codec/lists.d.ts +17 -26
  21. package/dist/core/codec/lists.js +117 -157
  22. package/dist/core/codec/marks.d.ts +4 -3
  23. package/dist/core/codec/marks.js +8 -3
  24. package/dist/core/codec/prose.css +84 -46
  25. package/dist/core/codec/schema.d.ts +18 -23
  26. package/dist/core/codec/schema.js +84 -76
  27. package/dist/core/codec/slash.d.ts +42 -48
  28. package/dist/core/codec/slash.js +0 -0
  29. package/dist/core/codec/table-copy.d.ts +4 -3
  30. package/dist/core/codec/table-copy.js +25 -7
  31. package/dist/core/codec/table-view.d.ts +12 -12
  32. package/dist/core/codec/table-view.js +460 -383
  33. package/dist/core/codec/table.d.ts +94 -61
  34. package/dist/core/codec/table.js +331 -83
  35. package/dist/core/codec/tasks.d.ts +6 -0
  36. package/dist/core/codec/tasks.js +30 -3
  37. package/dist/core/errors.d.ts +15 -30
  38. package/dist/core/errors.js +2 -6
  39. package/dist/core/index.js +7 -12
  40. package/dist/core/lifecycle.d.ts +7 -16
  41. package/dist/core/lifecycle.js +10 -21
  42. package/dist/core/rebind.svelte.d.ts +10 -20
  43. package/dist/core/rebind.svelte.js +15 -27
  44. package/dist/core/rungs.d.ts +14 -11
  45. package/dist/core/rungs.js +17 -19
  46. package/dist/core/teardown.d.ts +4 -6
  47. package/dist/core/teardown.js +7 -10
  48. package/dist/core/theme.css +74 -54
  49. package/dist/index.js +3 -8
  50. package/dist/preset/app.css +3 -1
  51. package/dist/preset/index.css +6 -8
  52. package/dist/preset/recipes.css +12 -15
  53. package/dist/preset/scale.css +18 -17
  54. package/dist/preset/shell.css +10 -0
  55. package/dist/preview/bridge.d.ts +18 -22
  56. package/dist/preview/bridge.js +29 -58
  57. package/dist/preview/controller.d.ts +21 -29
  58. package/dist/preview/controller.js +14 -39
  59. package/dist/preview/geometry.d.ts +13 -34
  60. package/dist/preview/geometry.js +13 -34
  61. package/dist/preview/index.js +4 -16
  62. package/dist/preview/paint.d.ts +3 -3
  63. package/dist/preview/paint.js +28 -68
  64. package/dist/preview/strings.d.ts +1 -1
  65. package/dist/preview/strings.js +2 -14
  66. package/dist/preview/zoom.d.ts +18 -23
  67. package/dist/preview/zoom.js +28 -27
  68. package/dist/visual/ArrayField.svelte +77 -16
  69. package/dist/visual/BooleanField.svelte +27 -14
  70. package/dist/visual/Card.svelte +61 -48
  71. package/dist/visual/CardControls.svelte +5 -11
  72. package/dist/visual/DateField.svelte +20 -4
  73. package/dist/visual/EnumField.svelte +3 -0
  74. package/dist/visual/FieldHint.svelte +2 -1
  75. package/dist/visual/FormatPopover.svelte +82 -15
  76. package/dist/visual/FormatPopover.svelte.d.ts +4 -0
  77. package/dist/visual/MatrixAdd.svelte +30 -12
  78. package/dist/visual/MatrixField.svelte +24 -16
  79. package/dist/visual/NumberField.svelte +7 -0
  80. package/dist/visual/ObjectField.svelte +4 -4
  81. package/dist/visual/ProseField.svelte +0 -5
  82. package/dist/visual/TipsCard.svelte +11 -10
  83. package/dist/visual/VariantField.svelte +3 -1
  84. package/dist/visual/VisualEditorInner.svelte +19 -3
  85. package/dist/visual/controls.css +7 -2
  86. package/dist/visual/fonts.d.ts +18 -0
  87. package/dist/visual/fonts.js +59 -0
  88. package/dist/visual/icons/nodes.d.ts +0 -5
  89. package/dist/visual/icons/nodes.js +0 -4
  90. package/dist/visual/props.d.ts +14 -1
  91. package/dist/visual/strings.d.ts +7 -0
  92. package/dist/visual/strings.js +5 -2
  93. package/dist/visual/structure.d.ts +43 -87
  94. package/dist/visual/structure.js +42 -83
  95. package/package.json +2 -2
@@ -1,4 +1,5 @@
1
1
  import { core } from '../lifecycle.js';
2
+ import { usvLength } from './decode.js';
2
3
  import { contentEdit, pmToContent } from './encode.js';
3
4
  import { valueEqual } from './reconcile.js';
4
5
  /** The alignments a column can take: the four the content declares, `none` first
@@ -9,11 +10,9 @@ export function emptyCell() {
9
10
  return { text: '', marks: [] };
10
11
  }
11
12
  /**
12
- * The row index space the chrome and the ops share: **0 is the header**, 1…n the body
13
- * rows. The split is how a table is stored, not a rank the ops answer to: a row op
14
- * works {@link allRows} and whichever row lands at index 0 is the header afterwards,
15
- * so index 0 moves and deletes like any other. The one row the model refuses is the
16
- * last, there being no table under it.
13
+ * The row index space the chrome and the ops share: **0 is the header**, 1…n the body rows. A row
14
+ * op works {@link allRows}, and whichever row lands at index 0 is the header afterwards. The one
15
+ * row the model refuses is the last, there being no table under it.
17
16
  */
18
17
  export function rowCount(props) {
19
18
  return props.rows.length + 1;
@@ -22,8 +21,10 @@ export function rowCount(props) {
22
21
  export function rowCells(props, r) {
23
22
  return r === 0 ? props.header : (props.rows[r - 1] ?? []);
24
23
  }
25
- /** Whether a row holds no text, which is what tells a row on offer from one taken up
26
- * (`table-view.ts` §`step`). Text alone: a mark over none is nothing a writer can see. */
24
+ /**
25
+ * Whether a row holds no text, telling a row on offer from one taken up (`table-view.ts` §`step`).
26
+ * Text alone: a mark over none is invisible.
27
+ */
27
28
  export function rowEmpty(props, r) {
28
29
  return rowCells(props, r).every((cell) => cell.text === '');
29
30
  }
@@ -36,43 +37,144 @@ export function columnCount(props) {
36
37
  return props.aligns.length;
37
38
  }
38
39
  /**
39
- * The rectangularizing constructor: one column count across `header`, every row,
40
- * `aligns` and `widths`, padding with empty cells, `none` and auto-fit. The column
41
- * count is the widest thing present, so a caller that appended to one axis alone still
42
- * gets a rectangle back, and a table is never narrower than one column. The layout
43
- * keys ride through, each absent at its default as the store writes it.
40
+ * The rectangularizing constructor: one column count (the widest thing present, at least one)
41
+ * across `header`, every row, `aligns` and `widths`, padding with empty cells, `none` and auto-fit.
42
+ * The layout keys ride through, each absent at its default as the store writes it.
44
43
  */
45
44
  export function normalizeTable(props) {
46
45
  const cols = Math.max(1, props.header.length, props.aligns.length, ...props.rows.map((row) => row.length));
47
46
  const fit = (cells) => Array.from({ length: cols }, (_, c) => cells[c] ?? emptyCell());
48
47
  const widths = Array.from({ length: cols }, (_, c) => props.widths?.[c] ?? null);
48
+ const [header = [], ...rows] = settleSpans([fit(props.header), ...props.rows.map(fit)], props.head);
49
49
  return {
50
- header: fit(props.header),
51
- rows: props.rows.map(fit),
50
+ header,
51
+ rows,
52
52
  aligns: Array.from({ length: cols }, (_, c) => props.aligns[c] ?? 'none'),
53
53
  ...(widths.some((w) => w !== null) && { widths }),
54
54
  ...(props.align && { align: props.align }),
55
- ...(props.headless && { headless: true })
55
+ ...(props.head !== undefined && props.head !== 1 && { head: props.head })
56
+ };
57
+ }
58
+ /** How many rows from the top draw as header rows, absent being one. */
59
+ export function headRows(props) {
60
+ return props.head ?? 1;
61
+ }
62
+ const SPAN_KEYS = ['colspan', 'rowspan'];
63
+ /** A span key as the cell holds it: a whole number from 1, else 1. */
64
+ function spanKey(cell, key) {
65
+ const n = cell[key];
66
+ return typeof n === 'number' && Number.isInteger(n) && n >= 1 ? n : 1;
67
+ }
68
+ /**
69
+ * Per row of {@link allRows} and per cell, the extent a drawn cell spans, or `null` for a slot
70
+ * another's span covers: the rule upstream draws and stores by. Cells claim slots in reading order,
71
+ * so an earlier span wins. A span takes the columns to its right, then the rows below, while each
72
+ * slot it would cover holds a cell with no text and no mark that no other span covers, stopping at
73
+ * the grid's edge and at the end of its own section (header rows or body).
74
+ */
75
+ function spansOf(rows, head) {
76
+ const top = Math.min(head, rows.length);
77
+ const covered = rows.map((row) => row.map(() => false));
78
+ const spans = rows.map((row) => row.map(() => null));
79
+ const free = (r, c) => {
80
+ const cell = rows[r]?.[c];
81
+ return !!cell && cell.text === '' && cell.marks.length === 0 && !covered[r][c];
82
+ };
83
+ for (let r = 0; r < rows.length; r++) {
84
+ const end = r < top ? top : rows.length;
85
+ for (let c = 0; c < rows[r].length; c++) {
86
+ if (covered[r][c])
87
+ continue;
88
+ const cell = rows[r][c];
89
+ let w = 1;
90
+ while (w < spanKey(cell, 'colspan') && free(r, c + w))
91
+ w++;
92
+ let h = 1;
93
+ const rowFree = (y) => Array.from({ length: w }, (_, x) => c + x).every((x) => free(y, x));
94
+ while (h < spanKey(cell, 'rowspan') && r + h < end && rowFree(r + h))
95
+ h++;
96
+ for (let y = r; y < r + h; y++)
97
+ for (let x = c; x < c + w; x++)
98
+ covered[y][x] = true;
99
+ spans[r][c] = { colspan: w, rowspan: h };
100
+ }
101
+ }
102
+ return spans;
103
+ }
104
+ /** {@link spansOf} over a table's rows, header first. */
105
+ export function spanGrid(props) {
106
+ return spansOf(allRows(props), headRows(props));
107
+ }
108
+ /**
109
+ * The drawn cell whose span covers `(r, c)`; the slot itself where nothing covers it or it lies
110
+ * outside the grid.
111
+ */
112
+ export function anchorAt(grid, r, c) {
113
+ for (let y = r; y >= 0; y--)
114
+ for (let x = c; x >= 0; x--) {
115
+ const span = grid[y]?.[x];
116
+ if (span && y + span.rowspan > r && x + span.colspan > c)
117
+ return { r: y, c: x, span };
118
+ }
119
+ return { r, c, span: { colspan: 1, rowspan: 1 } };
120
+ }
121
+ /** `cell` holding `span`, each key absent at 1. */
122
+ function withSpan(cell, span) {
123
+ const { colspan: _colspan, rowspan: _rowspan, ...rest } = cell;
124
+ return {
125
+ ...rest,
126
+ ...(span.colspan > 1 && { colspan: span.colspan }),
127
+ ...(span.rowspan > 1 && { rowspan: span.rowspan })
56
128
  };
57
129
  }
58
130
  /**
59
- * The rows as one list, header first: the space a row op works in. `header` is a
60
- * separate field because a table always has one and `header: []` is not a table — a
61
- * storage shape, not a rank a row op has to know about. Reading it as one list is what
62
- * lets move and delete treat every row alike, and {@link fromRows} splits it back.
131
+ * Each span written as the cell can cover it, absent at 1, and a covered cell's span keys dropped,
132
+ * its alignment kept: what the store settles a table to. A key holding no whole number from 1 stays
133
+ * as written and reads as 1.
134
+ */
135
+ function settleSpans(rows, head = 1) {
136
+ const spans = spansOf(rows, head);
137
+ return rows.map((row, r) => row.map((cell, c) => {
138
+ const span = spans[r][c];
139
+ if (!span)
140
+ return withSpan(cell, { colspan: 1, rowspan: 1 });
141
+ const settled = { ...cell };
142
+ for (const key of SPAN_KEYS) {
143
+ if (spanKey(cell, key) === 1 && cell[key] !== 1)
144
+ continue;
145
+ if (span[key] > 1)
146
+ settled[key] = span[key];
147
+ else
148
+ delete settled[key];
149
+ }
150
+ return settled;
151
+ }));
152
+ }
153
+ /** Every span in the grid, by its drawn cell. */
154
+ function spanList(props) {
155
+ return spanGrid(props).flatMap((row, r) => row.flatMap((span, c) => span && (span.colspan > 1 || span.rowspan > 1) ? [{ r, c, span }] : []));
156
+ }
157
+ /** `rows` with the span of the cell at `(r, c)` set to `span`. */
158
+ function respan(rows, r, c, span) {
159
+ return rows.map((row, y) => y === r ? row.map((cell, x) => (x === c ? withSpan(cell, span) : cell)) : row);
160
+ }
161
+ /**
162
+ * The rows as one list, header first: the space a row op works in, so move and delete treat every
163
+ * row alike. `header` is a separate field because a table always has one; {@link fromRows} splits
164
+ * it back.
63
165
  */
64
166
  export function allRows(props) {
65
167
  return [props.header, ...props.rows];
66
168
  }
67
- /** A flat list back into the stored shape: whatever sits at index 0 is the header, by
68
- * position rather than by having been one. No caller passes an empty list — every op
69
- * that could reach one refuses the last row first. */
169
+ /**
170
+ * A flat list back into the stored shape: whatever sits at index 0 is the header. No caller passes
171
+ * an empty list; every op that could reach one refuses the last row first.
172
+ */
70
173
  function fromRows(props, rows) {
71
174
  const [header = [], ...rest] = rows;
72
175
  return normalizeTable({ ...props, header, rows: rest });
73
176
  }
74
- /** A fresh table: a header plus `bodyRows` empty rows, every column unaligned.
75
- * Growth is cheap (Tab at the last cell appends a row), so the default is small. */
177
+ /** A fresh table: a header plus `bodyRows` empty rows, every column unaligned. */
76
178
  export function newTable(cols = 3, bodyRows = 2) {
77
179
  return normalizeTable({
78
180
  header: Array.from({ length: cols }, emptyCell),
@@ -93,28 +195,48 @@ export function withCell(props, r, c, cell) {
93
195
  export function insertRow(props, r) {
94
196
  const at = Math.max(0, Math.min(r, props.rows.length));
95
197
  const fresh = Array.from({ length: columnCount(props) }, emptyCell);
96
- return normalizeTable({
97
- ...props,
98
- rows: [...props.rows.slice(0, at), fresh, ...props.rows.slice(at)]
99
- });
198
+ let rows = allRows(props);
199
+ for (const { r: y, c, span } of spanList(props))
200
+ if (y <= at && y + span.rowspan - 1 > at)
201
+ rows = respan(rows, y, c, { ...span, rowspan: span.rowspan + 1 });
202
+ return fromRows(props, [...rows.slice(0, at + 1), fresh, ...rows.slice(at + 1)]);
100
203
  }
101
- /** Drop row `r`, the header included: the row left at index 0 is the header, which is
102
- * how a table whose first row went still has one. A no-op at the last row, that being
103
- * the removal of the table rather than of a row, and the island's own to answer. */
204
+ /**
205
+ * Drop row `r`, the header included: the row left at index 0 becomes the header. A no-op at the
206
+ * last row, which is the removal of the table and the island's to answer.
207
+ */
104
208
  export function deleteRow(props, r) {
105
- const rows = allRows(props);
209
+ let rows = allRows(props);
106
210
  if (rows.length <= 1 || r < 0 || r >= rows.length)
107
211
  return normalizeTable(props);
212
+ for (const { r: y, c, span } of spanList(props)) {
213
+ if (y > r || y + span.rowspan <= r)
214
+ continue;
215
+ const shorter = { ...span, rowspan: span.rowspan - 1 };
216
+ if (y < r)
217
+ rows = respan(rows, y, c, shorter);
218
+ else if (span.rowspan > 1) {
219
+ // The drawn cell goes with its row, so the one under it takes its text and the rest of
220
+ // the span.
221
+ const moved = withSpan(rows[y][c], shorter);
222
+ rows = rows.map((row, i) => i === y + 1 ? row.map((cell, x) => (x === c ? moved : cell)) : row);
223
+ }
224
+ }
108
225
  return fromRows(props, rows.filter((_, i) => i !== r));
109
226
  }
110
227
  /** A new empty column after column `c`. */
111
228
  export function insertColumn(props, c) {
112
229
  const at = Math.max(0, Math.min(c + 1, columnCount(props)));
113
230
  const splice = (xs, fresh) => [...xs.slice(0, at), fresh, ...xs.slice(at)];
231
+ let rows = allRows(props);
232
+ for (const { r, c: x, span } of spanList(props))
233
+ if (x < at && x + span.colspan > at)
234
+ rows = respan(rows, r, x, { ...span, colspan: span.colspan + 1 });
235
+ const [header = [], ...body] = rows;
114
236
  return normalizeTable({
115
237
  ...props,
116
- header: splice(props.header, emptyCell()),
117
- rows: props.rows.map((row) => splice(row, emptyCell())),
238
+ header: splice(header, emptyCell()),
239
+ rows: body.map((row) => splice(row, emptyCell())),
118
240
  aligns: splice(props.aligns, 'none'),
119
241
  widths: props.widths && splice(props.widths, null)
120
242
  });
@@ -124,57 +246,109 @@ export function deleteColumn(props, c) {
124
246
  if (columnCount(props) <= 1)
125
247
  return normalizeTable(props);
126
248
  const drop = (xs) => xs.filter((_, i) => i !== c);
249
+ let rows = allRows(props);
250
+ for (const { r, c: x, span } of spanList(props)) {
251
+ if (x > c || x + span.colspan <= c)
252
+ continue;
253
+ const narrower = { ...span, colspan: span.colspan - 1 };
254
+ if (x < c)
255
+ rows = respan(rows, r, x, narrower);
256
+ else if (span.colspan > 1) {
257
+ const moved = withSpan(rows[r][x], narrower);
258
+ rows = rows.map((row, i) => i === r ? row.map((cell, y) => (y === x + 1 ? moved : cell)) : row);
259
+ }
260
+ }
261
+ const [header = [], ...body] = rows;
127
262
  return normalizeTable({
128
263
  ...props,
129
- header: drop(props.header),
130
- rows: props.rows.map(drop),
264
+ header: drop(header),
265
+ rows: body.map(drop),
131
266
  aligns: drop(props.aligns),
132
267
  widths: props.widths && drop(props.widths)
133
268
  });
134
269
  }
135
- /** One element moved within an array, out of place. The whole of what both move ops
136
- * do to their axis, which is why they can only disagree about the index. */
270
+ /** `xs` read down its columns. */
271
+ function transpose(xs) {
272
+ return Array.from({ length: xs[0]?.length ?? 0 }, (_, c) => xs.map((row) => row[c]));
273
+ }
274
+ /**
275
+ * `lines` with a span released wherever moving line `from` to `to` would leave the lines it covers
276
+ * out of order or apart. Each span names its drawn cell by `line` and its place `at` along it, and
277
+ * the `length` it runs across the lines under `key`; a span whose lines keep their order and stay
278
+ * together keeps it.
279
+ */
280
+ function unspan(lines, spans, key, from, to) {
281
+ const order = move(lines.map((_, i) => i), from, to);
282
+ const place = (i) => order.indexOf(i);
283
+ let out = lines;
284
+ for (const { line, at, length } of spans) {
285
+ const together = Array.from({ length: length - 1 }, (_, k) => line + k).every((i) => place(i + 1) === place(i) + 1);
286
+ if (together)
287
+ continue;
288
+ out = out.map((cells, i) => i === line
289
+ ? cells.map((cell, x) => {
290
+ if (x !== at)
291
+ return cell;
292
+ const { [key]: _span, ...rest } = cell;
293
+ return rest;
294
+ })
295
+ : cells);
296
+ }
297
+ return out;
298
+ }
299
+ /**
300
+ * One element moved within an array, out of place: the whole of what both move ops do to their
301
+ * axis.
302
+ */
137
303
  function move(xs, from, to) {
138
304
  const next = [...xs];
139
305
  const [moved] = next.splice(from, 1);
140
306
  next.splice(to, 0, moved);
141
307
  return next;
142
308
  }
143
- /** Row `r` moved by `by` places, clamped: a drag that leaves the rectangle is a no-op
144
- * rather than an error, since the drop target is geometry and geometry runs past the
145
- * last row. The header travels like any other row, and so does the row that displaces
146
- * it: being the header is holding index 0, which a move is free to hand over. */
309
+ /**
310
+ * Row `r` moved by `by` places, clamped: a drag past the rectangle is a no-op, not an error. The
311
+ * header travels like any other row; being the header is holding index 0.
312
+ */
147
313
  export function moveRow(props, r, by) {
148
314
  const rows = allRows(props);
149
315
  const to = Math.max(0, Math.min(r + by, rows.length - 1));
150
316
  if (r === to || r < 0 || r >= rows.length)
151
317
  return normalizeTable(props);
152
- return fromRows(props, move(rows, r, to));
318
+ const spans = spanList(props).map(({ r: line, c: at, span }) => ({
319
+ line,
320
+ at,
321
+ length: span.rowspan
322
+ }));
323
+ return fromRows(props, move(unspan(rows, spans, 'rowspan', r, to), r, to));
153
324
  }
154
- /** Column `c` moved by `by` places, clamped. `aligns` and `widths` travel with the
155
- * column: each is the column's property, so a move that left it behind would retint
156
- * or resize the column that took the index. */
325
+ /**
326
+ * Column `c` moved by `by` places, clamped. `aligns` and `widths` travel with the column, being its
327
+ * properties.
328
+ */
157
329
  export function moveColumn(props, c, by) {
158
330
  const to = Math.max(0, Math.min(c + by, columnCount(props) - 1));
159
331
  if (c === to || c < 0 || c >= columnCount(props))
160
332
  return normalizeTable(props);
161
333
  const shift = (xs) => move(xs, c, to);
334
+ const spans = spanList(props).map(({ r: at, c: line, span }) => ({
335
+ line,
336
+ at,
337
+ length: span.colspan
338
+ }));
339
+ const [header = [], ...body] = transpose(unspan(transpose(allRows(props)), spans, 'colspan', c, to));
162
340
  return normalizeTable({
163
341
  ...props,
164
- header: shift(props.header),
165
- rows: props.rows.map(shift),
342
+ header: shift(header),
343
+ rows: body.map(shift),
166
344
  aligns: shift(props.aligns),
167
345
  widths: props.widths && shift(props.widths)
168
346
  });
169
347
  }
170
348
  /**
171
- * Every cell in the inclusive rectangle emptied, alignment kept: what a delete gesture
172
- * means over a block of cells that covers no whole rank. A rectangle spanning one axis
173
- * covers ranks and deletes them, and one spanning both is the table itself, so what
174
- * reaches here is a proper sub-rectangle and the shape it leaves is the shape it found.
175
- *
176
- * A cell keeps its own alignment, and a column its alignment and width: what a clear
177
- * empties is text.
349
+ * Every cell in the inclusive rectangle emptied, alignment kept (a cell its own, a column its
350
+ * alignment and width): a delete over a block covering no whole rank. A rectangle spanning one axis
351
+ * deletes ranks and one spanning both is the table, so what reaches here is a proper sub-rectangle.
178
352
  */
179
353
  export function clearCells(props, r0, c0, r1, c1) {
180
354
  const blank = (cells, r) => cells.map((cell, c) => r >= r0 && r <= r1 && c >= c0 && c <= c1 ? { ...cell, text: '', marks: [] } : cell);
@@ -184,8 +358,10 @@ export function clearCells(props, r0, c0, r1, c1) {
184
358
  rows: props.rows.map((row, i) => blank(row, i + 1))
185
359
  });
186
360
  }
187
- /** Set column `c`'s alignment and clear its cells' own `align`, which would outrank it:
188
- * what the held column's cluster and Shift+arrow write (`table-view.ts`). */
361
+ /**
362
+ * Set column `c`'s alignment and clear its cells' own `align`, which would outrank it
363
+ * (`table-view.ts`).
364
+ */
189
365
  export function setAlign(props, c, align) {
190
366
  const release = (cells) => cells.map((cell, i) => {
191
367
  if (i !== c || cell.align === undefined)
@@ -200,21 +376,96 @@ export function setAlign(props, c, align) {
200
376
  aligns: props.aligns.map((a, i) => (i === c ? align : a))
201
377
  });
202
378
  }
203
- /** Draw the first row as a header or as a body row: what the header toggle writes
204
- * (`table-view.ts`). */
205
- export function setHeadless(props, headless) {
206
- return normalizeTable({ ...props, headless: headless || undefined });
379
+ /** Draw the top `head` rows as header rows (`table-view.ts`). */
380
+ export function setHead(props, head) {
381
+ return normalizeTable({ ...props, head });
207
382
  }
208
- /** Set or clear the column weights: what a border drag and its double-click write
209
- * (`table-view.ts`). `undefined` returns every column to auto-fit. */
383
+ /** `block` grown until every drawn cell it touches lies wholly inside it. */
384
+ export function closeBlock(props, block) {
385
+ const grid = spanGrid(props);
386
+ let { r0, c0, r1, c1 } = block;
387
+ for (let grown = true; grown;) {
388
+ grown = false;
389
+ for (let r = r0; r <= r1; r++)
390
+ for (let c = c0; c <= c1; c++) {
391
+ const a = anchorAt(grid, r, c);
392
+ const next = {
393
+ r0: Math.min(r0, a.r),
394
+ c0: Math.min(c0, a.c),
395
+ r1: Math.max(r1, a.r + a.span.rowspan - 1),
396
+ c1: Math.max(c1, a.c + a.span.colspan - 1)
397
+ };
398
+ if (next.r0 !== r0 || next.c0 !== c0 || next.r1 !== r1 || next.c1 !== c1) {
399
+ ({ r0, c0, r1, c1 } = next);
400
+ grown = true;
401
+ }
402
+ }
403
+ }
404
+ return { r0, c0, r1, c1 };
405
+ }
406
+ /**
407
+ * Whether `block` can be one cell: more than one drawn cell, inside the header rows or the body.
408
+ */
409
+ export function canMerge(props, block) {
410
+ const { r0, c0, r1, c1 } = closeBlock(props, block);
411
+ const head = headRows(props);
412
+ const across = r0 < head && r1 >= head;
413
+ const { span } = anchorAt(spanGrid(props), r0, c0);
414
+ return !across && span.colspan * span.rowspan < (r1 - r0 + 1) * (c1 - c0 + 1);
415
+ }
416
+ /**
417
+ * The block's cells as one, at its top-left: the text of each drawn cell holding any, in reading
418
+ * order, a line apiece, every other slot an empty one the span covers, alignment kept. The top-left
419
+ * cell's alignment is the merged cell's. A block {@link canMerge} refuses comes back as it was.
420
+ */
421
+ export function mergeCells(props, block) {
422
+ if (!canMerge(props, block))
423
+ return normalizeTable(props);
424
+ const { r0, c0, r1, c1 } = closeBlock(props, block);
425
+ const grid = spanGrid(props);
426
+ const rows = allRows(props);
427
+ let text = '';
428
+ const marks = [];
429
+ for (let r = r0; r <= r1; r++)
430
+ for (let c = c0; c <= c1; c++) {
431
+ const cell = rows[r][c];
432
+ if (!grid[r][c] || cell.text === '')
433
+ continue;
434
+ if (text)
435
+ text += '\n';
436
+ const at = usvLength(text);
437
+ text += cell.text;
438
+ for (const m of cell.marks)
439
+ marks.push({ ...m, start: m.start + at, end: m.end + at });
440
+ }
441
+ const merged = rows.map((row, r) => row.map((cell, c) => {
442
+ if (r < r0 || r > r1 || c < c0 || c > c1)
443
+ return cell;
444
+ const layout = withSpan({ ...cell, text: '', marks: [] }, { colspan: 1, rowspan: 1 });
445
+ if (r !== r0 || c !== c0)
446
+ return layout;
447
+ return withSpan({ ...layout, text, marks }, { colspan: c1 - c0 + 1, rowspan: r1 - r0 + 1 });
448
+ }));
449
+ return fromRows(props, merged);
450
+ }
451
+ /**
452
+ * The merged cell covering `(r, c)` back to the cells under it: its text stays top-left, the rest
453
+ * are the empty cells it covered.
454
+ */
455
+ export function splitCell(props, r, c) {
456
+ const a = anchorAt(spanGrid(props), r, c);
457
+ return fromRows(props, respan(allRows(props), a.r, a.c, { colspan: 1, rowspan: 1 }));
458
+ }
459
+ /**
460
+ * Set or clear the column weights (`table-view.ts`); `undefined` returns every column to auto-fit.
461
+ */
210
462
  export function setWidths(props, widths) {
211
463
  return normalizeTable({ ...props, widths });
212
464
  }
213
465
  /**
214
- * Each column's share of the table's width, summing to 1, or `undefined` for a table
215
- * that auto-fits throughout. An auto-fit column beside weighted ones takes the mean
216
- * weight: the page sizes it to its content, which the editor's grid, laid out fixed so
217
- * the weights hold, cannot ask.
466
+ * Each column's share of the table's width, summing to 1, or `undefined` for a table that auto-fits
467
+ * throughout. An auto-fit column beside weighted ones takes the mean weight, since the editor's
468
+ * fixed-layout grid cannot size it to content as the page does.
218
469
  */
219
470
  export function columnShares(props) {
220
471
  const set = props.widths?.filter((w) => w !== null);
@@ -227,10 +478,8 @@ export function columnShares(props) {
227
478
  }
228
479
  // ── The cell codec ──────────────────────────────────────────────────────────
229
480
  /**
230
- * A cell as a one-paragraph `Content`: what `decode` takes under `cellSchema`. Each
231
- * `\n` in the text opens a `continues` line, which is the hard break it decodes to and
232
- * the one USV `pmToContent` projects a break back to. The cell's marks are that
233
- * content's marks, both being offsets into the same text.
481
+ * A cell as a one-paragraph `Content` for `decode` under `cellSchema`: each `\n` opens a
482
+ * `continues` line, the hard break it decodes to. The cell's marks are that content's marks.
234
483
  */
235
484
  export function cellContent(cell) {
236
485
  const breaks = cell.text.split('\n').length - 1;
@@ -251,14 +500,11 @@ export function cellContent(cell) {
251
500
  /**
252
501
  * The cell a nested PM doc projects, carrying `prior`'s identity anchors through.
253
502
  *
254
- * Anchors inside a cell are **preserved verbatim, never minted**: they are not in
255
- * the field plugin's coordinate space (the field's position map holds one `atom`
256
- * run for the whole table), so nothing here can mint one and the popover withholds
257
- * its `anchor` button while the caret is in a cell. Preserving them still means
258
- * rebasing: a cell edit is a splice like any other, so `mapMarks` carries each held
259
- * anchor through the cell's own delta by the rule the store applies to a field's,
260
- * and a mark the splice deleted through lands where the splice left it rather than
261
- * pointing past the text.
503
+ * Anchors inside a cell are preserved verbatim, never minted: they are not in the field plugin's
504
+ * coordinate space (its position map holds one `atom` run for the whole table), so the popover
505
+ * withholds its `anchor` button in a cell. Preserving still means rebasing: `mapMarks` carries each
506
+ * held anchor through the cell's delta by the store's rule, and a mark the splice deleted through
507
+ * lands where the splice left it.
262
508
  */
263
509
  export function cellFromDoc(doc, prior) {
264
510
  const projected = pmToContent(doc);
@@ -283,9 +529,10 @@ function cellLayout(cell) {
283
529
  const { text: _text, marks: _marks, ...layout } = cell;
284
530
  return layout;
285
531
  }
286
- /** Whether two cells are the same value: what tells an own edit (the projection the
287
- * view just produced) from an external one (an undo, a re-hydrate) without a flag.
288
- * The marks structurally, for the reason `reconcile.ts` states. */
532
+ /**
533
+ * Whether two cells are the same value, telling an own edit from an external one (an undo, a
534
+ * re-hydrate) without a flag. Marks compare structurally, as `reconcile.ts` states.
535
+ */
289
536
  export function cellEqual(a, b) {
290
537
  return a.text === b.text && valueEqual(a.marks, b.marks);
291
538
  }
@@ -295,6 +542,7 @@ export function shapeEqual(a, b) {
295
542
  return (a.rows.length === b.rows.length &&
296
543
  a.aligns.length === b.aligns.length &&
297
544
  a.aligns.every((x, i) => x === b.aligns[i]) &&
298
- !a.headless === !b.headless &&
545
+ headRows(a) === headRows(b) &&
546
+ valueEqual(spanGrid(a), spanGrid(b)) &&
299
547
  valueEqual(a.widths, b.widths));
300
548
  }
@@ -17,3 +17,9 @@ export declare function taskPlugin(): Plugin;
17
17
  * Shift-Tab lift keeps it.
18
18
  */
19
19
  export declare function splitTaskItem(itemType: NodeType): Command;
20
+ /**
21
+ * `checked` flipped on the nearest task item around the caret, or on a selected task item,
22
+ * as one transaction. It declines outside a task item and inside a fence, where `baseKeymap`'s `exitCode` is the
23
+ * nearer answer to `Mod-Enter`.
24
+ */
25
+ export declare function toggleTask(itemType: NodeType): Command;
@@ -1,10 +1,10 @@
1
1
  // A task item is a `list_item` whose `checked` is a boolean (CODEC §"Task items"). Its
2
2
  // `toDOM` draws a checkbox ahead of the item's blocks, disabled, which is the drawing a
3
3
  // held leaf, a copy and `renderContent` take; a leaf that edits mounts the item through
4
- // `taskItemView`, whose checkbox toggles. A task item is minted by source mode and by a
5
- // paste alone: no shorthand, command or key makes one.
4
+ // `taskItemView`, whose checkbox toggles, as `Mod-Enter` does (`toggleTask`). A task item
5
+ // is minted by source mode and by a paste alone: no shorthand, command or key makes one.
6
6
  import { DOMSerializer } from 'prosemirror-model';
7
- import { Plugin } from 'prosemirror-state';
7
+ import { NodeSelection, Plugin } from 'prosemirror-state';
8
8
  import { splitListItem } from 'prosemirror-schema-list';
9
9
  const SVG = 'http://www.w3.org/2000/svg';
10
10
  /** The id the checkbox names its item's blocks by, fresh per drawing. */
@@ -138,3 +138,30 @@ export function splitTaskItem(itemType) {
138
138
  });
139
139
  };
140
140
  }
141
+ /**
142
+ * `checked` flipped on the nearest task item around the caret, or on a selected task item,
143
+ * as one transaction. It declines outside a task item and inside a fence, where `baseKeymap`'s `exitCode` is the
144
+ * nearer answer to `Mod-Enter`.
145
+ */
146
+ export function toggleTask(itemType) {
147
+ return (state, dispatch) => {
148
+ const { selection } = state;
149
+ const { $from } = selection;
150
+ if (selection instanceof NodeSelection &&
151
+ selection.node.type === itemType &&
152
+ isTask(selection.node)) {
153
+ dispatch?.(state.tr.setNodeAttribute(selection.from, 'checked', !selection.node.attrs.checked));
154
+ return true;
155
+ }
156
+ if ($from.parent.type.spec.code)
157
+ return false;
158
+ for (let d = $from.depth; d > 0; d--) {
159
+ const node = $from.node(d);
160
+ if (node.type !== itemType || !isTask(node))
161
+ continue;
162
+ dispatch?.(state.tr.setNodeAttribute($from.before(d), 'checked', !node.attrs.checked));
163
+ return true;
164
+ }
165
+ return false;
166
+ };
167
+ }