@quillmark/svelte 0.2.0 → 0.3.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 (116) hide show
  1. package/README.md +2 -2
  2. package/THEMING.md +11 -7
  3. package/dist/core/address.d.ts +7 -7
  4. package/dist/core/address.js +6 -6
  5. package/dist/core/bloom.d.ts +5 -5
  6. package/dist/core/bloom.js +8 -8
  7. package/dist/core/codec/anchor.d.ts +1 -1
  8. package/dist/core/codec/anchor.js +1 -1
  9. package/dist/core/codec/atoms.d.ts +10 -0
  10. package/dist/core/codec/atoms.js +78 -0
  11. package/dist/core/codec/clipboard.d.ts +59 -0
  12. package/dist/core/codec/clipboard.js +202 -0
  13. package/dist/core/codec/code.d.ts +1 -1
  14. package/dist/core/codec/code.js +4 -4
  15. package/dist/core/codec/decode.d.ts +1 -1
  16. package/dist/core/codec/decode.js +8 -8
  17. package/dist/core/codec/encode.d.ts +4 -4
  18. package/dist/core/codec/encode.js +10 -10
  19. package/dist/core/codec/field.d.ts +21 -26
  20. package/dist/core/codec/field.js +33 -32
  21. package/dist/core/codec/index.js +5 -8
  22. package/dist/core/codec/inputrules.js +56 -18
  23. package/dist/core/codec/islands.d.ts +11 -7
  24. package/dist/core/codec/islands.js +16 -10
  25. package/dist/core/codec/keymap.d.ts +5 -6
  26. package/dist/core/codec/keymap.js +8 -7
  27. package/dist/core/codec/lists.d.ts +3 -3
  28. package/dist/core/codec/lists.js +32 -13
  29. package/dist/core/codec/marks.d.ts +1 -1
  30. package/dist/core/codec/marks.js +1 -1
  31. package/dist/core/codec/positions.js +1 -1
  32. package/dist/core/codec/prose.css +230 -215
  33. package/dist/core/codec/schema.js +4 -4
  34. package/dist/core/codec/slash.d.ts +39 -41
  35. package/dist/core/codec/slash.js +0 -0
  36. package/dist/core/codec/table-view.d.ts +12 -16
  37. package/dist/core/codec/table-view.js +488 -292
  38. package/dist/core/codec/table.d.ts +42 -15
  39. package/dist/core/codec/table.js +70 -26
  40. package/dist/core/errors.d.ts +5 -5
  41. package/dist/core/index.js +2 -2
  42. package/dist/core/lifecycle.js +1 -1
  43. package/dist/core/rebind.svelte.d.ts +2 -2
  44. package/dist/core/rebind.svelte.js +4 -4
  45. package/dist/core/teardown.d.ts +1 -1
  46. package/dist/core/teardown.js +1 -1
  47. package/dist/core/theme.css +144 -54
  48. package/dist/preset/index.css +1 -1
  49. package/dist/preset/recipes.css +31 -29
  50. package/dist/preset/scale.css +36 -25
  51. package/dist/preview/Preview.svelte +12 -10
  52. package/dist/preview/Preview.svelte.d.ts +3 -3
  53. package/dist/preview/bridge.js +4 -4
  54. package/dist/preview/controller.js +3 -3
  55. package/dist/preview/geometry.d.ts +2 -2
  56. package/dist/preview/geometry.js +2 -2
  57. package/dist/preview/index.js +3 -3
  58. package/dist/preview/overlay.d.ts +1 -1
  59. package/dist/preview/overlay.js +3 -3
  60. package/dist/preview/paint.js +15 -17
  61. package/dist/visual/ArrayField.svelte +80 -66
  62. package/dist/visual/ArrayField.svelte.d.ts +1 -1
  63. package/dist/visual/BooleanField.svelte +8 -6
  64. package/dist/visual/BooleanField.svelte.d.ts +2 -2
  65. package/dist/visual/Card.svelte +151 -325
  66. package/dist/visual/Card.svelte.d.ts +2 -6
  67. package/dist/visual/CardControls.svelte +1 -1
  68. package/dist/visual/DateField.svelte +19 -19
  69. package/dist/visual/DateField.svelte.d.ts +3 -3
  70. package/dist/visual/DiagnosticList.svelte +1 -1
  71. package/dist/visual/EnumField.svelte +16 -16
  72. package/dist/visual/EnumField.svelte.d.ts +2 -2
  73. package/dist/visual/Field.svelte +128 -115
  74. package/dist/visual/Field.svelte.d.ts +1 -1
  75. package/dist/visual/FieldHint.svelte +24 -42
  76. package/dist/visual/FieldHint.svelte.d.ts +1 -1
  77. package/dist/visual/FieldLabel.svelte +8 -8
  78. package/dist/visual/FormatPopover.svelte +80 -35
  79. package/dist/visual/NumberField.svelte +5 -5
  80. package/dist/visual/NumberField.svelte.d.ts +1 -1
  81. package/dist/visual/ObjectField.svelte +9 -9
  82. package/dist/visual/ObjectField.svelte.d.ts +1 -1
  83. package/dist/visual/ProseArrayElement.svelte +11 -13
  84. package/dist/visual/ProseArrayElement.svelte.d.ts +1 -1
  85. package/dist/visual/ProseField.svelte +53 -84
  86. package/dist/visual/ProseField.svelte.d.ts +12 -17
  87. package/dist/visual/SlashMenu.svelte +42 -16
  88. package/dist/visual/TextField.svelte +5 -5
  89. package/dist/visual/TextField.svelte.d.ts +1 -1
  90. package/dist/visual/TipsCard.svelte +43 -28
  91. package/dist/visual/VisualEditor.svelte +6 -6
  92. package/dist/visual/VisualEditorInner.svelte +122 -129
  93. package/dist/visual/controls.css +74 -30
  94. package/dist/visual/diagnostics.d.ts +5 -5
  95. package/dist/visual/diagnostics.js +3 -3
  96. package/dist/visual/domid.js +3 -3
  97. package/dist/visual/ext.d.ts +1 -1
  98. package/dist/visual/ext.js +1 -1
  99. package/dist/visual/icons/nodes.d.ts +3 -0
  100. package/dist/visual/icons/nodes.js +2 -1
  101. package/dist/visual/index.js +4 -4
  102. package/dist/visual/leaves.d.ts +3 -3
  103. package/dist/visual/leaves.js +1 -1
  104. package/dist/visual/links.d.ts +24 -0
  105. package/dist/visual/links.js +67 -0
  106. package/dist/visual/props.d.ts +8 -8
  107. package/dist/visual/signals.d.ts +6 -6
  108. package/dist/visual/strings.d.ts +14 -19
  109. package/dist/visual/strings.js +5 -7
  110. package/dist/visual/structure.d.ts +24 -17
  111. package/dist/visual/structure.js +8 -8
  112. package/dist/visual/synced.svelte.d.ts +1 -1
  113. package/dist/visual/synced.svelte.js +4 -4
  114. package/dist/visual/tips.d.ts +2 -2
  115. package/dist/visual/tips.js +3 -3
  116. package/package.json +1 -1
