@quillmark/svelte 0.16.0 → 0.18.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 (54) hide show
  1. package/README.md +4 -2
  2. package/dist/core/codec/index.d.ts +0 -3
  3. package/dist/core/rebind.svelte.js +5 -8
  4. package/dist/core/theme.css +12 -1
  5. package/dist/preset/index.d.ts +1 -0
  6. package/dist/preview/Preview.svelte +14 -19
  7. package/dist/preview/Preview.svelte.d.ts +3 -3
  8. package/dist/preview/index.d.ts +0 -1
  9. package/dist/visual/ArrayField.svelte +44 -41
  10. package/dist/visual/ArrayField.svelte.d.ts +0 -1
  11. package/dist/visual/BooleanField.svelte +16 -14
  12. package/dist/visual/BooleanField.svelte.d.ts +0 -1
  13. package/dist/visual/Card.svelte +65 -35
  14. package/dist/visual/CardControls.svelte.d.ts +0 -1
  15. package/dist/visual/DateField.svelte +14 -17
  16. package/dist/visual/DateField.svelte.d.ts +0 -1
  17. package/dist/visual/EnumField.svelte +9 -12
  18. package/dist/visual/EnumField.svelte.d.ts +0 -1
  19. package/dist/visual/Field.svelte +17 -10
  20. package/dist/visual/Field.svelte.d.ts +3 -0
  21. package/dist/visual/FieldHint.svelte.d.ts +0 -1
  22. package/dist/visual/FieldLabel.svelte +1 -2
  23. package/dist/visual/FormatPopover.svelte +22 -27
  24. package/dist/visual/FormatPopover.svelte.d.ts +0 -1
  25. package/dist/visual/MatrixAdd.svelte +210 -0
  26. package/dist/visual/MatrixAdd.svelte.d.ts +15 -0
  27. package/dist/visual/MatrixField.svelte +329 -84
  28. package/dist/visual/MatrixField.svelte.d.ts +8 -6
  29. package/dist/visual/NumberField.svelte +6 -10
  30. package/dist/visual/NumberField.svelte.d.ts +0 -1
  31. package/dist/visual/ObjectField.svelte +30 -17
  32. package/dist/visual/ObjectField.svelte.d.ts +3 -1
  33. package/dist/visual/ProseField.svelte +42 -41
  34. package/dist/visual/ProseField.svelte.d.ts +0 -1
  35. package/dist/visual/ProseValue.svelte +56 -56
  36. package/dist/visual/ProseValue.svelte.d.ts +0 -1
  37. package/dist/visual/SlashMenu.svelte +5 -15
  38. package/dist/visual/SlashMenu.svelte.d.ts +0 -1
  39. package/dist/visual/TextField.svelte +5 -9
  40. package/dist/visual/TextField.svelte.d.ts +0 -1
  41. package/dist/visual/TipsCard.svelte +10 -11
  42. package/dist/visual/TipsCard.svelte.d.ts +0 -1
  43. package/dist/visual/VisualEditorInner.svelte +21 -14
  44. package/dist/visual/VisualEditorInner.svelte.d.ts +0 -1
  45. package/dist/visual/checklists.d.ts +82 -0
  46. package/dist/visual/checklists.js +171 -0
  47. package/dist/visual/index.d.ts +0 -1
  48. package/dist/visual/strings.d.ts +15 -1
  49. package/dist/visual/strings.js +6 -0
  50. package/dist/visual/structure.d.ts +63 -31
  51. package/dist/visual/structure.js +122 -44
  52. package/package.json +20 -16
  53. package/dist/visual/synced.svelte.d.ts +0 -15
  54. package/dist/visual/synced.svelte.js +0 -34
@@ -9,7 +9,6 @@
9
9
  // Ambient from the editor root, with the package's English off-tree, so this
10
10
  // component renders standalone too.
11
11
  const t = wording();
12
- import { untrack } from 'svelte';
13
12
  import Icon from './icons/Icon.svelte';
14
13
  import type { Document, Quill, Addr, Diagnostic } from '@quillmark/wasm';
15
14
  import type { EditorErrorHandler } from '../core/errors.js';
@@ -19,6 +18,7 @@
19
18
  import { holdInView } from './hold.js';
20
19
  import type { FieldDomIds } from './domid.js';
21
20
  import Field from './Field.svelte';
