@quillmark/svelte 0.1.0 → 0.2.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 (64) hide show
  1. package/README.md +15 -11
  2. package/THEMING.md +42 -23
  3. package/dist/core/address.d.ts +39 -4
  4. package/dist/core/address.js +47 -19
  5. package/dist/core/codec/encode.js +4 -1
  6. package/dist/core/codec/field.d.ts +1 -1
  7. package/dist/core/codec/prose.css +41 -18
  8. package/dist/core/codec/table.js +3 -1
  9. package/dist/core/errors.d.ts +15 -5
  10. package/dist/core/index.d.ts +1 -1
  11. package/dist/core/index.js +5 -4
  12. package/dist/core/lifecycle.d.ts +21 -5
  13. package/dist/core/lifecycle.js +34 -8
  14. package/dist/core/rebind.svelte.d.ts +16 -2
  15. package/dist/core/rebind.svelte.js +26 -8
  16. package/dist/core/theme.css +50 -18
  17. package/dist/preset/recipes.css +55 -8
  18. package/dist/preset/scale.css +5 -4
  19. package/dist/preview/Preview.svelte +10 -9
  20. package/dist/preview/Preview.svelte.d.ts +5 -5
  21. package/dist/preview/bridge.d.ts +6 -4
  22. package/dist/preview/bridge.js +17 -7
  23. package/dist/preview/controller.d.ts +13 -6
  24. package/dist/preview/controller.js +5 -2
  25. package/dist/preview/geometry.d.ts +16 -1
  26. package/dist/preview/geometry.js +20 -0
  27. package/dist/preview/index.d.ts +1 -0
  28. package/dist/preview/index.js +7 -0
  29. package/dist/preview/overlay.d.ts +2 -1
  30. package/dist/preview/overlay.js +24 -9
  31. package/dist/preview/paint.js +4 -0
  32. package/dist/visual/ArrayField.svelte +26 -20
  33. package/dist/visual/ArrayField.svelte.d.ts +4 -3
  34. package/dist/visual/Card.svelte +21 -19
  35. package/dist/visual/Card.svelte.d.ts +8 -3
  36. package/dist/visual/DiagnosticList.svelte +1 -1
  37. package/dist/visual/EnumField.svelte +34 -14
  38. package/dist/visual/EnumField.svelte.d.ts +4 -3
  39. package/dist/visual/Field.svelte +87 -25
  40. package/dist/visual/Field.svelte.d.ts +11 -7
  41. package/dist/visual/FieldHint.svelte +59 -10
  42. package/dist/visual/FormatPopover.svelte +3 -1
  43. package/dist/visual/ObjectField.svelte +21 -2
  44. package/dist/visual/ObjectField.svelte.d.ts +3 -1
  45. package/dist/visual/ProseArrayElement.svelte +1 -6
  46. package/dist/visual/ProseArrayElement.svelte.d.ts +0 -1
  47. package/dist/visual/ProseField.svelte +20 -12
  48. package/dist/visual/ProseField.svelte.d.ts +5 -3
  49. package/dist/visual/TipsCard.svelte +1 -1
  50. package/dist/visual/VisualEditor.svelte +12 -13
  51. package/dist/visual/VisualEditor.svelte.d.ts +2 -3
  52. package/dist/visual/VisualEditorInner.svelte +140 -66
  53. package/dist/visual/VisualEditorInner.svelte.d.ts +2 -3
  54. package/dist/visual/controls.css +14 -11
  55. package/dist/visual/diagnostics.d.ts +12 -20
  56. package/dist/visual/diagnostics.js +6 -18
  57. package/dist/visual/index.d.ts +1 -0
  58. package/dist/visual/index.js +7 -0
  59. package/dist/visual/leaves.d.ts +47 -0
  60. package/dist/visual/leaves.js +31 -0
  61. package/dist/visual/props.d.ts +27 -6
  62. package/dist/visual/tips.d.ts +3 -1
  63. package/dist/visual/tips.js +5 -2
  64. package/package.json +3 -5
@@ -16,7 +16,7 @@
16
16
  <script lang="ts">
17
17
  import type { Document, Quill, Addr, Diagnostic, ResolvedField } from '@quillmark/wasm';
18
18
  import type { EditorErrorHandler } from '../core/errors.js';
