@quillmark/svelte 0.18.0 → 0.20.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 (56) hide show
  1. package/NOTICE +4 -2
  2. package/dist/core/codec/blocks.d.ts +11 -2
  3. package/dist/core/codec/blocks.js +45 -14
  4. package/dist/core/codec/breaks.d.ts +3 -2
  5. package/dist/core/codec/breaks.js +3 -2
  6. package/dist/core/codec/decode.d.ts +7 -3
  7. package/dist/core/codec/decode.js +34 -8
  8. package/dist/core/codec/field.d.ts +11 -5
  9. package/dist/core/codec/field.js +11 -8
  10. package/dist/core/codec/index.d.ts +1 -1
  11. package/dist/core/codec/index.js +1 -1
  12. package/dist/core/codec/islands.d.ts +3 -1
  13. package/dist/core/codec/islands.js +8 -5
  14. package/dist/core/codec/markdown-paste.d.ts +9 -0
  15. package/dist/core/codec/markdown-paste.js +49 -0
  16. package/dist/core/codec/prose.css +109 -1
  17. package/dist/core/codec/schema.d.ts +8 -2
  18. package/dist/core/codec/schema.js +16 -5
  19. package/dist/core/codec/slash.d.ts +9 -6
  20. package/dist/core/codec/slash.js +0 -0
  21. package/dist/core/codec/table-paste.d.ts +7 -0
  22. package/dist/core/codec/table-paste.js +132 -0
  23. package/dist/core/codec/table-view.d.ts +12 -0
  24. package/dist/core/codec/table-view.js +401 -57
  25. package/dist/core/codec/table.d.ts +38 -16
  26. package/dist/core/codec/table.js +106 -35
  27. package/dist/core/theme.css +1 -1
  28. package/dist/visual/ArrayField.svelte +17 -6
  29. package/dist/visual/ArrayField.svelte.d.ts +1 -1
  30. package/dist/visual/Field.svelte +9 -3
  31. package/dist/visual/Field.svelte.d.ts +1 -1
  32. package/dist/visual/FieldHint.svelte +62 -10
  33. package/dist/visual/FormatPopover.svelte +27 -31
  34. package/dist/visual/FormatPopover.svelte.d.ts +2 -2
  35. package/dist/visual/MatrixAdd.svelte +13 -132
  36. package/dist/visual/MatrixAdd.svelte.d.ts +2 -5
  37. package/dist/visual/MatrixField.svelte +5 -63
  38. package/dist/visual/MatrixField.svelte.d.ts +0 -3
  39. package/dist/visual/ObjectField.svelte +8 -4
  40. package/dist/visual/ObjectField.svelte.d.ts +4 -2
  41. package/dist/visual/ProseField.svelte +2 -1
  42. package/dist/visual/ProseValue.svelte +113 -40
  43. package/dist/visual/ProseValue.svelte.d.ts +8 -4
  44. package/dist/visual/VisualEditorInner.svelte +22 -22
  45. package/dist/visual/VisualEditorInner.svelte.d.ts +2 -2
  46. package/dist/visual/checklists.d.ts +0 -79
  47. package/dist/visual/checklists.js +0 -164
  48. package/dist/visual/guidance.d.ts +15 -0
  49. package/dist/visual/guidance.js +37 -0
  50. package/dist/visual/leaves.d.ts +27 -1
  51. package/dist/visual/leaves.js +46 -0
  52. package/dist/visual/strings.d.ts +3 -8
  53. package/dist/visual/strings.js +0 -3
  54. package/dist/visual/structure.js +1 -1
  55. package/dist/visual/tips.js +1 -1
  56. package/package.json +2 -2
@@ -25,10 +25,11 @@ export declare function cellAt(props: TableProps, r: number, c: number): TableCe
25
25
  /** The table's column count: the one number `header`, every row and `aligns` share. */
26
26
  export declare function columnCount(props: TableProps): number;
27
27
  /**
28
- * The rectangularizing constructor: one column count across `header`, every row and
29
- * `aligns`, padding with empty cells and `none`. The column count is the widest
30
- * thing present, so a caller that appended to one axis alone still gets a rectangle
31
- * back, and a table is never narrower than one column.
28
+ * The rectangularizing constructor: one column count across `header`, every row,
29
+ * `aligns` and `widths`, padding with empty cells, `none` and auto-fit. The column
30
+ * count is the widest thing present, so a caller that appended to one axis alone still
31
+ * gets a rectangle back, and a table is never narrower than one column. The layout
32
+ * keys ride through, each absent at its default as the store writes it.
32
33
  */
