@quillmark/svelte 0.12.0 → 0.13.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 (42) hide show
  1. package/dist/core/codec/decode.d.ts +17 -0
  2. package/dist/core/codec/decode.js +57 -4
  3. package/dist/core/codec/field.d.ts +64 -15
  4. package/dist/core/codec/field.js +201 -65
  5. package/dist/core/codec/index.d.ts +3 -3
  6. package/dist/core/codec/index.js +3 -3
  7. package/dist/core/codec/keymap.d.ts +5 -4
  8. package/dist/core/codec/keymap.js +5 -4
  9. package/dist/core/codec/prose.css +42 -0
  10. package/dist/core/codec/schema.d.ts +24 -5
  11. package/dist/core/codec/schema.js +40 -9
  12. package/dist/core/errors.d.ts +3 -2
  13. package/dist/core/theme.css +9 -6
  14. package/dist/preview/paint.js +21 -17
  15. package/dist/visual/ArrayField.svelte +66 -22
  16. package/dist/visual/ArrayField.svelte.d.ts +2 -0
  17. package/dist/visual/BooleanField.svelte +28 -6
  18. package/dist/visual/BooleanField.svelte.d.ts +2 -0
  19. package/dist/visual/Card.svelte +3 -3
  20. package/dist/visual/DateField.svelte +66 -31
  21. package/dist/visual/EnumField.svelte +27 -9
  22. package/dist/visual/EnumField.svelte.d.ts +5 -0
  23. package/dist/visual/Field.svelte +45 -10
  24. package/dist/visual/FormatPopover.svelte +4 -3
  25. package/dist/visual/MatrixField.svelte +3 -2
  26. package/dist/visual/NumberField.svelte +31 -14
  27. package/dist/visual/NumberField.svelte.d.ts +5 -1
  28. package/dist/visual/ObjectField.svelte +62 -15
  29. package/dist/visual/ProseField.svelte +53 -23
  30. package/dist/visual/ProseField.svelte.d.ts +15 -3
  31. package/dist/visual/ProseValue.svelte +102 -46
  32. package/dist/visual/ProseValue.svelte.d.ts +17 -5
  33. package/dist/visual/TextField.svelte +35 -18
  34. package/dist/visual/TextField.svelte.d.ts +7 -0
  35. package/dist/visual/VariantField.svelte +1 -0
  36. package/dist/visual/VisualEditorInner.svelte +24 -24
  37. package/dist/visual/controls.css +16 -5
  38. package/dist/visual/strings.d.ts +8 -1
  39. package/dist/visual/strings.js +2 -0
  40. package/dist/visual/structure.d.ts +77 -43
  41. package/dist/visual/structure.js +132 -57
  42. package/package.json +2 -2
@@ -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'}
@@ -312,7 +346,9 @@
312
346
  id={ids?.control}
313
347
  describedBy={describes}
314
348
  value={obj[key] as string | undefined}
315
- placeholder={sub.default != null ? String(sub.default) : undefined}
349
+ fallback={textDefault(sub)}
350
+ placeholder={noneOf(key, sub)}
351
+ example={exampleOf(key, sub)}
316
352
  onCommit={(v) => commitProp(key, v)}
317
353
  onKey={onCellKey ? (e) => onCellKey(e, key) : undefined}
318
354
  />
@@ -321,8 +357,12 @@
321
357
  leaves the leaf mounted and the caret in it. -->
322
358
  <ProseValue
323
359
  bind:this={proseEls[key]}
324
- content={() => contentAt([key]) ?? emptyContent()}
325
- plaintext={sub.type === 'plaintext'}
360
+ content={() => contentAt([key])}
361
+ fallback={contentDefault(sub)}
362
+ plaintext={baseType(sub) === 'plaintext'}
363
+ placeholder={noneOf(key, sub)}
364
+ example={exampleOf(key, sub)}
365
+ {block}
326
366
  label={named}
327
367
  labelledBy={ids?.label}
328
368
  describedBy={describes}