19
- import type { FieldController } from '../core/codec/index.js';
19
+ import type { LeafRegistry } from './leaves.js';
20
20
  import type { FieldModel, FieldSpan } from './structure.js';
21
21
  import { enumValues, ghostDefault, stringifyGhost } from './structure.js';
22
22
  import type { FieldDomIds } from './domid.js';
@@ -42,22 +42,26 @@
42
42
  doc: Document;
43
43
  /** The schema a prose leaf reads its content through (`ProseField`). */
44
44
  quill: Quill;
45
- /** LIVE prose address (getter-`card`); used only when control === 'prose'. */
46
- proseAddr: Addr;
45
+ /** This field's LIVE address (getter-`card`, so a card reorder re-targets in
46
+ * place): the prose leaf commits to it, and a focus reports it. */
47
+ addr: Addr;
47
48
  leafKey: string;
48
49
  /** This field's three DOM names, derived from `leafKey` (see `domid.ts`): how
49
50
  * the label and the control find each other. */
50
51
  domIds: FieldDomIds;
51
52
  onCommitScalar: (value: unknown) => void;
52
- /** Enum-option policy: `false` disables that option. Only the enum
53
- * control reads it; other controls ignore it. */
53
+ /** Enum-option policy: `false` marks that option unavailable. Only the enum
54
+ * control reads this pair. */
54
55
  optionAllowed?: (value: string) => boolean;
56
+ /** How a refused option draws: greyed (default) or left out. */
57
+ enumDisallowed?: 'hide' | 'disable';
55
58
  onFocus?: (addr: Addr) => void;
56
59
  onCaretMove?: (addr: Addr, pos: number) => void;
57
60
  onChange?: (addr: Addr) => void;
58
61
  onError?: EditorErrorHandler;
59
- register?: (key: string, controller: FieldController) => void;
60
- unregister?: (key: string) => void;
62
+ /** The editor's leaf registry (`leaves.ts`): a form control registers its
63
+ * landing handle here, a prose leaf its controller from inside `ProseField`. */
64
+ leaves?: LeafRegistry;
61
65
  diagnostics?: Diagnostic[];
62
66
  }
63
67
  let {
@@ -67,17 +71,17 @@
67
71
  provenance,
68
72
  doc,
69
73
  quill,
70
- proseAddr,
74
+ addr,
71
75
  leafKey,
72
76
  domIds,
73
77
  onCommitScalar,
74
78
  optionAllowed,
79
+ enumDisallowed,
75
80
  onFocus,
76
81
  onCaretMove,
77
82
  onChange,
78
83
  onError,
79
- register,
80
- unregister,
84
+ leaves,
81
85
  diagnostics
82
86
  }: Props = $props();
83
87
 
@@ -103,18 +107,66 @@
103
107
  // nothing, and silently.
104
108
  const describedBy = $derived(field.description ? domIds.description : undefined);
105
109
 
106
- // Focus handoff for the two controls a label click cannot reach natively. Both
107
- // expose their own `focus()` because "focusing" differs: a PM view restores a
108
- // selection, a date field lands on its first segment.
110
+ // ── Focus: one answer, two callers (`leaves.ts`) ─────────────────────────────
111
+ // A label click and the editor's `focusField`/`setCaret` ask the same question, so
112
+ // they read the same function and cannot land in different places. The four
113
+ // labelable controls are reached through the DOM id `for` already points at; the
114
+ // rest own what focusing means and expose `focus()`, because it differs: a PM view
115
+ // restores a selection, a date field lands on its first segment, an array lands on
116
+ // its first element or on the add affordance that is all an empty one has, an
117
+ // object on its first property.
109
118
  let proseEl = $state<{ focus: () => void } | undefined>();
110
119
  let dateEl = $state<{ focus: () => void } | undefined>();