33
34
  export declare function normalizeTable(props: TableProps): TableProps;
34
35
  /**
@@ -58,9 +59,9 @@ export declare function deleteColumn(props: TableProps, c: number): TableProps;
58
59
  * last row. The header travels like any other row, and so does the row that displaces
59
60
  * it: being the header is holding index 0, which a move is free to hand over. */
60
61
  export declare function moveRow(props: TableProps, r: number, by: number): TableProps;
61
- /** Column `c` moved by `by` places, clamped. `aligns` travels with the column: an
62
- * alignment is the column's property, so a move that left it behind would retint the
63
- * column that took the index. */
62
+ /** Column `c` moved by `by` places, clamped. `aligns` and `widths` travel with the
63
+ * column: each is the column's property, so a move that left it behind would retint
64
+ * or resize the column that took the index. */
64
65
  export declare function moveColumn(props: TableProps, c: number, by: number): TableProps;
65
66
  /**
66
67
  * Every cell in the inclusive rectangle emptied, alignment kept: what a delete gesture
@@ -68,17 +69,38 @@ export declare function moveColumn(props: TableProps, c: number, by: number): Ta
68
69
  * covers ranks and deletes them, and one spanning both is the table itself, so what
69
70
  * reaches here is a proper sub-rectangle and the shape it leaves is the shape it found.
70
71
  *
71
- * Alignment is the column's rather than the cells', so it survives a clear that spans one.
72
+ * A cell keeps its own alignment, and a column its alignment and width: what a clear
73
+ * empties is text.
72
74
  */
73
75
  export declare function clearCells(props: TableProps, r0: number, c0: number, r1: number, c1: number): TableProps;
74
- /** Set column `c`'s alignment: the one table capability the content round-trips
75
- * today and nothing in the editor could reach. */
76
+ /** Set column `c`'s alignment: what the held column's cluster and Shift+arrow write
77
+ * (`table-view.ts`). */
76
78
  export declare function setAlign(props: TableProps, c: number, align: TableAlign): TableProps;
79
+ /** Draw the first row as a header or as a body row: what the header toggle writes
80
+ * (`table-view.ts`). */
81
+ export declare function setHeadless(props: TableProps, headless: boolean): TableProps;
82
+ /** A cell's horizontal alignment, and its vertical one: the keys a cell holds beside
83
+ * its text, each absent at the default the column or the quill supplies. */
84
+ export type CellAlign = NonNullable<TableCell['align']>;
85
+ export type CellValign = NonNullable<TableCell['valign']>;
86
+ /** Every cell in the inclusive rectangle given `key`'s `value`, or cleared of it where
87
+ * `value` is absent, which returns the cell to its column's alignment or the quill's. */
88
+ export declare function setCellLayout<K extends 'align' | 'valign'>(props: TableProps, r0: number, c0: number, r1: number, c1: number, key: K, value: TableCell[K] | undefined): TableProps;
89
+ /** Set or clear the column weights: what a border drag and its double-click write
90
+ * (`table-view.ts`). `undefined` returns every column to auto-fit. */
91
+ export declare function setWidths(props: TableProps, widths: (number | null)[] | undefined): TableProps;
77
92
  /**
78
- * A cell as a one-line `Content`: what `decode` takes under the inline schema. The
79
- * cell's marks are that content's marks, because both are offsets into the same
80
- * text: the cell-local coordinate space is a `Content`'s coordinate space with
81
- * one line in it.
93
+ * Each column's share of the table's width, summing to 1, or `undefined` for a table
94
+ * that auto-fits throughout. An auto-fit column beside weighted ones takes the mean
95
+ * weight: the page sizes it to its content, which the editor's grid, laid out fixed so
96
+ * the weights hold, cannot ask.
97
+ */
98
+ export declare function columnShares(props: TableProps): number[] | undefined;
99
+ /**
100
+ * A cell as a one-paragraph `Content`: what `decode` takes under `cellSchema`. Each
101
+ * `\n` in the text opens a `continues` line, which is the hard break it decodes to and
102
+ * the one USV `pmToContent` projects a break back to. The cell's marks are that
103
+ * content's marks, both being offsets into the same text.
82
104
  */
83
105
  export declare function cellContent(cell: TableCell): Content;