@@ -338,6 +378,7 @@
338
378
  <ArrayField
339
379
  bind:this={nestedEls[key]}
340
380
  value={obj[key] as unknown[] | undefined}
381
+ fallback={Array.isArray(sub.default) ? sub.default : undefined}
341
382
  items={sub.items}
342
383
  layout={arrayLayout(sub)}
343
384
  max={sub.max}
@@ -469,6 +510,12 @@
469
510
  .qm-object-prop:only-child {
470
511
  grid-column: span var(--cols-half);
471
512
  }
513
+ /* A block prose property holds paragraphs, so a track beside it stands in whitespace
514
+ once it grows (`packable`); its label still rides the row above it. */
515
+ .qm-object-prop.qm-prop-wide,
516
+ .qm-object-prop.qm-prop-wide:only-child {
517
+ grid-column: 1 / -1;
518
+ }
472
519
  /* A container property takes the whole row and its own rows: its label rides inside
473
520
  the control it draws (a repeater's header, a matrix's) or above a subform of its
474
521
  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
+ /** A field's leaf, whose placeholder goes at its 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
+ /** A field's leaf, whose placeholder goes at its 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;
@@ -4,12 +4,12 @@
4
4
  `object`'s property, a variant's cell ({@link ObjectField}). None of them is
5
5
  `applyChange`-addressable: `Addr.field` is a flat name, so `keywords[0]` and
6
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
7
+ minimal PM view over the codec's decode/encode and a leaf schema, and on every edit
8
8
  hands the re-encoded `Content` up to the parent — {@link ArrayField}, which commits
9
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.
10
+ (`writer.set(field, next)`). Anchors inside a value are dropped on that write.
11
+ Mounts once per stable key (an element's session id, a property's name), so the
12
+ parent's re-derive leaves the caret where it is.
13
13
 
14
14
  The content is read, not passed: `reader.getContentAt(addr, path)` decodes through
15
15
  the codec the leaf's own declared type names, so what it rests as — the content
@@ -17,37 +17,55 @@
17
17
  being the row's or the cell's business. Read once, at mount, since that is when this
18
18
  leaf takes its state.
19
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.
20
+ The schema is the declared type's where the parent has room for it: a subform cell
21
+ declaring no `inline` takes the full row ({@link ObjectField}), a `richtext` one on the
22
+ block schema and a `plaintext` one on the plain schema, paragraphs and hard breaks.
23
+ An array element is one textblock whatever it declares, since Enter there is the
24
+ repeater's. There is no slash menu at either width, and an island draws as its
25
+ placeholder: an atom a keystroke can delete and no view edits.
26
+
27
+ A leaf over content its schema cannot hold is held: a narrowed one over anything but
28
+ one plain paragraph (`fitsInline`), a plain one over anything but plain lines
29
+ (`fitsPlain`). Its decode would join or drop what is left over, and the first
30
+ keystroke would write that loss back, so a held leaf draws its content on the block
31
+ schema, read-only, with a note inside its box, and commits nothing.
24
32
  -->
25
33
  <script lang="ts">
26
34
  import { onMount } from 'svelte';
27
35
  import { EditorState, Selection } from 'prosemirror-state';
28
36
  import { EditorView } from 'prosemirror-view';
29
37
  import {
38
+ blockSchema,
30
39
  decode,
40
+ emptyContent,
41
+ fitsLeaf,
42
+ heldAttributes,
43
+ leafSchema,
31
44
  pmToContent,
32
- inlineSchema,
33
- plaintextSchema,
34
45
  proseAttributes,
35
46
  proseLeafPlugins,
36
47
  buildLineIndex,
37
48
  usvToPM
38
49
  } from '../core/codec/index.js';
50
+ import { wording } from './strings.js';
39
51
  import './controls.css';
40
52
  import type { Content } from '@quillmark/wasm';
41
53
 
42
54
  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;
55
+ /** This leaf's content, read at mount (the parent's boundary read), `undefined`
56
+ * where nothing is stored. A thunk rather than a value: the parent re-derives
57
+ * per revision and this leaf takes its state once, so a value prop would be a
58
+ * boundary read per leaf per render, all but one of them discarded. */
59
+ content: () => Content | undefined;
60
+ /** The default an unset leaf holds, where it prints: drawn at the default rung,
61
+ * and handed up whole with the first edit in it, as `createField` takes one. */
62
+ fallback?: Content;
48
63
  /** The mark-free schema (a `plaintext` leaf): literal text, no formatting,
49
64
  * exactly as the scalar field of that type mounts. */
