@quillmark/svelte 0.21.0 → 0.22.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 (67) hide show
  1. package/NOTICE +6 -7
  2. package/README.md +1 -1
  3. package/dist/core/address.d.ts +6 -1
  4. package/dist/core/address.js +12 -3
  5. package/dist/core/bloom.d.ts +9 -4
  6. package/dist/core/bloom.js +18 -49
  7. package/dist/core/codec/atoms.d.ts +1 -1
  8. package/dist/core/codec/atoms.js +1 -1
  9. package/dist/core/codec/blocks.js +4 -2
  10. package/dist/core/codec/code.d.ts +1 -1
  11. package/dist/core/codec/code.js +1 -1
  12. package/dist/core/codec/decode.d.ts +5 -5
  13. package/dist/core/codec/decode.js +18 -12
  14. package/dist/core/codec/encode.js +6 -1
  15. package/dist/core/codec/field.d.ts +15 -10
  16. package/dist/core/codec/field.js +46 -17
  17. package/dist/core/codec/index.d.ts +1 -1
  18. package/dist/core/codec/index.js +2 -2
  19. package/dist/core/codec/inputrules.js +1 -1
  20. package/dist/core/codec/lists.d.ts +3 -2
  21. package/dist/core/codec/lists.js +21 -5
  22. package/dist/core/codec/nesting.d.ts +20 -0
  23. package/dist/core/codec/nesting.js +62 -0
  24. package/dist/core/codec/prose.css +74 -20
  25. package/dist/core/codec/schema.d.ts +2 -2
  26. package/dist/core/codec/schema.js +16 -7
  27. package/dist/core/codec/table-copy.d.ts +17 -0
  28. package/dist/core/codec/table-copy.js +84 -0
  29. package/dist/core/codec/table-view.d.ts +1 -7
  30. package/dist/core/codec/table-view.js +70 -119
  31. package/dist/core/codec/table.d.ts +2 -9
  32. package/dist/core/codec/table.js +10 -19
  33. package/dist/core/codec/tasks.d.ts +19 -0
  34. package/dist/core/codec/tasks.js +140 -0
  35. package/dist/core/errors.d.ts +2 -2
  36. package/dist/visual/ArrayField.svelte +101 -63
  37. package/dist/visual/ArrayField.svelte.d.ts +0 -2
  38. package/dist/visual/Card.svelte +9 -3
  39. package/dist/visual/Card.svelte.d.ts +2 -2
  40. package/dist/visual/Field.svelte +20 -16
  41. package/dist/visual/Field.svelte.d.ts +4 -2
  42. package/dist/visual/FieldLabel.svelte +5 -33
  43. package/dist/visual/FieldLabel.svelte.d.ts +0 -2
  44. package/dist/visual/FormatPopover.svelte +54 -73
  45. package/dist/visual/MatrixField.svelte +18 -12
  46. package/dist/visual/NumberField.svelte +32 -3
  47. package/dist/visual/ObjectField.svelte +38 -20
  48. package/dist/visual/ObjectField.svelte.d.ts +6 -0
  49. package/dist/visual/ProseField.svelte +10 -13
  50. package/dist/visual/ProseField.svelte.d.ts +5 -2
  51. package/dist/visual/ProseValue.svelte +25 -19
  52. package/dist/visual/ProseValue.svelte.d.ts +4 -4
  53. package/dist/visual/TextField.svelte +3 -2
  54. package/dist/visual/TipsCard.svelte +5 -2
  55. package/dist/visual/VariantField.svelte +1 -1
  56. package/dist/visual/VariantField.svelte.d.ts +1 -1
  57. package/dist/visual/VisualEditorInner.svelte +13 -13
  58. package/dist/visual/controls.css +19 -1
  59. package/dist/visual/icons/nodes.d.ts +0 -21
  60. package/dist/visual/icons/nodes.js +1 -7
  61. package/dist/visual/leaves.d.ts +12 -9
  62. package/dist/visual/leaves.js +19 -0
  63. package/dist/visual/strings.d.ts +10 -5
  64. package/dist/visual/strings.js +16 -9
  65. package/dist/visual/structure.d.ts +21 -31
  66. package/dist/visual/structure.js +54 -51
  67. package/package.json +3 -3
@@ -3,15 +3,16 @@
3
3
  shell-owned, observing the active leaf through
4
4
  `getActiveLeaf`: the VisualEditor's accessor over its `leaves` registry, and
5
5
  the whole of what this surface knows about the editor. The active leaf is a
6
- field's or a by-value one (an array's element, a subform's cell): its views either
7
- way, its controller only for a field's. A non-empty selection in the active leaf
6
+ field's or a by-value one (an array's element, a subform's cell), reached through
7
+ its views. A non-empty selection in the active leaf
8
8
  raises it over that leaf's selection rect; each button dispatches a PM `toggleMark`