84
106
  /**
@@ -98,6 +120,6 @@ export declare function cellFromDoc(doc: PMNode, prior: TableCell): TableCell;
98
120
  * view just produced) from an external one (an undo, a re-hydrate) without a flag.
99
121
  * The marks structurally, for the reason `reconcile.ts` states. */
100
122
  export declare function cellEqual(a: TableCell, b: TableCell): boolean;
101
- /** Whether two tables have the same rectangle and alignment: the change that forces
102
- * a rebuild rather than a per-cell reseed. */
123
+ /** Whether two tables have the same rectangle, alignment, header and widths: the
124
+ * change that forces a rebuild rather than a per-cell reseed. */
103
125
  export declare function shapeEqual(a: TableProps, b: TableProps): boolean;
@@ -36,18 +36,23 @@ export function columnCount(props) {
36
36
  return props.aligns.length;
37
37
  }
38
38
  /**
39
- * The rectangularizing constructor: one column count across `header`, every row and
40
- * `aligns`, padding with empty cells and `none`. The column count is the widest
41
- * thing present, so a caller that appended to one axis alone still gets a rectangle
42
- * back, and a table is never narrower than one column.
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.
43
44
  */
44
45
  export function normalizeTable(props) {
45
46
  const cols = Math.max(1, props.header.length, props.aligns.length, ...props.rows.map((row) => row.length));
46
47
  const fit = (cells) => Array.from({ length: cols }, (_, c) => cells[c] ?? emptyCell());
48
+ const widths = Array.from({ length: cols }, (_, c) => props.widths?.[c] ?? null);
47
49
  return {
48
50
  header: fit(props.header),
49
51
  rows: props.rows.map(fit),
50
- aligns: Array.from({ length: cols }, (_, c) => props.aligns[c] ?? 'none')
52
+ aligns: Array.from({ length: cols }, (_, c) => props.aligns[c] ?? 'none'),
53
+ ...(widths.some((w) => w !== null) && { widths }),
54
+ ...(props.align && { align: props.align }),
55
+ ...(props.headless && { headless: true })
51
56
  };
52
57
  }
53
58
  /**
@@ -62,9 +67,9 @@ export function allRows(props) {
62
67
  /** A flat list back into the stored shape: whatever sits at index 0 is the header, by
63
68
  * position rather than by having been one. No caller passes an empty list — every op
64
69
  * that could reach one refuses the last row first. */