21
+ import FieldHint from './FieldHint.svelte';
22
22
  import ProseField from './ProseField.svelte';
23
23
  import CardControls from './CardControls.svelte';
24
24
  import DiagnosticList from './DiagnosticList.svelte';
@@ -72,15 +72,7 @@
72
72
  leaves
73
73
  }: Props = $props();
74
74
 
75
- // Local title reconcile (external change only), like the scalar controls.
76
- // svelte-ignore state_referenced_locally
77
- let localTitle = $state(card.titleOverride);
78
- $effect(() => {
79
- const incoming = card.titleOverride;
80
- untrack(() => {
81
- if (incoming !== localTitle) localTitle = incoming;
82
- });
83
- });
75
+ let localTitle = $derived(card.titleOverride);
84
76
 
85
77
  // The pre-edit value Escape rolls back to; the rename itself stays live on input.
86
78
  let titleAtFocus = '';
@@ -208,10 +200,8 @@
208
200
  ancestor and nothing else marks the box its guidance belongs in. -->
209
201
  <section
210
202
  bind:this={el}
211
- class="qm-card"
203
+ class={['qm-card', { 'qm-main': card.isMain, 'qm-unschemable': card.unschemable }]}
212
204
  data-qm-card
213
- class:qm-main={card.isMain}
214
- class:qm-unschemable={card.unschemable}
215
205
  >