9
9
  command straight at the leaf's `EditorView`, whose own `dispatchTransaction` commits
10
10
  the result as it commits a keystroke: a field's lowers it to `markOps` through
11
11
  `applyChange` (field.ts), a by-value leaf's hands the re-encoded value to its parent
12
12
  (`ProseValue`). This component never touches the content. The keymap mirror
13
- (Mod-b/i/u) already lives in the codec's keymap (field.ts); this is the pointer
14
- affordance for all six marks.
13
+ (Mod-b/i/u) lives in the codec's keymap (field.ts); this is the pointer
14
+ affordance for all six marks. Mod-K is this surface's own key, the prompt it raises
15
+ being this surface's: a document `keydown` the leaf's keymap left unclaimed.
15
16
 
16
17
  Selection observation. The view's `dispatchTransaction` is the leaf's
17
18
  (read-only), so there is no hook to observe transactions from outside.
@@ -64,6 +65,7 @@
64
65
  // off-tree, so this component renders standalone too.
65
66
  const t = wording();
66
67
  import { toggleMark } from 'prosemirror-commands';
68
+ import { keydownHandler } from 'prosemirror-keymap';
67
69
  import { TextSelection, type Command } from 'prosemirror-state';
68
70
  import type { EditorView } from 'prosemirror-view';
69
71
  import type { MarkType } from 'prosemirror-model';
@@ -83,9 +85,8 @@
83
85
  let { getActiveLeaf }: Props = $props();
84
86
 
85
87
  const SETTLE_MS = 450;