50
65
  plaintext?: boolean;
66
+ /** A multi-block schema, for a cell the parent draws at full width: the block
67
+ * schema, or the plain one with `plaintext`. Absent, the leaf is one textblock. */
68
+ block?: boolean;
51
69
  /** Accessible name for the editable region, where nothing else names it: an
52
70
  * array has no per-element label. A cell with a label element takes
53
71
  * `labelledBy`. */
@@ -58,6 +76,12 @@
58
76
  labelledBy?: string;
59
77
  /** The parked `description` (FieldLabel) → `aria-describedby`. */
60
78
  describedBy?: string;
79
+ /** The empty leaf's ghosts, a subform cell's, until the leaf's first edit: the
80
+ * `none` an optional cell prints unset, and the `example:` drawn where there is no
81
+ * placeholder and in its stead while the leaf holds the focus (`createField`'s
82
+ * pair). Read per decoration pass. */
83
+ placeholder?: string;
84
+ example?: string;
61
85
  onChange: (rt: Content) => void;
62
86
  /** Raw keydown, for a container whose own keys run through this leaf: the
63
87
  * array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
@@ -66,15 +90,23 @@
66
90
  }
67
91
  let {
68
92
  content,
93
+ fallback,
69
94
  plaintext = false,
95
+ block = false,
70
96
  label,
71
97
  labelledBy,
72
98
  describedBy,
99
+ placeholder,
100
+ example,
73
101
  onChange,
74
102
  onKey
75
103
  }: Props = $props();
76
104
 
77
- let containerEl: HTMLDivElement | undefined = $state();
105
+ const t = wording();
106
+ const uid = $props.id();
107
+ const heldId = `${uid}-held`;
108
+ let editorEl: HTMLDivElement | undefined = $state();
109
+ let held = $state(false);
78
110
  let view: EditorView | undefined;
79
111
  /** Take the caret: what a parent placing focus on this leaf calls. The view's
80
112
  * focus, not the element's: a PM view restores its selection, where a bare DOM
@@ -107,39 +139,55 @@
107
139
  }
108
140
 
109
141
  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);
142
+ if (!editorEl) return;
143
+ // The same keymap and plugin stack a `createField` leaf mounts (shared
144
+ // `proseLeafPlugins`), minus the anchor-position plugin: anchors are dropped on
145
+ // the parent's value write, per the header.
146
+ const inline = !block;
147
+ const stored = content();
148
+ const rt = stored ?? fallback ?? emptyContent();
149
+ let defaulted = stored === undefined && !!fallback;
150
+ held = !fitsLeaf(rt, { inline, plaintext });
151
+ const named = held
152
+ ? heldAttributes({ label, labelledBy, describedBy }, heldId)
153
+ : (proseAttributes({ label, labelledBy, describedBy }) ?? {});
154
+ const defaultNamed = { ...named, 'data-default': '' };
155
+ const schema = held ? blockSchema : leafSchema({ plaintext, inline });
121
156
  const state = EditorState.create({
122
- doc: pmDoc,
123
- plugins: proseLeafPlugins(schema, { inline: true })
157
+ doc: decode(rt, schema),
158
+ plugins: held
159
+ ? []
160
+ : proseLeafPlugins(schema, {
161
+ inline,
162
+ placeholder: () => placeholder,
163
+ placeholderUntilEdit: true,
164
+ example: () => example
165
+ })
124
166
  });
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;
167
+ const mounted = new EditorView(
168
+ { mount: editorEl },
169
+ {
170
+ state,
171
+ editable: () => !held,
172
+ // Which of `aria-label` / `aria-labelledby` wins is the codec's one answer
173
+ // (`proseAttributes`), so a cell carrying a label element and a row carrying
174
+ // none cannot name their regions by different rules.
175
+ attributes: () => (defaulted ? defaultNamed : named),
176
+ dispatchTransaction(tr) {
177
+ const next = mounted.state.apply(tr);
178
+ const edit = tr.docChanged && !held;
179
+ if (edit) defaulted = false;
180
+ mounted.updateState(next);
181
+ if (edit) onChange(pmToContent(next.doc));
182
+ },
183
+ handleDOMEvents: {
184
+ keydown: (_v, e) => {
185
+ onKey?.(e);
186
+ return false;
187
+ }
140
188
  }
141
189
  }
142
- });
190
+ );
143
191
  view = mounted;
144
192
  return () => {
145
193
  view = undefined;
@@ -153,7 +201,15 @@
153
201
  like the text cell beside it. No floor: the reset in `core/codec/prose.css` makes one
154
202
  line of prose measure one line. Width is the row's or the cell's to give: the leaf
155
203
  fills the track it is placed in. -->
156
- <div bind:this={containerEl} class="qm-prose-value qm-control-box qm-focus-ring-within"></div>
204
+ <div class="qm-prose-value qm-control-box qm-focus-ring-within">
205
+ <div bind:this={editorEl}></div>
206
+ <!-- Inside the box, so the cell or row holds one child either way: a subform cell's
207
+ subgrid has a row for the label and one for the box, and an array row's slabs
208
+ stand the box's height. -->
209
+ {#if held}
210
+ <span id={heldId} class="qm-prose-held-note">{t.strings.proseHeld}</span>
211
+ {/if}
212
+ </div>
157
213
 
158
214
  <style>
159
215
  /* Caret-primary, matching ProseField: the contenteditable's own outline is
@@ -1,14 +1,20 @@
1
1
  import './controls.css';
2
2
  import type { Content } from '@quillmark/wasm';
3
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;
4
+ /** This leaf's content, read at mount (the parent's boundary read), `undefined`
5
+ * where nothing is stored. A thunk rather than a value: the parent re-derives
6
+ * per revision and this leaf takes its state once, so a value prop would be a
7
+ * boundary read per leaf per render, all but one of them discarded. */
8
+ content: () => Content | undefined;
9
+ /** The default an unset leaf holds, where it prints: drawn at the default rung,
10
+ * and handed up whole with the first edit in it, as `createField` takes one. */
11
+ fallback?: Content;
9
12
  /** The mark-free schema (a `plaintext` leaf): literal text, no formatting,
10
13
  * exactly as the scalar field of that type mounts. */
11
14
  plaintext?: boolean;
15
+ /** A multi-block schema, for a cell the parent draws at full width: the block
16
+ * schema, or the plain one with `plaintext`. Absent, the leaf is one textblock. */
17
+ block?: boolean;
12
18
  /** Accessible name for the editable region, where nothing else names it: an
13
19
  * array has no per-element label. A cell with a label element takes
14
20
  * `labelledBy`. */
@@ -19,6 +25,12 @@ interface Props {
19
25
  labelledBy?: string;
20
26
  /** The parked `description` (FieldLabel) → `aria-describedby`. */
21
27
  describedBy?: string;
28
+ /** The empty leaf's ghosts, a subform cell's, until the leaf's first edit: the
29
+ * `none` an optional cell prints unset, and the `example:` drawn where there is no
30
+ * placeholder and in its stead while the leaf holds the focus (`createField`'s
31
+ * pair). Read per decoration pass. */
32
+ placeholder?: string;
33
+ example?: string;
22
34
  onChange: (rt: Content) => void;
23
35
  /** Raw keydown, for a container whose own keys run through this leaf: the
24
36
  * array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own