@quillmark/svelte 0.0.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 (143) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +58 -0
  3. package/README.md +241 -0
  4. package/THEMING.md +153 -0
  5. package/dist/core/address.d.ts +56 -0
  6. package/dist/core/address.js +95 -0
  7. package/dist/core/bloom.d.ts +37 -0
  8. package/dist/core/bloom.js +116 -0
  9. package/dist/core/codec/anchor.d.ts +19 -0
  10. package/dist/core/codec/anchor.js +36 -0
  11. package/dist/core/codec/code.d.ts +12 -0
  12. package/dist/core/codec/code.js +118 -0
  13. package/dist/core/codec/decode.d.ts +24 -0
  14. package/dist/core/codec/decode.js +270 -0
  15. package/dist/core/codec/encode.d.ts +71 -0
  16. package/dist/core/codec/encode.js +480 -0
  17. package/dist/core/codec/field.d.ts +171 -0
  18. package/dist/core/codec/field.js +493 -0
  19. package/dist/core/codec/index.d.ts +22 -0
  20. package/dist/core/codec/index.js +43 -0
  21. package/dist/core/codec/inputrules.d.ts +7 -0
  22. package/dist/core/codec/inputrules.js +168 -0
  23. package/dist/core/codec/islands.d.ts +36 -0
  24. package/dist/core/codec/islands.js +82 -0
  25. package/dist/core/codec/keymap.d.ts +13 -0
  26. package/dist/core/codec/keymap.js +29 -0
  27. package/dist/core/codec/lists.d.ts +22 -0
  28. package/dist/core/codec/lists.js +104 -0
  29. package/dist/core/codec/marks.d.ts +37 -0
  30. package/dist/core/codec/marks.js +74 -0
  31. package/dist/core/codec/positions.d.ts +17 -0
  32. package/dist/core/codec/positions.js +108 -0
  33. package/dist/core/codec/prose.css +458 -0
  34. package/dist/core/codec/reconcile.d.ts +26 -0
  35. package/dist/core/codec/reconcile.js +64 -0
  36. package/dist/core/codec/schema.d.ts +13 -0
  37. package/dist/core/codec/schema.js +155 -0
  38. package/dist/core/codec/slash.d.ts +79 -0
  39. package/dist/core/codec/slash.js +0 -0
  40. package/dist/core/codec/table-view.d.ts +50 -0
  41. package/dist/core/codec/table-view.js +928 -0
  42. package/dist/core/codec/table.d.ts +85 -0
  43. package/dist/core/codec/table.js +224 -0
  44. package/dist/core/errors.d.ts +73 -0
  45. package/dist/core/errors.js +24 -0
  46. package/dist/core/index.d.ts +5 -0
  47. package/dist/core/index.js +19 -0
  48. package/dist/core/lifecycle.d.ts +7 -0
  49. package/dist/core/lifecycle.js +21 -0
  50. package/dist/core/rebind.svelte.d.ts +18 -0
  51. package/dist/core/rebind.svelte.js +44 -0
  52. package/dist/core/teardown.d.ts +20 -0
  53. package/dist/core/teardown.js +37 -0
  54. package/dist/core/theme.css +276 -0
  55. package/dist/index.d.ts +1 -0
  56. package/dist/index.js +9 -0
  57. package/dist/preset/index.css +10 -0
  58. package/dist/preset/recipes.css +188 -0
  59. package/dist/preset/scale.css +97 -0
  60. package/dist/preview/Preview.svelte +118 -0
  61. package/dist/preview/Preview.svelte.d.ts +39 -0
  62. package/dist/preview/bridge.d.ts +13 -0
  63. package/dist/preview/bridge.js +88 -0
  64. package/dist/preview/controller.d.ts +40 -0
  65. package/dist/preview/controller.js +141 -0
  66. package/dist/preview/geometry.d.ts +37 -0
  67. package/dist/preview/geometry.js +45 -0
  68. package/dist/preview/index.d.ts +5 -0
  69. package/dist/preview/index.js +14 -0
  70. package/dist/preview/overlay.d.ts +10 -0
  71. package/dist/preview/overlay.js +82 -0
  72. package/dist/preview/paint.d.ts +17 -0
  73. package/dist/preview/paint.js +262 -0
  74. package/dist/preview/strings.d.ts +14 -0
  75. package/dist/preview/strings.js +22 -0
  76. package/dist/visual/ArrayField.svelte +362 -0
  77. package/dist/visual/ArrayField.svelte.d.ts +24 -0
  78. package/dist/visual/BooleanField.svelte +88 -0
  79. package/dist/visual/BooleanField.svelte.d.ts +20 -0
  80. package/dist/visual/Card.svelte +849 -0
  81. package/dist/visual/Card.svelte.d.ts +43 -0
  82. package/dist/visual/CardControls.svelte +92 -0
  83. package/dist/visual/CardControls.svelte.d.ts +11 -0
  84. package/dist/visual/DateField.svelte +203 -0
  85. package/dist/visual/DateField.svelte.d.ts +28 -0
  86. package/dist/visual/DiagnosticList.svelte +50 -0
  87. package/dist/visual/DiagnosticList.svelte.d.ts +7 -0
  88. package/dist/visual/EnumField.svelte +169 -0
  89. package/dist/visual/EnumField.svelte.d.ts +25 -0
  90. package/dist/visual/Field.svelte +270 -0
  91. package/dist/visual/Field.svelte.d.ts +37 -0
  92. package/dist/visual/FieldHint.svelte +185 -0
  93. package/dist/visual/FieldHint.svelte.d.ts +13 -0
  94. package/dist/visual/FieldLabel.svelte +141 -0
  95. package/dist/visual/FieldLabel.svelte.d.ts +19 -0
  96. package/dist/visual/FormatPopover.svelte +396 -0
  97. package/dist/visual/FormatPopover.svelte.d.ts +9 -0
  98. package/dist/visual/NumberField.svelte +72 -0
  99. package/dist/visual/NumberField.svelte.d.ts +19 -0
  100. package/dist/visual/ObjectField.svelte +131 -0
  101. package/dist/visual/ObjectField.svelte.d.ts +17 -0
  102. package/dist/visual/ProseArrayElement.svelte +98 -0
  103. package/dist/visual/ProseArrayElement.svelte.d.ts +19 -0
  104. package/dist/visual/ProseField.svelte +213 -0
  105. package/dist/visual/ProseField.svelte.d.ts +50 -0
  106. package/dist/visual/SlashMenu.svelte +126 -0
  107. package/dist/visual/SlashMenu.svelte.d.ts +14 -0
  108. package/dist/visual/TextField.svelte +63 -0
  109. package/dist/visual/TextField.svelte.d.ts +23 -0
  110. package/dist/visual/TipsCard.svelte +191 -0
  111. package/dist/visual/TipsCard.svelte.d.ts +13 -0
  112. package/dist/visual/VisualEditor.svelte +96 -0
  113. package/dist/visual/VisualEditor.svelte.d.ts +14 -0
  114. package/dist/visual/VisualEditorInner.svelte +1032 -0
  115. package/dist/visual/VisualEditorInner.svelte.d.ts +17 -0
  116. package/dist/visual/controls.css +272 -0
  117. package/dist/visual/diagnostics.d.ts +52 -0
  118. package/dist/visual/diagnostics.js +83 -0
  119. package/dist/visual/domid.d.ts +21 -0
  120. package/dist/visual/domid.js +36 -0
  121. package/dist/visual/ext.d.ts +19 -0
  122. package/dist/visual/ext.js +29 -0
  123. package/dist/visual/icons/Icon.svelte +39 -0
  124. package/dist/visual/icons/Icon.svelte.d.ts +12 -0
  125. package/dist/visual/icons/nodes.d.ts +103 -0
  126. package/dist/visual/icons/nodes.js +55 -0
  127. package/dist/visual/index.d.ts +9 -0
  128. package/dist/visual/index.js +24 -0
  129. package/dist/visual/motion.d.ts +18 -0
  130. package/dist/visual/motion.js +53 -0
  131. package/dist/visual/props.d.ts +77 -0
  132. package/dist/visual/props.js +1 -0
  133. package/dist/visual/signals.d.ts +59 -0
  134. package/dist/visual/signals.js +1 -0
  135. package/dist/visual/strings.d.ts +123 -0
  136. package/dist/visual/strings.js +80 -0
  137. package/dist/visual/structure.d.ts +224 -0
  138. package/dist/visual/structure.js +270 -0
  139. package/dist/visual/synced.svelte.d.ts +15 -0
  140. package/dist/visual/synced.svelte.js +34 -0
  141. package/dist/visual/tips.d.ts +24 -0
  142. package/dist/visual/tips.js +50 -0
  143. package/package.json +102 -0
