@quillmark/svelte 0.6.0 → 0.7.1

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 (37) hide show
  1. package/THEMING.md +1 -1
  2. package/dist/core/codec/field.d.ts +15 -4
  3. package/dist/core/codec/field.js +13 -10
  4. package/dist/core/codec/index.d.ts +1 -1
  5. package/dist/core/codec/index.js +1 -1
  6. package/dist/core/codec/table-view.d.ts +4 -0
  7. package/dist/core/codec/table-view.js +10 -1
  8. package/dist/core/theme.css +14 -9
  9. package/dist/preset/recipes.css +15 -4
  10. package/dist/preview/bridge.d.ts +23 -0
  11. package/dist/preview/bridge.js +92 -35
  12. package/dist/preview/controller.d.ts +6 -5
  13. package/dist/preview/controller.js +13 -7
  14. package/dist/preview/index.js +3 -2
  15. package/dist/preview/paint.js +9 -1
  16. package/dist/visual/ArrayField.svelte +30 -23
  17. package/dist/visual/ArrayField.svelte.d.ts +9 -7
  18. package/dist/visual/Card.svelte +88 -45
  19. package/dist/visual/EnumField.svelte +4 -2
  20. package/dist/visual/Field.svelte +22 -2
  21. package/dist/visual/ObjectField.svelte +95 -75
  22. package/dist/visual/ObjectField.svelte.d.ts +7 -3
  23. package/dist/visual/ProseValue.svelte +165 -0
  24. package/dist/visual/ProseValue.svelte.d.ts +33 -0
  25. package/dist/visual/SlashMenu.svelte +7 -0
  26. package/dist/visual/VariantField.svelte +11 -6
  27. package/dist/visual/VariantField.svelte.d.ts +5 -1
  28. package/dist/visual/VisualEditorInner.svelte +29 -11
  29. package/dist/visual/hold.d.ts +17 -0
  30. package/dist/visual/hold.js +39 -0
  31. package/dist/visual/index.js +1 -1
  32. package/dist/visual/motion.d.ts +9 -0
  33. package/dist/visual/motion.js +10 -2
  34. package/dist/visual/strings.d.ts +4 -3
  35. package/package.json +2 -2
  36. package/dist/visual/ProseArrayElement.svelte +0 -139
  37. package/dist/visual/ProseArrayElement.svelte.d.ts +0 -25
@@ -1,33 +1,31 @@
1
1
  <!--
2
2
  An `object` field → a nested subform over `properties` (declaration order),
3
- committing the whole object by value on any nested change. Scalar properties
4
- only: a nested prose/array/object property renders a placeholder instead of
5
- recursing.
3
+ committing the whole object by value on any nested change. Leaf properties: a
4
+ scalar draws its own control, a content property mounts the by-value prose leaf
5
+ ({@link ProseValue}) over the boundary's nested read, and a nested `array`/`object`
6
+ property renders a placeholder instead of recursing.
6
7
 
7
- The nesting is a band: two full-width horizontals at `--_qm-border`, the figure the
8
- card draws around its own metadata (`.qm-meta-top` / `.qm-meta-bottom`) one octave
9
- down. Nothing insets, so a property's control keeps the card's left and right columns
10
- exactly as a field's does, and the field's own label caps the band from outside it —
11
- name, rule under the name, content, closing rule.
8
+ The nesting is a vertical at `--_qm-border`, the ladder's last stroke: the card's edge,
9
+ an open section's vertical one `--_qm-nest` in where the field is in one, this one a rung
10
+ further, the properties a rung inside it (ARCHITECTURE §"A plane is a tone").
12
11
 
13
- That absent inset is also what puts the properties on the section's own column tracks
14
- (below), so a subform's cells share edges with the fields above them rather than
15
- keeping a rhythm of their own.
16
-
17
- Which edges draw is {@link Props.edges}, and the rule is that a boundary is stated
18
- once: where a box already sits against the subform, that box IS the boundary and a
19
- stroke under it would say so twice. So a field-level subform takes the band, and a
20
- subform hanging off a control — a variant's cells under their discriminant, an
21
- array element's properties under its summary row — takes the closing rule alone.
22
- Sometimes two strokes and sometimes one: the open figure the card's bracket is.
12
+ One figure wherever it mounts: a variant's cells under their discriminant and an array
13
+ element's properties under its summary row sit at the depth a field-level subform sits
14
+ at, and the box above them states position rather than depth.
23
15
 
24
16
  A property's ghosted `default:` is the static schema `sub.default`, not the
25
17
  resolved provenance the top-level ghosts read (FIELD_PROVENANCE): `resolve`