111
- const onActivate = $derived(
112
- field.control === 'prose'
113
- ? () => proseEl?.focus()
114
- : field.control === 'date'
115
- ? () => dateEl?.focus()
116
- : undefined
117
- );
120
+ let arrayEl = $state<{ focus: () => void; focusElement: (k: number) => void } | undefined>();
121
+ let objectEl = $state<{ focus: () => void } | undefined>();
122
+ function focusControl(): void {
123
+ const owner = proseEl ?? dateEl ?? arrayEl ?? objectEl;
124
+ if (owner) return owner.focus();
125
+ document.getElementById(domIds.control)?.focus();
126
+ }
127
+ /** The array's per-ELEMENT landing, for the addresses the preview mints under an
128
+ * array field (`leaves.ts`); read at the call, so it tracks the mounted repeater. */
129
+ function focusElement(k: number): void {
130
+ arrayEl?.focusElement(k);
131
+ }
132
+ // Only where `for` cannot reach; the labelable four are the browser's own, and a
133
+ // second handler over them would be a focus the label already placed.
134
+ const onActivate = $derived(labelable ? undefined : focusControl);
135
+
136
+ /**
137
+ * This field's landing handle. The wrapper is the bloom host rather than the
138
+ * control: `bloomInside` appends an inset child and an `<input>` holds none.
139
+ *
140
+ * A prose leaf is absent here — it registers its own controller from inside
141
+ * `ProseField`, carrying the codec seam this handle has no half of — and reactive
142
+ * rather than mount-once, because a retype can swap the control under a leaf key
143
+ * that does not remount.
144
+ *
145
+ * An array carries the per-element lane as well; no other control has elements for
146
+ * an address to name.
147
+ */
148
+ let controlEl = $state<HTMLElement | undefined>();
149
+ $effect(() => {
150
+ if (field.control === 'prose' || !controlEl || !leaves) return;
151
+ const key = leafKey;
152
+ const registry = leaves;
153
+ registry.registerControl(key, {
154
+ focus: focusControl,
155
+ focusElement: field.control === 'array' ? focusElement : undefined,
156
+ el: controlEl
157
+ });
158
+ return () => registry.unregisterControl(key);
159
+ });
160
+
161
+ /**
162
+ * A form control has no controller to report its focus through, so the WRAPPER
163
+ * reports: `focusin` bubbles, so one handler covers a plain input, an array's N
164
+ * elements and an object's properties alike, and the active leaf names a scalar
165
+ * field the way it names a prose one. A prose leaf reports through its own
166
+ * controller — the source that drives its caret signals too — and is excluded here
167
+ * rather than counted twice.
168
+ */
169
+ const reportFocus = $derived(field.control === 'prose' ? undefined : () => onFocus?.(addr));
118
170
  </script>
119
171
 
120
172
  <div class="qm-field" class:cell={span === 'cell'} class:lone={span === 'lone'}>
@@ -129,13 +181,13 @@
129
181
  description={field.description}
130
182
  />
131
183
  {/if}