@@ -1,13 +1,13 @@
1
1
  <!--
2
2
  One element of an array-of-`richtext` field (fixture `references`). Array
3
- elements are NOT `applyChange`-addressable: `Addr.field` is a flat name, so
3
+ elements are not `applyChange`-addressable: `Addr.field` is a flat name, so
4
4
  `references.0` has no op address. So
5
- this is NOT a `createField` leaf: it mounts a minimal PM view over the codec's
5
+ this is not a `createField` leaf: it mounts a minimal PM view over the codec's
6
6
  decode/encode + inline schema, and on every edit hands the re-encoded
7
- `Content` UP to the parent {@link ArrayField}, which commits the WHOLE array
7
+ `Content` up to the parent {@link ArrayField}, which commits the whole array
8
8
  by value (`writer.set(field, arrayWithElementReplaced)`). Anchors within an
9
9
  element are dropped on that value write: acceptable for inline refs. Mounts
10
- ONCE per stable element id (no reset on the parent's re-derive).
10
+ once per stable element id (no reset on the parent's re-derive).
11
11
  -->
12
12
  <script lang="ts">
13
13
  import { onMount } from 'svelte';
@@ -24,7 +24,7 @@
24
24
  label?: string;
25
25
  onChange: (rt: Content) => void;
26
26
  /** Raw keydown, for a container whose own keys run through this element: the
27
- * array repeater's Enter/Backspace (`ArrayField`). Fires BEFORE the view's own
27
+ * array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
28
28
  * keymap, so the state it reads is the one this keystroke has yet to change. */
29
29
  onKey?: (e: KeyboardEvent) => void;
30
30
  }
@@ -32,7 +32,7 @@
32
32
 
33
33
  let containerEl: HTMLDivElement | undefined = $state();
34
34
  let view: EditorView | undefined;
35
- /** Take the caret: what a parent placing focus on this element calls. The VIEW's
35
+ /** Take the caret: what a parent placing focus on this element calls. The view's
36
36
  * focus, not the element's: a PM view restores its selection, where a bare DOM
37
37
  * focus on a contenteditable leaves the caret unplaced. */
38
38
  export function focus(): void {
@@ -41,7 +41,7 @@
41
41
 
42
42
  onMount(() => {
43
43
  if (!containerEl) return;
44
- // An array element is an inline, value-by-value leaf: the SAME keymap and
44
+ // An array element is an inline, value-by-value leaf: the same keymap and
45
45
  // plugin stack a `createField` leaf mounts (shared `proseLeafPlugins`), minus
46
46
  // the anchor-position plugin (element anchors are dropped on the array's value
47
47
  // write, per the header). Its own `dispatchTransaction` hands `Content` up.
@@ -79,15 +79,13 @@
79
79
  an array of `string` render rows of equal height. No floor: the reset in
80
80
  `core/codec/prose.css` makes one line of prose measure one line. Width is the
81
81
  row's to give: the element fills the track it is placed in (`ArrayField`). -->
82
- <div bind:this={containerEl} class="qm-array-prose qm-control-box"></div>
82
+ <div bind:this={containerEl} class="qm-array-prose qm-control-box qm-focus-ring-within"></div>
83
83
 
84
84
  <style>
85
- /* Caret-primary, matching ProseField: the contenteditable outline is dropped
86
- and the active element is cued by the wrapper border tint below, not a ring. */
85
+ /* Caret-primary, matching ProseField: the contenteditable's own outline is
86
+ dropped and the ring rides the wrapper (`qm-focus-ring-within`,
87
+ controls.css), which is where the box is. */
87
88
  .qm-array-prose :global(.ProseMirror) {
88
89
  outline: none;
89
90
  }
90
- .qm-array-prose:focus-within {
91
- border-color: var(--_qm-accent);
92
- }
93
91
  </style>
@@ -7,7 +7,7 @@ interface Props {
7
7
  label?: string;
8
8
  onChange: (rt: Content) => void;
9
9
  /** Raw keydown, for a container whose own keys run through this element: the
10
- * array repeater's Enter/Backspace (`ArrayField`). Fires BEFORE the view's own
10
+ * array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
11
11
  * keymap, so the state it reads is the one this keystroke has yet to change. */
12
12
  onKey?: (e: KeyboardEvent) => void;
13
13
  }
@@ -1,11 +1,9 @@
1
1
  <!--
2
- One content prose leaf: a thin Svelte mount of the codec's `createField`
3
- (VISUAL_EDITOR §Surface). Mounts ONCE per stable leaf key and tears down on
4
- unmount; the leaf owns its PM state, history, and per-keystroke `applyChange`
5
- commit, so this wrapper adds no logic beyond wiring and registration. The
6
- `addr` is supplied by the parent as a LIVE object (its `card` a getter over the
7
- stable-id→index map), so a card reorder re-targets this leaf's commits without
8
- a remount: the caret rides the untouched PM view.
2
+ One content prose leaf: a Svelte mount of the codec's `createField`
3
+ (VISUAL_EDITOR §Surface), which owns the PM state, the history and the
4
+ per-keystroke commit, so this wrapper is wiring and registration. `addr` is a
5
+ live object the parent builds, so a card reorder re-targets this leaf's commits
6
+ with the caret riding the untouched view.
9
7
  -->
10
8
  <script lang="ts">
11
9
  import { onMount } from 'svelte';
@@ -20,32 +18,28 @@
20
18
  interface Props {
21
19
  doc: Document;
22
20
  /** The schema this leaf reads its content through: `createField` binds a reader
23
- * off it, so a stored string decodes by declared type rather than by guess. */
21
+ * off it, so a stored string decodes by its declared type. */
24
22
  quill: Quill;
25
23
  addr: Addr;
26
24
  inline?: boolean;
27
25
  plaintext?: boolean;
28
26
  /**
29
- * Draw no box: the card body's leaf, and nothing else. A property
30
- * of the SLOT rather than of the leaf: `block` is not the same predicate, since
31
- * a `richtext` field without `inline` is block prose and still a control in a
32
- * row of controls. Only the body is paper, and only its caller knows it is.
27
+ * Draw no box: the card body's leaf, and nothing else. Not `block`, which is a
28
+ * different predicate: a `richtext` field without `inline` is block prose and
29
+ * still a control in a row of controls, so only the caller knows what is paper.
33
30
  */
34
31
  unframed?: boolean;
35
- /** Accessible name for a leaf NOTHING else names: an array element, or the
36
- * card body. A leaf with a field label takes `labelledBy` instead. */
32
+ /** Accessible name for a leaf nothing else names: an array element, or the
33
+ * card body. A leaf with a field label takes `labelledBy`. */
37
34
  label?: string;
38
35
  /** The field label's own id → `aria-labelledby` on the editable region. `for`
39
- * cannot reach a `contenteditable` (not a labelable element) so the
40
- * association runs the other way, and the label's click comes back through
41
- * the controller's `focus`. */
36
+ * cannot reach a `contenteditable`, so the association runs the other way and
37
+ * the label's click comes back through the controller's `focus`. */
42
38
  labelledBy?: string;
43
39
  /** The parked `description` (FieldLabel) → `aria-describedby`. */
44
40
  describedBy?: string;
45
- /** Ghost shown on the empty leaf. An inline leaf's is the resolved `default:`
46
- * or nothing; a body's always has text, falling back to an invitation
47
- * (`resolveBodyGhost`). Reactive: a change is pushed into the mounted view,
48
- * never a remount. */
41
+ /** Ghost shown on the empty leaf: an inline leaf's resolved `default:` or
42
+ * nothing, a body's always text (`resolveBodyGhost`). */
49
43
  placeholder?: string;
50
44
  /** Stable identity for the registry, stamped on the DOM node so a remount is
51
45
  * visible as one. */
@@ -55,9 +49,8 @@
55
49
  /** A commit landed on this leaf: the prose change lane. */
56
50
  onChange?: (addr: Addr) => void;
57
51
  onError?: EditorErrorHandler;
58
- /** The editor's leaf registry (`leaves.ts`). A prose leaf registers its
59
- * CONTROLLER: the landing handle plus the codec seam a form control has no half
60
- * of, which is what makes this leaf a caret target and not merely a focus one. */
52
+ /** The editor's leaf registry (`leaves.ts`). Registering the controller is what
53
+ * makes this leaf a caret target and not merely a focus one. */
61
54
  leaves?: LeafRegistry;
62
55
  }
63
56
 
@@ -82,33 +75,24 @@
82
75
 
83
76
  let containerEl: HTMLDivElement | undefined = $state();
84
77
 
85
- // The island chrome's wording, read where every other surface reads it. Passed as
86
- // a GETTER: the codec draws that chrome on each render, so a locale swap reaches a
87
- // mounted table without remounting the leaf and losing the caret.
78
+ // Handed to the codec as a getter: the island chrome redraws on each render, so a
79
+ // locale swap reaches a mounted table without remounting the leaf and losing the caret.
88
80
  const t = wording();
89
81
 
90
- /** The insert menu's live state, pushed by the leaf's trigger plugin. Held HERE
91
- * rather than in the shell: a trigger belongs to one caret in one leaf, so the
92
- * menu is the leaf's surface, unlike the one format popover that follows whichever
93
- * leaf is active. A constrained leaf never receives one (`createField` mounts the
94
- * trigger on the block schema alone). */
82
+ /** The insert menu's live state, pushed by the leaf's trigger plugin: the menu is
83
+ * the leaf's surface rather than the shell's (VISUAL_EDITOR §Chrome). A constrained
84
+ * leaf never receives one, the trigger mounting on the block schema alone. */
95
85
  let slash: SlashState | undefined = $state();
96
86
 
97
- // Block prose vs a control in a row of controls: the SAME predicate the codec
98
- // picks its schema by (`createField`: `plaintext` implies `inline`), so the box a
99
- // leaf draws and the schema it holds cannot disagree. Keyed on `inline` alone, a
100
- // `plaintext`-only field would take the body's floor while holding one paragraph.
87
+ // The codec's own schema predicate (`createField`: `plaintext` implies `inline`), so
88
+ // the box a leaf draws and the schema it holds cannot disagree. On `inline` alone a
89
+ // `plaintext` field would take the block floor while holding one paragraph.
101
90
  const block = $derived(!(inline || plaintext));
102
91
 
103
- // An absent richtext field (a `default:`-only field like `tag_line`) is handled
104
- // by the codec itself: `createField` decodes an empty content and overwrites on
105
- // the first edit; so this wrapper adds no pre-seeding, and an untouched field
106
- // stays absent (its default rendering intact) until actually edited.
107
92
  let controller: FieldController | undefined;
108
93
 
109
- /** Take the caret: what the label click calls. The CONTROLLER's focus, not the
110
- * container's: a PM view restores a selection that an `HTMLElement.focus` on
111
- * the wrapper leaves unplaced. */
94
+ /** Take the caret: what the label's click calls. The controller's focus, not the
95
+ * container's, which leaves the PM selection unplaced. */
112
96
  export function focus(): void {
113
97
  controller?.focus();
114
98
  }
@@ -127,7 +111,6 @@
127
111
  describedBy,
128
112
  placeholder,
129
113
  tableStrings: () => t.strings,
130
- slashStrings: () => t.strings,
131
114
  onSlash: (next) => {
132
115
  slash = next;
133
116
  },
@@ -144,10 +127,8 @@
144
127
  };
145
128
  });
146
129
 
147
- // The ghost outlives no remount of its own: the leaf is keyed by the card's
148
- // session id, so a card RETYPED to another kind keeps this view and would keep
149
- // the old kind's wording. Push it instead: the caret is worth more than the
150
- // simplicity of rebuilding.
130
+ // A retype does not remount the leaf (its key is the card's session id), so the new
131
+ // kind's ghost is pushed into the live view rather than paid for with the caret.
151
132
  $effect(() => {
152
133
  controller?.setPlaceholder(placeholder);
153
134
  });
@@ -157,59 +138,47 @@
157
138
  bind:this={containerEl}
158
139
  class="qm-prose"
159
140
  class:qm-control-box={!unframed}
141
+ class:qm-focus-ring-within={!unframed}
160
142
  class:qm-prose-block={block}
161
143
  data-leaf-key={leafKey}
162
144
  ></div>
163
145
  <SlashMenu menu={slash} leaf={() => controller} label={t.strings.slashLabel} />
164
146
 
165
147
  <style>
166
- /* The box is `.qm-control-box` (controls.css): the same rule the input beside it
167
- draws, so the two agree on height by construction rather than by two floors
168
- tuned to match. Nothing here restates it, and there is no
169
- `min-height`: `core/codec/prose.css` resets the paragraph box, so one line of
170
- prose in this box measures one line of text in that one. */
148
+ /* The box is `.qm-control-box` (controls.css), the same rule the input beside it
149
+ draws, so the two agree on height by construction. No `min-height` with it:
150
+ `core/codec/prose.css` resets the paragraph box, so one line of prose in this box
151
+ measures one line of text in that one. */
171
152
  .qm-prose {
172
- /* Positioned for the arrival wash `setCaret` inserts (`core/bloom.ts`): an
173
- inset child, so it covers the text without fading it or tinting the leaf's
174
- own surface. */
153
+ /* Containing block for the arrival wash's inset child (`core/bloom.ts`). */
175
154
  position: relative;
176
155
  }
177
- /* A leaf the inline schema does NOT constrain is prose rather than a cell in a row
178
- of controls, so it opens at a few lines and grows. The floor is three LINE BOXES,
179
- not a length: `1em` rather than a size rung because which rung this leaf reads is
180
- the rule below's, and a second naming of it drifts. Three is what an EMPTY body
181
- costs, which is the only state the floor is ever the height: the ghost takes the
182
- first line, so the opening reads as an invitation with room under it rather than
183
- as a drop. */
184
- .qm-prose-block {
156
+ /* A leaf the inline schema does not constrain is prose rather than a cell in a row of
157
+ controls, so it opens at three line boxes and grows: the ghost takes the first,
158
+ leaving room under it. `1em`, not a size rung, which is the rule below's to name.
159
+
160
+ On the editable rather than on the wrapper, which is what makes those lines part of
161
+ the body: height the wrapper holds is height outside the `contenteditable`, where a
162
+ press lands no caret. Inside it, the browser answers a press past the last line at
163
+ the end of it. */
164
+ .qm-prose-block :global(.ProseMirror) {
185
165
  min-height: calc(1em * var(--_qm-leading-body) * 3);
186
166
  }
187
- /* PAPER, and the box is what says so. Not `block` alone: `inline` is the quill's to
188
- declare, so a schema omitting it on a richtext field puts a block leaf in a field
189
- row, where the paper rung would break the height agreement `controls.css` builds by
190
- construction. */
167
+ /* Paper, and the withheld box is what says so. Not `block` alone: `inline` is the
168
+ quill's to declare, so a schema omitting it on a richtext field puts a block leaf in
169
+ a field row, where this rung would break the height agreement above. */
191
170
  .qm-prose-block:not(.qm-control-box) {
192
171
  font-size: var(--_qm-text-paper);
193
172
  }
194
- /* The caret is the prose leaf's focus indicator, not a ring: a ring around a
195
- contenteditable reads as form chrome rather than as paper. So the outline is
196
- dropped; a boxed leaf tints its wrapper border below. */
173
+ /* The caret is the body's focus indicator: a ring around the one surface in a card
174
+ that is paper reads as form chrome. So the contenteditable's own outline is dropped
175
+ everywhere, and a boxed leaf takes the shared ring on its wrapper instead
176
+ (`qm-focus-ring-within`, controls.css). */
197
177
  .qm-prose :global(.ProseMirror) {
198
178
  outline: none;
199
179
  }
200
- /* Active-leaf cue: tint the hairline to the focus hue, shared with the scalar
201
- ring and the preview active box: no added box (the editor↔preview address).
202
- Scoped to the leaves that HAVE a hairline: the body has none, and the caret is
203
- the whole indicator there: one surface per card, and the largest in it, so
204
- "which leaf am I in" is not a question it raises. A rule drawn to answer it
205
- would be a stroke bought back to replace the one unframing removed. */
206
- .qm-prose.qm-control-box:focus-within {
207
- border-color: var(--_qm-accent);
208
- }
209
- /* Empty-leaf ghost: the resolved `default:` as dim/italic ghost,
210
- matching the scalar ghost rung. Rendered from a node decoration's data attr so
211
- it stays out of the
212
- document; `float`/`height:0` keep it from displacing the caret. */
180
+ /* Empty-leaf ghost, at the scalar ghost rung: a node decoration's data attr, so it
181
+ stays out of the document; `float`/`height: 0` keep it from displacing the caret. */
213
182
  .qm-prose :global(.ProseMirror .qm-prose-placeholder::before) {
214
183
  content: attr(data-placeholder);
215
184
  color: var(--_qm-ink-ghost);
@@ -5,32 +5,28 @@ import type { EditorErrorHandler } from '../core/errors.js';
5
5
  interface Props {
6
6
  doc: Document;
7
7
  /** The schema this leaf reads its content through: `createField` binds a reader
8
- * off it, so a stored string decodes by declared type rather than by guess. */
8
+ * off it, so a stored string decodes by its declared type. */
9
9
  quill: Quill;
10
10
  addr: Addr;
11
11
  inline?: boolean;
12
12
  plaintext?: boolean;
13
13
  /**
14
- * Draw no box: the card body's leaf, and nothing else. A property
15
- * of the SLOT rather than of the leaf: `block` is not the same predicate, since
16
- * a `richtext` field without `inline` is block prose and still a control in a
17
- * row of controls. Only the body is paper, and only its caller knows it is.
14
+ * Draw no box: the card body's leaf, and nothing else. Not `block`, which is a
15
+ * different predicate: a `richtext` field without `inline` is block prose and
16
+ * still a control in a row of controls, so only the caller knows what is paper.
18
17
  */
19
18
  unframed?: boolean;
20
- /** Accessible name for a leaf NOTHING else names: an array element, or the
21
- * card body. A leaf with a field label takes `labelledBy` instead. */
19
+ /** Accessible name for a leaf nothing else names: an array element, or the
20
+ * card body. A leaf with a field label takes `labelledBy`. */
22
21
  label?: string;
23
22
  /** The field label's own id → `aria-labelledby` on the editable region. `for`
24
- * cannot reach a `contenteditable` (not a labelable element) so the
25
- * association runs the other way, and the label's click comes back through
26
- * the controller's `focus`. */
23
+ * cannot reach a `contenteditable`, so the association runs the other way and
24
+ * the label's click comes back through the controller's `focus`. */
27
25
  labelledBy?: string;
28
26
  /** The parked `description` (FieldLabel) → `aria-describedby`. */
29
27
  describedBy?: string;
30
- /** Ghost shown on the empty leaf. An inline leaf's is the resolved `default:`
31
- * or nothing; a body's always has text, falling back to an invitation
32
- * (`resolveBodyGhost`). Reactive: a change is pushed into the mounted view,
33
- * never a remount. */
28
+ /** Ghost shown on the empty leaf: an inline leaf's resolved `default:` or
29
+ * nothing, a body's always text (`resolveBodyGhost`). */
34
30
  placeholder?: string;
35
31
  /** Stable identity for the registry, stamped on the DOM node so a remount is
36
32
  * visible as one. */
@@ -40,9 +36,8 @@ interface Props {
40
36
  /** A commit landed on this leaf: the prose change lane. */
41
37
  onChange?: (addr: Addr) => void;
42
38
  onError?: EditorErrorHandler;
43
- /** The editor's leaf registry (`leaves.ts`). A prose leaf registers its
44
- * CONTROLLER: the landing handle plus the codec seam a form control has no half
45
- * of, which is what makes this leaf a caret target and not merely a focus one. */
39
+ /** The editor's leaf registry (`leaves.ts`). Registering the controller is what
40
+ * makes this leaf a caret target and not merely a focus one. */
46
41
  leaves?: LeafRegistry;
47
42
  }
48
43
  declare const ProseField: import("svelte").Component<Props, {
@@ -1,19 +1,21 @@
1
1
  <!--
2
2
  The slash menu. The leaf's own surface, not the
3
3
  shell's: the trigger belongs to one caret in one leaf, so it is mounted by
4
- `ProseField` beside the view it reads from, unlike the ONE shell-owned format
4
+ `ProseField` beside the view it reads from, unlike the one shell-owned format
5
5
  popover that follows whichever leaf is active.
6
6
 
7
- The MODEL is the codec's (`codec/slash.ts`: the trigger run, the query, the
7
+ The model is the codec's (`codec/slash.ts`: the trigger run, the query, the
8
8
  highlighted index, and what each pick does). This component draws it and nothing
9
9
  else. The keys are the leaf's keymap for the same reason: the caret stays in the
10
10
  contenteditable while the menu is open, so a focus-taking listbox would move the
11
- selection the insert is measured against. What is left here is the POINTER's half:
12
- buttons that pick, and that swallow their own mousedown so the caret stays put.
11
+ selection the insert is measured against. What is left here is mostly the pointer's
12
+ half: buttons that pick, and that swallow their own mousedown so the caret stays put.
13
13
 
14
- The highlight has ONE lane: `data-highlighted` marks whichever item the codec's
15
- index names, and a pointer entering an item MOVES that index rather than painting a
14
+ The highlight has one lane: `data-highlighted` marks whichever item the codec's
15
+ index names, and a pointer entering an item moves that index rather than painting a
16
16
  second highlight the keyboard cannot reach (controls.css, the shared menu recipe).
17
+ The keyboard's one claim here is the scroll: the arrows move an index, and only the
18
+ chrome knows the port that index has to stay inside.
17
19
  -->
18
20
  <script lang="ts">
19
21
  import { Popover } from 'bits-ui';
@@ -32,7 +34,7 @@
32
34
  let { menu, leaf, label }: Props = $props();
33
35
 
34
36
  /** Raised whenever the codec reports offers. Mirrored into local state rather than
35
- * derived, because the primitive OWNS this prop: its own dismissal layer (an
37
+ * derived, because the primitive owns this prop: its own dismissal layer (an
36
38
  * outside press) writes it, and a derived it cannot write to is an error rather
37
39
  * than a dismissal. The next report re-syncs, so the codec stays the authority on
38
40
  * whether a run is live. */
@@ -41,11 +43,21 @@
41
43
  open = !!menu && menu.items.length > 0;
42
44
  });
43
45
 
44
- /** The root to portal INTO, resolved from the leaf's own DOM so the menu lands
46
+ /** The root to portal into, resolved from the leaf's own DOM so the menu lands
45
47
  * inside whichever `[data-qm-root]` raised it and inherits the consumer's dials;
46
48
  * `undefined` falls back to bits-ui's `document.body`. */
47
49
  const portalTarget = $derived(leaf()?.el.closest<HTMLElement>('[data-qm-root]') ?? undefined);
48
50
 
51
+ let surface = $state<HTMLElement | undefined>();
52
+
53
+ /** Keep the keyboard's cursor in the port: the arrows move an index rather than a
54
+ * focus, so a row the list scrolled past has nothing bringing it back. `nearest`
55
+ * scrolls the least that works, and no ancestor the row is already visible in. */
56
+ $effect(() => {
57
+ void menu?.index;
58
+ surface?.querySelector('[data-highlighted]')?.scrollIntoView({ block: 'nearest' });
59
+ });
60
+
49
61
  /** Swallow the item's own mousedown: without it the browser focuses the item and
50
62
  * blurs the leaf, taking the trigger run's caret with it. */
51
63
  function keepFocus(e: MouseEvent): void {
@@ -55,12 +67,17 @@
55
67
 
56
68
  <Popover.Root bind:open>
57
69
  <Popover.Portal to={portalTarget}>
70
+ <!-- Hung off the trigger and flush against it: the anchor is the `/` itself
71
+ (`codec/slash.ts`), so the surface's top left corner sits at the line's bottom
72
+ edge under the character that opened the menu, and stays there as the query is
73
+ typed. Zero offset, because a gap reads as a surface floating near the caret
74
+ rather than one growing out of it. -->
58
75
  <Popover.Content
59
76
  customAnchor={menu?.anchor ?? null}
60
77
  side="bottom"
61
78
  align="start"
62
79
  trapFocus={false}
63
- sideOffset={6}
80
+ sideOffset={0}
64
81
  onOpenAutoFocus={(e: Event) => e.preventDefault()}
65
82
  onCloseAutoFocus={(e: Event) => e.preventDefault()}
66
83
  >
@@ -74,22 +91,26 @@
74
91
  a thing to click, tab into, or read. -->
75
92
  <div
76
93
  {...props}
94
+ bind:this={surface}
77
95
  class="qm-slash-menu qm-menu-surface"
78
96
  data-qm-root
79
97
  role="group"
80
98
  aria-label={label}
81
99
  inert={!raised}
82
100
  >
83
- {#each menu?.items ?? [] as item, i (item.id)}
101
+ <!-- The row is the command as typed, leading `/` and all: the menu is a
102
+ completion of the run in the text, so the row and the run read the
103
+ same. -->
104
+ {#each menu?.items ?? [] as name, i (name)}
84
105
  <button
85
106
  type="button"
86
107
  class="qm-menu-item qm-slash-item"
87
108
  data-highlighted={i === menu?.index ? '' : undefined}
88
109
  onmousedown={keepFocus}
89
- onclick={() => leaf()?.slashPick(item.id)}
90
- onpointerenter={() => leaf()?.slashFocus(item.id)}
110
+ onclick={() => leaf()?.slashPick(name)}
111
+ onpointerenter={() => leaf()?.slashFocus(name)}
91
112
  >
92
- {item.label}
113
+ /{name}
93
114
  </button>
94
115
  {/each}
95
116
  </div>
@@ -104,20 +125,25 @@
104
125
  `.qm-menu-surface` / `.qm-menu-item` (controls.css): the same recipe the enum
105
126
  listbox and the card stack's kind menu draw, because all three are lists picked
106
127
  from. What is here is this menu's own: a measure, so a long label does not stretch
107
- the surface across the card, and a scroll for a filtered list that stays long. */
128
+ the surface across the card, a scroll for a filtered list that stays long, and the
129
+ face. Monospace, where the sibling menus are not: they offer values of the document
130
+ and read as the prose around them, where these are commands, and the surface
131
+ already spells a literal in that face (the `code` mark, `codec/prose.css`). */
108
132
  .qm-slash-menu {
109
133
  min-width: 12rem;
110
134
  max-height: 16rem;
111
135
  overflow-y: auto;
136
+ font-family: var(--_qm-font-mono);
112
137
  }
113
138
  /* An item is a row of text, not a glyph: the whole row is the target, and the label
114
139
  is the accessible name with nothing beside it. A button is a button, so the UA's
115
- own box is taken back; what is left is the shared item recipe. */
140
+ own box is taken back; what is left is the shared item recipe. The fill is not
141
+ taken back here: a scoped block is unlayered, so a reset would outrank the recipe's
142
+ highlight; the resting transparent is the recipe's own (controls.css). */
116
143
  .qm-slash-item {
117
144
  display: block;
118
145
  width: 100%;
119
146
  border: none;
120
- background: none;
121
147
  font: inherit;
122
148
  color: inherit;
123
149
  text-align: left;
@@ -1,11 +1,11 @@
1
1
  <!--
2
- A `string` field → text input. Commits a non-empty edit LIVE (on input) via the
2
+ A `string` field → text input. Commits a non-empty edit live (on input) via the
3
3
  parent's typed `writer.set`, so the preview tracks typing. A cleared field is the
4
- UNSET rung of the commitment ladder (VISUAL_EDITOR §"Structure mirrors the schema"): it
4
+ unset rung of the commitment ladder (VISUAL_EDITOR §"Structure mirrors the schema"): it
5
5
  commits `undefined` (the parent removes the field, ghosted `default:` renders):
6
- but at `change` (blur), NOT per keystroke, so select-all-and-retype doesn't flash
6
+ but at `change` (blur), not per keystroke, so select-all-and-retype doesn't flash
7
7
  the field through its default between the delete and the first typed char.
8
- Trade-off (recorded in VISUAL_EDITOR): an explicit empty string OVER a non-empty
8
+ Trade-off (recorded in VISUAL_EDITOR): an explicit empty string over a non-empty
9
9
  default is inexpressible from the UI: clear and unset collapse to one gesture.
10
10
  -->
11
11
  <script lang="ts">
@@ -15,7 +15,7 @@
15
15
  interface Props {
16
16
  value: string | undefined;
17
17
  placeholder?: string;
18
- /** Accessible name for an input NOTHING else names: an array element, whose
18
+ /** Accessible name for an input nothing else names: an array element, whose
19
19
  * name is the field label plus its 1-based index. A field's own input takes
20
20
  * `id` instead and is named by the `<label for>` beside it. */
21
21
  label?: string;
@@ -2,7 +2,7 @@ import './controls.css';
2
2
  interface Props {
3
3
  value: string | undefined;
4
4
  placeholder?: string;
5
- /** Accessible name for an input NOTHING else names: an array element, whose
5
+ /** Accessible name for an input nothing else names: an array element, whose
6
6
  * name is the field label plus its 1-based index. A field's own input takes
7
7
  * `id` instead and is named by the `<label for>` beside it. */
8
8
  label?: string;