@quillmark/svelte 0.12.0 → 0.14.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 (46) hide show
  1. package/NOTICE +3 -2
  2. package/dist/core/codec/decode.d.ts +17 -0
  3. package/dist/core/codec/decode.js +57 -4
  4. package/dist/core/codec/field.d.ts +63 -15
  5. package/dist/core/codec/field.js +201 -65
  6. package/dist/core/codec/index.d.ts +3 -3
  7. package/dist/core/codec/index.js +3 -3
  8. package/dist/core/codec/keymap.d.ts +5 -4
  9. package/dist/core/codec/keymap.js +5 -4
  10. package/dist/core/codec/prose.css +42 -0
  11. package/dist/core/codec/schema.d.ts +24 -5
  12. package/dist/core/codec/schema.js +40 -9
  13. package/dist/core/errors.d.ts +3 -2
  14. package/dist/core/theme.css +9 -6
  15. package/dist/preview/paint.js +21 -17
  16. package/dist/visual/ArrayField.svelte +66 -22
  17. package/dist/visual/ArrayField.svelte.d.ts +2 -0
  18. package/dist/visual/BooleanField.svelte +28 -6
  19. package/dist/visual/BooleanField.svelte.d.ts +2 -0
  20. package/dist/visual/Card.svelte +4 -3
  21. package/dist/visual/DateField.svelte +158 -40
  22. package/dist/visual/DateField.svelte.d.ts +5 -2
  23. package/dist/visual/EnumField.svelte +27 -9
  24. package/dist/visual/EnumField.svelte.d.ts +5 -0
  25. package/dist/visual/Field.svelte +48 -11
  26. package/dist/visual/FormatPopover.svelte +4 -3
  27. package/dist/visual/MatrixField.svelte +3 -2
  28. package/dist/visual/NumberField.svelte +73 -29
  29. package/dist/visual/NumberField.svelte.d.ts +5 -1
  30. package/dist/visual/ObjectField.svelte +63 -15
  31. package/dist/visual/ProseField.svelte +53 -23
  32. package/dist/visual/ProseField.svelte.d.ts +15 -3
  33. package/dist/visual/ProseValue.svelte +102 -46
  34. package/dist/visual/ProseValue.svelte.d.ts +17 -5
  35. package/dist/visual/TextField.svelte +35 -18
  36. package/dist/visual/TextField.svelte.d.ts +7 -0
  37. package/dist/visual/VariantField.svelte +1 -0
  38. package/dist/visual/VisualEditorInner.svelte +24 -24
  39. package/dist/visual/controls.css +16 -5
  40. package/dist/visual/icons/nodes.d.ts +28 -0
  41. package/dist/visual/icons/nodes.js +14 -1
  42. package/dist/visual/strings.d.ts +11 -1
  43. package/dist/visual/strings.js +3 -0
  44. package/dist/visual/structure.d.ts +77 -43
  45. package/dist/visual/structure.js +138 -64
  46. package/package.json +2 -2
@@ -1,23 +1,32 @@
1
1
  <!--
2
2
  A `number` / `integer` field → numeric input (fixture `font_size` = 11.5).
3
3
  Commits at `change` (blur/Enter), not per keystroke: a partial numeric entry
4
- (`-`, `1.`, `1e`) is never a document state worth a boundary round-trip, and
4
+ (`-`, `1.`) is never a document state worth a boundary round-trip, and
5
5
  committing it live flashes a coercion diagnostic + `console.error` on every
6
6
  intermediate prefix, announced by `DiagnosticList`'s `role="status"` live
7
- region. A blank entry commits `undefined`: the unset rung of the
8
- commitment ladder: the parent removes the field and the engine renders the
9
- ghosted `default:`. Settling at `change` also keeps
10
- select-all-and-retype from flashing the preview through the default mid-keystroke.
7
+ region.
11
8
 