132
- <div class="qm-field-control">
184
+ <div class="qm-field-control" bind:this={controlEl} onfocusin={reportFocus}>
133
185
  {#if field.control === 'prose'}
134
186
  <ProseField
135
187
  {quill}
136
188
  bind:this={proseEl}
137
189
  {doc}
138
- addr={proseAddr}
190
+ {addr}
139
191
  inline={field.inline}
140
192
  plaintext={field.plaintext}
141
193
  labelledBy={domIds.label}
@@ -145,8 +197,7 @@
145
197
  {onCaretMove}
146
198
  {onChange}
147
199
  {onError}
148
- {register}
149
- {unregister}
200
+ {leaves}
150
201
  />
151
202
  {:else if field.control === 'enum'}
152
203
  <EnumField
@@ -157,6 +208,7 @@
157
208
  {describedBy}
158
209
  onCommit={onCommitScalar}
159
210
  {optionAllowed}
211
+ {enumDisallowed}
160
212
  />
161
213
  {:else if field.control === 'number'}
162
214
  <NumberField
@@ -186,6 +238,7 @@
186
238
  />
187
239
  {:else if field.control === 'array'}
188
240
  <ArrayField
241
+ bind:this={arrayEl}
189
242
  value={value as unknown[] | undefined}
190
243
  items={field.schema.items}
191
244
  label={field.label}
@@ -194,10 +247,10 @@
194
247
  labelId={domIds.label}
195
248
  descriptionId={domIds.description}
196
249
  onCommit={onCommitScalar}
197
- onFocusEl={() => onFocus?.(proseAddr)}
198
250
  />
199
251
  {:else if field.control === 'object'}
200
252
  <ObjectField
253
+ bind:this={objectEl}
201
254
  value={value as Record<string, unknown> | undefined}
202
255
  properties={field.schema.properties}
203
256
  label={field.label}
@@ -262,6 +315,15 @@
262
315
  .qm-field.cell {
263
316
  grid-column: span 1;
264
317
  }
318
+ /* Positioned for the arrival wash a landing inserts (`core/bloom.ts`), the way
319
+ `.qm-prose` is: an inset child over the control, since an `<input>` takes none.
320
+ The radius is the control's own, so the wash's corners are the box's rather than
321
+ square over a rounded one; on an array or a subform it bounds a group, where
322
+ there is no single box for it to disagree with. */
323
+ .qm-field-control {
324
+ position: relative;
325
+ border-radius: var(--_qm-radius-inner);
326
+ }
265
327
  /* A run of one takes half the capacity from column 1: `--cols-half` is the section's
266
328
  capacity halved, so the edge lands on a track boundary at every capacity. */
267
329
  .qm-field.lone {
@@ -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 { FieldController } from '../core/codec/index.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 {
@@ -14,22 +14,26 @@ interface Props {
14
14
  doc: Document;
15
15
  /** The schema a prose leaf reads its content through (`ProseField`). */
16
16
  quill: Quill;
17
- /** LIVE prose address (getter-`card`); used only when control === 'prose'. */
18
- proseAddr: Addr;
17
+ /** This field's LIVE address (getter-`card`, so a card reorder re-targets in
18
+ * place): the prose leaf commits to it, and a focus reports it. */
19
+ addr: Addr;
19
20
  leafKey: string;
20
21
  /** This field's three DOM names, derived from `leafKey` (see `domid.ts`): how
21
22
  * the label and the control find each other. */
22
23
  domIds: FieldDomIds;
23
24
  onCommitScalar: (value: unknown) => void;
24
- /** Enum-option policy: `false` disables that option. Only the enum
25
- * control reads it; other controls ignore it. */
25
+ /** Enum-option policy: `false` marks that option unavailable. Only the enum
26
+ * control reads this pair. */
26
27
  optionAllowed?: (value: string) => boolean;
28
+ /** How a refused option draws: greyed (default) or left out. */
29
+ enumDisallowed?: 'hide' | 'disable';
27
30
  onFocus?: (addr: Addr) => void;
28
31
  onCaretMove?: (addr: Addr, pos: number) => void;
29
32
  onChange?: (addr: Addr) => void;
30
33
  onError?: EditorErrorHandler;
31
- register?: (key: string, controller: FieldController) => void;
32
- unregister?: (key: string) => void;
34
+ /** The editor's leaf registry (`leaves.ts`): a form control registers its
35
+ * landing handle here, a prose leaf its controller from inside `ProseField`. */
36
+ leaves?: LeafRegistry;
33
37
  diagnostics?: Diagnostic[];
34
38
  }
35
39
  declare const Field: import("svelte").Component<Props, {}, "">;
@@ -45,6 +45,39 @@
45
45
  /** The root to portal INTO: `document.body` would escape the consumer's dials
46
46
  * along with the editor's subtree. `undefined` falls back to bits-ui's default. */
47
47
  const portalTarget = $derived(triggerEl?.closest<HTMLElement>('[data-qm-root]') ?? undefined);
48
+
49
+ /** Placement, re-measured on each opening: the surface rides the label rung beside
50
+ * the glyph, or stands over the field. */
51
+ let beside = $state(false);
52
+
53
+ /** Whether this description sets one line. Off a probe rather than the surface
54
+ * itself, since the side is a prop the surface is positioned FROM, and a side chosen
55
+ * after the fact moves a surface the reader is already looking at. The probe wears
56
+ * the surface's own class inside the portal target, so it wraps to the same measure
57
+ * under the same dials; `width: max-content` keeps that measure the class's rather
58
+ * than the containing block's. Line boxes are counted off a range over the text
59
+ * rather than derived from a height, which would need the line rung and the inset
60
+ * restated here as numbers. A DOM with no layout yields none, which stands the
61
+ * surface over the field. */
62
+ function ridesTheRung(): boolean {
63
+ const host = portalTarget ?? document.body;
64
+ const probe = document.createElement('div');
65
+ probe.className = 'qm-hint-popover';
66
+ probe.style.cssText =
67
+ 'position:absolute;width:max-content;visibility:hidden;pointer-events:none';
68
+ probe.textContent = description;
69
+ host.appendChild(probe);
70
+ const range = document.createRange();
71
+ range.selectNodeContents(probe);
72
+ const lines = range.getClientRects().length;
73
+ probe.remove();
74
+ return lines === 1;
75
+ }
76
+
77
+ function raise(): void {
78
+ beside = ridesTheRung();
79
+ open = true;
80
+ }
48
81
  </script>
49
82
 
50
83
  <!-- The trigger is OURS, not `Popover.Trigger`, and the surface anchors to it:
@@ -60,7 +93,7 @@
60
93
  aria-expanded={open}
61
94
  aria-describedby={describedBy}
62
95
  onpointerenter={(e) => {
63
- if (e.pointerType !== 'touch') open = true;
96
+ if (e.pointerType !== 'touch') raise();
64
97
  }}
65
98
  onpointerleave={(e) => {
66
99
  if (e.pointerType !== 'touch') open = false;
@@ -69,13 +102,16 @@
69
102
  // Touch only, and a TOGGLE: the one modality with no hover to leave and no
70
103
  // blur on the way out. A mouse press falls through to hover, which already
71
104
  // has it open and will close it on the way off.
72
- if (e.pointerType === 'touch') open = !open;
105
+ if (e.pointerType === 'touch') {
106
+ if (open) open = false;
107
+ else raise();
108
+ }
73
109
  }}
74
110
  onfocus={(e) => {
75
111
  // Keyboard arrival only. A pointer press focuses the button too, and opening
76
112
  // on that would re-open what the tap just toggled shut; `:focus-visible` is
77
113
  // the UA's own answer to which arrival this was.
78
- if (e.currentTarget.matches(':focus-visible')) open = true;
114
+ if (e.currentTarget.matches(':focus-visible')) raise();
79
115
  }}
80
116
  onblur={() => (open = false)}
81
117
  onkeydown={(e) => {
@@ -88,12 +124,21 @@
88
124
  }}><Icon name="info" size={13} /></button
89
125
  >
90
126
 
127
+ <!-- PLACEMENT IS THE DESCRIPTION'S HEIGHT. One line rides the label rung beside its own
128
+ glyph, where the overhang past the rung's line box falls in the label-to-control gaps.
129
+ Anything taller stands over the field: beside the glyph it would hang onto THE CONTROL
130
+ IT DESCRIBES, the one overlap a reader cannot clear by reaching for what is hidden,
131
+ since a pointer leaving the trigger closes the surface. Over the field it covers a
132
+ neighbour's control instead, which reaching for it dismisses. -->
133
+ <!-- `align="center"` for the rung, `"start"` over the field: centred, a surface taller
134
+ than the glyph's line box would hang half its height onto the field's own control. -->
135
+
91
136
  <Popover.Root bind:open>
92
137
  <Popover.Portal to={portalTarget}>
93
138
  <Popover.Content
94
139
  customAnchor={triggerEl ?? null}
95
- side="top"
96
- align="start"
140
+ side={beside ? 'right' : 'top'}
141
+ align={beside ? 'center' : 'start'}
97
142
  sideOffset={6}
98
143
  trapFocus={false}
99
144
  onOpenAutoFocus={(e: Event) => e.preventDefault()}
@@ -168,18 +213,22 @@
168
213
  }
169
214
  .qm-field-hint:hover,
170
215
  .qm-field-hint:focus-visible {
171
- color: var(--_qm-ink-meta);
216
+ color: var(--_qm-ink-label);
172
217
  }
173
218
  /* The lift, the translucency, the blur and the scale-in come from
174
219
  `.qm-popover-surface` (controls.css). This surface's own is a MEASURE: guidance
175
220
  is prose, and prose past ~40 characters a line stops being scannable; plus the
176
221
  inset and the meta type rung that keep it a note about a field rather than a
177
- second field. */
178
- .qm-hint-popover {
222
+ second field.
223
+
224
+ Unscoped, because the placement probe wears this class on a node Svelte never
225
+ compiled: a scoped rule would leave the probe wrapping to a different measure
226
+ than the surface it stands in for, which is the one thing the probe must not do. */
227
+ :global(.qm-hint-popover) {
179
228
  max-width: 22rem;
180
229
  padding: var(--_qm-space) var(--_qm-space-2);
181
- font-size: var(--_qm-text-meta);
230
+ font-size: var(--_qm-text-label);
182
231
  line-height: var(--_qm-leading-body);
183
- color: var(--_qm-ink-meta);
232
+ color: var(--_qm-ink-label);
184
233
  }
185
234
  </style>
@@ -385,10 +385,12 @@
385
385
  gap: var(--_qm-space);
386
386
  align-items: center;
387
387
  }
388
+ /* The box's recipe without `.qm-input`, which adds the `width: 100%` that would
389
+ stretch this to the popover. */
388
390
  .qm-link-input {
389
391
  font: inherit;
390
392
  font-size: var(--_qm-text-body);
391
- padding: var(--_qm-space) var(--_qm-space-2);
393
+ padding: var(--_qm-inset-control);
392
394
  border: var(--_qm-border-width) solid var(--_qm-border);
393
395
  border-radius: var(--_qm-radius-inner);
394
396
  min-width: 12rem;
@@ -37,6 +37,19 @@
37
37
  const entries = $derived(Object.entries(properties ?? {}));
38
38
  const obj = $derived((value ?? {}) as Record<string, unknown>);
39
39
 
40
+ /** Take the caret: the FIRST property's control, a subform having no single control
41
+ * of its own to land on. Resolved off the DOM rather than a ref per property: every
42
+ * property is a scalar control the DOM already knows how to focus (the date field's
43
+ * segments carry `tabindex`, the `literal` separators between them do not), so a
44
+ * handle each would be five refs restating document order. An empty or wholly
45
+ * unsupported subform lands nothing. */
46
+ let rootEl = $state<HTMLElement | undefined>();
47
+ export function focus(): void {
48
+ rootEl
49
+ ?.querySelector<HTMLElement>('input, select, button, [tabindex]:not([tabindex="-1"])')
50
+ ?.focus();
51
+ }
52
+
40
53
  function commitProp(key: string, v: unknown): void {
41
54
  if (v === undefined) {
42
55
  // A nested control cleared (the unset rung): drop the key so the property
@@ -51,7 +64,13 @@
51
64
  }
52
65
  </script>
53
66
 
54
- <div class="qm-object" role="group" aria-labelledby={labelledBy} aria-describedby={describedBy}>
67
+ <div
68
+ bind:this={rootEl}
69
+ class="qm-object"
70
+ role="group"
71
+ aria-labelledby={labelledBy}
72
+ aria-describedby={describedBy}
73
+ >
55
74
  {#each entries as [key, sub] (key)}
56
75
  {@const kind = controlKind(sub)}
57
76
  {@const propLabel = `${label != null ? `${label} ` : ''}${sub.ui?.title ?? humanize(key)}`}
@@ -117,7 +136,7 @@
117
136
  font-size: var(--_qm-text-label);
118
137
  /* Soft (500), a rung under the top-level field label's 600: a nested object
119
138
  prop reads as secondary. Resolves the label-weight disagreement. */
120
- font-weight: var(--_qm-weight-soft);
139
+ font-weight: var(--_qm-weight-label);
121
140
  /* A label is a line, not a passage, so it overrides the root's reading
122
141
  rhythm the way every other label rung does. */
123
142
  line-height: var(--_qm-leading-tight);
@@ -12,6 +12,8 @@ interface Props {
12
12
  describedBy?: string;
13
13
  onCommit: (obj: Record<string, unknown>) => void;
14
14
  }
15
- declare const ObjectField: import("svelte").Component<Props, {}, "">;
15
+ declare const ObjectField: import("svelte").Component<Props, {
16
+ focus: () => void;
17
+ }, "">;
16
18
  type ObjectField = ReturnType<typeof ObjectField>;
17
19
  export default ObjectField;
@@ -27,9 +27,8 @@
27
27
  * array repeater's Enter/Backspace (`ArrayField`). Fires BEFORE the view's own
28
28
  * keymap, so the state it reads is the one this keystroke has yet to change. */
29
29
  onKey?: (e: KeyboardEvent) => void;
30
- onFocusEl?: () => void;
31
30
  }
32
- let { value, plaintext, label, onChange, onKey, onFocusEl }: Props = $props();
31
+ let { value, plaintext, label, onChange, onKey }: Props = $props();
33
32
 
34
33
  let containerEl: HTMLDivElement | undefined = $state();
35
34
  let view: EditorView | undefined;
@@ -62,10 +61,6 @@
62
61
  if (tr.docChanged) onChange(pmToContent(next.doc));
63
62
  },
64
63
  handleDOMEvents: {
65
- focus: () => {
66
- onFocusEl?.();
67
- return false;
68
- },
69
64
  keydown: (_v, e) => {
70
65
  onKey?.(e);
71
66
  return false;
@@ -10,7 +10,6 @@ interface Props {
10
10
  * array repeater's Enter/Backspace (`ArrayField`). Fires BEFORE the view's own
11
11
  * keymap, so the state it reads is the one this keystroke has yet to change. */
12
12
  onKey?: (e: KeyboardEvent) => void;
13
- onFocusEl?: () => void;
14
13
  }
15
14
  declare const ProseArrayElement: import("svelte").Component<Props, {
16
15
  focus: () => void;
@@ -10,6 +10,7 @@
10
10
  <script lang="ts">
11
11
  import { onMount } from 'svelte';
12
12
  import { createField, type FieldController, type SlashState } from '../core/codec/index.js';
13
+ import type { LeafRegistry } from './leaves.js';
13
14
  import SlashMenu from './SlashMenu.svelte';
14
15
  import { wording } from './strings.js';
15
16
  import './controls.css';
@@ -54,8 +55,10 @@
54
55
  /** A commit landed on this leaf: the prose change lane. */
55
56
  onChange?: (addr: Addr) => void;
56
57
  onError?: EditorErrorHandler;
57
- register?: (key: string, controller: FieldController) => void;
58
- unregister?: (key: string) => void;
58
+ /** The editor's leaf registry (`leaves.ts`). A prose leaf registers its
59
+ * CONTROLLER: the landing handle plus the codec seam a form control has no half
60
+ * of, which is what makes this leaf a caret target and not merely a focus one. */
61
+ leaves?: LeafRegistry;
59
62
  }
60
63
 
61
64
  let {
@@ -74,8 +77,7 @@
74
77
  onCaretMove,
75
78
  onChange,
76
79
  onError,
77
- register,
78
- unregister
80
+ leaves
79
81
  }: Props = $props();
80
82
 
81
83
  let containerEl: HTMLDivElement | undefined = $state();
@@ -134,9 +136,9 @@
134
136
  onChange,
135
137
  onError
136
138
  });
137
- register?.(leafKey, controller);
139
+ leaves?.registerProse(leafKey, controller);
138
140
  return () => {
139
- unregister?.(leafKey);
141
+ leaves?.unregisterProse(leafKey);
140
142
  controller?.destroy();
141
143
  controller = undefined;
142
144
  };
@@ -172,16 +174,22 @@
172
174
  own surface. */
173
175
  position: relative;
174
176
  }
175
- /* A leaf the inline schema does NOT constrain (the body) is paper rather than a
176
- cell in a row of controls, so it opens at a few lines and grows. The floor is
177
- three LINE BOXES, not a length: size times leading is what one line measures, so
178
- both factors are named or the expression stops meaning the three lines it claims.
179
- It moves with the type ramp and with nothing else. Three is what an EMPTY body
177
+ /* A leaf the inline schema does NOT constrain is prose rather than a cell in a row
178
+ of controls, so it opens at a few lines and grows. The floor is three LINE BOXES,
179
+ not a length: `1em` rather than a size rung because which rung this leaf reads is
180
+ the rule below's, and a second naming of it drifts. Three is what an EMPTY body
180
181
  costs, which is the only state the floor is ever the height: the ghost takes the
181
182
  first line, so the opening reads as an invitation with room under it rather than
182
183
  as a drop. */
183
184
  .qm-prose-block {
184
- min-height: calc(var(--_qm-text-body) * var(--_qm-leading-body) * 3);
185
+ min-height: calc(1em * var(--_qm-leading-body) * 3);
186
+ }
187
+ /* PAPER, and the box is what says so. Not `block` alone: `inline` is the quill's to
188
+ declare, so a schema omitting it on a richtext field puts a block leaf in a field
189
+ row, where the paper rung would break the height agreement `controls.css` builds by
190
+ construction. */
191
+ .qm-prose-block:not(.qm-control-box) {
192
+ font-size: var(--_qm-text-paper);
185
193
  }
186
194
  /* The caret is the prose leaf's focus indicator, not a ring: a ring around a
187
195
  contenteditable reads as form chrome rather than as paper. So the outline is
@@ -1,4 +1,4 @@
1
- import { type FieldController } from '../core/codec/index.js';
1
+ import type { LeafRegistry } from './leaves.js';
2
2
  import './controls.css';
3
3
  import type { Document, Quill, Addr } from '@quillmark/wasm';
4
4
  import type { EditorErrorHandler } from '../core/errors.js';
@@ -40,8 +40,10 @@ interface Props {
40
40
  /** A commit landed on this leaf: the prose change lane. */
41
41
  onChange?: (addr: Addr) => void;
42
42
  onError?: EditorErrorHandler;
43
- register?: (key: string, controller: FieldController) => void;
44
- unregister?: (key: string) => void;
43
+ /** The editor's leaf registry (`leaves.ts`). A prose leaf registers its
44
+ * CONTROLLER: the landing handle plus the codec seam a form control has no half
45
+ * of, which is what makes this leaf a caret target and not merely a focus one. */
46
+ leaves?: LeafRegistry;
45
47
  }
