@quillmark/svelte 0.19.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 (55) 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 +4 -0
  29. package/dist/visual/Field.svelte +8 -2
  30. package/dist/visual/Field.svelte.d.ts +1 -1
  31. package/dist/visual/FieldHint.svelte +62 -10
  32. package/dist/visual/FormatPopover.svelte +27 -31
  33. package/dist/visual/FormatPopover.svelte.d.ts +2 -2
  34. package/dist/visual/MatrixAdd.svelte +13 -132
  35. package/dist/visual/MatrixAdd.svelte.d.ts +2 -5
  36. package/dist/visual/MatrixField.svelte +5 -63
  37. package/dist/visual/MatrixField.svelte.d.ts +0 -3
  38. package/dist/visual/ObjectField.svelte +7 -3
  39. package/dist/visual/ObjectField.svelte.d.ts +4 -2
  40. package/dist/visual/ProseField.svelte +2 -1
  41. package/dist/visual/ProseValue.svelte +113 -40
  42. package/dist/visual/ProseValue.svelte.d.ts +8 -4
  43. package/dist/visual/VisualEditorInner.svelte +22 -22
  44. package/dist/visual/VisualEditorInner.svelte.d.ts +2 -2
  45. package/dist/visual/checklists.d.ts +0 -79
  46. package/dist/visual/checklists.js +0 -164
  47. package/dist/visual/guidance.d.ts +15 -0
  48. package/dist/visual/guidance.js +37 -0
  49. package/dist/visual/leaves.d.ts +27 -1
  50. package/dist/visual/leaves.js +46 -0
  51. package/dist/visual/strings.d.ts +3 -8
  52. package/dist/visual/strings.js +0 -3
  53. package/dist/visual/structure.js +1 -1
  54. package/dist/visual/tips.js +1 -1
  55. 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';
@@ -600,6 +601,7 @@
600
601
  >
601
602
  <FieldLabel
602
603
  label={columnTitle(key, sub)}
604
+ descriptionId={idBase != null ? propertyDomIds(idBase, key).description : undefined}
603
605
  required={obliged(sub)}
604
606
  description={sub.description}
605
607
  />
@@ -612,6 +614,7 @@
612
614
  <ObjectField
613
615
  bare
614
616
  bind:this={cellEls[id]}
617
+ {idBase}
615
618
  value={(arr[k] ?? {}) as Record<string, unknown>}
616
619
  properties={items?.properties}
617
620
  label={rowName(k)}
@@ -697,6 +700,7 @@
697
700
  <ProseValue
698
701
  bind:this={els[id]}
699
702
  content={() => readAt([k])}
703
+ value={arr[k]}
700
704
  plaintext={items != null && baseType(items) === 'plaintext'}
701
705
  label={rowName(k)}
702
706
  onChange={(rt) => commitElement(k, rt)}
@@ -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
@@ -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()}
@@ -2,16 +2,18 @@
2
2
  The formatting selection popover (VISUAL_EDITOR §Chrome). One popover,
3
3
  shell-owned, observing the active leaf through
4
4
  `getActiveLeaf`: the VisualEditor's accessor over its `leaves` registry, and
5
- the whole of what this surface knows about the editor. A non-empty selection in
6
- the active leaf raises it over
7
- that leaf's selection rect; each button dispatches a PM `toggleMark` command
8
- straight at the leaf's `EditorView`. The codec's own `dispatchTransaction`
9
- (field.ts, read-only from here) lowers the resulting transaction to
10
- `markOps` and commits via `applyChange`: this component never touches the
11
- content. The keymap mirror (Mod-b/i/u) already lives in the codec's keymap
12
- (field.ts); this is the pointer affordance for all six marks.
5
+ the whole of what this surface knows about the editor. The active leaf is a
6
+ field's or a by-value one (an array's element, a subform's cell): its views either
7
+ way, its controller only for a field's. A non-empty selection in the active leaf
8
+ raises it over that leaf's selection rect; each button dispatches a PM `toggleMark`
9
+ command straight at the leaf's `EditorView`, whose own `dispatchTransaction` commits
10
+ the result as it commits a keystroke: a field's lowers it to `markOps` through
11
+ `applyChange` (field.ts), a by-value leaf's hands the re-encoded value to its parent
12
+ (`ProseValue`). This component never touches the content. The keymap mirror
13
+ (Mod-b/i/u) already lives in the codec's keymap (field.ts); this is the pointer
14
+ affordance for all six marks.
13
15
 
14
- Selection observation. The view's `dispatchTransaction` is the codec's
16
+ Selection observation. The view's `dispatchTransaction` is the leaf's
15
17
  (read-only), so there is no hook to observe transactions from outside.