216
206
  {#if card.unschemable}
217
207
  {@render recoveryShell()}
@@ -252,9 +242,7 @@
252
242
  <div class="qm-card-body">
253
243
  {#if hasMeta}
254
244
  <div
255
- class="qm-card-meta"
256
- class:qm-meta-top={!card.isMain}
257
- class:qm-meta-bottom={card.hasBody}
245
+ class={['qm-card-meta', { 'qm-meta-top': !card.isMain, 'qm-meta-bottom': card.hasBody }]}
258
246
  >
259
247
  {#each ungrouped as section (section.group ?? '_ungrouped')}
260
248
  <div class="qm-section">
@@ -265,26 +253,42 @@
265
253
  <!-- One open at a time. Rendered only when a card has groups, so an empty
266
254
  accordion does not stand a gap under the ungrouped fields. -->
267
255
  {#if grouped.length}
268
- <div class="qm-groups" class:qm-instant={!animate}>
256
+ <div class={['qm-groups', { 'qm-instant': !animate }]}>
269
257
  {#each grouped as section (section.group)}
270
258
  {@const group = section.group as string}
271
259
  {@const isOpen = expanded === group}
272
260
  {@const panelId = ops.panelId(group)}
273
- <div class="qm-group" class:qm-open={isOpen}>
274
- <button
275
- type="button"
276
- class="qm-group-header"
277
- bind:this={headers[group]}
278
- aria-expanded={isOpen}
279
- aria-controls={panelId}
280
- onclick={() => toggleGroup(group)}
281
- >
282
- <Icon
283
- name="chevron-right"
284
- class="qm-group-chevron"
285
- size={CHEVRON}
286
- />{section.label}
287
- </button>
261
+ {@const headIds = section.head ? ops.domIds(section.head.name) : undefined}
262
+ {@const headHint = section.head?.description}
263
+ <div class={['qm-group', { 'qm-open': isOpen }]}>
264
+ <!-- A flat field alone in its section is named by the header, which
265
+ takes its label id and parks its description: the field draws no
266
+ label of its own to say the section's name twice. -->
267
+ <div class="qm-group-head">
268
+ <button
269
+ type="button"
270
+ class="qm-group-header"
271
+ id={headIds?.label}
272
+ bind:this={headers[group]}
273
+ aria-expanded={isOpen}
274
+ aria-controls={panelId}
275
+ onclick={() => toggleGroup(group)}
276
+ >
277
+ <Icon
278
+ name="chevron-right"
279
+ class="qm-group-chevron"
280
+ size={CHEVRON}
281
+ />{section.label}
282
+ </button>
283
+ {#if headIds && headHint}
284
+ <FieldHint
285
+ description={headHint}
286
+ label={section.label}
287
+ describedBy={headIds.description}
288
+ />
289
+ <span class="qm-visually-hidden" id={headIds.description}>{headHint}</span>
290
+ {/if}
291
+ </div>
288
292
  <!-- The panel is clipped, not unmounted, so without `inert` every field
289
293
  in a hidden section keeps its place in the tab order and in the a11y
290
294
  tree, under a header announcing `aria-expanded="false"`. -->
@@ -295,7 +299,7 @@
295
299
  bind:this={panels[group]}
296
300
  >
297
301
  <div class="qm-group-panel-inner">
298
- {@render sectionFields(section.fields)}
302
+ {@render sectionFields(section.fields, section.head)}
299
303
  </div>
300
304
  </div>
301
305
  </div>
@@ -332,7 +336,7 @@
332
336
  {/if}
333
337
  </section>
334
338
 
335
- {#snippet sectionFields(fields: FieldModel[])}
339
+ {#snippet sectionFields(fields: FieldModel[], head?: FieldModel)}
336
340
  <div class="qm-fields qm-tracks">
337
341
  {#each placeFields(fields) as { field: f, span } (mountKey(f.name, f.schema))}
338
342
  <Field
@@ -354,6 +358,7 @@
354
358
  {onError}
355
359
  {leaves}
356
360
  diagnostics={ops.diagFor(f.name)}
361
+ headed={f === head}
357
362
  />
358
363
  {/each}
359
364
  </div>
@@ -591,7 +596,8 @@
591
596
  display: grid;
592
597
  grid-template-columns: repeat(var(--cols), 1fr);
593
598
  column-gap: var(--_qm-space-2);
594
- row-gap: var(--_qm-space-3);
599
+ --row-gap: var(--_qm-space-3);
600
+ row-gap: var(--row-gap);
595
601
  }
596
602
  /* The nesting vertical, one `--_qm-nest` in from the card's edge with the panel's
597
603
  fields the same rung off it (ARCHITECTURE §"A plane is a tone"). `--_qm-border` and
@@ -680,6 +686,30 @@
680
686
  text-align: left;
681
687
  transition: color var(--_qm-duration-fast) var(--_qm-ease-reverse);
682
688
  }
689
+ /* The header row: the press target, and beside it the guidance marker a headed
690
+ section carries (`FieldHint`), which is its own control and so not inside the
691
+ button. Positioned for the reason the header is, the row being the box that
692
+ stands in the band. */
693
+ .qm-group-head {
694
+ position: relative;
695
+ display: flex;
696
+ align-items: center;
697
+ gap: var(--_qm-space);
698
+ }
699
+ .qm-group-head > .qm-group-header {
700
+ flex: 1;
701
+ min-width: 0;
702
+ }
703
+ /* The parked description: `FieldLabel`'s recipe, for the one description a
704
+ header rather than a label parks. */
705
+ .qm-visually-hidden {
706
+ position: absolute;
707
+ width: 1px;
708
+ height: 1px;
709
+ overflow: hidden;
710
+ clip-path: inset(50%);
711
+ white-space: nowrap;
712
+ }
683
713
  .qm-group-header :global(.qm-group-chevron) {
684
714
  flex-shrink: 0;
685
715
  display: block;
@@ -1,4 +1,3 @@
1
- import './controls.css';
2
1
  interface Props {
3
2
  isFirst: boolean;
4
3
  isLast: boolean;
@@ -25,9 +25,9 @@
25
25
  values are data, not input: `parseDate` throws on anything malformed, so a bad
26
26
  string degrades to an empty field rather than taking the editor down with it.
27
27
 
28
- `syncedLocal` reconciles by identity, so the local must hold the string, not the
29
- parsed value: a fresh `CalendarDate` is never `===` the last one, which would
30
- make every reconcile fire and re-render all seven segments on each commit.
28
+ The local is a `$derived`, which compares by identity, so it holds the string, not
29
+ the parsed value: a fresh `CalendarDate` is never `===` the last one, so the
30
+ re-derivation each commit causes would re-render all seven segments.
31
31
 
32
32
  The ghost is the default's digits, not a format hint. An unset field
33
33
  carrying a `default:` prints the default's digits in the segments at the default
@@ -51,7 +51,6 @@
51
51
  import { getLocalTimeZone, parseDate, today, type DateValue } from '@internationalized/date';
52
52
  import Icon from './icons/Icon.svelte';
53
53
  import { wording } from './strings.js';
54
- import { syncedLocal } from './synced.svelte.js';
55
54
  import './controls.css';
56
55
 
57
56
  const t = wording();
@@ -109,31 +108,29 @@
109
108
 
110
109
  const fallbackDate = $derived(toDateValue(fallback?.slice(0, 10) ?? ''));
111
110
  /** The default is the primitive's value: from entering an unset field until an
112
- * edit, a cleared segment or leaving. Part of the projection, so a reconcile
111
+ * edit, a cleared segment or leaving. Part of the projection, so a re-derivation
113
112
  * neither drops a seat nor restores one over the segments a clear left. */
114
113
  let seat = $state(false);
115
- // Local value synced to `value` as a string (see the identity note above);
116
- // own-edits stay local, only an external change reconciles back in. Driven
117
- // controlled (`value` + `onValueChange`, never `bind:`) so reconciliation stays
118
- // the package's.
119
- const local = syncedLocal(
120
- () => value?.slice(0, 10) ?? (seat && fallbackDate ? fallbackDate.toString() : '')
114
+ // Driven controlled (`value` + `onValueChange`, never `bind:`) so reconciliation
115
+ // stays the package's.
116
+ let local = $derived(
117
+ value?.slice(0, 10) ?? (seat && fallbackDate ? fallbackDate.toString() : '')
121
118
  );
122
- const parsed = $derived(toDateValue(local.value));
119
+ const parsed = $derived(toDateValue(local));
123
120
  // The date the empty segments ghost, or undefined when there is nothing to ghost:
124
121
  // the field is unset and the default has a date form. A non-blank local that
125
122
  // fails to parse is AUTHORED-but-malformed, which the empty field states
126
123
  // honestly: the ghost would claim it unset. Held as the parsed value rather than
127
124
  // a boolean so the substitution below narrows on the one fact it needs.
128
- const ghost = $derived(local.value === '' ? fallbackDate : undefined);
129
- const seated = $derived(value == null && seat && local.value !== '');
125
+ const ghost = $derived(local === '' ? fallbackDate : undefined);
126
+ const seated = $derived(value == null && seat && local !== '');
130
127
  /** Who put `today` here, while the field follows the render date. */
131
128
  const follows = $derived(
132
- local.value === TODAY ? 'authored' : value == null && fallback === TODAY ? 'default' : undefined
129
+ local === TODAY ? 'authored' : value == null && fallback === TODAY ? 'default' : undefined
133
130
  );
134
131
 
135
132
  function write(next: string): void {
136
- local.value = next;
133
+ local = next;
137
134
  seat = false;
138
135
  onCommit(next);
139
136
  }
@@ -207,7 +204,7 @@
207
204
  // seated default or `today` would write it.
208
205
  const next = d?.toString() ?? '';
209
206
  if (next === (parsed?.toString() ?? '')) return;
210
- local.value = next;
207
+ local = next;
211
208
  if (!d) seat = false;
212
209
  onCommit(d?.toString());
213
210
  }}
@@ -1,4 +1,3 @@
1
- import './controls.css';
2
1
  interface Props {
3
2
  value: string | undefined;
4
3
  /** The declared `default:` (`YYYY-MM-DD` or `today`): parsed for display only,
@@ -15,7 +15,6 @@
15
15
  <script lang="ts">
16
16
  import { Select } from 'bits-ui';
17
17
  import Icon from './icons/Icon.svelte';
18
- import { syncedLocal } from './synced.svelte.js';
19
18
  import { wording } from './strings.js';
20
19
  import './controls.css';
21
20
 
@@ -67,23 +66,21 @@
67
66
  // the like), so it never collides with a real option.
68
67
  const UNSET = '__qm_unset__';
69
68
 
70
- // Local selection synced to `value` (sentinel when unauthored); own-picks stay
71
- // local, only an external change reconciles back in (see `syncedLocal`). Driven
72
- // controlled (`value` + `onValueChange`, never `bind:`) so reconciliation stays
73
- // the package's rather than the primitive's.
74
- const local = syncedLocal(() => value ?? UNSET);
69
+ // Driven controlled (`value` + `onValueChange`, never `bind:`) so reconciliation
70
+ // stays the package's rather than the primitive's.
71
+ let local = $derived(value ?? UNSET);
75
72
 
76
73
  /** The blank has no glyph of its own: the quill's `ui.blank_title` stands in for
77
74
  * it, else an em dash. */
78
75
  const dash = (v: string | undefined) => v || blankTitle || '—';
79
76
 
80
- const unset = $derived(local.value === UNSET);
77
+ const unset = $derived(local === UNSET);
81
78
  /** The ghost is a member the render prints, rather than a word for the blank or for
82
79
  * `none`: it draws at the default rung (theme.css). */
83
80
  const ghostsMember = $derived(!optional && !!fallback);
84
81
  const ghostText = $derived(optional ? t.strings.optionalGhost : dash(fallback));
85
82
  /** What the closed trigger shows: the pick, or the ghosted default while unset. */
86
- const shown = $derived(unset ? ghostText : dash(local.value));
83
+ const shown = $derived(unset ? ghostText : dash(local));
87
84
 
88
85
  /** The root to portal into: `document.body` would escape the consumer's dials
89
86
  * along with the editor's subtree. `undefined` falls back to bits-ui's default. */
@@ -99,10 +96,10 @@
99
96
  <Select.Root
100
97
  type="single"
101
98
  allowDeselect={false}
102
- value={local.value}
99
+ value={local}
103
100
  onValueChange={(v) => {
104
101
  if (v == null) return;
105
- local.value = v;
102
+ local = v;
106
103
  // Sentinel → unset (parent `removeField`, default renders); any real pick
107
104
  // (incl. the default value) → a genuine write.
108
105
  onCommit(v === UNSET ? undefined : v);
@@ -152,12 +149,12 @@
152
149
  policy when it is the one selected: a listbox whose selected value
153
150
  has no row shows nothing for what the document says, and offers none
154
151
  for the primitive to mark, type-ahead to, or key onto
155
- (VISUAL_EDITOR §"Enum policy"). Off `local.value`, not `value`: the
152
+ (VISUAL_EDITOR §"Enum policy"). Off `local`, not `value`: the
156
153
  row that exists is the row the control has selected, through the
157
154
  window where an own pick has not reconciled back. -->
158
155
  {#each values as v (v)}
159
156
  {@const allowed = optionAllowed?.(v) !== false}
160
- {#if allowed || enumDisallowed === 'disable' || v === local.value}
157
+ {#if allowed || enumDisallowed === 'disable' || v === local}
161
158
  <Select.Item
162
159
  class="qm-menu-item qm-select-item"
163
160
  value={v}
@@ -1,4 +1,3 @@
1
- import './controls.css';
2
1
  interface Props {
3
2
  value: string | undefined;
4
3
  values: string[];
@@ -33,10 +33,12 @@
33
33
  VARIANT_DISCRIMINANT,
34
34
  arrayLayout,
35
35
  baseType,
36
+ flatObject,
36
37
  ghostDefault,
37
38
  isContainer,
38
39
  optionalCell,
39
40
  printedText,
41
+ renderedBoolean,
40
42
  stringifyGhost,
41
43
  titleText
42
44
  } from './structure.js';
@@ -88,6 +90,9 @@
88
90
  * landing handle here, a prose leaf its controller from inside `ProseField`. */
89
91
  leaves?: LeafRegistry;
90
92
  diagnostics?: Diagnostic[];
93
+ /** The section's header names this field and parks its description, under this
94
+ * field's own label and description ids ({@link GroupSection.head}). */
95
+ headed?: boolean;
91
96
  }
92
97
  let {
93
98
  field,
@@ -107,7 +112,8 @@
107
112
  onChange,
108
113
  onError,
109
114
  leaves,
110
- diagnostics
115
+ diagnostics,
116
+ headed = false
111
117
  }: Props = $props();
112
118
 
113
119
  const t = wording();
@@ -157,6 +163,7 @@
157
163
  const describedBy = $derived(field.description ? domIds.description : undefined);
158
164
  // Two controls own their label row, the add chip and the held count sharing it.
159
165
  const ownsLabel = $derived(field.control === 'array' || field.control === 'matrix');
166
+ const flat = $derived(flatObject(field.schema));
160
167
 
161
168
  // A container walks its diagnostics down to the cell each names and draws the rest
162
169
  // at its own foot (`diagnostics.ts`); every other control draws them here, under
@@ -229,12 +236,10 @@
229
236
  * an address to name. `el` is the field-wide box, which a landing at depth does not
230
237
  * wash: that lane hands back its own row (`leaves.ts`).
231
238
  */
232
- let controlEl = $state<HTMLElement | undefined>();
233
- $effect(() => {
234
- if (field.control === 'prose' || !controlEl || !leaves) return;
239
+ function registerControl(wrapper: HTMLElement): (() => void) | undefined {
240
+ if (field.control === 'prose' || !leaves) return;
235
241
  const key = leafKey;
236
242
  const registry = leaves;
237
- const wrapper = controlEl;
238
243
  registry.registerControl(key, {
239
244
  focus: focusControl,
240
245
  focusPath: isContainer(field.control) ? focusPath : undefined,
@@ -243,7 +248,7 @@
243
248
  }
244
249
  });
245
250
  return () => registry.unregisterControl(key);
246
- });
251
+ }
247
252
 
248
253
  /**
249
254
  * A form control has no controller to report its focus through, so the wrapper
@@ -256,8 +261,8 @@
256
261
  const reportFocus = $derived(field.control === 'prose' ? undefined : () => onFocus?.(addr));
257
262
  </script>
258
263
 
259
- <div class="qm-field" class:cell={span === 'cell'}>
260
- {#if !ownsLabel}
264
+ <div class={['qm-field', { cell: span === 'cell' }]}>
265
+ {#if !ownsLabel && !headed}
261
266
  <FieldLabel
262
267
  label={field.label}
263
268
  controlId={labelable ? domIds.control : undefined}
@@ -273,7 +278,7 @@
273
278
  rule below holds why). One markup for both spans; a field owning its row nests a
274
279
  level and measures the same. -->
275
280
  <div class="qm-field-stack">
276
- <div class="qm-field-control" bind:this={controlEl} onfocusin={reportFocus}>
281
+ <div class="qm-field-control" {@attach registerControl} onfocusin={reportFocus}>
277
282
  {#if field.control === 'prose'}
278
283
  <ProseField
279
284
  {quill}
@@ -336,7 +341,7 @@
336
341
  />
337
342
  {:else if field.control === 'boolean'}
338
343
  <BooleanField
339
- value={value as boolean | undefined}
344
+ value={renderedBoolean(value)}
340
345
  fallback={ghost as boolean | undefined}
341
346
  {optional}
342
347
  id={domIds.control}
@@ -376,6 +381,7 @@
376
381
  bind:this={objectEl}
377
382
  value={value as Record<string, unknown> | undefined}
378
383
  properties={field.schema.properties}
384
+ {flat}
379
385
  label={field.label}
380
386
  idBase={domIds.control}
381
387
  labelledBy={domIds.label}
@@ -394,6 +400,7 @@
394
400
  labelId={domIds.label}
395
401
  descriptionId={domIds.description}
396
402
  idBase={domIds.control}
403
+ listKey={leafKey}
397
404
  {contentAt}
398
405
  onCommit={onCommitScalar}
399
406
  diagnostics={deep}
@@ -35,6 +35,9 @@ interface Props {
35
35
  * landing handle here, a prose leaf its controller from inside `ProseField`. */
36
36
  leaves?: LeafRegistry;
37
37
  diagnostics?: Diagnostic[];
38
+ /** The section's header names this field and parks its description, under this
39
+ * field's own label and description ids ({@link GroupSection.head}). */
40
+ headed?: boolean;
38
41
  }
39
42
  declare const Field: import("svelte").Component<Props, {}, "">;
40
43
  type Field = ReturnType<typeof Field>;
@@ -1,4 +1,3 @@
1
- import './controls.css';
2
1
  interface Props {
3
2
  description: string;
4
3
  /** The field's label: the trigger's own name, so 30 info buttons on a card
@@ -65,8 +65,7 @@
65
65
  label is not a tab stop, and the control it hands focus to is already the
66
66
  keyboard's own way in. -->
67
67
  <label
68
- class="qm-field-label"
69
- class:targeted={controlId != null || onActivate != null}
68
+ class={['qm-field-label', { targeted: controlId != null || onActivate != null }]}
70
69
  {id}
71
70
  for={controlId}
72
71
  onclick={onActivate}
@@ -52,6 +52,7 @@
52
52
  selection the command is supposed to act on.
53
53
  -->
54
54
  <script lang="ts">
55
+ import { onDestroy } from 'svelte';
55
56
  import { wording } from './strings.js';
56
57
 
57
58
  // The surface's words, ambient from the editor root; the package's English
@@ -105,7 +106,6 @@
105
106
  /** Whether the selection carried a link when the prompt was raised. */
106
107
  let linkPresent = $state(false);
107
108
  let linkValue = $state('');
108
- let linkInputEl = $state<HTMLInputElement | undefined>(undefined);
109
109
  let contentEl = $state<HTMLElement | undefined>(undefined);
110
110
  /** The root to portal into: `document.body` escapes the editor's subtree and
111
111
  * the consumer's dials with it, so a pane-scoped palette misses this surface.
@@ -212,25 +212,11 @@
212
212
  deferredSync();
213
213
  }
214
214
 
215
- $effect(() => {
216
- document.addEventListener('selectionchange', deferredSync);
217
- document.addEventListener('focusout', deferredSync);
218
- // Capture, so the gesture is seen whatever a view in the middle of it does with
219
- // the event. `pointercancel` is the release a gesture the browser takes over gets.
220
- document.addEventListener('pointerdown', onPointerDown, true);
221
- document.addEventListener('pointerup', onPointerUp, true);
222
- document.addEventListener('pointercancel', onPointerUp, true);
223
- return () => {
224
- document.removeEventListener('selectionchange', deferredSync);
225
- document.removeEventListener('focusout', deferredSync);
226
- document.removeEventListener('pointerdown', onPointerDown, true);
227
- document.removeEventListener('pointerup', onPointerUp, true);
228
- document.removeEventListener('pointercancel', onPointerUp, true);
229
- // A burst-coalescing rAF may still be in flight at teardown; cancel it so
230
- // `sync()` never runs against the destroyed component.
231
- if (pending) cancelAnimationFrame(rafHandle);
232
- pending = false;
233
- };
215
+ // A burst-coalescing rAF may still be in flight at teardown; cancel it so
216
+ // `sync()` never runs against the destroyed component.
217
+ onDestroy(() => {
218
+ if (pending) cancelAnimationFrame(rafHandle);
219
+ pending = false;
234
220
  });
235
221
 
236
222
  // Any close path that bypasses `sync` (e.g. Escape while the link input holds
@@ -245,12 +231,10 @@
245
231
  // surface already open, so without this the URL is typed into the document.
246
232
  // Selected rather than merely focused, the input arriving seeded and replacing
247
233
  // that value being what the prompt is usually raised for.
248
- $effect(() => {
249
- if (linkPromptOpen && linkInputEl) {
250
- linkInputEl.focus();
251
- linkInputEl.select();
252
- }
253
- });
234
+ function focusPrompt(input: HTMLInputElement): void {
235
+ input.focus();
236
+ input.select();
237
+ }
254
238
 
255
239
  /** Swallow the button's mousedown so focus/selection never leave the leaf. */
256
240
  function keepFocus(e: MouseEvent): void {
@@ -338,6 +322,17 @@
338
322
  }
339
323
  </script>
340
324
 
325
+ <!-- The pointer handlers capture, so the gesture is seen whatever a view in the middle of
326
+ it does with the event. `pointercancel` is the release a gesture the browser takes
327
+ over gets. -->
328
+ <svelte:document
329
+ onselectionchange={deferredSync}
330
+ onfocusout={deferredSync}
331
+ onpointerdowncapture={onPointerDown}
332
+ onpointerupcapture={onPointerUp}
333
+ onpointercancelcapture={onPointerUp}
334
+ />
335
+
341
336
  <Popover.Root bind:open>
342
337
  <Popover.Portal to={portalTarget}>
343
338
  <!-- A flip is measured against the root the surface portals into, which is the box
@@ -383,7 +378,7 @@
383
378
  }}
384
379
  >
385
380
  <input
386
- bind:this={linkInputEl}
381
+ {@attach focusPrompt}
387
382
  class="qm-link-input"
388
383
  type="text"
389
384
  placeholder={t.strings.linkPlaceholder}
@@ -1,5 +1,4 @@
1
1
  import { type FieldController } from '../core/codec/index.js';
2
- import './controls.css';
3
2
  interface Props {
4
3
  /** The observation seam VisualEditor exposes over its `leaves` registry. */
5
4
  getActiveLeaf: () => FieldController | undefined;