46
48
  declare const ProseField: import("svelte").Component<Props, {
47
49
  focus: () => void;
@@ -185,7 +185,7 @@
185
185
  button carries a label and so widens its inset and takes the meta type rung. */
186
186
  .qm-tips-action {
187
187
  padding: var(--_qm-space-half) var(--_qm-space-2);
188
- font-size: var(--_qm-text-meta);
188
+ font-size: var(--_qm-text-label);
189
189
  color: var(--_qm-ink-label);
190
190
  }
191
191
  </style>
@@ -24,9 +24,8 @@
24
24
  -->
25
25
  <script lang="ts">
26
26
  import { untrack } from 'svelte';
27
- import { reportError } from '../core/errors.js';
28
- import type { ContentHit } from '@quillmark/wasm';
29
- import type { DocPath } from '../core/address.js';
27
+ import { reportRebindIgnored } from '../core/rebind.svelte.js';
28
+ import type { DocPath, Landing } from '../core/address.js';
30
29
  import type { CardId } from './signals.js';
31
30
  import type { VisualEditorProps } from './props.js';
32
31
  import VisualEditorInner from './VisualEditorInner.svelte';
@@ -54,12 +53,10 @@
54
53
  }
55
54
  if (quill === boundQuill || reported) return;
56
55
  reported = true;