65
- function fromRows(rows, aligns) {
70
+ function fromRows(props, rows) {
66
71
  const [header = [], ...rest] = rows;
67
- return normalizeTable({ header, rows: rest, aligns });
72
+ return normalizeTable({ ...props, header, rows: rest });
68
73
  }
69
74
  /** A fresh table: a header plus `bodyRows` empty rows, every column unaligned.
70
75
  * Growth is cheap (Tab at the last cell appends a row), so the default is small. */
@@ -79,9 +84,9 @@ export function newTable(cols = 3, bodyRows = 2) {
79
84
  export function withCell(props, r, c, cell) {
80
85
  const replace = (cells) => cells.map((old, i) => (i === c ? cell : old));
81
86
  return normalizeTable({
87
+ ...props,
82
88
  header: r === 0 ? replace(props.header) : props.header,
83
- rows: props.rows.map((row, i) => (i === r - 1 ? replace(row) : row)),
84
- aligns: props.aligns
89
+ rows: props.rows.map((row, i) => (i === r - 1 ? replace(row) : row))
85
90
  });
86
91
  }
87
92
  /** A new empty body row below row `r` (row 0, the header, opens the first body row). */
@@ -100,16 +105,18 @@ export function deleteRow(props, r) {
100
105
  const rows = allRows(props);
101
106
  if (rows.length <= 1 || r < 0 || r >= rows.length)
102
107
  return normalizeTable(props);
103
- return fromRows(rows.filter((_, i) => i !== r), props.aligns);
108
+ return fromRows(props, rows.filter((_, i) => i !== r));
104
109
  }
105
110
  /** A new empty column after column `c`. */
106
111
  export function insertColumn(props, c) {
107
112
  const at = Math.max(0, Math.min(c + 1, columnCount(props)));
108
- const splice = (cells) => [...cells.slice(0, at), emptyCell(), ...cells.slice(at)];
113
+ const splice = (xs, fresh) => [...xs.slice(0, at), fresh, ...xs.slice(at)];
109
114
  return normalizeTable({
110
- header: splice(props.header),
111
- rows: props.rows.map(splice),
112
- aligns: [...props.aligns.slice(0, at), 'none', ...props.aligns.slice(at)]
115
+ ...props,
116
+ header: splice(props.header, emptyCell()),
117
+ rows: props.rows.map((row) => splice(row, emptyCell())),
118
+ aligns: splice(props.aligns, 'none'),
119
+ widths: props.widths && splice(props.widths, null)
113
120
  });
114
121
  }
115
122
  /** Drop column `c`; a no-op at one column (a table has at least one). */
@@ -118,9 +125,11 @@ export function deleteColumn(props, c) {
118
125
  return normalizeTable(props);
119
126
  const drop = (xs) => xs.filter((_, i) => i !== c);
120
127
  return normalizeTable({
128
+ ...props,
121
129
  header: drop(props.header),
122
130
  rows: props.rows.map(drop),
123
- aligns: drop(props.aligns)
131
+ aligns: drop(props.aligns),
132
+ widths: props.widths && drop(props.widths)
124
133
  });
125
134
  }
126
135
  /** One element moved within an array, out of place. The whole of what both move ops
@@ -140,20 +149,22 @@ export function moveRow(props, r, by) {
140
149
  const to = Math.max(0, Math.min(r + by, rows.length - 1));
141
150
  if (r === to || r < 0 || r >= rows.length)
142
151
  return normalizeTable(props);
143
- return fromRows(move(rows, r, to), props.aligns);
152
+ return fromRows(props, move(rows, r, to));
144
153
  }
145
- /** Column `c` moved by `by` places, clamped. `aligns` travels with the column: an
146
- * alignment is the column's property, so a move that left it behind would retint the
147
- * column that took the index. */
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. */
148
157
  export function moveColumn(props, c, by) {
149
158
  const to = Math.max(0, Math.min(c + by, columnCount(props) - 1));
150
159
  if (c === to || c < 0 || c >= columnCount(props))
151
160
  return normalizeTable(props);
152
161
  const shift = (xs) => move(xs, c, to);
153
162
  return normalizeTable({
163
+ ...props,
154
164
  header: shift(props.header),
155
165
  rows: props.rows.map(shift),
156
- aligns: shift(props.aligns)
166
+ aligns: shift(props.aligns),
167
+ widths: props.widths && shift(props.widths)
157
168
  });
158
169
  }
159
170
  /**
@@ -162,35 +173,86 @@ export function moveColumn(props, c, by) {
162
173
  * covers ranks and deletes them, and one spanning both is the table itself, so what
163
174
  * reaches here is a proper sub-rectangle and the shape it leaves is the shape it found.
164
175
  *
165
- * Alignment is the column's rather than the cells', so it survives a clear that spans one.
176
+ * A cell keeps its own alignment, and a column its alignment and width: what a clear
177
+ * empties is text.
166
178
  */
167
179
  export function clearCells(props, r0, c0, r1, c1) {
168
- const blank = (cells, r) => cells.map((cell, c) => (r >= r0 && r <= r1 && c >= c0 && c <= c1 ? emptyCell() : cell));
180
+ const blank = (cells, r) => cells.map((cell, c) => r >= r0 && r <= r1 && c >= c0 && c <= c1 ? { ...cell, text: '', marks: [] } : cell);
169
181
  return normalizeTable({
182
+ ...props,
170
183
  header: blank(props.header, 0),
171
- rows: props.rows.map((row, i) => blank(row, i + 1)),
172
- aligns: props.aligns
184
+ rows: props.rows.map((row, i) => blank(row, i + 1))
173
185
  });
174
186
  }
175
- /** Set column `c`'s alignment: the one table capability the content round-trips
176
- * today and nothing in the editor could reach. */
187
+ /** Set column `c`'s alignment: what the held column's cluster and Shift+arrow write
188
+ * (`table-view.ts`). */
177
189
  export function setAlign(props, c, align) {
178
190
  return normalizeTable({
179
191
  ...props,
180
192
  aligns: props.aligns.map((a, i) => (i === c ? align : a))
181
193
  });
182
194
  }
195
+ /** Draw the first row as a header or as a body row: what the header toggle writes
196
+ * (`table-view.ts`). */
197
+ export function setHeadless(props, headless) {
198
+ return normalizeTable({ ...props, headless: headless || undefined });
199
+ }
200
+ /** Every cell in the inclusive rectangle given `key`'s `value`, or cleared of it where
201
+ * `value` is absent, which returns the cell to its column's alignment or the quill's. */
202
+ export function setCellLayout(props, r0, c0, r1, c1, key, value) {
203
+ const lay = (cells, r) => cells.map((cell, c) => {
204
+ if (r < r0 || r > r1 || c < c0 || c > c1)
205
+ return cell;
206
+ const next = { ...cell, [key]: value };
207
+ if (value === undefined)
208
+ delete next[key];
209
+ return next;
210
+ });
211
+ return normalizeTable({
212
+ ...props,
213
+ header: lay(props.header, 0),
214
+ rows: props.rows.map((row, i) => lay(row, i + 1))
215
+ });
216
+ }
217
+ /** Set or clear the column weights: what a border drag and its double-click write
218
+ * (`table-view.ts`). `undefined` returns every column to auto-fit. */
219
+ export function setWidths(props, widths) {
220
+ return normalizeTable({ ...props, widths });
221
+ }
222
+ /**
223
+ * Each column's share of the table's width, summing to 1, or `undefined` for a table
224
+ * that auto-fits throughout. An auto-fit column beside weighted ones takes the mean
225
+ * weight: the page sizes it to its content, which the editor's grid, laid out fixed so
226
+ * the weights hold, cannot ask.
227
+ */
228
+ export function columnShares(props) {
229
+ const set = props.widths?.filter((w) => w !== null);
230
+ if (!props.widths || !set?.length)
231
+ return undefined;
232
+ const mean = set.reduce((a, b) => a + b, 0) / set.length;
233
+ const weights = props.widths.map((w) => w ?? mean);
234
+ const total = weights.reduce((a, b) => a + b, 0);
235
+ return weights.map((w) => w / total);
236
+ }
183
237
  // ── The cell codec ──────────────────────────────────────────────────────────
184
238
  /**
185
- * A cell as a one-line `Content`: what `decode` takes under the inline schema. The
186
- * cell's marks are that content's marks, because both are offsets into the same
187
- * text: the cell-local coordinate space is a `Content`'s coordinate space with
188
- * one line in it.
239
+ * A cell as a one-paragraph `Content`: what `decode` takes under `cellSchema`. Each
240
+ * `\n` in the text opens a `continues` line, which is the hard break it decodes to and
241
+ * the one USV `pmToContent` projects a break back to. The cell's marks are that
242
+ * content's marks, both being offsets into the same text.
189
243
  */
190
244
  export function cellContent(cell) {
245
+ const breaks = cell.text.split('\n').length - 1;
191
246
  return {
192
247
  text: cell.text,
193
- lines: [{ containers: [], kind: 'para' }],
248
+ lines: [
249
+ { containers: [], kind: 'para' },
250
+ ...Array.from({ length: breaks }, () => ({
251
+ containers: [],
252
+ kind: 'para',
253
+ continues: true
254
+ }))
255
+ ],
194
256
  marks: cell.marks,
195
257
  islands: []
196
258
  };
@@ -210,29 +272,38 @@ export function cellContent(cell) {
210
272
  export function cellFromDoc(doc, prior) {
211
273
  const projected = pmToContent(doc);
212
274
  const text = projected.text;
275
+ const layout = cellLayout(prior);
213
276
  const anchors = prior.marks.filter((m) => m.type === 'anchor');
214
277
  if (!anchors.length)
215
- return { text, marks: projected.marks };
278
+ return { ...layout, text, marks: projected.marks };
216
279
  const before = cellContent(prior);
217
280
  const delta = contentEdit(before, cellContent({ text, marks: [] })).delta;
218
281
  const rebased = core()
219
282
  .mapMarks(before, delta ? { delta } : {})
220
283
  .filter((m) => m.type === 'anchor');
221
284
  return {
285
+ ...layout,
222
286
  text,
223
287
  marks: [...projected.marks, ...rebased].sort((a, b) => a.start - b.start || a.end - b.end)
224
288
  };
225
289
  }
290
+ /** A cell's keys beside its text and marks: what no edit of the text touches. */
291
+ function cellLayout(cell) {
292
+ const { text: _text, marks: _marks, ...layout } = cell;
293
+ return layout;
294
+ }
226
295
  /** Whether two cells are the same value: what tells an own edit (the projection the
227
296
  * view just produced) from an external one (an undo, a re-hydrate) without a flag.
228
297
  * The marks structurally, for the reason `reconcile.ts` states. */
229
298
  export function cellEqual(a, b) {
230
299
  return a.text === b.text && valueEqual(a.marks, b.marks);
231
300
  }
232
- /** Whether two tables have the same rectangle and alignment: the change that forces
233
- * a rebuild rather than a per-cell reseed. */
301
+ /** Whether two tables have the same rectangle, alignment, header and widths: the
302
+ * change that forces a rebuild rather than a per-cell reseed. */
234
303
  export function shapeEqual(a, b) {
235
304
  return (a.rows.length === b.rows.length &&
236
305
  a.aligns.length === b.aligns.length &&
237
- a.aligns.every((x, i) => x === b.aligns[i]));
306
+ a.aligns.every((x, i) => x === b.aligns[i]) &&
307
+ !a.headless === !b.headless &&
308
+ valueEqual(a.widths, b.widths));
238
309
  }
@@ -235,7 +235,7 @@
235
235
  Offset-free is the whole of it: an offset states where the light is and no pole
236
236
  carries that, where a halo states occlusion. Toward black at both poles for the
237
237
  same reason — a crevice is darker than what surrounds it in a dark room too — so
238
- the cue means one thing whichever way the scale runs. Spent only on what portals,
238
+ the cue means one thing whichever way the scale runs. Spent only on what floats,
239
239
  and the one shadow any surface casts, which is review's to hold. */
240
240
  --_qm-lift: 0 0 12px color-mix(in oklab, #000 16%, transparent);
241
241
  /* The recede ladder: a hover-revealed or disabled affordance at rest, and a
@@ -76,6 +76,7 @@
76
76
  type ArrayLayout
77
77
  } from './structure.js';
78
78
  import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
79
+ import { propertyDomIds } from './domid.js';
79
80
  import type { LandingBox } from './leaves.js';
80
81
  import { reorder, reorderArm } from './motion.js';
81
82
  import Icon from './icons/Icon.svelte';
@@ -91,7 +92,7 @@
91
92
  const CHEVRON = 16;
92
93
 
93
94
  interface Props {
94
- value: unknown[] | undefined;
95
+ value: unknown;
95
96
  /** The declared `default:`: the rows an unset array draws, which a gesture takes. */
96
97
  fallback?: unknown[];
97
98
  items: QuillFieldSchema | undefined;
@@ -156,11 +157,18 @@
156
157
  const table = $derived(layout === 'table' && control === 'object');
157
158
  const columns = $derived(Object.entries(items?.properties ?? {}));
158
159
  const defaulted = $derived(value == null && (fallback?.length ?? 0) > 0);
159
- const arr = $derived((value ?? fallback ?? []) as unknown[]);
160
- /** The rows' content read: the boundary's, or, while the rows are the default, the
161
- * literal's own leaf at the codec its declared type names. */
160
+ // A bare value is the one-element list the render floor wraps it into (canon
161
+ // `SCHEMAS.md` §"Type coercion"), and the reader answers no element of it.
162
+ const bare = $derived(value != null && !Array.isArray(value));
163
+ const arr = $derived(rowsOf(value));
164
+ function rowsOf(v: unknown): unknown[] {
165
+ if (v == null) return fallback ?? [];
166
+ return Array.isArray(v) ? v : [v];
167
+ }
168
+ /** The rows' content read: the boundary's, or, while the rows are the default or a
169
+ * bare value, the literal's own leaf at the codec its declared type names. */
162
170
  function readAt(path: PathStep[]): Content | undefined {
163
- if (!defaulted) return contentAt(path);
171
+ if (!defaulted && !bare) return contentAt(path);
164
172
  const leaf = schemaAt({ type: 'array', items }, path);
165
173
  let v: unknown = arr;
166
174
  for (const step of path) v = (v as Record<string | number, unknown> | undefined)?.[step];
@@ -177,7 +185,7 @@
177
185
  // an effect-only seed mounts every element editor in a second render.
178
186
  const seq = new IdSeq();
179
187
  // svelte-ignore state_referenced_locally
180
- let ids = $state<string[]>(seq.take((value ?? fallback ?? []).length));
188
+ let ids = $state<string[]>(seq.take(rowsOf(value).length));
181
189
  // Length reconcile (defend against an out-of-band length change);
182
190
  // order is maintained by the mutators, not here.
183
191
  $effect(() => {
@@ -593,6 +601,7 @@
593
601
  >
594
602
  <FieldLabel
595
603
  label={columnTitle(key, sub)}
604
+ descriptionId={idBase != null ? propertyDomIds(idBase, key).description : undefined}
596
605
  required={obliged(sub)}
597
606
  description={sub.description}
598
607
  />
@@ -605,6 +614,7 @@
605
614
  <ObjectField
606
615
  bare
607
616
  bind:this={cellEls[id]}
617
+ {idBase}
608
618
  value={(arr[k] ?? {}) as Record<string, unknown>}
609
619
  properties={items?.properties}
610
620
  label={rowName(k)}
@@ -690,6 +700,7 @@
690
700
  <ProseValue
691
701
  bind:this={els[id]}
692
702
  content={() => readAt([k])}
703
+ value={arr[k]}
693
704
  plaintext={items != null && baseType(items) === 'plaintext'}
694
705
  label={rowName(k)}
695
706
  onChange={(rt) => commitElement(k, rt)}
@@ -3,7 +3,7 @@ import { type ArrayLayout } from './structure.js';
3
3
  import { type DeepDiagnostic } from './diagnostics.js';
4
4
  import type { LandingBox } from './leaves.js';
5
5
  interface Props {
6
- value: unknown[] | undefined;
6
+ value: unknown;
7
7
  /** The declared `default:`: the rows an unset array draws, which a gesture takes. */
8
8
  fallback?: unknown[];
9
9
  items: QuillFieldSchema | undefined;
@@ -27,7 +27,7 @@
27
27
  ResolvedField
28
28
  } from '@quillmark/wasm';
29
29
  import type { EditorErrorHandler } from '../core/errors.js';
30
- import type { LandingBox, LeafRegistry } from './leaves.js';
30
+ import { seatValueLeaves, type LandingBox, type LeafRegistry } from './leaves.js';
31
31
  import type { FieldModel, FieldSpan } from './structure.js';
32
32
  import {
33
33
  VARIANT_DISCRIMINANT,
@@ -250,6 +250,13 @@
250
250
  return () => registry.unregisterControl(key);
251
251
  }
252
252
 
253
+ // A by-value prose leaf anywhere inside this field's containers is reached under
254
+ // this field's key: the active address names the field, never the element or cell.
255
+ seatValueLeaves({
256
+ focus: (leaf) => leaves?.focusValue(leafKey, leaf),
257
+ release: (leaf) => leaves?.releaseValue(leafKey, leaf)
258
+ });
259
+
253
260
  /**
254
261
  * A form control has no controller to report its focus through, so the wrapper
255
262
  * reports: `focusin` bubbles, so one handler covers a plain input, an array's N
@@ -361,7 +368,7 @@
361
368
  {:else if field.control === 'array'}
362
369
  <ArrayField
363
370
  bind:this={arrayEl}
364
- value={value as unknown[] | undefined}
371
+ {value}
365
372
  fallback={Array.isArray(field.schema.default) ? field.schema.default : undefined}
366
373
  items={field.schema.items}
367
374
  layout={arrayLayout(field.schema)}
@@ -400,7 +407,6 @@
400
407
  labelId={domIds.label}
401
408
  descriptionId={domIds.description}
402
409
  idBase={domIds.control}
403
- listKey={leafKey}
404
410
  {contentAt}
405
411
  onCommit={onCommitScalar}
406
412
  diagnostics={deep}
@@ -1,6 +1,6 @@
1
1
  import type { Document, Quill, Addr, Diagnostic, ResolvedField } from '@quillmark/wasm';
2
2
  import type { EditorErrorHandler } from '../core/errors.js';
3
- import type { LeafRegistry } from './leaves.js';
3
+ import { type LeafRegistry } from './leaves.js';
4
4
  import type { FieldModel, FieldSpan } from './structure.js';
5
5
  import type { FieldDomIds } from './domid.js';
6
6
  interface Props {
@@ -20,9 +20,13 @@
20
20
  {@link FieldLabel} renders and the `aria-describedby` the control carries, which
21
21
  holds whether the surface is open or not. So no reader depends on a hover.
22
22
 
23
- Three ways in, one per input modality: hover (pointer), focus (keyboard), tap
24
- (touch); wired here rather than taken from `Tooltip`, whose trigger returns early
25
- on `pointerType === 'touch'` and closes on click.
23
+ Three ways in, one per input modality: hover (pointer), tap (touch), and an
24
+ activation with no pointer behind it (keyboard), wired here rather than taken from
25
+ `Tooltip`, whose trigger returns early on `pointerType === 'touch'` and closes on
26
+ click. The keyboard's activation is `Mod-/` on the control the description describes
27
+ (`guidance.ts`), not a stop on this glyph: a stop here would double every described
28
+ field's Tab stops, so the glyph is out of the tab order. A screen reader activating
29
+ it lands on the same toggle.
26
30
  -->
27
31
  <script lang="ts">
28
32
  import { Popover } from 'bits-ui';
@@ -81,10 +85,51 @@
81
85
  return lines === 1;
82
86
  }
83
87
 
84
- function raise(): void {
88
+ /** How far the trigger's target reaches past its glyph on the axis the surface stands
89
+ * on, which makes the side offset clearance from the target rather than from the
90
+ * glyph. The target is `.qm-tap-floor`'s box (controls.css), centred on the glyph and
91
+ * read off it rather than restated. A surface landing on the target takes a resting
92
+ * pointer off the trigger, which closes the surface, and hands the pointer back as it
93
+ * goes: a loop. A DOM with no layout reads none. */
94
+ let reach = $state(0);
95
+
96
+ function targetReach(): number {
97
+ if (triggerEl === undefined) return 0;
98
+ const glyph = triggerEl.getBoundingClientRect();
99
+ const target = getComputedStyle(triggerEl, '::after');
100
+ const [across, own] = beside ? [target.width, glyph.width] : [target.height, glyph.height];
101
+ return Math.max(0, (parseFloat(across) - own) / 2) || 0;
102
+ }
103
+
104
+ /** What a keyboard raise was asked from: the described control, holding focus while
105
+ * the trigger never does, so its leaving and its Escape are what lower the surface. */
106
+ let from = $state<HTMLElement | undefined>(undefined);
107
+
108
+ function raise(origin?: HTMLElement): void {
109
+ from = origin;
85
110
  beside = ridesTheRung();
111
+ reach = targetReach();
86
112
  open = true;
87
113
  }
114
+
115
+ $effect(() => {
116
+ if (!open || !from) return;
117
+ const owner = from;
118
+ const leave = (e: FocusEvent) => {
119
+ if (!(e.relatedTarget instanceof Node && owner.contains(e.relatedTarget))) open = false;
120
+ };
121
+ const escape = (e: KeyboardEvent) => {
122
+ if (e.key !== 'Escape' || e.defaultPrevented) return;
123
+ e.stopPropagation();
124
+ open = false;
125
+ };
126
+ owner.addEventListener('focusout', leave);
127
+ owner.addEventListener('keydown', escape);
128
+ return () => {
129
+ owner.removeEventListener('focusout', leave);
130
+ owner.removeEventListener('keydown', escape);
131
+ };
132
+ });
88
133
  </script>
89
134
 
90
135
  <!-- The trigger is ours, not `Popover.Trigger`, and the surface anchors to it:
@@ -96,6 +141,7 @@
96
141
  bind:this={triggerEl}
97
142
  type="button"
98
143
  class="qm-field-hint qm-icon-btn qm-focus-ring qm-tap-floor"
144
+ tabindex="-1"
99
145
  aria-label="{label} guidance"
100
146
  aria-expanded={open}
101
147
  aria-describedby={describedBy}
@@ -114,11 +160,17 @@
114
160
  else raise();
115
161
  }
116
162
  }}
117
- onfocus={(e) => {
118
- // Keyboard arrival only. A pointer press focuses the button too, and opening
119
- // on that would re-open what the tap just toggled shut; `:focus-visible` is
120
- // the UA's own answer to which arrival this was.
121
- if (e.currentTarget.matches(':focus-visible')) raise();
163
+ onclick={(e) => {
164
+ // No pointer behind it (`detail` 0): the guidance key or a screen reader. A
165
+ // mouse click falls through to hover and a tap to `pointerup`, as above. The
166
+ // owner is whatever holds focus and is described by this trigger's node.
167
+ if (e.detail !== 0) return;
168
+ if (open) open = false;
169
+ else
170
+ raise(
171
+ document.activeElement?.closest<HTMLElement>(`[aria-describedby~="${describedBy}"]`) ??
172
+ undefined
173
+ );
122
174
  }}
123
175
  onblur={() => (open = false)}
124
176
  onkeydown={(e) => {
@@ -153,7 +205,7 @@
153
205
  collisionBoundary={bounds}
154
206
  side={beside ? 'right' : 'top'}
155
207
  align={beside ? 'center' : 'start'}
156
- sideOffset={6}
208
+ sideOffset={6 + reach}
157
209
  trapFocus={false}
158
210
  onOpenAutoFocus={(e: Event) => e.preventDefault()}
159
211
  onCloseAutoFocus={(e: Event) => e.preventDefault()}