12
- `type="text"`, not `type="number"`: a native number input sanitizes an
13
- invalid string to `""` before the DOM `value` setter even runs (verified:
14
- `.value = "abc"` on `type="number"` never lands), which would make a
15
- genuinely bad entry untypeable. The commit-time coercion diagnostic
16
- (VISUAL_EDITOR §Diagnostics) needs exactly that path reachable through the
17
- UI, so a non-blank entry that fails to parse forwards the raw string to
18
- `onCommit` unchanged: the boundary's own `writer.set` coercion is the judge
19
- (throws a `QuillmarkError` the parent turns into a field diagnostic), not a
20
- client-side guess. `inputmode` keeps the numeric mobile keyboard.
9
+ An unset field holds its resolved `default:` as its text, at the default rung
10
+ (theme.css), and an edit that settles writes it as authored, as `TextField` takes
11
+ its default. A number has no empty answer (canon `SCHEMAS.md` §"Native validation"),
12
+ so a blank entry commits `undefined`, the unset rung: the parent removes the field,
13
+ the engine renders the default, and the input holds it again.
14
+
15
+ The entry grammar is plain decimal: an optional sign and digits, and for a `number`
16
+ a `.` and an exponent, the form `String()` gives a value too small or large for
17
+ digits. A keystroke that leaves the text outside a prefix of it does not land, and in
18
+ a `number` a `,` lands as `.`, the decimal key of a comma locale's keypad. A deletion
19
+ always lands, so a stored value outside the grammar (`abc`) can be cleared, and a
20
+ paste, a drop or an IME composition lands as it comes, judged when it settles.
21
+ `type="text"`, not `type="number"`: a native number input sanitizes an invalid
22
+ string to `""` rather than showing it, and steps on a scroll-wheel. `inputmode`
23
+ keeps the numeric mobile keyboard.
24
+
25
+ A settled entry in the grammar commits as a number. Anything else forwards the raw
26
+ string to `onCommit`: the boundary's `writer.set` coercion is the judge (throws a
27
+ `QuillmarkError` the parent turns into a field diagnostic, VISUAL_EDITOR
28
+ §Diagnostics). `Number()` alone is no gate: it reads `0x1F` and `Infinity` as
29
+ numbers, and `1.000` as a whole one.
21
30
  -->
22
31
  <script lang="ts">
23
32
  import { syncedLocal } from './synced.svelte.js';
@@ -26,7 +35,11 @@
26
35
  interface Props {
27
36
  value: number | undefined;
28
37
  integer?: boolean;
29
- fallback?: number;
38
+ /** The resolved `default:` as text: what an unset field holds. */
39
+ fallback?: string;
40
+ /** Words at rest about an unset field that holds no text: the `None` an optional
41
+ * cell prints. */
42
+ placeholder?: string;
30
43
  /** Accessible name for an input nothing else names: an object property, whose
31
44
  * name is the field label plus the property's. A field's own input takes `id`
32
45
  * instead and is named by the `<label for>` beside it. */
@@ -38,21 +51,50 @@
38
51
  describedBy?: string;
39
52
  onCommit: (v: number | string | undefined) => void;
40
53
  }
41
- let { value, integer, fallback, label, id, describedBy, onCommit }: Props = $props();
54
+ let { value, integer, fallback, placeholder, label, id, describedBy, onCommit }: Props = $props();
55
+
56
+ // Local input state synced to `value` (as a string projection), or to the default
57
+ // an unset field holds; own-typing stays local, only an external change reconciles
58
+ // back in (see `syncedLocal`).
59
+ const local = syncedLocal(() => (value != null ? String(value) : (fallback ?? '')));
60
+ const defaulted = $derived(value == null && !!fallback && local.value === fallback);
42
61
 
43
- // Local input state synced to `value` (as a string projection); own-typing
44
- // stays local, only an external change reconciles back in (see `syncedLocal`).
45
- const local = syncedLocal(() => (value != null ? String(value) : ''));
62
+ const partial = $derived(
63
+ integer ? /^\s*[+-]?\d*\s*$/ : /^\s*[+-]?\d*\.?\d*(?:[eE][+-]?\d*)?\s*$/
64
+ );
65
+ const whole = $derived(integer ? /^[+-]?\d+$/ : /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/);
66
+
67
+ function filter(e: InputEvent): void {
68
+ if (e.inputType !== 'insertText' || e.data == null) return;
69
+ const el = e.currentTarget as HTMLInputElement;
70
+ const from = el.selectionStart ?? el.value.length;
71
+ const to = el.selectionEnd ?? el.value.length;
72
+ const data = !integer && e.data === ',' ? '.' : e.data;
73
+ const next = el.value.slice(0, from) + data + el.value.slice(to);
74
+ if (!partial.test(next)) return e.preventDefault();
75
+ if (data === e.data) return;
76
+ e.preventDefault();
77
+ // `insertText` lands on the undo stack and fires `input`; `setRangeText` does neither.
78
+ if (document.execCommand?.('insertText', false, data)) return;
79
+ el.setRangeText(data, from, to, 'end');
80
+ local.value = el.value;
81
+ }
46
82
 