26
18
  carries no per-property row (an object field resolves as one row whose value is
27
19
  the whole object).
20
+
21
+ Every caller hands down `contentAt`, which is `reader.getContentAt` with the field
22
+ already bound: a field-level subform passes the property key through untouched, a
23
+ variant passes its cell's, and an array's open row prefixes its index. So a cell
24
+ reads at whatever depth it sits, which nothing here knows.
28
25
  -->
29
26
  <script lang="ts">
30
- import type { QuillFieldSchema } from '@quillmark/wasm';
27
+ import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
28
+ import { emptyContent } from '../core/codec/index.js';
31
29
  import { controlKind, humanize, obliged } from './structure.js';
32
30
  import { wording } from './strings.js';
33
31
  import { propertyDomIds } from './domid.js';
@@ -37,6 +35,7 @@
37
35
  import NumberField from './NumberField.svelte';
38
36
  import BooleanField from './BooleanField.svelte';
39
37
  import DateField from './DateField.svelte';
38
+ import ProseValue from './ProseValue.svelte';
40
39
 
41
40
  interface Props {
42
41
  value: Record<string, unknown> | undefined;
@@ -56,20 +55,16 @@
56
55
  idBase?: string;
57
56
  /** Accessible-name prefix used only on the `aria-label` fallback above. */
58
57
  label?: string;
59
- /** Which strokes the subform draws; see the note above. */
60
- edges?: 'band' | 'close';
58
+ /** The boundary's nested content read, rooted at this subform: `path` is a
59
+ * `PathStep[]` from here to the leaf, which the caller prefixes with whatever
60
+ * stands between this subform and the field (`reader.getContentAt`). Asked only
61
+ * for a content-typed property: on any other the read is not content and throws.
62
+ * `undefined` for a property the stored value does not reach. */
63
+ contentAt: (path: PathStep[]) => Content | undefined;
61
64
  onCommit: (obj: Record<string, unknown>) => void;
62
65
  }
63
- let {
64
- value,
65
- properties,
66
- labelledBy,
67
- describedBy,
68
- idBase,
69
- label,
70
- edges = 'band',
71
- onCommit
72
- }: Props = $props();
66
+ let { value, properties, labelledBy, describedBy, idBase, label, contentAt, onCommit }: Props =
67
+ $props();
73
68
 
74
69
  const t = wording();
75
70
  const entries = $derived(Object.entries(properties ?? {}));
@@ -88,26 +83,48 @@
88
83
  `${label != null ? `${label} ` : ''}${title(key, sub)}` +
89
84
  (required(sub) ? ` ${t.strings.fieldRequired}` : '');
90
85
 
91
- /** Take the caret: the first property's control, a subform having no single control
92
- * of its own to land on. Resolved off the DOM rather than a ref per property: every
93
- * property is a scalar control the DOM already knows how to focus (the date field's
94
- * segments carry `tabindex`, the `literal` separators between them do not), so a
95
- * handle each would be five refs restating document order. An empty or wholly
96
- * unsupported subform lands nothing. */
86
+ /** The prose cells' handles, keyed by property name — which is stable, a subform
87
+ * drawing its properties by declaration and never by position. A ref only where
88
+ * the DOM cannot answer: a `contenteditable` matches no focusable selector, and
89
+ * focusing the node is not focusing the view ({@link ProseValue.focus}). A scalar
90
+ * control is resolved off the markup, so a handle each would be five refs
91
+ * restating document order.
92
+ *
93
+ * `$state` for the binding's sake: `bind:this` into a property of a plain object
94
+ * is a write Svelte cannot track, and it says so once per cell per render
95
+ * (`ArrayField` keeps its element refs the same way). */
96
+ const proseEls: Record<string, { focus: () => void } | undefined> = $state({});
97
97
  let rootEl = $state<HTMLElement | undefined>();
98
98
  const FOCUSABLE = 'input, select, button, [tabindex]:not([tabindex="-1"])';
99
+ /** Take the caret: the first property that has somewhere to put it, in document
100
+ * order — a subform has no single control of its own to land on, and a property
101
+ * standing the unsupported line has none either, so the walk steps past it. An
102
+ * empty or wholly unsupported subform lands nothing. */
99
103
  export function focus(): void {
100
- rootEl?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
104
+ for (const cell of propCells()) if (landOn(cell)) return;
101
105
  }