@@ -0,0 +1,396 @@
1
+ <!--
2
+ The formatting selection popover (VISUAL_EDITOR §Chrome). ONE popover,
3
+ shell-owned, observing the ACTIVE leaf through
4
+ `getActiveLeaf`: the VisualEditor's accessor over its `leaves` registry, and
5
+ the whole of what this surface knows about the editor. A non-empty selection in
6
+ the active leaf raises it over
7
+ that leaf's selection rect; each button dispatches a PM `toggleMark` command
8
+ straight at the leaf's `EditorView`. The codec's own `dispatchTransaction`
9
+ (field.ts, read-only from here) lowers the resulting transaction to
10
+ `markOps` and commits via `applyChange`: this component never touches the
11
+ content. The keymap mirror (Mod-b/i/u) already lives in the codec's keymap
12
+ (field.ts); this is the pointer affordance for all six marks.
13
+
14
+ SELECTION OBSERVATION. The view's `dispatchTransaction` is the codec's
15
+ (read-only), so there is no hook to observe transactions from outside.
16
+ Fallback: a document-level `selectionchange` listener, coalesced to one
17
+ `requestAnimationFrame`-deferred check (see `deferredSync` below: a bare
18
+ microtask is NOT late enough; verified a "select all" gesture fires several
19
+ transient `selectionchange` events first). `focusout` keeps the popover honest
20
+ when focus leaves the leaf.
21
+
22
+ Nothing here watches SCROLL. `sync` decides whether the surface is up and what
23
+ its buttons read, which a scroll changes neither of; where it sits is the
24
+ anchor's own question, and the anchor answers it by measuring when floating-ui
25
+ asks rather than by being handed a rect (`codec/anchor.ts`).
26
+
27
+ FOCUS DISCIPLINE. Three failure modes a naive Popover.Content invites:
28
+ (1) its default `trapFocus` (true) redirects any focus landing outside its
29
+ DOM back inside; which would make it impossible to click back into
30
+ the editor to change the selection while the popover is showing. Fixed
31
+ by `trapFocus={false}`.
32
+ (2) its open-auto-focus (unconditional on mount, regardless of trapFocus;
33
+ verified against bits-ui's FocusScope) would steal focus from the
34
+ editor onto the first button the instant the popover appears. Fixed by
35
+ `onOpenAutoFocus` calling `preventDefault`.
36
+ (3) its default `onCloseAutoFocus` returns focus to the element focused
37
+ before open (the prose leaf holding the selection). Clicking a native
38
+ input (card-title, TextField, NumberField) while the popover is open
39
+ bounces focus off that input back to the leaf; ProseMirror re-asserts
40
+ the retained selection and the browser scrolls it into view, yanking
41
+ the viewport off the clicked field. Fixed by `onCloseAutoFocus`
42
+ calling `preventDefault`: the intentional returns after mark toggle
43
+ / link submit go through `view.focus` in `toggle`/`submitLink`.
44
+ A mark button ALSO swallows its own `mousedown` (prosemirror-menu's own
45
+ trick): without it, the browser's default mousedown action focuses the
46
+ button before `click` fires, blurring the editor and collapsing the
47
+ selection the command is supposed to act on.
48
+ -->
49
+ <script lang="ts">
50
+ import { wording } from './strings.js';
51
+
52
+ // The surface's words, ambient from the editor root; the package's English
53
+ // off-tree, so this component renders standalone too.
54
+ const t = wording();
55
+ import { toggleMark } from 'prosemirror-commands';
56
+ import { TextSelection } from 'prosemirror-state';
57
+ import type { EditorView } from 'prosemirror-view';
58
+ import type { MarkType } from 'prosemirror-model';
59
+ import { Popover } from 'bits-ui';
60
+ import Icon from './icons/Icon.svelte';
61
+ import type { IconName } from './icons/nodes.js';
62
+ import {
63
+ rangeAnchor,
64
+ type FieldController,
65
+ type LeafViews,
66
+ type RangeAnchor
67
+ } from '../core/codec/index.js';
68
+ import './controls.css';
69
+
70
+ type LeafWithView = FieldController & Partial<LeafViews>;
71
+
72
+ interface Props {
73
+ /** The observation seam VisualEditor exposes over its `leaves` registry. */
74
+ getActiveLeaf: () => FieldController | undefined;
75
+ }
76
+ let { getActiveLeaf }: Props = $props();
77
+
78
+ /** Mark size: the shared control-glyph rule for the popover icons. */
79
+ const GLYPH = 15;
80
+ /** The six content formatting marks; `anchor` is a 7th, rendered separately (a decoration toggle, not a PM `toggleMark` (see the button below). Each carries a glyph) the icon *is* the label. */
81
+ const MARKS: { name: string; icon: IconName; title: string }[] = $derived([
82
+ { name: 'strong', icon: 'bold', title: t.strings.formatBold },
83
+ { name: 'em', icon: 'italic', title: t.strings.formatEmphasis },
84
+ { name: 'underline', icon: 'underline', title: t.strings.formatUnderline },
85
+ { name: 'strike', icon: 'strikethrough', title: t.strings.formatStrikethrough },
86
+ { name: 'code', icon: 'code', title: t.strings.formatCode },
87
+ { name: 'link', icon: 'link', title: t.strings.formatLink }
88
+ ]);
89
+
90
+ let open = $state(false);
91
+ let activeMarks = $state<Record<string, boolean>>({});
92
+ /** Whether the selection is in the FIELD's coordinate space, which an anchor needs
93
+ * and a table cell is not (see `sync`). */
94
+ let anchorAvailable = $state(true);
95
+ let linkPromptOpen = $state(false);
96
+ let linkValue = $state('');
97
+ let contentEl = $state<HTMLElement | undefined>(undefined);
98
+ /** The root to portal INTO: `document.body` escapes the editor's subtree and
99
+ * the consumer's dials with it, so a pane-scoped palette misses this surface.
100
+ * Resolved from the active leaf's own DOM, so the popover lands inside whichever
101
+ * root raised it; `undefined` falls back to bits-ui's `document.body` for a leaf
102
+ * mounted outside any root. */
103
+ let portalTarget = $state<HTMLElement | undefined>(undefined);
104
+
105
+ /** The selection the surface hangs off, live rather than measured (`codec/anchor.ts`): floating-ui re-reads it through every scroll and reflow, so `sync` mints one only when the RANGE moves. A NEW object each time it does, so bits-ui's `watch( => opts.customAnchor.current, …)` sees the change and repositions (a mutated-in-place object would not). */
106
+ let anchor = $state<RangeAnchor | undefined>(undefined);
107
+
108
+ /** The view the caret is actually in: the leaf's own, or the NESTED cell view of a
109
+ * table island (`codec/table-view.ts`). The six marks are the inline schema's
110
+ * too, so a selection in a cell raises the same popover and toggles the same
111
+ * commands; only `anchor` is withheld (below). */
112
+ function activeLeafView(): EditorView | undefined {
113
+ const leaf = getActiveLeaf() as LeafWithView | undefined;
114
+ return leaf?.focusedView?.() ?? leaf?.view;
115
+ }
116
+
117
+ /** Recompute open/anchor/active-marks from the active leaf's CURRENT PM selection. */
118
+ function sync(): void {
119
+ const insidePopover =
120
+ !!contentEl && !!document.activeElement && contentEl.contains(document.activeElement);
121
+ // Interacting with the popover itself (e.g. the link input): leave state as-is.
122
+ // Costs nothing to hold: the anchor minted below goes on measuring while this
123
+ // returns early, so a surface frozen here still tracks the selection it covers.
124
+ if (insidePopover) return;
125
+ const leaf = getActiveLeaf() as LeafWithView | undefined;
126
+ const view = leaf?.focusedView?.() ?? leaf?.view;
127
+ // A non-empty TEXT selection. A NODE selection is non-empty too and has nothing
128
+ // to format: it covers a leaf block (an island, a rule), which is exactly where
129
+ // Escape out of a table cell lands.
130
+ const selection = view?.state.selection;
131
+ const formattable = selection instanceof TextSelection && !selection.empty;
132
+ if (!view || !view.hasFocus() || !formattable) {
133
+ open = false;
134
+ linkPromptOpen = false;
135
+ return;
136
+ }
137
+ portalTarget = view.dom.closest<HTMLElement>('[data-qm-root]') ?? undefined;
138
+ const { from, to } = view.state.selection;
139
+ anchor = rangeAnchor(view, from, to);
140
+ const marks: Record<string, boolean> = {};
141
+ for (const m of MARKS) {
142
+ const type = view.state.schema.marks[m.name];
143
+ marks[m.name] = !!type && view.state.doc.rangeHasMark(from, to, type);
144
+ }
145
+ // `anchor` is a decoration, not a PM mark (CODEC §Marks): its active state is
146
+ // whether the selection covers an identity anchor, read off the leaf in USV.
147
+ //
148
+ // A selection inside a table CELL is not in that coordinate space at all: the
149
+ // field's position map holds one `atom` run for the whole island, so there is
150
+ // no USV offset to mint an anchor at. The button is withheld rather than
151
+ // disabled, because what it would toggle does not exist there (CODEC §Islands).
152
+ anchorAvailable = !!leaf && view === leaf.view;
153
+ if (leaf && anchorAvailable) {
154
+ const sel = leaf.selectionRange();
155
+ marks.anchor = leaf.anchorsInRange(sel.from, sel.to).length > 0;
156
+ }
157
+ activeMarks = marks;
158
+ open = true;
159
+ }
160
+
161
+ // A burst of DOM events (verified: browser "select all" processing fires
162
+ // `selectionchange` several times, transiently EMPTY before landing on the
163
+ // final range) must coalesce to ONE settled check, not one `sync()` per
164
+ // event: reacting to each intermediate state mounts/unmounts
165
+ // `Popover.Content` repeatedly within a few event-loop turns, which is both
166
+ // visibly unstable (a moving, momentarily detached click target) and races
167
+ // bits-ui's own internal effects (observed: Svelte's
168
+ // `derived_inert` warning from a stale read after a too-fast unmount). A
169
+ // microtask is not late enough: those transient events are themselves
170
+ // separated by microtasks. `requestAnimationFrame` is: it runs once
171
+ // per-frame, after the browser has finished dispatching every synchronous
172
+ // consequence of the user gesture, so it always reads the FINAL, settled
173
+ // selection. `pending` collapses a burst to a single scheduled callback.
174
+ let pending = false;
175
+ let rafHandle = 0;
176
+ function deferredSync(): void {
177
+ if (pending) return;
178
+ pending = true;
179
+ rafHandle = requestAnimationFrame(() => {
180
+ pending = false;
181
+ sync();
182
+ });
183
+ }
184
+
185
+ $effect(() => {
186
+ document.addEventListener('selectionchange', deferredSync);
187
+ document.addEventListener('focusout', deferredSync);
188
+ return () => {
189
+ document.removeEventListener('selectionchange', deferredSync);
190
+ document.removeEventListener('focusout', deferredSync);
191
+ // A burst-coalescing rAF may still be in flight at teardown; cancel it so
192
+ // `sync()` never runs against the destroyed component.
193
+ if (pending) cancelAnimationFrame(rafHandle);
194
+ pending = false;
195
+ };
196
+ });
197
+
198
+ // Any close path that bypasses `sync` (e.g. Escape while the link input holds
199
+ // focus: bits-ui flips `open` itself) must not leave a stale prompt for the
200
+ // next open.
201
+ $effect(() => {
202
+ if (!open) linkPromptOpen = false;
203
+ });
204
+
205
+ /** Swallow the button's mousedown so focus/selection never leave the leaf. */
206
+ function keepFocus(e: MouseEvent): void {
207
+ e.preventDefault();
208
+ }
209
+
210
+ function toggle(name: string): void {
211
+ const view = activeLeafView();
212
+ if (!view) return;
213
+ const type: MarkType | undefined = view.state.schema.marks[name];
214
+ if (!type) return;
215
+ if (name === 'link' && !activeMarks.link) {
216
+ linkPromptOpen = true;
217
+ linkValue = '';
218
+ return;
219
+ }
220
+ toggleMark(type)(view.state, view.dispatch);
221
+ view.focus();
222
+ sync();
223
+ }
224
+
225
+ /**
226
+ * Toggle an identity anchor over the selection: the `anchor`
227
+ * button's answer to a formatting toggle. If the selection already covers
228
+ * anchors, remove them; else insert one at its start with a freshly-minted
229
+ * unique id (the caller-supplied, invariant id the 0.97 policy settles).
230
+ * Zero-width and glyph-less (CODEC §Marks): the identity handle persists in
231
+ * the content, its chrome awaiting comment-thread UX.
232
+ */
233
+ function toggleAnchor(): void {
234
+ const leaf = getActiveLeaf();
235
+ const view = activeLeafView();
236
+ if (!leaf || !view || view.state.selection.empty) return;
237
+ const { from, to } = leaf.selectionRange();
238
+ const covered = leaf.anchorsInRange(from, to);
239
+ if (covered.length) covered.forEach((id) => leaf.removeAnchor(id));
240
+ else leaf.insertAnchor(crypto.randomUUID(), from);
241
+ view.focus();
242
+ sync();
243
+ }
244
+
245
+ function submitLink(): void {
246
+ const view = activeLeafView();
247
+ const type = view?.state.schema.marks.link;
248
+ linkPromptOpen = false;
249
+ if (!view || !type) return;
250
+ const href = linkValue.trim();
251
+ if (href) toggleMark(type, { href })(view.state, view.dispatch);
252
+ view.focus();
253
+ sync();
254
+ }
255
+
256
+ function cancelLink(): void {
257
+ linkPromptOpen = false;
258
+ activeLeafView()?.focus();
259
+ sync();
260
+ }
261
+ </script>
262
+
263
+ <Popover.Root bind:open>
264
+ <Popover.Portal to={portalTarget}>
265
+ <Popover.Content
266
+ customAnchor={anchor ?? null}
267
+ side="top"
268
+ align="center"
269
+ trapFocus={false}
270
+ sideOffset={8}
271
+ onOpenAutoFocus={(e: Event) => e.preventDefault()}
272
+ onCloseAutoFocus={(e: Event) => e.preventDefault()}
273
+ >
274
+ <!-- The `child` snippet, so the surface IS the primitive's content node
275
+ rather than a pill inside it: the dismissal is a CSS animation and the
276
+ primitive unmounts on the content node's own animations finishing. It
277
+ also keeps the recipe reachable from this
278
+ component's scoped styles, which a class handed to a primitive as a
279
+ string never picks up the scoping hash for. `wrapperProps` is
280
+ floating-ui's positioning box: spread, never styled. `inert` is the half
281
+ of the dismissal the recipe cannot carry: the surface is still on screen
282
+ for the length of the fade, and a surface on its way out is not a thing
283
+ to click, tab into, or read. -->
284
+ {#snippet child({ props, wrapperProps, open: raised })}
285
+ <div {...wrapperProps}>
286
+ <div
287
+ bind:this={contentEl}
288
+ {...props}
289
+ class="qm-format-popover qm-popover-surface"
290
+ data-qm-root
291
+ inert={!raised}
292
+ >
293
+ {#if linkPromptOpen}
294
+ <form
295
+ class="qm-link-prompt"
296
+ onsubmit={(e) => {
297
+ e.preventDefault();
298
+ submitLink();
299
+ }}
300
+ >
301
+ <input
302
+ class="qm-link-input"
303
+ type="text"
304
+ placeholder={t.strings.linkPlaceholder}
305
+ bind:value={linkValue}
306
+ />
307
+ <button type="submit" class="qm-icon-btn qm-mark-btn">{t.strings.linkApply}</button>
308
+ <button
309
+ type="button"
310
+ class="qm-icon-btn qm-mark-btn"
311
+ onmousedown={keepFocus}
312
+ onclick={cancelLink}>{t.strings.linkCancel}</button
313
+ >
314
+ </form>
315
+ {:else}
316
+ <!-- `role="group"`, not `toolbar`: these buttons carry no roving-tabindex /
317
+ arrow-key navigation, so the ARIA toolbar contract would be a lie;
318
+ a labelled group is the honest description. -->
319
+ <div class="qm-format-buttons" role="group" aria-label={t.strings.formatGroup}>
320
+ {#each MARKS as m (m.name)}
321
+ <button
322
+ type="button"
323
+ class="qm-icon-btn qm-mark-btn"
324
+ class:active={activeMarks[m.name]}
325
+ title={m.title}
326
+ aria-label={m.title}
327
+ onmousedown={keepFocus}
328
+ onclick={() => toggle(m.name)}><Icon name={m.icon} size={GLYPH} /></button
329
+ >
330
+ {/each}
331
+ {#if anchorAvailable}
332
+ <button
333
+ type="button"
334
+ class="qm-icon-btn qm-mark-btn"
335
+ class:active={activeMarks.anchor}
336
+ title={t.strings.formatAnchorTitle}
337
+ aria-label={t.strings.formatAnchor}
338
+ onmousedown={keepFocus}
339
+ onclick={toggleAnchor}><Icon name="hash" size={GLYPH} /></button
340
+ >
341
+ {/if}
342
+ </div>
343
+ {/if}
344
+ </div>
345
+ </div>
346
+ {/snippet}
347
+ </Popover.Content>
348
+ </Popover.Portal>
349
+ </Popover.Root>
350
+
351
+ <style>
352
+ /* This surface PORTALS out of the leaf's DOM but INTO the nearest `[data-qm-root]`
353
+ (the target is resolved in `sync`), so it inherits the consumer's dials like
354
+ every other surface. It carries the marker itself too: floating over content is
355
+ still a detached subtree for the derivation's purposes, and the marker is what
356
+ applies it (core/theme.css). */
357
+ /* The lift, the translucency, the blur and the scale-in come from
358
+ `.qm-popover-surface` (controls.css); this surface's own is a row and an inset. */
359
+ .qm-format-popover {
360
+ display: flex;
361
+ padding: var(--_qm-space);
362
+ }
363
+ .qm-format-buttons {
364
+ display: flex;
365
+ gap: var(--_qm-space-half);
366
+ }
367
+ /* Chrome, type, target floor, hover fill and disabled recede come from
368
+ `.qm-icon-btn` (controls.css); what is here is this surface's own. The border is
369
+ drawn transparent rather than absent so the active inversion adds no width and
370
+ shifts no glyph, and the ink is stated because the family deliberately declares
371
+ none: its callers disagree, and a popover over content reads at the card's. */
372
+ .qm-mark-btn {
373
+ border: var(--_qm-border-width) solid transparent;
374
+ padding: var(--_qm-space) var(--_qm-space-2);
375
+ color: var(--_qm-ink);
376
+ }
377
+ /* Active is an ink inversion, the one state on this surface a hover fill cannot
378
+ say: a mark already carried is not a mark under the pointer. */
379
+ .qm-mark-btn.active {
380
+ background: var(--_qm-ink);
381
+ color: var(--_qm-surface);
382
+ }
383
+ .qm-link-prompt {
384
+ display: flex;
385
+ gap: var(--_qm-space);
386
+ align-items: center;
387
+ }
388
+ .qm-link-input {
389
+ font: inherit;
390
+ font-size: var(--_qm-text-body);
391
+ padding: var(--_qm-space) var(--_qm-space-2);
392
+ border: var(--_qm-border-width) solid var(--_qm-border);
393
+ border-radius: var(--_qm-radius-inner);
394
+ min-width: 12rem;
395
+ }
396
+ </style>
@@ -0,0 +1,9 @@
1
+ import { type FieldController } from '../core/codec/index.js';
2
+ import './controls.css';
3
+ interface Props {
4
+ /** The observation seam VisualEditor exposes over its `leaves` registry. */
5
+ getActiveLeaf: () => FieldController | undefined;
6
+ }
7
+ declare const FormatPopover: import("svelte").Component<Props, {}, "">;
8
+ type FormatPopover = ReturnType<typeof FormatPopover>;
9
+ export default FormatPopover;
@@ -0,0 +1,72 @@
1
+ <!--
2
+ A `number` / `integer` field → numeric input (fixture `font_size` = 11.5).
3
+ Commits at `change` (blur/Enter), NOT per keystroke: a partial numeric entry
4
+ (`-`, `1.`, `1e`) is never a document state worth a boundary round-trip, and
5
+ committing it live flashes a coercion diagnostic + `console.error` on every
6
+ intermediate prefix, announced by `DiagnosticList`'s `role="status"` live
7
+ region. A blank entry commits `undefined`: the UNSET rung of the
8
+ commitment ladder: the parent removes the field and the engine renders the
9
+ ghosted `default:`. Settling at `change` also keeps
10
+ select-all-and-retype from flashing the preview through the default mid-keystroke.
11
+
12
+ `type="text"`, not `type="number"`: a native number input SANITIZES an
13
+ invalid string to `""` before the DOM `value` setter even runs (verified:
14
+ `.value = "abc"` on `type="number"` never lands), which would make a
15
+ genuinely bad entry untypeable. The commit-time coercion diagnostic
16
+ (VISUAL_EDITOR §Diagnostics) needs exactly that path reachable through the
17
+ UI, so a non-blank entry that fails to parse forwards the RAW STRING to
18
+ `onCommit` unchanged: the boundary's own `writer.set` coercion is the judge
19
+ (throws a `QuillmarkError` the parent turns into a field diagnostic), not a
20
+ client-side guess. `inputmode` keeps the numeric mobile keyboard.
21
+ -->
22
+ <script lang="ts">
23
+ import { syncedLocal } from './synced.svelte.js';
24
+ import './controls.css';
25
+
26
+ interface Props {
27
+ value: number | undefined;
28
+ integer?: boolean;
29
+ fallback?: number;
30
+ /** Accessible name for an input NOTHING else names: an object property, whose
31
+ * name is the field label plus the property's. A field's own input takes `id`
32
+ * instead and is named by the `<label for>` beside it. */
33
+ label?: string;
34
+ /** `<label for>` target. Set → the label names this input, so `aria-label`
35
+ * comes off: two names is where implementations disagree about which wins. */
36
+ id?: string;
37
+ /** The parked `description` (FieldLabel): announced after the name. */
38
+ describedBy?: string;
39
+ onCommit: (v: number | string | undefined) => void;
40
+ }
41
+ let { value, integer, fallback, label, id, describedBy, onCommit }: Props = $props();
42
+
43
+ // Local input state synced to `value` (as a string projection); own-typing
44
+ // stays local, only an external change reconciles back in (see `syncedLocal`).
45
+ const local = syncedLocal(() => (value != null ? String(value) : ''));
46
+
47
+ // Parse a settled entry and emit it; `local` is owned by `oninput`. Blank →
48
+ // `undefined` (the unset rung: parent removes the field, default renders).
49
+ function commit(raw: string): void {
50
+ if (raw.trim() === '') return void onCommit(undefined);
51
+ // Number(), not parseFloat/parseInt: a prefix parse would silently commit
52
+ // `14.5` for `14.5x` (and truncate `11.9` → 11 on integer fields) instead
53
+ // of letting the boundary judge the full entry.
54
+ const n = Number(raw);
55
+ onCommit(Number.isNaN(n) ? raw : n);
56
+ }
57
+ </script>
58
+
59
+ <input
60
+ class="qm-input qm-focus-ring"
61
+ type="text"
62
+ inputmode={integer ? 'numeric' : 'decimal'}
63
+ value={local.value}
64
+ {id}
65
+ placeholder={fallback != null ? String(fallback) : ''}
66
+ aria-label={id ? undefined : label}
67
+ aria-describedby={describedBy}
68
+ oninput={(e) => {
69
+ local.value = (e.currentTarget as HTMLInputElement).value;
70
+ }}
71
+ onchange={(e) => commit((e.currentTarget as HTMLInputElement).value)}
72
+ />
@@ -0,0 +1,19 @@
1
+ import './controls.css';
2
+ interface Props {
3
+ value: number | undefined;
4
+ integer?: boolean;
5
+ fallback?: number;
6
+ /** Accessible name for an input NOTHING else names: an object property, whose
7
+ * name is the field label plus the property's. A field's own input takes `id`
8
+ * instead and is named by the `<label for>` beside it. */
9
+ label?: string;
10
+ /** `<label for>` target. Set → the label names this input, so `aria-label`
11
+ * comes off: two names is where implementations disagree about which wins. */
12
+ id?: string;
13
+ /** The parked `description` (FieldLabel): announced after the name. */
14
+ describedBy?: string;
15
+ onCommit: (v: number | string | undefined) => void;
16
+ }
17
+ declare const NumberField: import("svelte").Component<Props, {}, "">;
18
+ type NumberField = ReturnType<typeof NumberField>;
19
+ export default NumberField;
@@ -0,0 +1,131 @@
1
+ <!--
2
+ An `object` field → a nested subform over `properties` (declaration order),
3
+ committing the WHOLE object by value on any nested change. SCALAR properties
4
+ only: a nested prose/array/object property renders a placeholder instead of
5
+ recursing.
6
+
7
+ A property's ghosted `default:` is the static schema `sub.default`, not the
8
+ resolved provenance the top-level ghosts read (FIELD_PROVENANCE): `resolve`
9
+ carries no per-property row (an object field resolves as one row whose value is
10
+ the whole object). Thread it through `ghostDefault` once resolve exposes
11
+ per-property provenance.
12
+ -->
13
+ <script lang="ts">
14
+ import type { QuillFieldSchema } from '@quillmark/wasm';
15
+ import { controlKind, enumValues, humanize } from './structure.js';
16
+ import TextField from './TextField.svelte';
17
+ import EnumField from './EnumField.svelte';
18
+ import NumberField from './NumberField.svelte';
19
+ import BooleanField from './BooleanField.svelte';
20
+ import DateField from './DateField.svelte';
21
+
22
+ interface Props {
23
+ value: Record<string, unknown> | undefined;
24
+ properties: Record<string, QuillFieldSchema> | undefined;
25
+ /** Accessible-name prefix for the property controls. */
26
+ label?: string;
27
+ /** The field label's own id. A subform is a GROUP of controls, not one control
28
+ * `for` could reach, so the field's label names the set and each property
29
+ * control keeps its own composed `aria-label`. */
30
+ labelledBy?: string;
31
+ /** The parked `description` (FieldLabel): announced on entering the group. */
32
+ describedBy?: string;
33
+ onCommit: (obj: Record<string, unknown>) => void;
34
+ }
35
+ let { value, properties, label, labelledBy, describedBy, onCommit }: Props = $props();
36
+
37
+ const entries = $derived(Object.entries(properties ?? {}));
38
+ const obj = $derived((value ?? {}) as Record<string, unknown>);
39
+
40
+ function commitProp(key: string, v: unknown): void {
41
+ if (v === undefined) {
42
+ // A nested control cleared (the unset rung): drop the key so the property
43
+ // is ABSENT in the committed object (resolving to its own `default:`)
44
+ // rather than an `undefined` hole carried through `writer.set`.
45
+ const rest = { ...obj };
46
+ delete rest[key];
47
+ onCommit(rest);
48
+ } else {
49
+ onCommit({ ...obj, [key]: v });
50
+ }
51
+ }
52
+ </script>
53
+
54
+ <div class="qm-object" role="group" aria-labelledby={labelledBy} aria-describedby={describedBy}>
55
+ {#each entries as [key, sub] (key)}
56
+ {@const kind = controlKind(sub)}
57
+ {@const propLabel = `${label != null ? `${label} ` : ''}${sub.ui?.title ?? humanize(key)}`}
58
+ <div class="qm-object-prop">
59
+ <span class="qm-object-label">{sub.ui?.title ?? humanize(key)}</span>
60
+ {#if kind === 'enum'}
61
+ <EnumField
62
+ label={propLabel}
63
+ value={obj[key] as string | undefined}
64
+ values={enumValues(sub) ?? []}
65
+ fallback={sub.default as string | undefined}
66
+ onCommit={(v) => commitProp(key, v)}
67
+ />
68
+ {:else if kind === 'number'}
69
+ <NumberField
70
+ label={propLabel}
71
+ value={obj[key] as number | undefined}
72
+ integer={sub.type === 'integer'}
73
+ fallback={sub.default as number | undefined}
74
+ onCommit={(v) => commitProp(key, v)}
75
+ />
76
+ {:else if kind === 'boolean'}
77
+ <BooleanField
78
+ label={propLabel}
79
+ value={obj[key] as boolean | undefined}
80
+ fallback={sub.default as boolean | undefined}
81
+ onCommit={(v) => commitProp(key, v)}
82
+ />
83
+ {:else if kind === 'date'}
84
+ <DateField
85
+ label={propLabel}
86
+ value={obj[key] as string | undefined}
87
+ onCommit={(v) => commitProp(key, v)}
88
+ />
89
+ {:else if kind === 'text'}
90
+ <TextField
91
+ label={propLabel}
92
+ value={obj[key] as string | undefined}
93
+ placeholder={sub.default != null ? String(sub.default) : undefined}
94
+ onCommit={(v) => commitProp(key, v)}
95
+ />
96
+ {:else}
97
+ <span class="qm-unsupported">({kind} property — not editable in V1)</span>
98
+ {/if}
99
+ </div>
100
+ {/each}
101
+ </div>
102
+
103
+ <style>
104
+ .qm-object {
105
+ display: flex;
106
+ flex-direction: column;
107
+ gap: var(--_qm-space-2);
108
+ border-left: var(--_qm-border-width) solid var(--_qm-border);
109
+ padding-left: var(--_qm-space-2);
110
+ }
111
+ .qm-object-prop {
112
+ display: flex;
113
+ flex-direction: column;
114
+ gap: var(--_qm-space-half);
115
+ }
116
+ .qm-object-label {
117
+ font-size: var(--_qm-text-label);
118
+ /* Soft (500), a rung under the top-level field label's 600: a nested object
119
+ prop reads as secondary. Resolves the label-weight disagreement. */
120
+ font-weight: var(--_qm-weight-soft);
121
+ /* A label is a line, not a passage, so it overrides the root's reading
122
+ rhythm the way every other label rung does. */
123
+ line-height: var(--_qm-leading-tight);
124
+ color: var(--_qm-ink-label);
125
+ }
126
+ .qm-unsupported {
127
+ font-size: var(--_qm-text-body);
128
+ color: var(--_qm-ink-ghost);
129
+ font-style: italic;
130
+ }
131
+ </style>
@@ -0,0 +1,17 @@
1
+ import type { QuillFieldSchema } from '@quillmark/wasm';
2
+ interface Props {
3
+ value: Record<string, unknown> | undefined;
4
+ properties: Record<string, QuillFieldSchema> | undefined;
5
+ /** Accessible-name prefix for the property controls. */
6
+ label?: string;
7
+ /** The field label's own id. A subform is a GROUP of controls, not one control
8
+ * `for` could reach, so the field's label names the set and each property
9
+ * control keeps its own composed `aria-label`. */
10
+ labelledBy?: string;
11
+ /** The parked `description` (FieldLabel): announced on entering the group. */
12
+ describedBy?: string;
13
+ onCommit: (obj: Record<string, unknown>) => void;
14
+ }
15
+ declare const ObjectField: import("svelte").Component<Props, {}, "">;
16
+ type ObjectField = ReturnType<typeof ObjectField>;
17
+ export default ObjectField;