47
83
  // Parse a settled entry and emit it; `local` is owned by `oninput`. Blank →
48
- // `undefined` (the unset rung: parent removes the field, default renders).
49
- function commit(raw: string): void {
50
- if (raw.trim() === '') return void onCommit(undefined);
51
- // Number(), not parseFloat/parseInt: a prefix parse would silently commit
52
- // `14.5` for `14.5x` (and truncate `11.9` → 11 on integer fields) instead
53
- // of letting the boundary judge the full entry.
54
- const n = Number(raw);
55
- onCommit(Number.isNaN(n) ? raw : n);
84
+ // `undefined`, and the input takes the default back: nothing else reconciles it, the
85
+ // projection having read the default throughout. Onto the element as well, since the
86
+ // attribute Svelte last wrote may already be the default, and it writes no repeat.
87
+ function commit(el: HTMLInputElement): void {
88
+ const raw = el.value;
89
+ const entry = raw.trim();
90
+ if (entry === '') {
91
+ onCommit(undefined);
92
+ local.value = el.value = fallback ?? '';
93
+ return;
94
+ }
95
+ // Finite as well: a digit run past `Number.MAX_VALUE` reads as `Infinity`.
96
+ const n = whole.test(entry) ? Number(entry) : NaN;
97
+ onCommit(Number.isFinite(n) ? n : raw);
56
98
  }
57
99
  </script>
58
100
 
@@ -62,11 +104,13 @@
62
104
  inputmode={integer ? 'numeric' : 'decimal'}
63
105
  value={local.value}
64
106
  {id}
65
- placeholder={fallback != null ? String(fallback) : ''}
107
+ {placeholder}
108
+ data-default={defaulted ? '' : undefined}
66
109
  aria-label={id ? undefined : label}
67
110
  aria-describedby={describedBy}
111
+ onbeforeinput={filter}
68
112
  oninput={(e) => {
69
113
  local.value = (e.currentTarget as HTMLInputElement).value;
70
114
  }}
71
- onchange={(e) => commit((e.currentTarget as HTMLInputElement).value)}
115
+ onchange={(e) => commit(e.currentTarget as HTMLInputElement)}
72
116
  />
@@ -2,7 +2,11 @@ import './controls.css';
2
2
  interface Props {
3
3
  value: number | undefined;
4
4
  integer?: boolean;
5
- fallback?: number;
5
+ /** The resolved `default:` as text: what an unset field holds. */
6
+ fallback?: string;
7
+ /** Words at rest about an unset field that holds no text: the `None` an optional
8
+ * cell prints. */
9
+ placeholder?: string;
6
10
  /** Accessible name for an input nothing else names: an object property, whose
7
11
  * name is the field label plus the property's. A field's own input takes `id`
8
12
  * instead and is named by the `<label for>` beside it. */
@@ -19,10 +19,10 @@
19
19
  An array element's properties hang a rung further in ({@link ArrayField}), the summary
20
20
  above them standing for the whole row.
21
21
 
22
- A property's ghosted `default:` is the static schema `sub.default`, not the
23
- resolved provenance the top-level ghosts read (FIELD_PROVENANCE): `resolve`
24
- carries no per-property row (an object field resolves as one row whose value is
25
- the whole object).
22
+ A property's `default:`, which an unset cell holds until an edit takes it, is the
23
+ static schema `sub.default`, not the resolved provenance a field reads
24
+ (FIELD_PROVENANCE): `resolve` carries no per-property row (an object field resolves
25
+ as one row whose value is the whole object).
26
26
 
27
27
  Every caller hands down `contentAt`, which is `reader.getContentAt` with the field
28
28
  already bound: a field-level subform passes the property key through untouched, a
@@ -34,8 +34,20 @@
34
34
  -->
35
35
  <script lang="ts">
36
36
  import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
37
- import { emptyContent } from '../core/codec/index.js';
38
- import { arrayLayout, controlKind, humanize, isContainer, obliged } from './structure.js';
37
+ import {
38
+ arrayLayout,
39
+ baseType,
40
+ controlKind,
41
+ declaredContent,
42
+ exampleGhost,
43
+ humanize,
44
+ isContainer,
45
+ mountKey,
46
+ obliged,
47
+ optionalCell,
48
+ printedText,
49
+ shortCell
50
+ } from './structure.js';
39
51
  import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
40
52
  import type { LandingBox } from './leaves.js';
41
53
  import { wording } from './strings.js';
@@ -117,7 +129,19 @@
117
129
  * (VISUAL_EDITOR §"Enum variants"). */
118
130
  const required = obliged;
119
131
 
120
- const title = (key: string, sub: QuillFieldSchema): string => sub.ui?.title ?? humanize(key);
132
+ const title = (key: string, sub: QuillFieldSchema): string => sub.title ?? humanize(key);
133
+ /** What an unset optional cell ghosts, which is what it prints: `none`. An answered
134
+ * cell ghosts nothing, an empty answer printing empty. */
135
+ const noneOf = (key: string, sub: QuillFieldSchema): string | undefined =>
136
+ obj[key] == null && optionalCell(sub) ? t.strings.optionalGhost : undefined;
137
+ /** What an unset free-text cell ghosts where nothing prints ({@link exampleGhost}). */
138
+ const exampleOf = (key: string, sub: QuillFieldSchema): string | undefined =>
139
+ obj[key] == null ? exampleGhost(sub) : undefined;
140
+ /** A scalar cell's `default:` as the text an unset cell holds, where it prints. */
141
+ const textDefault = (sub: QuillFieldSchema): string | undefined => printedText(sub.default);
142
+ /** A content cell's `default:` as the content an unset leaf holds, where it prints. */
143
+ const contentDefault = (sub: QuillFieldSchema): Content | undefined =>
144
+ declaredContent(sub.default, baseType(sub) === 'richtext');
121
145
  /** The `aria-label` fallback, for a subform mounted without a field's id space:
122
146
  * the field's name and the property's, since nothing else names the control. */
123
147
  const fallbackName = (key: string, sub: QuillFieldSchema): string =>
@@ -237,7 +261,7 @@
237
261
  <!-- The grid is one box in, so the wrapper can be the query container it reads its
238
262
  capacity from: a container cannot query itself. -->
239
263
  <div class="qm-object-grid qm-tracks">
240
- {#each entries as [key, sub] (key)}
264
+ {#each entries as [key, sub] (mountKey(key, sub))}
241
265
  {@const kind = controlKind(sub)}
242
266
  {@const ids = idBase && !bare ? propertyDomIds(idBase, key) : undefined}
243
267
  {@const named = ids ? undefined : fallbackName(key, sub)}
@@ -255,8 +279,14 @@
255
279
  kind === 'boolean'}
256
280
  <!-- A container takes the full span at every depth: its rows are the document's
257
281
  to count, and a track beside it would stand in a column of whitespace
258
- (`packable`). -->
259
- <div class="qm-object-prop" class:qm-prop-full={isContainer(kind)} data-qm-prop={key}>
282
+ (`packable`). A block prose cell spans it too, and keeps its label row. -->
283
+ {@const block = kind === 'prose' && !shortCell(sub)}
284
+ <div
285
+ class="qm-object-prop"
286
+ class:qm-prop-full={isContainer(kind)}
287
+ class:qm-prop-wide={block}
288
+ data-qm-prop={key}
289
+ >
260
290
  {#if ids && kind !== 'array' && kind !== 'matrix'}
261
291
  <FieldLabel
262
292
  label={title(key, sub)}
@@ -276,6 +306,8 @@
276
306
  value={obj[key] as string | undefined}
277
307
  values={sub.values ?? []}
278
308
  fallback={sub.default as string | undefined}
309
+ blankTitle={sub.ui?.blank_title}
310
+ optional={optionalCell(sub)}
279
311
  onCommit={(v) => commitProp(key, v)}
280
312
  />
281
313
  {:else if kind === 'number'}
@@ -284,8 +316,9 @@
284
316
  id={ids?.control}
285
317
  describedBy={describes}
286
318
  value={obj[key] as number | undefined}
287
- integer={sub.type === 'integer'}
288
- fallback={sub.default as number | undefined}
319
+ integer={baseType(sub) === 'integer'}
320
+ fallback={textDefault(sub)}
321
+ placeholder={noneOf(key, sub)}
289
322
  onCommit={(v) => commitProp(key, v)}
290
323
  />
291
324
  {:else if kind === 'boolean'}
@@ -295,6 +328,7 @@
295
328
  describedBy={describes}
296
329
  value={obj[key] as boolean | undefined}
297
330
  fallback={sub.default as boolean | undefined}
331
+ optional={optionalCell(sub)}
298
332
  onCommit={(v) => commitProp(key, v)}
299
333
  />
300
334
  {:else if kind === 'date'}
@@ -304,6 +338,7 @@
304
338
  describedBy={describes}
305
339
  value={obj[key] as string | undefined}
306
340
  fallback={sub.default != null ? String(sub.default) : undefined}
341
+ datetime={baseType(sub) === 'datetime'}
307
342
  onCommit={(v) => commitProp(key, v)}
308
343
  />
309
344
  {:else if kind === 'text'}
@@ -312,7 +347,9 @@
312
347
  id={ids?.control}
313
348
  describedBy={describes}
314
349
  value={obj[key] as string | undefined}
315
- placeholder={sub.default != null ? String(sub.default) : undefined}
350
+ fallback={textDefault(sub)}
351
+ placeholder={noneOf(key, sub)}
352
+ example={exampleOf(key, sub)}
316
353
  onCommit={(v) => commitProp(key, v)}
317
354
  onKey={onCellKey ? (e) => onCellKey(e, key) : undefined}
318
355
  />
@@ -321,8 +358,12 @@
321
358
  leaves the leaf mounted and the caret in it. -->
322
359
  <ProseValue
323
360
  bind:this={proseEls[key]}
324
- content={() => contentAt([key]) ?? emptyContent()}
325
- plaintext={sub.type === 'plaintext'}
361
+ content={() => contentAt([key])}
362
+ fallback={contentDefault(sub)}
363
+ plaintext={baseType(sub) === 'plaintext'}
364
+ placeholder={noneOf(key, sub)}
365
+ example={exampleOf(key, sub)}
366
+ {block}
326
367
  label={named}
327
368
  labelledBy={ids?.label}
328
369
  describedBy={describes}
@@ -338,6 +379,7 @@
338
379
  <ArrayField
339
380
  bind:this={nestedEls[key]}
340
381
  value={obj[key] as unknown[] | undefined}
382
+ fallback={Array.isArray(sub.default) ? sub.default : undefined}
341
383
  items={sub.items}
342
384
  layout={arrayLayout(sub)}
343
385
  max={sub.max}
@@ -469,6 +511,12 @@
469
511
  .qm-object-prop:only-child {
470
512
  grid-column: span var(--cols-half);
471
513
  }
514
+ /* A block prose property holds paragraphs, so a track beside it stands in whitespace
515
+ once it grows (`packable`); its label still rides the row above it. */
516
+ .qm-object-prop.qm-prop-wide,
517
+ .qm-object-prop.qm-prop-wide:only-child {
518
+ grid-column: 1 / -1;
519
+ }
472
520
  /* A container property takes the whole row and its own rows: its label rides inside
473
521
  the control it draws (a repeater's header, a matrix's) or above a subform of its
474
522
  own, and neither is a track a scalar's label shares. */
@@ -11,7 +11,7 @@
11
11
  import SlashMenu from './SlashMenu.svelte';
12
12
  import { wording } from './strings.js';
13
13
  import './controls.css';
14
- import type { Document, Quill, Addr } from '@quillmark/wasm';
14
+ import type { Document, Quill, Addr, Content, Diagnostic } from '@quillmark/wasm';
15
15
  import type { EditorErrorHandler } from '../core/errors.js';
16
16
 
17
17
  interface Props {
@@ -37,9 +37,18 @@
37
37
  labelledBy?: string;
38
38
  /** The parked `description` (FieldLabel) → `aria-describedby`. */
39
39
  describedBy?: string;
40
- /** Ghost shown on the empty leaf: an inline leaf's resolved `default:` or
41
- * nothing, a body's always text (`resolveBodyGhost`). */
40
+ /** The resolved `default:` where it prints: what an unset field's leaf holds
41
+ * until an edit takes it (`createField`). */
42
+ fallback?: Content;
43
+ /** Ghost shown on the empty leaf: the `none` an optional field prints unset, or
44
+ * nothing, and a body's always text (`resolveBodyGhost`). */
42
45
  placeholder?: string;
46
+ /** The placeholder goes at the leaf's first edit (`createField`). */
47
+ placeholderUntilEdit?: boolean;
48
+ /** Ghost shown on the empty leaf until its first edit, and in the placeholder's
49
+ * stead while the leaf holds the focus: an unset field's `example:`
50
+ * (`exampleGhost`). */
51
+ example?: string;
43
52
  /** Registry identity, stamped on the DOM node so a remount is visible as one. */
44
53
  leafKey: string;
45
54
  onFocus?: (addr: Addr) => void;
@@ -50,6 +59,9 @@
50
59
  /** The editor's leaf registry (`leaves.ts`). Registering the controller is what
51
60
  * makes this leaf a caret target and not merely a focus one. */
52
61
  leaves?: LeafRegistry;
62
+ /** The diagnostics routed to this field: each set re-evaluates a hold
63
+ * (`createField`), a re-validation being what follows an external write. */
64
+ diagnostics?: Diagnostic[];
53
65
  }
54
66
 
55
67
  let {
@@ -62,16 +74,23 @@
62
74
  label,
63
75
  labelledBy,
64
76
  describedBy,
77
+ fallback,
65
78
  placeholder,
79
+ placeholderUntilEdit,
80
+ example,
66
81
  leafKey,
67
82
  onFocus,
68
83
  onCaretMove,
69
84
  onChange,
70
85
  onError,
71
- leaves
86
+ leaves,
87
+ diagnostics
72
88
  }: Props = $props();
73
89
 
74
90
  let containerEl: HTMLDivElement | undefined = $state();
91
+ let held = $state(false);
92
+ const uid = $props.id();
93
+ const heldId = `${uid}-held`;
75
94
 
76
95
  // Handed to the codec as a getter: the island chrome redraws on each render, so a
77
96
  // locale swap reaches a mounted table without remounting the leaf and losing the caret.
@@ -82,10 +101,9 @@
82
101
  * trigger mounting on the block schema alone. */
83
102
  let slash: SlashState | undefined = $state();
84
103
 
85
- // The codec's own schema predicate (`createField`: `plaintext` implies `inline`), so
86
- // the box a leaf draws and the schema it holds cannot disagree. On `inline` alone a
87
- // `plaintext` field would take the block floor while holding one paragraph.
88
- const block = $derived(!(inline || plaintext));
104
+ // The codec's own schema predicate (`leafSchema`: `inline` is one textblock at either
105
+ // type), so the box a leaf draws and the schema it holds cannot disagree.
106
+ const block = $derived(!inline);
89
107
 
90
108
  let controller: FieldController | undefined;
91
109
 
@@ -107,7 +125,10 @@
107
125
  label,
108
126
  labelledBy,
109
127
  describedBy,
128
+ fallback,
110
129
  placeholder,
130
+ placeholderUntilEdit,
131
+ example,
111
132
  tableStrings: () => t.strings,
112
133
  onSlash: (next) => {
113
134
  slash = next;
@@ -115,7 +136,11 @@
115
136
  onFocus,
116
137
  onCaretMove,
117
138
  onChange,
118
- onError
139
+ onError,
140
+ heldNoteId: heldId,
141
+ onHold: (next) => {
142
+ held = next;
143
+ }
119
144
  });
120
145
  leaves?.registerProse(leafKey, controller);
121
146
  return () => {
@@ -126,10 +151,23 @@
126
151
  });
127
152
 
128
153
  // A retype does not remount the leaf (its key is the card's session id), so the new
129
- // kind's ghost is pushed into the live view rather than paid for with the caret.
154
+ // kind's ghost and default are pushed into the live view rather than paid for with
155
+ // the caret.
130
156
  $effect(() => {
131
157
  controller?.setPlaceholder(placeholder);
132
158
  });
159
+ $effect(() => {
160
+ controller?.setExample(example);
161
+ });
162
+ $effect(() => {
163
+ controller?.setFallback(fallback);
164
+ });
165
+
166
+ // Only a narrowed or a plain leaf can hold, so only one of them asks.
167
+ $effect(() => {
168
+ void diagnostics;
169
+ if (inline || plaintext) controller?.applyExternal();
170
+ });
133
171
  </script>
134
172
 
135
173
  <div
@@ -139,7 +177,11 @@
139
177
  class:qm-focus-ring-within={!unframed}
140
178
  class:qm-prose-block={block}
141
179
  data-leaf-key={leafKey}
142
- ></div>
180
+ >
181
+ {#if held}
182
+ <span id={heldId} class="qm-prose-held-note">{t.strings.proseHeld}</span>
183
+ {/if}
184
+ </div>
143
185
  <SlashMenu menu={slash} leaf={() => controller} label={t.strings.slashLabel} />
144
186
 
145
187
  <style>
@@ -179,16 +221,4 @@
179
221
  .qm-prose :global(.ProseMirror) {
180
222
  outline: none;
181
223
  }
182
- /* Empty-leaf ghost, at the rung every scalar's placeholder takes: a node decoration's
183
- data attr, so it stays out of the document; `float`/`height: 0` keep it from
184
- displacing the caret. The italic is what a leaf with no written neighbour has
185
- instead of the step to `ink`. */
186
- .qm-prose :global(.ProseMirror .qm-prose-placeholder::before) {
187
- content: attr(data-placeholder);
188
- color: var(--_qm-ink-label);
189
- font-style: italic;
190
- float: left;
191
- height: 0;
192
- pointer-events: none;
193
- }
194
224
  </style>
@@ -1,6 +1,6 @@
1
1
  import type { LeafRegistry } from './leaves.js';
2
2
  import './controls.css';
3
- import type { Document, Quill, Addr } from '@quillmark/wasm';
3
+ import type { Document, Quill, Addr, Content, Diagnostic } from '@quillmark/wasm';
4
4
  import type { EditorErrorHandler } from '../core/errors.js';
5
5
  interface Props {
6
6
  doc: Document;
@@ -25,9 +25,18 @@ interface Props {
25
25
  labelledBy?: string;
26
26
  /** The parked `description` (FieldLabel) → `aria-describedby`. */
27
27
  describedBy?: string;
28
- /** Ghost shown on the empty leaf: an inline leaf's resolved `default:` or
29
- * nothing, a body's always text (`resolveBodyGhost`). */
28
+ /** The resolved `default:` where it prints: what an unset field's leaf holds
29
+ * until an edit takes it (`createField`). */
30
+ fallback?: Content;
31
+ /** Ghost shown on the empty leaf: the `none` an optional field prints unset, or
32
+ * nothing, and a body's always text (`resolveBodyGhost`). */
30
33
  placeholder?: string;
34
+ /** The placeholder goes at the leaf's first edit (`createField`). */
35
+ placeholderUntilEdit?: boolean;
36
+ /** Ghost shown on the empty leaf until its first edit, and in the placeholder's
37
+ * stead while the leaf holds the focus: an unset field's `example:`
38
+ * (`exampleGhost`). */
39
+ example?: string;
31
40
  /** Registry identity, stamped on the DOM node so a remount is visible as one. */
32
41
  leafKey: string;
33
42
  onFocus?: (addr: Addr) => void;
@@ -38,6 +47,9 @@ interface Props {
38
47
  /** The editor's leaf registry (`leaves.ts`). Registering the controller is what
39
48
  * makes this leaf a caret target and not merely a focus one. */
40
49
  leaves?: LeafRegistry;
50
+ /** The diagnostics routed to this field: each set re-evaluates a hold
51
+ * (`createField`), a re-validation being what follows an external write. */
52
+ diagnostics?: Diagnostic[];
41
53
  }
42
54
  declare const ProseField: import("svelte").Component<Props, {
43
55
  focus: () => void;