102
- /** A label click for the one property `for` cannot reach: the date control, whose
103
- * focus lives on a segment. Scoped to that property's own cell, so the click lands
104
- * where the label is rather than on the subform's first control. */
106
+ /** A label click for the properties `for` cannot reach: the date control, whose
107
+ * focus lives on a segment, and a prose cell, which is a `contenteditable`. Scoped
108
+ * to that property's own cell, so the click lands where the label is rather than on
109
+ * the subform's first control. */
105
110
  function focusProp(key: string): void {
106
- // Matched on the dataset: a key is the schema's own string, and spelling one into
107
- // a selector needs `CSS.escape`, which the test DOM does not carry.
108
- for (const cell of rootEl?.querySelectorAll<HTMLElement>('[data-qm-prop]') ?? []) {
109
- if (cell.dataset.qmProp === key) return cell.querySelector<HTMLElement>(FOCUSABLE)?.focus();
111
+ for (const cell of propCells()) if (cell.dataset.qmProp === key) return void landOn(cell);
112
+ }
113
+ // Matched on the dataset: a key is the schema's own string, and spelling one into a
114
+ // selector needs `CSS.escape`, which the test DOM does not carry.
115
+ function propCells(): HTMLElement[] {
116
+ return [...(rootEl?.querySelectorAll<HTMLElement>('[data-qm-prop]') ?? [])];
117
+ }
118
+ /** Land in one property's cell, answering whether it took the caret. */
119
+ function landOn(cell: HTMLElement): boolean {
120
+ const prose = proseEls[cell.dataset.qmProp ?? ''];
121
+ if (prose) {
122
+ prose.focus();
123
+ return true;
110
124
  }
125
+ const el = cell.querySelector<HTMLElement>(FOCUSABLE);
126
+ el?.focus();
127
+ return !!el;
111
128
  }
112
129
 
113
130
  function commitProp(key: string, v: unknown): void {
@@ -127,7 +144,6 @@
127
144
  <div
128
145
  bind:this={rootEl}
129
146
  class="qm-object"
130
- class:close={edges === 'close'}
131
147
  role="group"
132
148
  aria-labelledby={labelledBy}
133
149
  aria-describedby={describedBy}
@@ -138,8 +154,8 @@
138
154
  {@const named = ids ? undefined : fallbackName(key, sub)}
139
155
  {@const describes = sub.description && ids ? ids.description : undefined}
140
156
  <!-- `for` reaches the four labelable controls; the date field's focus lives on a
141
- segment, so it takes the click handoff instead, exactly as `Field` does one
142
- level up. -->
157
+ segment and a prose cell is a `contenteditable`, so those take the click
158
+ handoff instead, exactly as `Field` does one level up. -->
143
159
  {@const labelable =
144
160
  kind === 'text' || kind === 'enum' || kind === 'number' || kind === 'boolean'}
145
161
  <div class="qm-object-prop" data-qm-prop={key}>
@@ -200,6 +216,18 @@
200
216
  placeholder={sub.default != null ? String(sub.default) : undefined}
201
217
  onCommit={(v) => commitProp(key, v)}
202
218
  />
219
+ {:else if kind === 'prose'}
220
+ <!-- Keyed by name, so a re-derive of the container this cell commits whole
221
+ leaves the leaf mounted and the caret in it. -->
222
+ <ProseValue
223
+ bind:this={proseEls[key]}
224
+ content={() => contentAt([key]) ?? emptyContent()}
225
+ plaintext={sub.type === 'plaintext'}
226
+ label={named}
227
+ labelledBy={ids?.label}
228
+ describedBy={describes}
229
+ onChange={(rt) => commitProp(key, rt)}
230
+ />
203
231
  {:else}
204
232
  <!-- A property the subform does not recurse into. What it says is what the
205
233
  user can do about it, which is edit the field from the source surface;
@@ -211,46 +239,38 @@
211
239
  </div>
212
240
 
213
241
  <style>
214
- /* The band. `border-block` draws both strokes as one declaration, which is what
215
- makes the figure symmetric by construction rather than by two lines agreeing;
216
- `padding-block` at the same rung on both sides is the other half of that.
242
+ /* The vertical and the rung between it and the properties, `--_qm-nest` being the step
243
+ the card and the section hold too.
217
244
 
218
245
  `--_qm-border` and not `--_qm-border-faint`: this is the stroke that structures
219
- (ARCHITECTURE §"A plane is a tone"), the one the metadata bracket and the open
220
- section's vertical read. `faint` separates without structuring — a table's
221
- interior lines under a frame that is doing the structuring — which is the other
222
- job.
246
+ (ARCHITECTURE §"A plane is a tone"), the one the card's edge and the section's
247
+ vertical read. `faint` separates without structuring — a table's interior lines
248
+ under a frame that is doing the structuring — which is the other job.
223
249
 
224
- The columns are the section's, arrived at by arithmetic rather than by `subgrid`:
225
- the band insets nothing horizontally, so this box spans exactly what `.qm-fields`
226
- spans, and equal tracks at the same count over the same width with the same column
227
- gap fall on the same edges. `subgrid` would want the tracks chained through the
228
- three wrappers between the section grid and here (`Field.svelte`), none of which is
229
- a grid. `--cols` inherits instead, so the container query stepping the section's
230
- capacity steps the subform's with it — no second query container, no second set of
231
- rungs, nothing measured. */
250
+ `padding-block` is the stroke's end caps, the same rung and the same reason as a
251
+ section's (`Card.svelte`). A cap and not a leading gap: what stands the subform off
252
+ the label or the box above it is still that stacker's own.
253
+
254
+ The tracks are the section's count over the subform's own width — `--cols` inherits,
255
+ so the container query stepping the section's capacity steps this with it, and there
256
+ is no second query container and nothing measured. The edges are not the section's:
257
+ the inset is the nesting, and a cell lining up with the field above it would claim a
258
+ depth it is not at. */
232
259
  .qm-object {
233
260
  display: grid;
234
261
  grid-template-columns: repeat(var(--cols), 1fr);
235
262
  gap: var(--_qm-space-2);
236
- border-block: var(--_qm-border-width) solid var(--_qm-border);
237
- padding-block: var(--_qm-space-2);
238
- }
239
- /* Hanging off a control (a variant's discriminant, an array element's summary row):
240
- that box is the top boundary already, and a stroke under it would state the
241
- boundary twice. The closing rule is not optional in the same breath — without it
242
- the properties float between the box above and whatever follows. */
243
- .qm-object.close {
244
- border-block-start: none;
245
- padding-block-start: 0;
263
+ border-inline-start: var(--_qm-border-width) solid var(--_qm-border);
264
+ padding-inline-start: var(--_qm-nest);
265
+ padding-block: var(--_qm-space);
246
266
  }
247
267
  /* A property measures like a field: two tracks over the subform's own rows, so a
248
268
  label wrapping in one column leaves its control on the row's baseline rather than a
249
269
  line below it — `Field.svelte`'s rule for a row-sharing field, one level in. The
250
270
  tracks are this grid's own; the section's belong to the fields.
251
271
 
252
- `min-width: 0` is what holds the arithmetic above: a property overflowing its `1fr`
253
- grows the track, and the subform's edges leave the section's with it. */
272
+ `min-width: 0` because a property overflowing its `1fr` grows the track, and the
273
+ subform with it, past the field that holds it. */
254
274
  .qm-object-prop {
255
275
  display: grid;
256
276
  grid-row: span 2;
@@ -1,4 +1,4 @@
1
- import type { QuillFieldSchema } from '@quillmark/wasm';
1
+ import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
2
2
  interface Props {
3
3
  value: Record<string, unknown> | undefined;
4
4
  properties: Record<string, QuillFieldSchema> | undefined;
@@ -17,8 +17,12 @@ interface Props {
17
17
  idBase?: string;
18
18
  /** Accessible-name prefix used only on the `aria-label` fallback above. */
19
19
  label?: string;
20
- /** Which strokes the subform draws; see the note above. */
21
- edges?: 'band' | 'close';
20
+ /** The boundary's nested content read, rooted at this subform: `path` is a
21
+ * `PathStep[]` from here to the leaf, which the caller prefixes with whatever
22
+ * stands between this subform and the field (`reader.getContentAt`). Asked only
23
+ * for a content-typed property: on any other the read is not content and throws.
24
+ * `undefined` for a property the stored value does not reach. */
25
+ contentAt: (path: PathStep[]) => Content | undefined;
22
26
  onCommit: (obj: Record<string, unknown>) => void;
23
27
  }
24
28
  declare const ObjectField: import("svelte").Component<Props, {
@@ -0,0 +1,165 @@
1
+ <!--
2
+ A prose leaf the parent commits by value: an element of a `richtext` (fixture
3
+ `keywords`) or `plaintext` (`errata`) array, and a content cell of a subform — an
4
+ `object`'s property, a variant's cell ({@link ObjectField}). None of them is
5
+ `applyChange`-addressable: `Addr.field` is a flat name, so `keywords[0]` and
6
+ `contact.note` have no op address. So this is not a `createField` leaf: it mounts a
7
+ minimal PM view over the codec's decode/encode + inline schema, and on every edit
8
+ hands the re-encoded `Content` up to the parent — {@link ArrayField}, which commits
9
+ the whole array, or {@link ObjectField}, which commits the whole container
10
+ (`writer.set(field, next)`). Anchors inside a value are dropped on that write:
11
+ acceptable for inline refs. Mounts once per stable key (an element's session id, a
12
+ property's name), so the parent's re-derive leaves the caret where it is.
13
+
14
+ The content is read, not passed: `reader.getContentAt(addr, path)` decodes through
15
+ the codec the leaf's own declared type names, so what it rests as — the content
16
+ object, a `plaintext` literal, the authored string a transport door left — stops
17
+ being the row's or the cell's business. Read once, at mount, since that is when this
18
+ leaf takes its state.
19
+
20
+ One textblock wherever it mounts, no islands and no slash menu. `plaintext` is
21
+ inline at the codec, so that is the declared shape for every plaintext leaf; a block
22
+ `richtext` is held one rung narrower than it declares, having no room for a second
23
+ paragraph in the figure a repeater row and a subform cell draw.
24
+ -->
25
+ <script lang="ts">
26
+ import { onMount } from 'svelte';
27
+ import { EditorState, Selection } from 'prosemirror-state';
28
+ import { EditorView } from 'prosemirror-view';
29
+ import {
30
+ decode,
31
+ pmToContent,
32
+ inlineSchema,
33
+ plaintextSchema,
34
+ proseAttributes,
35
+ proseLeafPlugins,
36
+ buildLineIndex,
37
+ usvToPM
38
+ } from '../core/codec/index.js';
39
+ import './controls.css';
40
+ import type { Content } from '@quillmark/wasm';
41
+
42
+ interface Props {
43
+ /** This leaf's content, read at mount (the parent's boundary read). A thunk
44
+ * rather than a value: the parent re-derives per revision and this leaf takes
45
+ * its state once, so a value prop would be a boundary read per leaf per render,
46
+ * all but one of them discarded. */
47
+ content: () => Content;
48
+ /** The mark-free schema (a `plaintext` leaf): literal text, no formatting,
49
+ * exactly as the scalar field of that type mounts. */
50
+ plaintext?: boolean;
51
+ /** Accessible name for the editable region, where nothing else names it: an
52
+ * array has no per-element label. A cell with a label element takes
53
+ * `labelledBy`. */
54
+ label?: string;
55
+ /** The label's own id → `aria-labelledby`. `for` cannot reach a
56
+ * `contenteditable`, so a subform cell's label associates the other way and
57
+ * hands its click back through {@link focus}. */
58
+ labelledBy?: string;
59
+ /** The parked `description` (FieldLabel) → `aria-describedby`. */
60
+ describedBy?: string;
61
+ onChange: (rt: Content) => void;
62
+ /** Raw keydown, for a container whose own keys run through this leaf: the
63
+ * array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
64
+ * keymap, so the state it reads is the one this keystroke has yet to change. */
65
+ onKey?: (e: KeyboardEvent) => void;
66
+ }
67
+ let {
68
+ content,
69
+ plaintext = false,
70
+ label,
71
+ labelledBy,
72
+ describedBy,
73
+ onChange,
74
+ onKey
75
+ }: Props = $props();
76
+
77
+ let containerEl: HTMLDivElement | undefined = $state();
78
+ let view: EditorView | undefined;
79
+ /** Take the caret: what a parent placing focus on this leaf calls. The view's
80
+ * focus, not the element's: a PM view restores its selection, where a bare DOM
81
+ * focus on a contenteditable leaves the caret unplaced. And the reveal beside it,
82
+ * since PM's focus prevents the scroll a `string` element's input takes by
83
+ * default — an array whose two item types revealed differently is one row of it
84
+ * landing off screen. */
85
+ export function focus(): void {
86
+ if (!view) return;
87
+ view.focus();
88
+ view.dom.scrollIntoView({ block: 'nearest' });
89
+ }
90
+ /** Take the caret to USV `pos` in this leaf's own content: what a landing on an
91
+ * element address resolves to when the compile answered a cluster-exact offset
92
+ * (`leaves.ts`). `createField`'s body over the view this component owns.
93
+ *
94
+ * The index is built here rather than held: a landing is a discrete act, and one
95
+ * doc walk per click is cheaper than a cache to invalidate per keystroke. `usvToPM`
96
+ * clamps, so an offset past a value the compile has moved on from lands the end.
97
+ *
98
+ * `Selection.near`, not `TextSelection.create`: the mapped position can be
99
+ * non-inline. Focus first, then the caret, flagged for scroll — the order and the
100
+ * reasons are `FieldController.setCaret`'s (`codec/field.ts`). */
101
+ export function setCaret(pos: number): void {
102
+ if (!view) return;
103
+ const pm = usvToPM(buildLineIndex(view.state.doc), pos);
104
+ const sel = Selection.near(view.state.doc.resolve(pm));
105
+ view.focus();
106
+ view.dispatch(view.state.tr.setSelection(sel).scrollIntoView());
107
+ }
108
+
109
+ onMount(() => {
110
+ if (!containerEl) return;
111
+ // An inline, value-by-value leaf: the same keymap and plugin stack a
112
+ // `createField` leaf mounts (shared `proseLeafPlugins`), minus the
113
+ // anchor-position plugin (anchors are dropped on the parent's value write, per
114
+ // the header). Its own `dispatchTransaction` hands `Content` up.
115
+ //
116
+ // The schema is the declared type's, as `createField` picks it: `plaintext`
117
+ // declares no mark types, so there is nothing to toggle, to paste in, or to
118
+ // mint a shorthand with.
119
+ const schema = plaintext ? plaintextSchema : inlineSchema;
120
+ const pmDoc = decode(content(), schema);
121
+ const state = EditorState.create({
122
+ doc: pmDoc,
123
+ plugins: proseLeafPlugins(schema, { inline: true })
124
+ });
125
+ const mounted = new EditorView(containerEl, {
126
+ state,
127
+ // Which of `aria-label` / `aria-labelledby` wins is the codec's one answer
128
+ // (`proseAttributes`), so a cell carrying a label element and a row carrying
129
+ // none cannot name their regions by different rules.
130
+ attributes: proseAttributes({ label, labelledBy, describedBy }),
131
+ dispatchTransaction(tr) {
132
+ const next = mounted.state.apply(tr);
133
+ mounted.updateState(next);
134
+ if (tr.docChanged) onChange(pmToContent(next.doc));
135
+ },
136
+ handleDOMEvents: {
137
+ keydown: (_v, e) => {
138
+ onKey?.(e);
139
+ return false;
140
+ }
141
+ }
142
+ });
143
+ view = mounted;
144
+ return () => {
145
+ view = undefined;
146
+ mounted.destroy();
147
+ };
148
+ });
149
+ </script>
150
+
151
+ <!-- `.qm-control-box` (controls.css) is the whole box, so an array of `richtext` and
152
+ an array of `string` render rows of equal height, and a subform's prose cell measures
153
+ like the text cell beside it. No floor: the reset in `core/codec/prose.css` makes one
154
+ line of prose measure one line. Width is the row's or the cell's to give: the leaf
155
+ fills the track it is placed in. -->
156
+ <div bind:this={containerEl} class="qm-prose-value qm-control-box qm-focus-ring-within"></div>
157
+
158
+ <style>
159
+ /* Caret-primary, matching ProseField: the contenteditable's own outline is
160
+ dropped and the ring rides the wrapper (`qm-focus-ring-within`,
161
+ controls.css), which is where the box is. */
162
+ .qm-prose-value :global(.ProseMirror) {
163
+ outline: none;
164
+ }
165
+ </style>
@@ -0,0 +1,33 @@
1
+ import './controls.css';
2
+ import type { Content } from '@quillmark/wasm';
3
+ interface Props {
4
+ /** This leaf's content, read at mount (the parent's boundary read). A thunk
5
+ * rather than a value: the parent re-derives per revision and this leaf takes
6
+ * its state once, so a value prop would be a boundary read per leaf per render,
7
+ * all but one of them discarded. */
8
+ content: () => Content;
9
+ /** The mark-free schema (a `plaintext` leaf): literal text, no formatting,
10
+ * exactly as the scalar field of that type mounts. */
11
+ plaintext?: boolean;
12
+ /** Accessible name for the editable region, where nothing else names it: an
13
+ * array has no per-element label. A cell with a label element takes
14
+ * `labelledBy`. */
15
+ label?: string;
16
+ /** The label's own id → `aria-labelledby`. `for` cannot reach a
17
+ * `contenteditable`, so a subform cell's label associates the other way and
18
+ * hands its click back through {@link focus}. */
19
+ labelledBy?: string;
20
+ /** The parked `description` (FieldLabel) → `aria-describedby`. */
21
+ describedBy?: string;
22
+ onChange: (rt: Content) => void;
23
+ /** Raw keydown, for a container whose own keys run through this leaf: the
24
+ * array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
25
+ * keymap, so the state it reads is the one this keystroke has yet to change. */
26
+ onKey?: (e: KeyboardEvent) => void;
27
+ }
28
+ declare const ProseValue: import("svelte").Component<Props, {
29
+ focus: () => void;
30
+ setCaret: (pos: number) => void;
31
+ }, "">;
32
+ type ProseValue = ReturnType<typeof ProseValue>;
33
+ export default ProseValue;
@@ -72,8 +72,15 @@
72
72
  edge under the character that opened the menu, and stays there as the query is
73
73
  typed. Zero offset, because a gap reads as a surface floating near the caret
74
74
  rather than one growing out of it. -->
75
+ <!-- The boundary is the root the surface portals into, the box that clips it where
76
+ the consumer scrolls one ({@link FormatPopover} states it in full). It is also
77
+ what keeps the row scroll above inside the list: `nearest` scrolls any ancestor
78
+ the row is not visible in, and a surface raised into room the pane clips leaves
79
+ the pane one of them — so the scroll moves the pane, and with it the caret this
80
+ menu is anchored to. -->
75
81
  <Popover.Content
76
82
  customAnchor={menu?.anchor ?? null}
83
+ collisionBoundary={portalTarget ?? []}
77
84
  side="bottom"
78
85
  align="start"
79
86
  trapFocus={false}
@@ -4,14 +4,16 @@
4
4
  a container, `{value: <member>, …that member's fields}`, and commits whole: it is one
5
5
  cell to `Addr`, so there is no per-cell write address.
6
6
 
7
- The cells are {@link ObjectField}, which already draws a scalar field set keyed by
7
+ The cells are {@link ObjectField}, which already draws a leaf field set keyed by
8
8
  name over a container its parent commits by value. It takes the whole container as its
9
9
  `value` and the world's declaration as its `properties`, so what it hands back is the
10
10
  container with one cell written — and cells outside the drawn world ride through
11
- untouched rather than needing to be merged back.
11
+ untouched rather than needing to be merged back. A content cell reads at the key it
12
+ declares: the boundary's schema walk unions the worlds, so a dormant cell reads absent
13
+ rather than raising.
12
14
  -->
13
15
  <script lang="ts">
14
- import type { QuillFieldSchema } from '@quillmark/wasm';
16
+ import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
15
17
  import EnumField from './EnumField.svelte';
16
18
  import ObjectField from './ObjectField.svelte';
17
19
  import {
@@ -34,6 +36,10 @@
34
36
  id?: string;
35
37
  labelledBy?: string;
36
38
  describedBy?: string;
39
+ /** The boundary's nested content read for this field, which the cells take
40
+ * unprefixed: a variant's cell is one key in from the container it rests as, so
41
+ * a `plaintext` cell's path is its own name ({@link ObjectField}). */
42
+ contentAt: (path: PathStep[]) => Content | undefined;
37
43
  onCommit: (v: Record<string, unknown> | undefined) => void;
38
44
  optionAllowed?: (value: string) => boolean;
39
45
  enumDisallowed?: 'hide' | 'disable';
@@ -46,6 +52,7 @@
46
52
  id,
47
53
  labelledBy,
48
54
  describedBy,
55
+ contentAt,
49
56
  onCommit,
50
57
  optionAllowed,
51
58
  enumDisallowed
@@ -73,8 +80,6 @@
73
80
  flip would carry it between them. -->
74
81
  {#if cells}
75
82
  {#key member}
76
- <!-- `close`: the discriminant's box directly above IS the cells' top boundary,
77
- so the band's opening stroke would state it twice. -->
78
83
  <ObjectField
79
84
  value={value ?? {}}
80
85
  properties={cells}
@@ -82,7 +87,7 @@
82
87
  idBase={id}
83
88
  {labelledBy}
84
89
  {describedBy}
85
- edges="close"
90
+ {contentAt}
86
91
  onCommit={(obj) => onCommit(obj)}
87
92
  />
88
93
  {/key}
@@ -1,4 +1,4 @@
1
- import type { QuillFieldSchema } from '@quillmark/wasm';
1
+ import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
2
2
  interface Props {
3
3
  /** The stored container, or undefined while the field is unset. */
4
4
  value: Record<string, unknown> | undefined;
@@ -12,6 +12,10 @@ interface Props {
12
12
  id?: string;
13
13
  labelledBy?: string;
14
14
  describedBy?: string;
15
+ /** The boundary's nested content read for this field, which the cells take
16
+ * unprefixed: a variant's cell is one key in from the container it rests as, so
17
+ * a `plaintext` cell's path is its own name ({@link ObjectField}). */
18
+ contentAt: (path: PathStep[]) => Content | undefined;
15
19
  onCommit: (v: Record<string, unknown> | undefined) => void;
16
20
  optionAllowed?: (value: string) => boolean;
17
21
  enumDisallowed?: 'hide' | 'disable';
@@ -66,7 +66,7 @@
66
66
  } from './diagnostics.js';
67
67
  import { fieldDomIds, groupPanelId } from './domid.js';
68
68
  import { createLeafRegistry, type FieldControl } from './leaves.js';
69
- import { reorder } from './motion.js';
69
+ import { reorder, reorderTrips } from './motion.js';
70
70
  import { tipsChannel } from './tips.js';
71
71
  import { patchEditorExt } from './ext.js';
72
72
  import Card from './Card.svelte';
@@ -185,6 +185,9 @@
185
185
  };
186
186
  let mainCard = $state<CardHandle | undefined>(undefined);
187
187
  let cardRefs = $state<(CardHandle | undefined)[]>([]);
188
+ /** The slot each card sits in: what a reorder animates, and so what the post-mutation
189
+ * scroll waits on before it measures anything. */
190
+ let slotEls = $state<(HTMLElement | undefined)[]>([]);
188
191
  /** The stack's own element: it carries `data-qm-root`, so it is what the kind
189
192
  * menu portals into, the way each leaf's surface resolves its nearest root. */
190
193
  let rootEl = $state<HTMLElement | undefined>(undefined);
@@ -327,16 +330,29 @@
327
330
  * on a document of any length an insert lands off-screen, and a
328
331
  * viewport that does not follow reads as nothing happening.
329
332
  *
330
- * Coalesced on a single pending id: two quick adds resolve their `tick()` in
331
- * order, the earlier one sees a newer pending id and drops out, so one smooth
332
- * scroll runs to the last card rather than two fighting over the same scroller.
333
+ * Coalesced on the last call: each takes a ticket and a continuation holding a stale
334
+ * one drops out, so two mutations inside a tick run one smooth scroll to the last of
335
+ * them rather than two fighting over the same scroller. A ticket rather than the id
336
+ * being scrolled to, because an insert and a move of the *same* card are two calls
337
+ * naming one id, on different terms — `center` against `nearest`.
333
338
  */
334
- let pendingScrollId: string | null = null;
339
+ let scrollTicket = 0;
335
340
  async function scrollCardIntoView(id: string, block: ScrollLogicalPosition): Promise<void> {
336
- pendingScrollId = id;
341
+ const ticket = ++scrollTicket;
337
342
  if (!(await span.resumes(tick()))) return;
338
- if (pendingScrollId !== id) return;
339
- pendingScrollId = null;
343
+ if (ticket !== scrollTicket) return;
344
+ // A reorder is running by this flush and holds the slot at its pre-move position
345
+ // for the length of the trip (`motion.ts`), so asking now measures the box the
346
+ // card is leaving — where a `nearest` would take the stack back to for the card
347
+ // that moved in from off screen. The trip says the card moved; the scroll follows
348
+ // it to where it landed. `allSettled`, because a second reorder cancels the first
349
+ // and a cancelled run rejects.
350
+ const slot = slotEls[cardIndexOf(id)];
351
+ const trips = slot ? reorderTrips(slot) : [];
352
+ if (trips.length) {
353
+ if (!(await span.resumes(Promise.allSettled(trips.map((trip) => trip.finished))))) return;
354
+ if (ticket !== scrollTicket) return;
355
+ }
340
356
  const i = cardIndexOf(id);
341
357
  if (i >= 0) cardRefs[i]?.scrollIntoViewCard(block);
342
358
  }
@@ -813,7 +829,7 @@
813
829
  rather than being slid across. It is also the shape `animate:` asks for, being the
814
830
  keyed block's only child. -->
815
831
  {#each model.cards as c, i (c.id)}
816
- <div class="qm-card-slot" animate:reorder={isReordering}>
832
+ <div class="qm-card-slot" bind:this={slotEls[i]} animate:reorder={isReordering}>
817
833
  <Card
818
834
  bind:this={cardRefs[i]}
819
835
  card={c}
@@ -874,10 +890,12 @@
874
890
  >{marked ? merged.addCard : ''}</DropdownMenu.Trigger
875
891
  >
876
892
  <DropdownMenu.Portal to={rootEl}>
877
- <DropdownMenu.Content sideOffset={4}>
893
+ <DropdownMenu.Content collisionBoundary={rootEl ?? []} sideOffset={4}>
878
894
  <!-- Portalled out of the row but into the stack's root, and carrying the
879
895
  marker itself: floating is still a detached subtree to the
880
- derivation, like FormatPopover and the enum listbox. -->
896
+ derivation, like FormatPopover and the enum listbox. The root is the
897
+ flip's boundary for the reason it is theirs: it is the box that clips
898
+ the surface where the consumer scrolls one. -->
881
899
  <div class="qm-menu-surface" data-qm-root>
882
900
  {#each kinds as k (k)}
883
901
  <DropdownMenu.Item class="qm-menu-item" onSelect={() => addCard(atIndex, k)}