86
- /** The six content formatting marks. `anchor` is a seventh, drawn separately below:
87
- * a decoration toggle rather than a PM `toggleMark`. One string per mark carries both
88
- * the tooltip and the accessible name; the anchor button spells the two separately. */
88
+ /** The six content formatting marks. `anchor` is withheld: it draws nothing until
89
+ * comments do. One string per mark carries both the tooltip and the accessible name. */
89
90
  const MARKS: { name: string; icon: IconName; title: string }[] = $derived([
90
91
  { name: 'strong', icon: 'bold', title: t.strings.formatBold },
91
92
  { name: 'em', icon: 'italic', title: t.strings.formatEmphasis },
@@ -98,13 +99,14 @@
98
99
  let open = $state(false);
99
100
  /** A pointer is down outside this surface: the selection is still being made. */
100
101
  let pressed = false;
101
- /** Whether an anchor can be minted over the selection: in a field's own view, never
102
- * in a table cell or a by-value leaf (see `sync`). */
103
- let anchorAvailable = $state(true);
104
102
  let linkPromptOpen = $state(false);
105
103
  /** Whether the selection carried a link when the prompt was raised. */
106
104
  let linkPresent = $state(false);
107
105
  let linkValue = $state('');
106
+ /** The view the prompt was raised over. The prompt takes the focus, and a cell view
107
+ * without it is no longer the leaf's `focusedView`. */
108
+ let promptView: EditorView | undefined;
109
+ let promptInput = $state<HTMLInputElement | undefined>(undefined);
108
110
  let contentEl = $state<HTMLElement | undefined>(undefined);
109
111
  /** The root to portal into: `document.body` escapes the editor's subtree and
110
112
  * the consumer's dials with it, so a pane-scoped palette misses this surface.
@@ -119,7 +121,7 @@
119
121
  /** The view the caret is actually in: the leaf's own, or the nested cell view of a
120
122
  * table island (`codec/table-view.ts`). The six marks are the inline schema's
121
123
  * too, so a selection in a cell raises the same popover and toggles the same
122
- * commands; only `anchor` is withheld (below). */
124
+ * commands. */
123
125
  function activeLeafView(): EditorView | undefined {
124
126
  return getActiveLeaf()?.views.focusedView();
125
127
  }
@@ -133,18 +135,16 @@
133
135
  // returns early, so a surface frozen here still tracks the selection it covers.
134
136
  if (insidePopover) return;
135
137
  if (pressed) return;
136
- const leaf = getActiveLeaf();
137
- const view = leaf?.views.focusedView();
138
+ const view = activeLeafView();
138
139
  // A non-empty text selection. A node selection is non-empty too and has nothing
139
140
  // to format: it covers a leaf block (an island, a rule), which is exactly where
140
141
  // Escape out of a table cell lands.
141
142
  const selection = view?.state.selection;
142
143
  const formattable = selection instanceof TextSelection && !selection.empty;
143
- // …in a leaf that has formatting at all. A `plaintext` leaf declares no mark
144
- // types (`plaintextSchema`, `plainSchema`), so this surface withholds itself over
145
- // one entirely, the way it never rises over the text input a `string` field draws:
146
- // the value is literal text, and a mark on it is a value the boundary refuses to
147
- // coerce back.
144
+ // …in a leaf that has formatting at all. A `text` leaf declares no mark types
145
+ // (`plaintextSchema`, `plainSchema`), so this surface withholds itself over one
146
+ // entirely, the way it never rises over a form control: the value is literal
147
+ // text, and a mark on it is a value the boundary refuses to coerce back.
148
148
  if (
149
149
  !view ||
150
150
  !view.hasFocus() ||
@@ -163,13 +163,6 @@
163
163
  portalTarget = view.dom.closest<HTMLElement>('[data-qm-root]') ?? undefined;
164
164
  const { from, to } = view.state.selection;
165
165
  anchor = rangeAnchor(view, from, to);
166
- // An anchor is minted through a field's controller, which a by-value leaf has
167
- // none of: its anchors are dropped on the parent's value write. A selection inside
168
- // a table cell is not in the field's coordinate space at all: the field's position
169
- // map holds one `atom` run for the whole island, so there is no USV offset to mint
170
- // an anchor at. The button is withheld rather than disabled, because what it would
171
- // toggle does not exist there (CODEC §Islands).
172
- anchorAvailable = !!leaf?.controller && view === leaf.views.view;
173
166
  open = true;
174
167
  }
175
168
 
@@ -266,6 +259,7 @@
266
259
  if (name === 'link') {
267
260
  linkValue = hrefInSelection(view.state);
268
261
  linkPresent = linkValue.length > 0;
262
+ promptView = view;
269
263
  linkPromptOpen = true;
270
264
  return;
271
265
  }
@@ -274,43 +268,12 @@
274
268
  sync();
275
269
  }
276
270
 
277
- /**
278
- * A fresh anchor id: 128 random bits as hex, the id being opaque to the
279
- * boundary and needing only to be unique. `crypto.randomUUID` is
280
- * secure-context-only, and absent on a page served over plain `http` to
281
- * something other than localhost; `getRandomValues` is there either way.
282
- */
283
- function mintAnchorId(): string {
284
- return [...crypto.getRandomValues(new Uint8Array(16))]
285
- .map((b) => b.toString(16).padStart(2, '0'))
286
- .join('');
287
- }
288
-
289
- /**
290
- * Toggle an identity anchor over the selection: the `anchor`
291
- * button's answer to a formatting toggle. If the selection already covers
292
- * anchors, remove them; else insert one at its start with a freshly-minted
293
- * unique id (the caller-supplied, invariant id the 0.97 policy settles).
294
- * Zero-width and glyph-less (CODEC §Marks): the identity handle persists in
295
- * the content, its chrome awaiting comment-thread UX.
296
- */
297
- function toggleAnchor(): void {
298
- const leaf = getActiveLeaf()?.controller;
299
- const view = activeLeafView();
300
- if (!leaf || !view || view.state.selection.empty) return;
301
- const { from, to } = leaf.selectionRange();
302
- const covered = leaf.anchorsInRange(from, to);
303
- if (covered.length) covered.forEach((id) => leaf.removeAnchor(id));
304
- else leaf.insertAnchor(mintAnchorId(), from);
305
- view.focus();
306
- sync();
307
- }
308
-
309
271
  /** Every exit from the prompt: lower it, run `cmd` if there is one, and hand the
310
272
  * leaf back its focus and the surface its buttons. */
311
273
  function closePrompt(cmd?: Command): void {
312
274
  linkPromptOpen = false;
313
- const view = activeLeafView();
275
+ const view = promptView && !promptView.isDestroyed ? promptView : activeLeafView();
276
+ promptView = undefined;
314
277
  if (view) {
315
278
  cmd?.(view.state, view.dispatch);
316
279
  view.focus();
@@ -325,6 +288,30 @@
325
288
  closePrompt(setLink(normalizeHref(linkValue)));
326
289
  }
327
290
 
291
+ /** Mod-K is a press of the Link button: claimed only where the surface would stand
292
+ * over the selection, and a second press while the prompt is up hands it the focus. */
293
+ function raiseLink(view: EditorView): boolean {
294
+ const inside = !!contentEl?.contains(document.activeElement);
295
+ if (inside && linkPromptOpen) return true;
296
+ if (!inside && !view.hasFocus()) return false;
297
+ sync();
298
+ if (!open) return false;
299
+ const reraised = linkPromptOpen;
300
+ toggle('link');
301
+ if (reraised && promptInput) focusPrompt(promptInput);
302
+ return true;
303
+ }
304
+
305
+ const linkKey = keydownHandler({
306
+ 'Mod-k': (_state, _dispatch, view) => !!view && raiseLink(view)
307
+ });
308
+
309
+ function onKeyDown(e: KeyboardEvent): void {
310
+ if (e.defaultPrevented) return;
311
+ const view = activeLeafView();
312
+ if (view && linkKey(view, e)) e.preventDefault();
313
+ }
314
+
328
315
  function removeLink(): void {
329
316
  closePrompt(clearLink);
330
317
  }
@@ -338,6 +325,7 @@
338
325
  it does with the event. `pointercancel` is the release a gesture the browser takes
339
326
  over gets. -->
340
327
  <svelte:document
328
+ onkeydown={onKeyDown}
341
329
  onselectionchange={deferredSync}
342
330
  onfocusout={deferredSync}
343
331
  onpointerdowncapture={onPointerDown}
@@ -391,26 +379,29 @@
391
379
  >
392
380
  <input
393
381
  {@attach focusPrompt}
394
- class="qm-link-input"
382
+ bind:this={promptInput}
383
+ class="qm-link-input qm-focus-ring"
395
384
  type="text"
396
385
  placeholder={t.strings.linkPlaceholder}
397
386
  aria-label={t.strings.formatLink}
398
387
  bind:value={linkValue}
399
388
  />
400
- <button type="submit" class="qm-icon-btn qm-mark-btn">{t.strings.linkApply}</button>
389
+ <button type="submit" class="qm-icon-btn qm-mark-btn qm-focus-ring"
390
+ >{t.strings.linkApply}</button
391
+ >
401
392
  <!-- Only where there is a link to remove: an arm that does nothing is
402
393
  an arm to read past on every other visit to the prompt. -->
403
394
  {#if linkPresent}
404
395
  <button
405
396
  type="button"
406
- class="qm-icon-btn qm-mark-btn"
397
+ class="qm-icon-btn qm-mark-btn qm-focus-ring"
407
398
  onmousedown={keepFocus}
408
399
  onclick={removeLink}>{t.strings.linkRemove}</button
409
400
  >
410
401
  {/if}
411
402
  <button
412
403
  type="button"
413
- class="qm-icon-btn qm-mark-btn"
404
+ class="qm-icon-btn qm-mark-btn qm-focus-ring"
414
405
  onmousedown={keepFocus}
415
406
  onclick={cancelLink}>{t.strings.linkCancel}</button
416
407
  >
@@ -423,23 +414,13 @@
423
414
  {#each MARKS as m (m.name)}
424
415
  <button
425
416
  type="button"
426
- class="qm-icon-btn qm-mark-btn"
417
+ class="qm-icon-btn qm-mark-btn qm-focus-ring"
427
418
  title={m.title}
428
419
  aria-label={m.title}
429
420
  onmousedown={keepFocus}
430
421
  onclick={() => toggle(m.name)}><Icon name={m.icon} /></button
431
422
  >
432
423
  {/each}
433
- {#if anchorAvailable}
434
- <button
435
- type="button"
436
- class="qm-icon-btn qm-mark-btn"
437
- title={t.strings.formatAnchorTitle}
438
- aria-label={t.strings.formatAnchor}
439
- onmousedown={keepFocus}
440
- onclick={toggleAnchor}><Icon name="hash" /></button
441
- >
442
- {/if}
443
424
  </div>
444
425
  {/if}
445
426
  </div>
@@ -53,7 +53,7 @@
53
53
  import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
54
54
  import type { LandingBox } from './leaves.js';
55
55
  import { propertyDomIds } from './domid.js';
56
- import { bloomInside } from '../core/bloom.js';
56
+ import { bloom } from '../core/bloom.js';
57
57
  import Icon from './icons/Icon.svelte';
58
58
  import FieldLabel from './FieldLabel.svelte';
59
59
  import ObjectField from './ObjectField.svelte';
@@ -215,7 +215,7 @@
215
215
  // the box stays where the next item is typed.
216
216
  const first = Object.keys(schema.properties ?? {})[0];
217
217
  if (first !== undefined) ref(colEls, id)?.focusPath([first]);
218
- if (box) bloomInside(box);
218
+ if (box) bloom(box);
219
219
  }
220
220
 
221
221
  /** Take the caret: the first item's control, else the add box. */
@@ -276,13 +276,17 @@
276
276
  </span>
277
277
  </div>
278
278
  <div
279
- class={['qm-matrix-roster', { 'qm-tracks': compact, compact }]}
279
+ class={['qm-matrix-roster qm-wash', { 'qm-tracks': compact, compact }]}
280
280
  data-default={defaulted ? '' : undefined}
281
281
  bind:this={rosterEl}
282
282
  >
283
283
  {#each members as m (m.id)}
284
284
  {@const on = held(m.id)}
285
- <div class={['qm-member', { held: on }]} bind:this={memberEls[m.id]}>
285
+ <div
286
+ class={['qm-member qm-wash', { held: on }]}
287
+ data-qm-prop={m.id}
288
+ bind:this={memberEls[m.id]}
289
+ >
286
290
  <div class="qm-member-head">
287
291
  <!-- A real checkbox with its face drawn here: the UA's face is shadow DOM no
288
292
  dial reaches, so the input is `appearance: none` and the box and the
@@ -306,7 +310,11 @@
306
310
  {/each}
307
311
  {#each added as item, k (item.id)}
308
312
  {@const name = item.title || t.strings.matrixItemTitle(label ?? '', k + 1)}
309
- <div class="qm-member held added" bind:this={memberEls[item.id]}>
313
+ <div
314
+ class="qm-member qm-wash held added"
315
+ data-qm-prop={item.id}
316
+ bind:this={memberEls[item.id]}
317
+ >
310
318
  <div class="qm-member-head">
311
319
  <!-- Held by being present: the tick is fixed, and the remove is its untick. -->
312
320
  <span class="qm-tick" aria-hidden="true">
@@ -317,6 +325,7 @@
317
325
  class="qm-input qm-focus-ring qm-member-title-input"
318
326
  type="text"
319
327
  id={itemTitleId(item.id)}
328
+ data-qm-prop={MATRIX_TITLE}
320
329
  aria-label={t.strings.matrixItemTitle(label ?? '', k + 1)}
321
330
  value={item.title}
322
331
  bind:this={titleEls[item.id]}
@@ -391,11 +400,9 @@
391
400
  color: var(--_qm-ink-label);
392
401
  font-variant-numeric: tabular-nums;
393
402
  }
394
- /* The roster, one member to a row in declaration order. Positioned for the wash's
395
- inset child (`washBox`), and rounded to the rung a row's box draws, as an array's
396
- rows are. */
403
+ /* The roster, one member to a row in declaration order, rounded for the wash
404
+ (`washBox`) to the rung a row's box draws, as an array's rows are. */
397
405
  .qm-matrix-roster {
398
- position: relative;
399
406
  display: flex;
400
407
  flex-direction: column;
401
408
  gap: var(--_qm-space);
@@ -411,10 +418,9 @@
411
418
  align-items: start;
412
419
  column-gap: var(--_qm-space-2);
413
420
  }
414
- /* A member is a row: positioned for the wash a landing on it blooms, rounded to the
415
- rung the rows around it draw. */
421
+ /* A member is a row, rounded for the wash a landing on it blooms to the rung the
422
+ rows around it draw. */
416
423
  .qm-member {
417
- position: relative;
418
424
  display: flex;
419
425
  flex-direction: column;
420
426
  gap: var(--_qm-space);
@@ -4,7 +4,8 @@
4
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.
7
+ region. Escape takes a pending entry back to what the field holds, as it takes a
8
+ card rename back, and the focus stays; with nothing pending the key is not claimed.
8
9
 
9
10
  An unset field holds its resolved `default:` as its text, at the default rung
10
11
  (theme.css), and an edit that settles writes it as authored, as `TextField` takes
@@ -52,7 +53,8 @@
52
53
  }
53
54
  let { value, integer, fallback, placeholder, label, id, describedBy, onCommit }: Props = $props();
54
55
 
55
- let local = $derived(value != null ? String(value) : (fallback ?? ''));
56
+ const resting = $derived(value != null ? String(value) : (fallback ?? ''));
57
+ let local = $derived(resting);
56
58
  const defaulted = $derived(value == null && !!fallback && local === fallback);
57
59
 
58
60
  const partial = $derived(
@@ -76,13 +78,37 @@
76
78
  local = el.value;
77
79
  }
78
80
 
81
+ // Where the reverted text differs from the last settled one, a browser fires `change`
82
+ // on leaving; that one commits nothing. An entry that settled outside the grammar was
83
+ // handed up as a string and refused, so taking it back settles the held value again,
84
+ // which clears the refusal drawn on the field.
85
+ let reverted = false;
86
+ let offered = false;
87
+ function revert(e: KeyboardEvent): void {
88
+ if (e.key !== 'Escape' || e.isComposing) return;
89
+ const el = e.currentTarget as HTMLInputElement;
90
+ if (el.value === resting) return;
91
+ e.preventDefault();
92
+ local = el.value = resting;
93
+ reverted = true;
94
+ if (offered) {
95
+ offered = false;
96
+ onCommit(value);
97
+ }
98
+ }
99
+
79
100
  // Parse a settled entry and emit it; `local` is owned by `oninput`. Blank →
80
101
  // `undefined`, and the input takes the default back: nothing else reconciles it, the
81
102
  // projection having read the default throughout. Onto the element as well, since the
82
103
  // attribute Svelte last wrote may already be the default, and it writes no repeat.
83
104
  function commit(el: HTMLInputElement): void {
105
+ if (reverted) {
106
+ reverted = false;
107
+ return;
108
+ }
84
109
  const raw = el.value;
85
110
  const entry = raw.trim();
111
+ offered = false;
86
112
  if (entry === '') {
87
113
  onCommit(undefined);
88
114
  local = el.value = fallback ?? '';
@@ -90,7 +116,8 @@
90
116
  }
91
117
  // Finite as well: a digit run past `Number.MAX_VALUE` reads as `Infinity`.
92
118
  const n = whole.test(entry) ? Number(entry) : NaN;
93
- onCommit(Number.isFinite(n) ? n : raw);
119
+ offered = !Number.isFinite(n);
120
+ onCommit(offered ? raw : n);
94
121
  }
95
122
  </script>
96
123
 
@@ -107,6 +134,8 @@
107
134
  onbeforeinput={filter}
108
135
  oninput={(e) => {
109
136
  local = (e.currentTarget as HTMLInputElement).value;
137
+ reverted = false;
110
138
  }}
139
+ onkeydown={revert}
111
140
  onchange={(e) => commit(e.currentTarget as HTMLInputElement)}
112
141
  />
@@ -41,18 +41,21 @@
41
41
  baseType,
42
42
  controlKind,
43
43
  declaredContent,
44
+ fieldModels,
44
45
  flatObject,
45
46
  humanize,
46
47
  isContainer,
48
+ literalText,
47
49
  mountKey,
48
- obliged,
49
50
  optionalCell,
51
+ placeFields,
50
52
  printedText,
51
53
  renderedBoolean,
52
54
  shortCell
53
55
  } from './structure.js';
54
56
  import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
55
57
  import type { LandingBox } from './leaves.js';
58
+ import { isEmptyContent } from '../core/codec/index.js';
56
59
  import { wording } from './strings.js';
57
60
  import { propertyDomIds } from './domid.js';
58
61
  import FieldLabel from './FieldLabel.svelte';
@@ -102,6 +105,12 @@
102
105
  /** `ui.layout: flat`: no vertical and no rung, the properties at the depth of the
103
106
  * list the subform stands in. */
104
107
  flat?: boolean;
108
+ /** A card-level object, or one nested in it: its properties are placed as a
109
+ * card's fields are (`placeFields`), so moving a field into a group leaves its row
110
+ * as it was. Unset,
111
+ * every short cell packs: an array's record, a variant's world and a matrix
112
+ * member's columns are one record's cells, not a card's fields. */
113
+ placed?: boolean;
105
114
  /** Raw keydown off a text or prose cell, for a row whose own keys run through its
106
115
  * cells: the table's Enter and Backspace ({@link ArrayField}). */
107
116
  onCellKey?: (e: KeyboardEvent, key: string) => void;
@@ -121,6 +130,7 @@
121
130
  onCommit,
122
131
  bare = false,
123
132
  flat = false,
133
+ placed = false,
124
134
  onCellKey,
125
135
  diagnostics
126
136
  }: Props = $props();
@@ -129,15 +139,16 @@
129
139
  const entries = $derived(Object.entries(properties ?? {}));
130
140
  const obj = $derived((value ?? {}) as Record<string, unknown>);
131
141
  const has = (key: string): boolean => Object.hasOwn(properties ?? {}, key);
142
+ const spans = $derived(
143
+ placed
144
+ ? new Map(
145
+ placeFields(fieldModels({ fields: properties ?? {} })).map((p) => [p.field.name, p.span])
146
+ )
147
+ : undefined
148
+ );
132
149
  const routed = $derived(splitDeep(diagnostics));
133
150
  const foot = $derived(unrouted(routed, (step) => typeof step === 'string' && has(step)));
134
151
 
135
- /** Obligation, read off the cell exactly as `fieldModels` reads it off a field
136
- * ({@link obliged}). `validate` anchors it per leaf, so a property with no
137
- * `default:` is obliged in its own right and the container around it holds none
138
- * (VISUAL_EDITOR §"Enum variants"). */
139
- const required = obliged;
140
-
141
152
  const title = (key: string, sub: QuillFieldSchema): string => sub.title ?? humanize(key);
142
153
  /** What an unset optional cell ghosts, which is what it prints: `none`. An answered
143
154
  * cell ghosts nothing, an empty answer printing empty. */
@@ -151,8 +162,7 @@
151
162
  /** The `aria-label` fallback, for a subform mounted without a field's id space:
152
163
  * the field's name and the property's, since nothing else names the control. */
153
164
  const fallbackName = (key: string, sub: QuillFieldSchema): string =>
154
- `${label != null ? `${label} ` : ''}${title(key, sub)}` +
155
- (required(sub) ? ` ${t.strings.fieldRequired}` : '');
165
+ `${label != null ? `${label} ` : ''}${title(key, sub)}`;
156
166
 
157
167
  /** The handles of the cells the DOM cannot answer for, keyed by property name —
158
168
  * which is stable, a subform drawing its properties by declaration and never by
@@ -285,10 +295,17 @@
285
295
  kind === 'boolean'}
286
296
  <!-- A container takes the full span at every depth: its rows are the document's
287
297
  to count, and a track beside it would stand in a column of whitespace
288
- (`packable`). A block prose cell spans it too, and keeps its label row. -->
298
+ (`packable`). A block prose cell spans it too, and keeps its label row, as
299
+ does a placed cell `placeFields` gives the whole row. -->
289
300
  {@const block = kind === 'prose' && !shortCell(sub)}
290
301
  <div
291
- class={['qm-object-prop', { 'qm-prop-full': isContainer(kind), 'qm-prop-wide': block }]}
302
+ class={[
303
+ 'qm-object-prop',
304
+ {
305
+ 'qm-prop-full': isContainer(kind),
306
+ 'qm-prop-wide': block || (!isContainer(kind) && spans?.get(key) === 'full')
307
+ }
308
+ ]}
292
309
  data-qm-prop={key}
293
310
  >
294
311
  {#if ids && kind !== 'array' && kind !== 'matrix'}
@@ -298,7 +315,6 @@
298
315
  id={ids.label}
299
316
  descriptionId={describes}
300
317
  onActivate={labelable ? undefined : () => focusProp(key)}
301
- required={required(sub)}
302
318
  description={sub.description}
303
319
  />
304
320
  {/if}
@@ -364,13 +380,14 @@
364
380
  content={() => contentAt([key])}
365
381
  value={obj[key]}
366
382
  fallback={contentDefault(sub)}
367
- plaintext={baseType(sub) === 'plaintext'}
383
+ plaintext={literalText(sub)}
368
384
  placeholder={noneOf(key, sub)}
369
385
  {block}
370
386
  label={named}
371
387
  labelledBy={ids?.label}
372
388
  describedBy={describes}
373
- onChange={(rt) => commitProp(key, rt)}
389
+ onChange={(rt) =>
390
+ commitProp(key, optionalCell(sub) && isEmptyContent(rt) ? undefined : rt)}
374
391
  onKey={onCellKey ? (e) => onCellKey(e, key) : undefined}
375
392
  />
376
393
  {:else if kind === 'array'}
@@ -386,7 +403,6 @@
386
403
  layout={arrayLayout(sub)}
387
404
  max={sub.max}
388
405
  label={title(key, sub)}
389
- required={required(sub)}
390
406
  description={sub.description}
391
407
  labelId={ids?.label}
392
408
  descriptionId={describes}
@@ -401,6 +417,7 @@
401
417
  value={obj[key] as Record<string, unknown> | undefined}
402
418
  properties={sub.properties}
403
419
  flat={flatObject(sub)}
420
+ {placed}
404
421
  label={ids ? undefined : fallbackName(key, sub)}
405
422
  idBase={ids?.control}
406
423
  labelledBy={ids?.label}
@@ -514,15 +531,16 @@
514
531
  align-items: start;
515
532
  min-width: 0;
516
533
  }
517
- /* A subform of one takes half the capacity: at full capacity a single track reads as
518
- truncated, and there are no sibling properties for it to line up with. A stranded
519
- field takes the whole row instead (`placeFields`): the width it would leave empty is
520
- the card's, where this one sits inside a figure the nesting has already narrowed. */
534
+ /* A record of one cell takes half the capacity: at full capacity a single track reads
535
+ as truncated, and there are no sibling properties for it to line up with. A placed
536
+ property's span is `placeFields`', which strands a lone one to the whole row as it
537
+ does a card's field. */
521
538
  .qm-object-prop:only-child {
522
539
  grid-column: span var(--cols-half);
523
540
  }
524
541
  /* A block prose property holds paragraphs, so a track beside it stands in whitespace
525
- once it grows (`packable`); its label still rides the row above it. */
542
+ once it grows (`packable`); its label still rides the row above it. A placed
543
+ property `placeFields` gives the whole row spans it the same way. */
526
544
  .qm-object-prop.qm-prop-wide,
527
545
  .qm-object-prop.qm-prop-wide:only-child {
528
546
  grid-column: 1 / -1;
@@ -35,6 +35,12 @@ interface Props {
35
35
  /** `ui.layout: flat`: no vertical and no rung, the properties at the depth of the
36
36
  * list the subform stands in. */
37
37
  flat?: boolean;
38
+ /** A card-level object, or one nested in it: its properties are placed as a
39
+ * card's fields are (`placeFields`), so moving a field into a group leaves its row
40
+ * as it was. Unset,
41
+ * every short cell packs: an array's record, a variant's world and a matrix
42
+ * member's columns are one record's cells, not a card's fields. */
43
+ placed?: boolean;
38
44
  /** Raw keydown off a text or prose cell, for a row whose own keys run through its
39
45
  * cells: the table's Enter and Backspace ({@link ArrayField}). */
40
46
  onCellKey?: (e: KeyboardEvent, key: string) => void;
@@ -24,8 +24,9 @@
24
24
  plaintext?: boolean;
25
25
  /**
26
26
  * Draw no box: the card body's leaf, and nothing else. Not `block`, which is a
27
- * different predicate: a `richtext` field without `inline` is block prose and
28
- * still a control in a row of controls, so only the caller knows what is paper.
27
+ * different predicate: a `richtext` field declaring `multiline: true` is block
28
+ * prose and still a control in a row of controls, so only the caller knows what
29
+ * is paper.
29
30
  */
30
31
  unframed?: boolean;
31
32
  /** Accessible name for a leaf nothing else names: an array element, or the
@@ -45,6 +46,8 @@
45
46
  placeholder?: string;
46
47
  /** The placeholder goes at the leaf's first edit (`createField`). */
47
48
  placeholderUntilEdit?: boolean;
49
+ /** An emptied leaf removes the field (`createField`). */
50
+ emptyUnsets?: boolean;
48
51
  /** Registry identity, stamped on the DOM node so a remount is visible as one. */
49
52
  leafKey: string;
50
53
  onFocus?: (addr: Addr) => void;
@@ -73,6 +76,7 @@
73
76
  fallback,
74
77
  placeholder,
75
78
  placeholderUntilEdit,
79
+ emptyUnsets,
76
80
  leafKey,
77
81
  onFocus,
78
82
  onCaretMove,
@@ -122,6 +126,7 @@
122
126
  fallback,
123
127
  placeholder,
124
128
  placeholderUntilEdit,
129
+ emptyUnsets,
125
130
  tableStrings: () => t.strings,
126
131
  slashStrings: () => t.strings,
127
132
  onSlash: (next) => {
@@ -166,7 +171,7 @@
166
171
  <div
167
172
  {@attach mountField}
168
173
  class={[
169
- 'qm-prose',
174
+ 'qm-prose qm-wash',
170
175
  { 'qm-control-box': !unframed, 'qm-focus-ring-within': !unframed, 'qm-prose-block': block }
171
176
  ]}
172
177
  data-leaf-key={leafKey}
@@ -178,14 +183,6 @@
178
183
  <SlashMenu menu={slash} leaf={() => controller} label={t.strings.slashLabel} />
179
184
 
180
185
  <style>
181
- /* The box is `.qm-control-box` (controls.css), the same rule the input beside it
182
- draws, so the two agree on height by construction. No `min-height` with it:
183
- `core/codec/prose.css` resets the paragraph box, so one line of prose in this box
184
- measures one line of text in that one. */
185
- .qm-prose {
186
- /* Containing block for the arrival wash's inset child (`core/bloom.ts`). */
187
- position: relative;
188
- }
189
186
  /* A leaf the inline schema does not constrain grows, and opens at one line box: the
190
187
  empty leaf is the height its first line will take, so the first keystroke displaces
191
188
  nothing below it. `1em`, not a size rung, which is the rule below's to name.
@@ -196,8 +193,8 @@
196
193
  .qm-prose-block :global(.ProseMirror) {
197
194
  min-height: calc(1em * var(--_qm-leading-body));
198
195
  }
199
- /* Paper, and the withheld box is what says so. Not `block` alone: `inline` is the
200
- quill's to declare, so a schema omitting it on a richtext field puts a block leaf in
196
+ /* Paper, and the withheld box is what says so. Not `block` alone: `multiline` is the
197
+ quill's to declare, so a schema spelling it on a richtext field puts a block leaf in
201
198
  a field row, where this rung would break the height agreement above. */
202
199
  .qm-prose-block:not(.qm-control-box) {
203
200
  font-size: var(--_qm-text-paper);
@@ -11,8 +11,9 @@ interface Props {
11
11
  plaintext?: boolean;
12
12
  /**
13
13
  * Draw no box: the card body's leaf, and nothing else. Not `block`, which is a
14
- * different predicate: a `richtext` field without `inline` is block prose and
15
- * still a control in a row of controls, so only the caller knows what is paper.
14
+ * different predicate: a `richtext` field declaring `multiline: true` is block
15
+ * prose and still a control in a row of controls, so only the caller knows what
16
+ * is paper.
16
17
  */
17
18
  unframed?: boolean;
18
19
  /** Accessible name for a leaf nothing else names: an array element, or the
@@ -32,6 +33,8 @@ interface Props {
32
33
  placeholder?: string;
33
34
  /** The placeholder goes at the leaf's first edit (`createField`). */
34
35
  placeholderUntilEdit?: boolean;
36
+ /** An emptied leaf removes the field (`createField`). */
37
+ emptyUnsets?: boolean;
35
38
  /** Registry identity, stamped on the DOM node so a remount is visible as one. */
36
39
  leafKey: string;
37
40
  onFocus?: (addr: Addr) => void;