57
- reportError(props.onError, {
58
- code: 'rebind-ignored',
59
- severity: 'dev',
60
- message:
61
- 'quill swapped in place; the mounted leaves still commit against the quill this doc mounted with. Swap the doc with it, or remount the editor, to rebind.'
62
- });
56
+ reportRebindIgnored(
57
+ props.onError,
58
+ 'quill swapped in place; the mounted leaves still commit against the quill this doc mounted with. Swap the doc with it, or remount the editor, to rebind.'
59
+ );
63
60
  });
64
61
  });
65
62
 
@@ -67,11 +64,13 @@
67
64
  // landing between a swap and the incoming mount is a no-op rather than a throw,
68
65
  // which is what lets a host hold ONE `bind:this` across two documents.
69
66
 
70
- /** Place the caret at a preview hit. */
71
- export async function setCaret(hit: ContentHit): Promise<void> {
72
- await inner?.setCaret(hit);
67
+ /** Land a preview pick: a caret where the payload carries one and the leaf can
68
+ * spend it, an array element where the address names one, a focus otherwise. */
69
+ export async function setCaret(at: Landing): Promise<void> {
70
+ await inner?.setCaret(at);
73
71
  }
74
- /** Reveal and focus the leaf at `field`, without placing a caret inside it. */
72
+ /** Reveal and focus the field at `field` — any mounted one — without placing a
73
+ * caret inside it. */
75
74
  export async function focusField(field: DocPath): Promise<void> {
76
75
  await inner?.focusField(field);
77
76
  }
@@ -1,9 +1,8 @@
1
- import type { ContentHit } from '@quillmark/wasm';
2
- import type { DocPath } from '../core/address.js';
1
+ import type { DocPath, Landing } from '../core/address.js';
3
2
  import type { CardId } from './signals.js';
4
3
  import type { VisualEditorProps } from './props.js';
5
4
  declare const VisualEditor: import("svelte").Component<VisualEditorProps, {
6
- setCaret: (hit: ContentHit) => Promise<void>;
5
+ setCaret: (at: Landing) => Promise<void>;
7
6
  focusField: (field: DocPath) => Promise<void>;
8
7
  insertCard: (kind: string, at?: number) => CardId | undefined;
9
8
  removeCard: (cardId: CardId) => void;