16
18
  Fallback: a document-level `selectionchange` listener, coalesced to one
17
19
  `requestAnimationFrame`-deferred check (see `deferredSync` below: a bare
@@ -65,21 +67,14 @@
65
67
  import { Popover } from 'bits-ui';
66
68
  import Icon from './icons/Icon.svelte';
67
69
  import type { IconName } from './icons/nodes.js';
68
- import {
69
- hasMarks,
70
- rangeAnchor,
71
- type FieldController,
72
- type LeafViews,
73
- type RangeAnchor
74
- } from '../core/codec/index.js';
70
+ import { hasMarks, rangeAnchor, type RangeAnchor } from '../core/codec/index.js';
71
+ import type { ActiveProse } from './leaves.js';
75
72
  import { clearLink, hrefInSelection, normalizeHref, setLink } from './links.js';
76
73
  import './controls.css';
77
74
 
78
- type LeafWithView = FieldController & Partial<LeafViews>;
79
-
80
75
  interface Props {
81
76
  /** The observation seam VisualEditor exposes over its `leaves` registry. */
82
- getActiveLeaf: () => FieldController | undefined;
77
+ getActiveLeaf: () => ActiveProse | undefined;
83
78
  }
84
79
  let { getActiveLeaf }: Props = $props();
85
80
 
@@ -99,8 +94,8 @@
99
94
  let open = $state(false);
100
95
  /** A pointer is down outside this surface: the selection is still being made. */
101
96
  let pressed = false;
102
- /** Whether the selection is in the field's coordinate space, which an anchor needs
103
- * and a table cell is not (see `sync`). */
97
+ /** Whether an anchor can be minted over the selection: in a field's own view, never
98
+ * in a table cell or a by-value leaf (see `sync`). */
104
99
  let anchorAvailable = $state(true);
105
100
  let linkPromptOpen = $state(false);
106
101
  /** Whether the selection carried a link when the prompt was raised. */
@@ -122,8 +117,7 @@
122
117
  * too, so a selection in a cell raises the same popover and toggles the same
123
118
  * commands; only `anchor` is withheld (below). */
124
119
  function activeLeafView(): EditorView | undefined {
125
- const leaf = getActiveLeaf() as LeafWithView | undefined;
126
- return leaf?.focusedView?.() ?? leaf?.view;
120
+ return getActiveLeaf()?.views.focusedView();
127
121
  }
128
122
 
129
123
  function sync(): void {
@@ -134,8 +128,8 @@
134
128
  // returns early, so a surface frozen here still tracks the selection it covers.
135
129
  if (insidePopover) return;
136
130
  if (pressed) return;
137
- const leaf = getActiveLeaf() as LeafWithView | undefined;
138
- const view = leaf?.focusedView?.() ?? leaf?.view;
131
+ const leaf = getActiveLeaf();
132
+ const view = leaf?.views.focusedView();
139
133
  // A non-empty text selection. A node selection is non-empty too and has nothing
140
134
  // to format: it covers a leaf block (an island, a rule), which is exactly where
141
135
  // Escape out of a table cell lands.
@@ -160,11 +154,13 @@
160
154
  portalTarget = view.dom.closest<HTMLElement>('[data-qm-root]') ?? undefined;
161
155
  const { from, to } = view.state.selection;
162
156
  anchor = rangeAnchor(view, from, to);
163
- // A selection inside a table cell is not in the field's coordinate space at all:
164
- // the field's position map holds one `atom` run for the whole island, so there is
165
- // no USV offset to mint an anchor at. The button is withheld rather than
166
- // disabled, because what it would toggle does not exist there (CODEC §Islands).
167
- anchorAvailable = !!leaf && view === leaf.view;
157
+ // An anchor is minted through a field's controller, which a by-value leaf has
158
+ // none of: its anchors are dropped on the parent's value write. A selection inside
159
+ // a table cell is not in the field's coordinate space at all: the field's position
160
+ // map holds one `atom` run for the whole island, so there is no USV offset to mint
161
+ // an anchor at. The button is withheld rather than disabled, because what it would
162
+ // toggle does not exist there (CODEC §Islands).
163
+ anchorAvailable = !!leaf?.controller && view === leaf.views.view;
168
164
  open = true;
169
165
  }
170
166
 
@@ -283,7 +279,7 @@
283
279
  * the content, its chrome awaiting comment-thread UX.
284
280
  */
285
281
  function toggleAnchor(): void {
286
- const leaf = getActiveLeaf();
282
+ const leaf = getActiveLeaf()?.controller;
287
283
  const view = activeLeafView();
288
284
  if (!leaf || !view || view.state.selection.empty) return;
289
285
  const { from, to } = leaf.selectionRange();