@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.
- package/LICENSE +201 -0
- package/NOTICE +58 -0
- package/README.md +241 -0
- package/THEMING.md +153 -0
- package/dist/core/address.d.ts +56 -0
- package/dist/core/address.js +95 -0
- package/dist/core/bloom.d.ts +37 -0
- package/dist/core/bloom.js +116 -0
- package/dist/core/codec/anchor.d.ts +19 -0
- package/dist/core/codec/anchor.js +36 -0
- package/dist/core/codec/code.d.ts +12 -0
- package/dist/core/codec/code.js +118 -0
- package/dist/core/codec/decode.d.ts +24 -0
- package/dist/core/codec/decode.js +270 -0
- package/dist/core/codec/encode.d.ts +71 -0
- package/dist/core/codec/encode.js +480 -0
- package/dist/core/codec/field.d.ts +171 -0
- package/dist/core/codec/field.js +493 -0
- package/dist/core/codec/index.d.ts +22 -0
- package/dist/core/codec/index.js +43 -0
- package/dist/core/codec/inputrules.d.ts +7 -0
- package/dist/core/codec/inputrules.js +168 -0
- package/dist/core/codec/islands.d.ts +36 -0
- package/dist/core/codec/islands.js +82 -0
- package/dist/core/codec/keymap.d.ts +13 -0
- package/dist/core/codec/keymap.js +29 -0
- package/dist/core/codec/lists.d.ts +22 -0
- package/dist/core/codec/lists.js +104 -0
- package/dist/core/codec/marks.d.ts +37 -0
- package/dist/core/codec/marks.js +74 -0
- package/dist/core/codec/positions.d.ts +17 -0
- package/dist/core/codec/positions.js +108 -0
- package/dist/core/codec/prose.css +458 -0
- package/dist/core/codec/reconcile.d.ts +26 -0
- package/dist/core/codec/reconcile.js +64 -0
- package/dist/core/codec/schema.d.ts +13 -0
- package/dist/core/codec/schema.js +155 -0
- package/dist/core/codec/slash.d.ts +79 -0
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/table-view.d.ts +50 -0
- package/dist/core/codec/table-view.js +928 -0
- package/dist/core/codec/table.d.ts +85 -0
- package/dist/core/codec/table.js +224 -0
- package/dist/core/errors.d.ts +73 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +19 -0
- package/dist/core/lifecycle.d.ts +7 -0
- package/dist/core/lifecycle.js +21 -0
- package/dist/core/rebind.svelte.d.ts +18 -0
- package/dist/core/rebind.svelte.js +44 -0
- package/dist/core/teardown.d.ts +20 -0
- package/dist/core/teardown.js +37 -0
- package/dist/core/theme.css +276 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +9 -0
- package/dist/preset/index.css +10 -0
- package/dist/preset/recipes.css +188 -0
- package/dist/preset/scale.css +97 -0
- package/dist/preview/Preview.svelte +118 -0
- package/dist/preview/Preview.svelte.d.ts +39 -0
- package/dist/preview/bridge.d.ts +13 -0
- package/dist/preview/bridge.js +88 -0
- package/dist/preview/controller.d.ts +40 -0
- package/dist/preview/controller.js +141 -0
- package/dist/preview/geometry.d.ts +37 -0
- package/dist/preview/geometry.js +45 -0
- package/dist/preview/index.d.ts +5 -0
- package/dist/preview/index.js +14 -0
- package/dist/preview/overlay.d.ts +10 -0
- package/dist/preview/overlay.js +82 -0
- package/dist/preview/paint.d.ts +17 -0
- package/dist/preview/paint.js +262 -0
- package/dist/preview/strings.d.ts +14 -0
- package/dist/preview/strings.js +22 -0
- package/dist/visual/ArrayField.svelte +362 -0
- package/dist/visual/ArrayField.svelte.d.ts +24 -0
- package/dist/visual/BooleanField.svelte +88 -0
- package/dist/visual/BooleanField.svelte.d.ts +20 -0
- package/dist/visual/Card.svelte +849 -0
- package/dist/visual/Card.svelte.d.ts +43 -0
- package/dist/visual/CardControls.svelte +92 -0
- package/dist/visual/CardControls.svelte.d.ts +11 -0
- package/dist/visual/DateField.svelte +203 -0
- package/dist/visual/DateField.svelte.d.ts +28 -0
- package/dist/visual/DiagnosticList.svelte +50 -0
- package/dist/visual/DiagnosticList.svelte.d.ts +7 -0
- package/dist/visual/EnumField.svelte +169 -0
- package/dist/visual/EnumField.svelte.d.ts +25 -0
- package/dist/visual/Field.svelte +270 -0
- package/dist/visual/Field.svelte.d.ts +37 -0
- package/dist/visual/FieldHint.svelte +185 -0
- package/dist/visual/FieldHint.svelte.d.ts +13 -0
- package/dist/visual/FieldLabel.svelte +141 -0
- package/dist/visual/FieldLabel.svelte.d.ts +19 -0
- package/dist/visual/FormatPopover.svelte +396 -0
- package/dist/visual/FormatPopover.svelte.d.ts +9 -0
- package/dist/visual/NumberField.svelte +72 -0
- package/dist/visual/NumberField.svelte.d.ts +19 -0
- package/dist/visual/ObjectField.svelte +131 -0
- package/dist/visual/ObjectField.svelte.d.ts +17 -0
- package/dist/visual/ProseArrayElement.svelte +98 -0
- package/dist/visual/ProseArrayElement.svelte.d.ts +19 -0
- package/dist/visual/ProseField.svelte +213 -0
- package/dist/visual/ProseField.svelte.d.ts +50 -0
- package/dist/visual/SlashMenu.svelte +126 -0
- package/dist/visual/SlashMenu.svelte.d.ts +14 -0
- package/dist/visual/TextField.svelte +63 -0
- package/dist/visual/TextField.svelte.d.ts +23 -0
- package/dist/visual/TipsCard.svelte +191 -0
- package/dist/visual/TipsCard.svelte.d.ts +13 -0
- package/dist/visual/VisualEditor.svelte +96 -0
- package/dist/visual/VisualEditor.svelte.d.ts +14 -0
- package/dist/visual/VisualEditorInner.svelte +1032 -0
- package/dist/visual/VisualEditorInner.svelte.d.ts +17 -0
- package/dist/visual/controls.css +272 -0
- package/dist/visual/diagnostics.d.ts +52 -0
- package/dist/visual/diagnostics.js +83 -0
- package/dist/visual/domid.d.ts +21 -0
- package/dist/visual/domid.js +36 -0
- package/dist/visual/ext.d.ts +19 -0
- package/dist/visual/ext.js +29 -0
- package/dist/visual/icons/Icon.svelte +39 -0
- package/dist/visual/icons/Icon.svelte.d.ts +12 -0
- package/dist/visual/icons/nodes.d.ts +103 -0
- package/dist/visual/icons/nodes.js +55 -0
- package/dist/visual/index.d.ts +9 -0
- package/dist/visual/index.js +24 -0
- package/dist/visual/motion.d.ts +18 -0
- package/dist/visual/motion.js +53 -0
- package/dist/visual/props.d.ts +77 -0
- package/dist/visual/props.js +1 -0
- package/dist/visual/signals.d.ts +59 -0
- package/dist/visual/signals.js +1 -0
- package/dist/visual/strings.d.ts +123 -0
- package/dist/visual/strings.js +80 -0
- package/dist/visual/structure.d.ts +224 -0
- package/dist/visual/structure.js +270 -0
- package/dist/visual/synced.svelte.d.ts +15 -0
- package/dist/visual/synced.svelte.js +34 -0
- package/dist/visual/tips.d.ts +24 -0
- package/dist/visual/tips.js +50 -0
- package/package.json +102 -0
|
@@ -0,0 +1,1032 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
The federated WYSIWYG surface (VISUAL_EDITOR). A THIN composition over many
|
|
3
|
+
small editors; NOT one PM document spanning the page.
|
|
4
|
+
|
|
5
|
+
MOUNTED ONCE PER DOCUMENT. `VisualEditor` keys this component on `doc`, so
|
|
6
|
+
everything below seeds from a handle that cannot change under it: `cardIds`, the
|
|
7
|
+
id `seq`, the leaf registry, the commit-error map, the active address and the
|
|
8
|
+
card refs are all mount-scoped, and the prose leaves close over the `doc` they
|
|
9
|
+
mounted against. That is what makes them plain state rather than something
|
|
10
|
+
threaded through a generation token.
|
|
11
|
+
|
|
12
|
+
It owns:
|
|
13
|
+
• structure: the schema × payload join, re-derived from the live `Document`;
|
|
14
|
+
• stable card identity: a session-id array reordered in lockstep with the
|
|
15
|
+
content, resolved to an index ONLY at the mutation boundary;
|
|
16
|
+
• commit routing: prose leaves lower to `applyChange` (in the codec); scalars/
|
|
17
|
+
arrays/objects go through the typed `writer`; structure through the mutators;
|
|
18
|
+
• focus + the bridge outputs (`onActiveLeafChange`, `onCaretMove`) and the
|
|
19
|
+
`setCaret(hit)` entry wired to the preview;
|
|
20
|
+
• the ONE formatting popover (`FormatPopover`, mounted once, observing the
|
|
21
|
+
active leaf via `getActiveLeaf`) and diagnostics routing (`diagnostics.ts`:
|
|
22
|
+
quill.validate + local commit errors + the external `diagnostics` prop,
|
|
23
|
+
merged into `diagByKey` and threaded to each `<Field>`/card body).
|
|
24
|
+
|
|
25
|
+
REACTIVITY ACROSS THE WASM HANDLE. The `Document` is opaque to Svelte, so a
|
|
26
|
+
`revision` counter is bumped after every scalar/structure mutation and the card
|
|
27
|
+
tree is `$derived` by RE-READING `doc.main`/`doc.cards`/`quill.schema`. Prose
|
|
28
|
+
leaves are mounted ONCE per stable leaf key (keyed `<Card>`/`<ProseField>`);
|
|
29
|
+
they commit to the doc directly and do NOT bump `revision`, so a re-derive never
|
|
30
|
+
remounts them or drops a caret. `doc.main`/`doc.cards` allocate per read: read
|
|
31
|
+
once per derive.
|
|
32
|
+
-->
|
|
33
|
+
<script lang="ts">
|
|
34
|
+
import { onDestroy, tick } from 'svelte';
|
|
35
|
+
import { DropdownMenu } from 'bits-ui';
|
|
36
|
+
import { isQuillmarkError, MAIN_CARD_ADDR } from '@quillmark/wasm';
|
|
37
|
+
import { cardPath, fieldPathForAddr, type DocPath } from '../core/address.js';
|
|
38
|
+
import { errorMessage, reportError } from '../core/errors.js';
|
|
39
|
+
import { bloomInside } from '../core/bloom.js';
|
|
40
|
+
import { createLifespan } from '../core/teardown.js';
|
|
41
|
+
import type {
|
|
42
|
+
Addr,
|
|
43
|
+
CardAddr,
|
|
44
|
+
ContentHit,
|
|
45
|
+
Diagnostic,
|
|
46
|
+
Resolved,
|
|
47
|
+
ResolvedField
|
|
48
|
+
} from '@quillmark/wasm';
|
|
49
|
+
import type { VisualEditorProps } from './props.js';
|
|
50
|
+
import { mergeStrings, setWording } from './strings.js';
|
|
51
|
+
import type { CardId, ChangeSource } from './signals.js';
|
|
52
|
+
import type { FieldController } from '../core/codec/index.js';
|
|
53
|
+
import {
|
|
54
|
+
IdSeq,
|
|
55
|
+
fieldModels,
|
|
56
|
+
groupOrder,
|
|
57
|
+
groupSections,
|
|
58
|
+
groupLabel,
|
|
59
|
+
cardTitle,
|
|
60
|
+
bodyEnabled,
|
|
61
|
+
humanize,
|
|
62
|
+
provenanceMap,
|
|
63
|
+
resolvedByCardIndex,
|
|
64
|
+
ghostDefault,
|
|
65
|
+
stringifyGhost,
|
|
66
|
+
resolveBodyGhost,
|
|
67
|
+
NO_RESOLVED_ROWS,
|
|
68
|
+
type BodyPlaceholder,
|
|
69
|
+
type CardModel,
|
|
70
|
+
type ResolvedCardRows
|
|
71
|
+
} from './structure.js';
|
|
72
|
+
import {
|
|
73
|
+
fieldKeyToString,
|
|
74
|
+
parsePath,
|
|
75
|
+
resolveCardKey,
|
|
76
|
+
routeAndResolve,
|
|
77
|
+
mergeDiagnostics,
|
|
78
|
+
type FieldKey,
|
|
79
|
+
type RoutedDiagnostic
|
|
80
|
+
} from './diagnostics.js';
|
|
81
|
+
import { fieldDomIds, groupPanelId } from './domid.js';
|
|
82
|
+
import { reorder } from './motion.js';
|
|
83
|
+
import { tipsChannel } from './tips.js';
|
|
84
|
+
import { patchEditorExt } from './ext.js';
|
|
85
|
+
import Card from './Card.svelte';
|
|
86
|
+
import TipsCard from './TipsCard.svelte';
|
|
87
|
+
import FormatPopover from './FormatPopover.svelte';
|
|
88
|
+
// The add trigger and the kind menu draw shared recipes; a component carrying a
|
|
89
|
+
// shared class without also pulling its rule ships unstyled (controls.css).
|
|
90
|
+
import './controls.css';
|
|
91
|
+
|
|
92
|
+
let {
|
|
93
|
+
doc,
|
|
94
|
+
quill,
|
|
95
|
+
onActiveLeafChange,
|
|
96
|
+
onCaretMove,
|
|
97
|
+
onChange,
|
|
98
|
+
onError,
|
|
99
|
+
diagnostics,
|
|
100
|
+
enumOptionAllowed,
|
|
101
|
+
strings,
|
|
102
|
+
formatDiagnostic,
|
|
103
|
+
class: className,
|
|
104
|
+
style
|
|
105
|
+
}: VisualEditorProps = $props();
|
|
106
|
+
|
|
107
|
+
// The wording, merged once and published to the tree. A getter pair rather than a
|
|
108
|
+
// snapshot: a consumer swapping locale mid-session re-renders every label,
|
|
109
|
+
// including the ones eight components down that never see a prop.
|
|
110
|
+
const merged = $derived(mergeStrings(strings));
|
|
111
|
+
setWording({
|
|
112
|
+
get strings() {
|
|
113
|
+
return merged;
|
|
114
|
+
},
|
|
115
|
+
get formatDiagnostic() {
|
|
116
|
+
return formatDiagnostic;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ── Reactivity + session identity ───────────────────────────────────────────
|
|
121
|
+
let revision = $state(0);
|
|
122
|
+
// This editor's own id, prefixed onto every field's DOM ids (`domid.ts`). The
|
|
123
|
+
// leaf-key space is unique per EDITOR, not per page: two editors mounted
|
|
124
|
+
// side-by-side both hold `main:subject`, and a duplicate `id` makes `for` resolve
|
|
125
|
+
// to whichever mounted first. `$props.id()` is stable across SSR and hydration,
|
|
126
|
+
// which a module counter is not.
|
|
127
|
+
const uid = $props.id();
|
|
128
|
+
const seq = new IdSeq();
|
|
129
|
+
// Session ids, one per composable card, reordered in lockstep with structure ops.
|
|
130
|
+
// svelte-ignore state_referenced_locally
|
|
131
|
+
let cardIds = $state<string[]>(seq.take(doc.cardCount));
|
|
132
|
+
|
|
133
|
+
// Local commit-error diagnostics (VISUAL_EDITOR §Diagnostics, producer #2):
|
|
134
|
+
// one slot per field key, replaced on each failed `commitScalar`, cleared on
|
|
135
|
+
// the next successful one for that field. Id-keyed (not positional) so an
|
|
136
|
+
// error stays pinned to its field across a card reorder.
|
|
137
|
+
let commitErrors = $state(new Map<string, RoutedDiagnostic>());
|
|
138
|
+
/** Edit the commit-error map copy-on-write: a `$state` Map is not deeply
|
|
139
|
+
* reactive, so a mutation in place re-derives nothing. */
|
|
140
|
+
function editCommitErrors(edit: (m: Map<string, RoutedDiagnostic>) => void): void {
|
|
141
|
+
const next = new Map(commitErrors);
|
|
142
|
+
edit(next);
|
|
143
|
+
commitErrors = next;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const kinds = $derived(Object.keys(quill.schema.card_kinds ?? {}));
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Re-derive the card tree and report the edit. Called for the two lanes that go
|
|
150
|
+
* through this component; a prose leaf commits itself and reports through
|
|
151
|
+
* `proseChanged`, which does NOT bump the revision (bumping it would re-derive
|
|
152
|
+
* and remount every leaf, costing the caret on every keystroke).
|
|
153
|
+
*
|
|
154
|
+
* `cardId` is passed rather than derived from `at`: the sites below hold the id
|
|
155
|
+
* already, and the removal has no address to derive one from. `at` is an `Addr` for
|
|
156
|
+
* a leaf and a CARD INDEX for a structure op, whose subject is the card rather than
|
|
157
|
+
* anything inside it; either way the path is minted HERE, after the bump, because
|
|
158
|
+
* an op's own index is only addressable against the tree the bump re-derived (an
|
|
159
|
+
* insert's card does not exist in the previous one, and a retype's kind is the
|
|
160
|
+
* previous kind).
|
|
161
|
+
*/
|
|
162
|
+
function bump(source: ChangeSource, cardId?: CardId, at?: Addr | number): void {
|
|
163
|
+
revision++;
|
|
164
|
+
const path =
|
|
165
|
+
at == null ? undefined : typeof at === 'number' ? cardPath(at, liveKinds()) : pathFor(at);
|
|
166
|
+
onChange?.({ source, cardId, path });
|
|
167
|
+
}
|
|
168
|
+
/** Resolve a stable card id to its current content index, or -1 if gone: read
|
|
169
|
+
* at the mutation boundary, never cached (VISUAL_EDITOR §"The address is the spine"). */
|
|
170
|
+
function cardIndexOf(id: string): number {
|
|
171
|
+
return cardIds.indexOf(id);
|
|
172
|
+
}
|
|
173
|
+
/** The inverse, for the two signals that arrive as an address and nothing else
|
|
174
|
+
* (a focus, a prose commit): `'main'` for a main address, else the live id at
|
|
175
|
+
* that index. Resolved at the emit site off the same array the mutation boundary
|
|
176
|
+
* reads, never cached. */
|
|
177
|
+
function cardIdOf(addr: Addr): CardId {
|
|
178
|
+
return addr.card == null ? 'main' : cardIds[addr.card];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Teardown (core/teardown.ts: unregister, cancel, then free) ──────────────
|
|
182
|
+
// A document swap is a REMOUNT (see the remount contract above), so a destroy is
|
|
183
|
+
// the only way this surface ends and one span covers both. Cancellers register
|
|
184
|
+
// below in the order they run: the registry that resolves new work first, then
|
|
185
|
+
// the work already scheduled. The document handle is the consumer's to free, so
|
|
186
|
+
// the span holds nothing to release.
|
|
187
|
+
const span = createLifespan();
|
|
188
|
+
onDestroy(() => span.end());
|
|
189
|
+
|
|
190
|
+
// ── Leaf registry (setCaret target lookup + the active-leaf seam) ───────────
|
|
191
|
+
const leaves = new Map<string, FieldController>();
|
|
192
|
+
// Each leaf unregisters itself on its own teardown; this covers the surface
|
|
193
|
+
// going away as a whole, where a leaf's cleanup order relative to the parent's
|
|
194
|
+
// is Svelte's business and not something to depend on.
|
|
195
|
+
span.onEnd(() => leaves.clear());
|
|
196
|
+
// Card handles, for `setCaret`'s reveal hop: the one thing a leaf's own controller
|
|
197
|
+
// cannot do, since which group is open is the card's state (Card §revealLeaf).
|
|
198
|
+
type CardHandle = {
|
|
199
|
+
revealLeaf(key: string): void;
|
|
200
|
+
scrollIntoViewCard(block: ScrollLogicalPosition): void;
|
|
201
|
+
};
|
|
202
|
+
let mainCard = $state<CardHandle | undefined>(undefined);
|
|
203
|
+
let cardRefs = $state<(CardHandle | undefined)[]>([]);
|
|
204
|
+
/** The stack's own element: it carries `data-qm-root`, so it is what the kind
|
|
205
|
+
* menu portals INTO, the way each leaf's surface resolves its nearest root. */
|
|
206
|
+
let rootEl = $state<HTMLElement | undefined>(undefined);
|
|
207
|
+
function register(key: string, controller: FieldController): void {
|
|
208
|
+
leaves.set(key, controller);
|
|
209
|
+
}
|
|
210
|
+
function unregister(key: string): void {
|
|
211
|
+
leaves.delete(key);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Focus + bridge outputs ──────────────────────────────────────────────────
|
|
215
|
+
// `activeCardId` is the id-keyed half of `activeAddr` (whose `card` is positional),
|
|
216
|
+
// and it feeds lookups only: the `activeController` seam below, and the clear on
|
|
217
|
+
// delete. Nothing draws it: a card's active treatment is its controls' reveal,
|
|
218
|
+
// which the card reads off `:focus-within`.
|
|
219
|
+
let activeAddr = $state<Addr | undefined>(undefined);
|
|
220
|
+
let activeCardId = $state<CardId | undefined>(undefined);
|
|
221
|
+
|
|
222
|
+
/** Snapshot a (possibly getter-backed) addr to a plain, index-resolved value. */
|
|
223
|
+
function normalize(addr: Addr): Addr {
|
|
224
|
+
const card = addr.card;
|
|
225
|
+
return card != null ? { card, field: addr.field } : { field: addr.field };
|
|
226
|
+
}
|
|
227
|
+
function handleFocus(addr: Addr): void {
|
|
228
|
+
const plain = normalize(addr);
|
|
229
|
+
activeAddr = plain;
|
|
230
|
+
activeCardId = cardIdOf(plain);
|
|
231
|
+
const field = pathFor(plain);
|
|
232
|
+
if (field != null) onActiveLeafChange?.({ field, cardId: activeCardId });
|
|
233
|
+
}
|
|
234
|
+
/** No card kinds are read for a main address; the shared empty stands in so the
|
|
235
|
+
* common case allocates nothing. */
|
|
236
|
+
const NO_KINDS: readonly string[] = [];
|
|
237
|
+
/**
|
|
238
|
+
* The live kinds by content index, off the DERIVED card tree rather than
|
|
239
|
+
* `doc.cards`: they are already in hand, and `doc.cards` serializes every card on
|
|
240
|
+
* each read, which is not a thing to do per keystroke.
|
|
241
|
+
*/
|
|
242
|
+
function liveKinds(): string[] {
|
|
243
|
+
return model.cards.map((c) => c.kind);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* A leaf's canonical `DocPath`. `undefined` for an address outside the live card
|
|
247
|
+
* array (a stale addr: drop rather than mis-target).
|
|
248
|
+
*/
|
|
249
|
+
function pathFor(addr: Addr): DocPath | undefined {
|
|
250
|
+
return fieldPathForAddr(addr, addr.card == null ? NO_KINDS : liveKinds());
|
|
251
|
+
}
|
|
252
|
+
// The last PLACE reported, so a caret that did not move is not news. A leaf fires
|
|
253
|
+
// one caret signal per transaction, and a transaction need not have moved the
|
|
254
|
+
// caret to exist: a mark toggle over the same range, an anchor op, a plugin's own
|
|
255
|
+
// bookkeeping. The memo is the editor's rather than each leaf's, because a place
|
|
256
|
+
// spans leaves: a caret that leaves `main.body` at 5 for a card leaf and comes
|
|
257
|
+
// back to 5 made two moves, and a per-leaf memo would swallow the second. An edit
|
|
258
|
+
// that reflows the page UNDER a caret holding its offset is the case this drops,
|
|
259
|
+
// and a preview does not need the signal to answer it: the place is unchanged, so
|
|
260
|
+
// the one it already followed is the one its recompile re-locates.
|
|
261
|
+
let lastCaretField: DocPath | undefined;
|
|
262
|
+
let lastCaretPos: number | undefined;
|
|
263
|
+
function handleCaret(addr: Addr, pos: number): void {
|
|
264
|
+
const field = pathFor(normalize(addr));
|
|
265
|
+
if (field == null) return;
|
|
266
|
+
if (field === lastCaretField && pos === lastCaretPos) return;
|
|
267
|
+
lastCaretField = field;
|
|
268
|
+
lastCaretPos = pos;
|
|
269
|
+
onCaretMove?.({ field, pos });
|
|
270
|
+
}
|
|
271
|
+
/** A prose leaf's own commit: the third change lane, and the one that must not
|
|
272
|
+
* bump `revision` (see {@link bump}). */
|
|
273
|
+
function proseChanged(addr: Addr): void {
|
|
274
|
+
const plain = normalize(addr);
|
|
275
|
+
onChange?.({ source: 'prose', cardId: cardIdOf(plain), path: pathFor(plain) });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── Commit routing ──────────────────────────────────────────────────────────
|
|
279
|
+
// Scalars / arrays / objects → the typed writer (schema-checked). Prose leaves
|
|
280
|
+
// commit themselves via the codec (applyChange) and do NOT pass through here.
|
|
281
|
+
// A scalar control commits `undefined` for a cleared entry: the unset lane
|
|
282
|
+
// below (`doc.removeField`), not a write.
|
|
283
|
+
//
|
|
284
|
+
// A bad value makes `writer.set` THROW a `QuillmarkError`, whose
|
|
285
|
+
// `diagnostics[0]` carries a `code` and a canonical `path` (e.g.
|
|
286
|
+
// `edit::field_coercion_failed` at `main.font_size`, or `edit::unknown_field`). The
|
|
287
|
+
// editor already KNOWS the field/card being committed, so it KEYS the entry
|
|
288
|
+
// from THAT address: id-keyed so it survives a later card reorder, never
|
|
289
|
+
// parsed from the positional path; while surfacing the thrown diagnostic
|
|
290
|
+
// verbatim (its `code`/`message`) as the payload (VISUAL_EDITOR §Diagnostics,
|
|
291
|
+
// producer #2). It is stashed in `commitErrors`; a subsequent SUCCESSFUL commit
|
|
292
|
+
// for the same field clears it. Nothing here gates: the value is not written
|
|
293
|
+
// (the document is unchanged on throw, per the boundary's own transactional
|
|
294
|
+
// contract) and editing continues.
|
|
295
|
+
function commitScalar(id: string, isMain: boolean, name: string, value: unknown): void {
|
|
296
|
+
const key: FieldKey = { card: isMain ? undefined : id, field: name };
|
|
297
|
+
const keyStr = fieldKeyToString(key);
|
|
298
|
+
try {
|
|
299
|
+
if (value === undefined) {
|
|
300
|
+
// The UNSET rung of the commitment ladder (a cleared scalar control,
|
|
301
|
+
// VISUAL_EDITOR §"Structure mirrors the schema"): REMOVE the field so the
|
|
302
|
+
// engine's authored › `default:` › zero-fill resolve renders the
|
|
303
|
+
// default, rather than baking a snapshot the schema can't track
|
|
304
|
+
// (canon SCHEMAS.md: the engine never persists a default; nor do we).
|
|
305
|
+
// Removal writes no value, so there is nothing for a schema to conform:
|
|
306
|
+
// it goes through the quill-free `doc.removeField` (bare string = main
|
|
307
|
+
// `{ field }`; `{ card, field }` for a card), NOT the typed writer.
|
|
308
|
+
if (isMain) {
|
|
309
|
+
doc.removeField(name);
|
|
310
|
+
} else {
|
|
311
|
+
const i = cardIndexOf(id);
|
|
312
|
+
if (i < 0) return;
|
|
313
|
+
doc.removeField({ card: i, field: name });
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
const w = quill.writer(doc);
|
|
317
|
+
if (isMain) {
|
|
318
|
+
w.set(name, value);
|
|
319
|
+
} else {
|
|
320
|
+
const i = cardIndexOf(id);
|
|
321
|
+
if (i < 0) return;
|
|
322
|
+
w.card(i).set(name, value);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (commitErrors.has(keyStr)) editCommitErrors((m) => m.delete(keyStr));
|
|
326
|
+
bump('field', id, makeAddr(id, isMain, name));
|
|
327
|
+
} catch (e) {
|
|
328
|
+
const diagnostic: Diagnostic = (isQuillmarkError(e) ? e.diagnostics[0] : undefined) ?? {
|
|
329
|
+
severity: 'error',
|
|
330
|
+
message: errorMessage(e)
|
|
331
|
+
};
|
|
332
|
+
editCommitErrors((m) => m.set(keyStr, { key, diagnostic }));
|
|
333
|
+
// Both channels, deliberately: the diagnostic pins to the field on
|
|
334
|
+
// screen, the error reaches the app's sink (core/errors.ts).
|
|
335
|
+
reportError(onError, {
|
|
336
|
+
code: 'commit-refused',
|
|
337
|
+
severity: 'error',
|
|
338
|
+
message: `write to ${name} refused: ${errorMessage(e)}`,
|
|
339
|
+
cause: e,
|
|
340
|
+
path: pathFor(makeAddr(id, isMain, name))
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── Structure mutators (resolve id→index here, then reorder ids in lockstep) ──
|
|
346
|
+
/**
|
|
347
|
+
* Scroll the card `id` into view once the mutation that placed it has rendered:
|
|
348
|
+
* on a document of any length an insert lands off-screen, and a
|
|
349
|
+
* viewport that does not follow reads as nothing happening.
|
|
350
|
+
*
|
|
351
|
+
* Coalesced on a single pending id: two quick adds resolve their `tick()` in
|
|
352
|
+
* order, the earlier one sees a newer pending id and drops out, so one smooth
|
|
353
|
+
* scroll runs to the last card rather than two fighting over the same scroller.
|
|
354
|
+
* The two guards below answer different questions: the span, whether this surface
|
|
355
|
+
* is still there; the pending id, whether this continuation is still the current one.
|
|
356
|
+
*/
|
|
357
|
+
let pendingScrollId: string | null = null;
|
|
358
|
+
async function scrollCardIntoView(id: string, block: ScrollLogicalPosition): Promise<void> {
|
|
359
|
+
pendingScrollId = id;
|
|
360
|
+
if (!(await span.resumes(tick()))) return;
|
|
361
|
+
if (pendingScrollId !== id) return;
|
|
362
|
+
pendingScrollId = null;
|
|
363
|
+
const i = cardIndexOf(id);
|
|
364
|
+
if (i >= 0) cardRefs[i]?.scrollIntoViewCard(block);
|
|
365
|
+
}
|
|
366
|
+
/** Returns the new card's session key, or `undefined` when the kind seeds nothing
|
|
367
|
+
* or the insert threw: what `insertCard` hands a host that wants to track it. */
|
|
368
|
+
function addCard(atIndex: number, kind: string): CardId | undefined {
|
|
369
|
+
try {
|
|
370
|
+
const overlay = doc.seedOverlay(kind);
|
|
371
|
+
const card = quill.seedCard(kind, overlay);
|
|
372
|
+
if (!card) return undefined;
|
|
373
|
+
doc.insertCard(card, atIndex);
|
|
374
|
+
const id = seq.next();
|
|
375
|
+
cardIds = [...cardIds.slice(0, atIndex), id, ...cardIds.slice(atIndex)];
|
|
376
|
+
bump('structure', id, atIndex);
|
|
377
|
+
// `center` for an insert: the new card is the subject, and centring it shows
|
|
378
|
+
// the neighbours it landed between.
|
|
379
|
+
void scrollCardIntoView(id, 'center');
|
|
380
|
+
return id;
|
|
381
|
+
} catch (e) {
|
|
382
|
+
reportError(onError, {
|
|
383
|
+
code: 'card-op-failed',
|
|
384
|
+
severity: 'error',
|
|
385
|
+
message: `adding a ${kind} card failed: ${errorMessage(e)}`,
|
|
386
|
+
cause: e
|
|
387
|
+
});
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// The reorder gesture's arming window: the reconcile that moves a
|
|
392
|
+
// slot is the trip, and every other reconcile that happens to move one is not. Read
|
|
393
|
+
// through a getter rather than passed as a value, so the flag stays out of the
|
|
394
|
+
// template and needs no reactivity for it: `animate:` asks at apply time, which is a
|
|
395
|
+
// microtask after the mutation and well inside the frame that disarms it.
|
|
396
|
+
let reordering = false;
|
|
397
|
+
let reorderFrame = 0;
|
|
398
|
+
function armReorder(): void {
|
|
399
|
+
reordering = true;
|
|
400
|
+
reorderFrame = requestAnimationFrame(() => (reordering = false));
|
|
401
|
+
}
|
|
402
|
+
span.onEnd(() => cancelAnimationFrame(reorderFrame));
|
|
403
|
+
const isReordering = (): boolean => reordering;
|
|
404
|
+
|
|
405
|
+
function moveCardById(id: string, dir: -1 | 1): void {
|
|
406
|
+
const from = cardIndexOf(id);
|
|
407
|
+
if (from < 0) return;
|
|
408
|
+
const to = from + dir;
|
|
409
|
+
if (to < 0 || to >= doc.cardCount) return;
|
|
410
|
+
armReorder();
|
|
411
|
+
doc.moveCard(from, to);
|
|
412
|
+
const w = cardIds.slice();
|
|
413
|
+
const [x] = w.splice(from, 1);
|
|
414
|
+
w.splice(to, 0, x);
|
|
415
|
+
cardIds = w;
|
|
416
|
+
bump('structure', id, to);
|
|
417
|
+
// `nearest` for a reorder: the card was already in view and only needs to stay
|
|
418
|
+
// there, so a card that never left the viewport does not move it at all.
|
|
419
|
+
void scrollCardIntoView(id, 'nearest');
|
|
420
|
+
}
|
|
421
|
+
function removeCardById(id: string): void {
|
|
422
|
+
const i = cardIndexOf(id);
|
|
423
|
+
if (i < 0) return;
|
|
424
|
+
doc.removeCard(i);
|
|
425
|
+
cardIds = cardIds.filter((_, k) => k !== i);
|
|
426
|
+
if (activeCardId === id) {
|
|
427
|
+
activeCardId = undefined;
|
|
428
|
+
activeAddr = undefined;
|
|
429
|
+
}
|
|
430
|
+
// Drop any commit-error diagnostics id-keyed to the now-gone card: id-keying
|
|
431
|
+
// (VISUAL_EDITOR §"The address is the spine") avoids mis-attributing them to
|
|
432
|
+
// whichever card next takes this position, but an orphaned entry would
|
|
433
|
+
// otherwise sit in the map forever (ids are never reused).
|
|
434
|
+
if ([...commitErrors.keys()].some((k) => k.startsWith(`${id}:`)))
|
|
435
|
+
editCommitErrors((m) => {
|
|
436
|
+
for (const k of [...m.keys()]) if (k.startsWith(`${id}:`)) m.delete(k);
|
|
437
|
+
});
|
|
438
|
+
// No addr: the removed card has no address left, and the surviving cards'
|
|
439
|
+
// addresses all shifted. The stack changed, not a leaf. The id still names
|
|
440
|
+
// WHICH card went, which is the one thing a host keying on it needs, and the
|
|
441
|
+
// only handle the removal leaves it.
|
|
442
|
+
bump('structure', id);
|
|
443
|
+
}
|
|
444
|
+
function retypeCardById(id: string, kind: string): void {
|
|
445
|
+
const i = cardIndexOf(id);
|
|
446
|
+
if (i < 0) return;
|
|
447
|
+
try {
|
|
448
|
+
doc.setCardKind(i, kind);
|
|
449
|
+
bump('structure', id, i);
|
|
450
|
+
} catch (e) {
|
|
451
|
+
reportError(onError, {
|
|
452
|
+
code: 'card-op-failed',
|
|
453
|
+
severity: 'error',
|
|
454
|
+
message: `retyping the card to ${kind} failed: ${errorMessage(e)}`,
|
|
455
|
+
cause: e
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
function renameCardById(id: string, title: string): void {
|
|
460
|
+
const i = cardIndexOf(id);
|
|
461
|
+
if (i < 0) return;
|
|
462
|
+
patchEditorExt(doc, { card: i }, { title });
|
|
463
|
+
bump('structure', id, i);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Clear the tips channel: the dismissal write, and the ONLY write
|
|
467
|
+
* tips make. `undefined` drops the key while `title` and any later sibling ride
|
|
468
|
+
* through; `ext.ts` holds why that matters.
|
|
469
|
+
*/
|
|
470
|
+
function dismissTips(): void {
|
|
471
|
+
patchEditorExt(doc, MAIN_CARD_ADDR, { tips: undefined });
|
|
472
|
+
// Document-level chrome, not a leaf's: no addr, as for a card removal, and no
|
|
473
|
+
// card either. Tips ride `main`'s `$ext` because that is where a document-scoped
|
|
474
|
+
// `$ext` lives, which is not a claim that the main card changed.
|
|
475
|
+
bump('structure');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ── Addressing + per-card op bundle ─────────────────────────────────────────
|
|
479
|
+
/** A LIVE card address: `card` is a getter, so a reorder re-targets in place. */
|
|
480
|
+
function cardAddr(id: string, field?: string): Addr {
|
|
481
|
+
return field != null
|
|
482
|
+
? ({
|
|
483
|
+
get card() {
|
|
484
|
+
return cardIndexOf(id);
|
|
485
|
+
},
|
|
486
|
+
field
|
|
487
|
+
} as Addr)
|
|
488
|
+
: ({
|
|
489
|
+
get card() {
|
|
490
|
+
return cardIndexOf(id);
|
|
491
|
+
}
|
|
492
|
+
} as Addr);
|
|
493
|
+
}
|
|
494
|
+
function makeAddr(id: string, isMain: boolean, field?: string): Addr {
|
|
495
|
+
if (isMain) return field != null ? { field } : {};
|
|
496
|
+
return cardAddr(id, field);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ── Diagnostics routing (VISUAL_EDITOR §Diagnostics) ────────────────────────
|
|
500
|
+
// Producer #1: quill.validate(doc), re-run every revision (empirically always
|
|
501
|
+
// `[]` for usaf_memo (no field in the fixture carries a `!must_fill` marker)
|
|
502
|
+
// but the routing exists for the general contract, not just this fixture).
|
|
503
|
+
const validation = $derived.by(() => {
|
|
504
|
+
revision; // re-run on every mutation, per VISUAL_EDITOR §Diagnostics
|
|
505
|
+
try {
|
|
506
|
+
return quill.validate(doc);
|
|
507
|
+
} catch (e) {
|
|
508
|
+
reportError(onError, {
|
|
509
|
+
code: 'validate-failed',
|
|
510
|
+
severity: 'error',
|
|
511
|
+
message: `quill.validate threw; this derive contributes no validation diagnostics: ${errorMessage(e)}`,
|
|
512
|
+
cause: e
|
|
513
|
+
});
|
|
514
|
+
return [] as Diagnostic[];
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// Merge all three producers: validate() + external (both positional `.path`,
|
|
519
|
+
// resolved to the live stable-id keying) + local commit errors (already
|
|
520
|
+
// id-keyed). Precedence is errors-before-warnings within a field's list
|
|
521
|
+
// (mergeDiagnostics sorts; nothing is dropped: diagnostics never gate, so
|
|
522
|
+
// nothing here hides one either).
|
|
523
|
+
const diagByKey = $derived.by(() => {
|
|
524
|
+
const fromValidate = routeAndResolve(validation, cardIds);
|
|
525
|
+
const fromExternal = routeAndResolve(diagnostics, cardIds);
|
|
526
|
+
return mergeDiagnostics(fromValidate, fromExternal, [...commitErrors.values()]);
|
|
527
|
+
});
|
|
528
|
+
function opsFor(id: string, isMain: boolean) {
|
|
529
|
+
// ONE identity per field: the leaf registry, the DOM's three names, and the
|
|
530
|
+
// diagnostics map all key off this string, so a leaf, its label and its
|
|
531
|
+
// diagnostics can only ever resolve together.
|
|
532
|
+
const leafKey = (field?: string) => fieldKeyToString({ card: isMain ? undefined : id, field });
|
|
533
|
+
return {
|
|
534
|
+
makeAddr: (field?: string) => makeAddr(id, isMain, field),
|
|
535
|
+
leafKey,
|
|
536
|
+
domIds: (field?: string) => fieldDomIds(uid, leafKey(field)),
|
|
537
|
+
panelId: (group: string) => groupPanelId(uid, isMain ? undefined : id, group),
|
|
538
|
+
commit: (name: string, value: unknown) => commitScalar(id, isMain, name, value),
|
|
539
|
+
move: (dir: -1 | 1) => moveCardById(id, dir),
|
|
540
|
+
remove: () => removeCardById(id),
|
|
541
|
+
retype: (kind: string) => retypeCardById(id, kind),
|
|
542
|
+
rename: (title: string) => renameCardById(id, title),
|
|
543
|
+
diagFor: (field?: string) => diagByKey.get(leafKey(field)),
|
|
544
|
+
// Bind the consumer policy hook to this field's resolved addr;
|
|
545
|
+
// no hook → every option allowed.
|
|
546
|
+
enumAllowed: (field: string, value: string) =>
|
|
547
|
+
enumOptionAllowed?.(makeAddr(id, isMain, field), value) ?? true
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ── The empty-body ghost's consumer wording ─────────────────────────────────
|
|
552
|
+
// The hook is consulted once per KIND and its answer kept for the session, which
|
|
553
|
+
// is the whole determinism guarantee: a hook that samples a witty set at random
|
|
554
|
+
// is impure by design, and this cache is what makes its answer look chosen:
|
|
555
|
+
// same string for every card of a kind, and the same one after a remount or any
|
|
556
|
+
// re-derive. Keyed by kind rather than by card id deliberately: two empty cards
|
|
557
|
+
// that are the same kind ARE the same invitation, and disagreeing ghosts read as
|
|
558
|
+
// a glitch. Retyping a card crosses to another key and re-asks, which is right:
|
|
559
|
+
// it is a different card now. Plain (non-`$state`) on purpose: memoization, so
|
|
560
|
+
// filling it during a derive must not feed back into one.
|
|
561
|
+
let ghostHook: BodyPlaceholder | undefined;
|
|
562
|
+
const ghostByKind = new Map<string, string | undefined>();
|
|
563
|
+
function customBodyGhost(kind: string, isMain: boolean): string | undefined {
|
|
564
|
+
const bodyPlaceholder = merged.bodyPlaceholder;
|
|
565
|
+
// A swapped hook invalidates every answer the old one gave.
|
|
566
|
+
if (bodyPlaceholder !== ghostHook) {
|
|
567
|
+
ghostByKind.clear();
|
|
568
|
+
ghostHook = bodyPlaceholder;
|
|
569
|
+
}
|
|
570
|
+
if (!bodyPlaceholder) return undefined;
|
|
571
|
+
// `main` is not a `card_kinds` key, so a kind that spells it collides without
|
|
572
|
+
// the flag in the key.
|
|
573
|
+
const key = `${isMain ? '1' : '0'}\0${kind}`;
|
|
574
|
+
if (!ghostByKind.has(key)) ghostByKind.set(key, bodyPlaceholder({ kind, isMain }));
|
|
575
|
+
return ghostByKind.get(key);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ── The derived card tree (schema × payload join) ───────────────────────────
|
|
579
|
+
function buildCard(
|
|
580
|
+
id: string,
|
|
581
|
+
isMain: boolean,
|
|
582
|
+
kind: string,
|
|
583
|
+
card: {
|
|
584
|
+
payloadItems: { type: string; key?: string; value?: unknown }[];
|
|
585
|
+
ext?: Record<string, unknown>;
|
|
586
|
+
},
|
|
587
|
+
cardSchema: Parameters<typeof fieldModels>[0] | undefined,
|
|
588
|
+
rows: ResolvedCardRows
|
|
589
|
+
): CardModel {
|
|
590
|
+
const values: Record<string, unknown> = {};
|
|
591
|
+
for (const p of card.payloadItems)
|
|
592
|
+
if (p.type === 'field' && p.key != null) values[p.key] = p.value;
|
|
593
|
+
const fields = cardSchema ? fieldModels(cardSchema) : [];
|
|
594
|
+
const sections = cardSchema
|
|
595
|
+
? groupSections(fields, groupOrder(cardSchema), (g) => groupLabel(cardSchema, g))
|
|
596
|
+
: [];
|
|
597
|
+
const extEditor = card.ext?.editor as { title?: string } | undefined;
|
|
598
|
+
const hasBody = bodyEnabled(cardSchema);
|
|
599
|
+
return {
|
|
600
|
+
id,
|
|
601
|
+
isMain,
|
|
602
|
+
kind,
|
|
603
|
+
// No schema for this kind → a recovery shell, not a field list.
|
|
604
|
+
// `main` always resolves `schema.main`, so it is never unschemable.
|
|
605
|
+
unschemable: !isMain && !cardSchema,
|
|
606
|
+
titleOverride: extEditor?.title ?? '',
|
|
607
|
+
titlePlaceholder: cardTitle(cardSchema, kind, values, undefined),
|
|
608
|
+
values,
|
|
609
|
+
provenance: provenanceMap(rows.fields),
|
|
610
|
+
sections,
|
|
611
|
+
hasBody,
|
|
612
|
+
// The body ghosts its resolved `default:` exactly as a scalar does: the
|
|
613
|
+
// same text-ghost projection `<Field>` applies to a field's row; and falls
|
|
614
|
+
// back to an invitation where a scalar shows nothing, because an empty body
|
|
615
|
+
// is a surface to write on and an empty control is a value not yet given.
|
|
616
|
+
// Asked only for a card that HAS a body, so the hook is never consulted
|
|
617
|
+
// about one that renders none.
|
|
618
|
+
bodyGhost: hasBody
|
|
619
|
+
? resolveBodyGhost(
|
|
620
|
+
stringifyGhost(ghostDefault(rows.body ?? undefined)),
|
|
621
|
+
customBodyGhost(kind, isMain),
|
|
622
|
+
merged.bodyGhost
|
|
623
|
+
)
|
|
624
|
+
: undefined
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const model = $derived.by(() => {
|
|
629
|
+
revision; // re-derive on every mutation
|
|
630
|
+
const schema = quill.schema;
|
|
631
|
+
const main = doc.main; // allocate once
|
|
632
|
+
const cards = doc.cards; // allocate once
|
|
633
|
+
// The provenance channel (FIELD_PROVENANCE): one whole-doc resolve per
|
|
634
|
+
// derive, feeding the ghosted `default:` only. Guarded: provenance is
|
|
635
|
+
// chrome, so a resolve failure degrades to no ghosts, never a blank form.
|
|
636
|
+
let resolved: Resolved | undefined;
|
|
637
|
+
try {
|
|
638
|
+
resolved = quill.resolve(doc);
|
|
639
|
+
} catch (e) {
|
|
640
|
+
reportError(onError, {
|
|
641
|
+
code: 'resolve-failed',
|
|
642
|
+
severity: 'error',
|
|
643
|
+
message: `quill.resolve threw; ghosted defaults fall back to none: ${errorMessage(e)}`,
|
|
644
|
+
cause: e
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
const byCard = resolvedByCardIndex(resolved);
|
|
648
|
+
return {
|
|
649
|
+
// Tips are DOCUMENT-level, not a property of the main card: they
|
|
650
|
+
// hang off `main`'s `$ext` because that is where a document-scoped `$ext`
|
|
651
|
+
// lives, and the model says so at the root rather than making every card
|
|
652
|
+
// carry a field one card renders.
|
|
653
|
+
tips: tipsChannel((main.ext?.editor as { tips?: unknown } | undefined)?.tips),
|
|
654
|
+
main: buildCard('main', true, 'main', main, schema.main, {
|
|
655
|
+
fields: resolved?.main.fields ?? [],
|
|
656
|
+
body: resolved?.main.body ?? null
|
|
657
|
+
}),
|
|
658
|
+
cards: cards.map((c, i) =>
|
|
659
|
+
buildCard(
|
|
660
|
+
cardIds[i] ?? `orphan${i}`,
|
|
661
|
+
false,
|
|
662
|
+
c.kind,
|
|
663
|
+
c,
|
|
664
|
+
schema.card_kinds?.[c.kind],
|
|
665
|
+
byCard.get(i) ?? NO_RESOLVED_ROWS
|
|
666
|
+
)
|
|
667
|
+
)
|
|
668
|
+
};
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// ── Public entry points ─────────────────────────────────────────────────────
|
|
672
|
+
/**
|
|
673
|
+
* Resolve a preview `ContentHit` to a mounted leaf and place its caret.
|
|
674
|
+
*
|
|
675
|
+
* Async because the reveal has to RENDER before the landing: a collapsed group
|
|
676
|
+
* is `inert`, which swallows a focus silently, so a caret placed in the same
|
|
677
|
+
* tick as the reveal would go nowhere and report nothing. The consumer's
|
|
678
|
+
* `onCaretPick` ignores the promise: awaiting it is for a caller that wants to
|
|
679
|
+
* observe where the caret went.
|
|
680
|
+
*
|
|
681
|
+
* A destroy lands INSIDE this one: `leaf` is looked up before the flush and
|
|
682
|
+
* dispatched into after it, and a PM view destroyed in that window throws on the
|
|
683
|
+
* dispatch. A consumer's `onCaretPick` outlives the surface it points at too, so
|
|
684
|
+
* the span is asked on the way in as well as after the await.
|
|
685
|
+
*/
|
|
686
|
+
export async function setCaret(hit: ContentHit): Promise<void> {
|
|
687
|
+
const leaf = await revealLeaf(hit.field);
|
|
688
|
+
if (!leaf) return;
|
|
689
|
+
// A `'segment'` hit landed on origin-less ink (list markers, a code fence's
|
|
690
|
+
// interior): `pos` is the segment START, not a cluster-exact caret
|
|
691
|
+
// (HitGranularity), so just focus the leaf rather than snap the caret to a
|
|
692
|
+
// spot the click did not resolve. `'cluster'` (and an absent granularity:
|
|
693
|
+
// the backend did not report it, treat as exact) places the caret.
|
|
694
|
+
if (hit.granularity === 'segment') leaf.focus();
|
|
695
|
+
else leaf.setCaret(hit.pos);
|
|
696
|
+
// The arrival cue. Unconditional, unlike the preview side's change-guarded
|
|
697
|
+
// bloom: a preview click is one discrete act, and its commonest target is the
|
|
698
|
+
// leaf ALREADY focused (where landing a caret changes nothing on screen) or
|
|
699
|
+
// one off-screen, where the browser's focus-scroll moves the page and leaves
|
|
700
|
+
// the caret to be hunted for in a long form.
|
|
701
|
+
bloomInside(leaf.el);
|
|
702
|
+
}
|
|
703
|
+
/** The active leaf's controller: the formatting popover's observation seam. */
|
|
704
|
+
export function getActiveLeaf(): FieldController | undefined {
|
|
705
|
+
if (!activeAddr) return undefined;
|
|
706
|
+
const card = activeAddr.card != null ? activeCardId : undefined;
|
|
707
|
+
return leaves.get(fieldKeyToString({ card, field: activeAddr.field }));
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Reveal the leaf at `field` and hand back its controller, once the reveal has
|
|
712
|
+
* RENDERED. The shared half of the two landing verbs: a collapsed group is clipped
|
|
713
|
+
* to zero height and sits inside an `inert` panel, which swallows a focus silently,
|
|
714
|
+
* so a caret placed in the same tick as the reveal goes nowhere and reports
|
|
715
|
+
* nothing. Exactly one card holds the key; the rest do nothing.
|
|
716
|
+
*
|
|
717
|
+
* A destroy lands INSIDE this: the leaf is looked up before the flush and
|
|
718
|
+
* dispatched into after it, and a PM view destroyed in that window throws on the
|
|
719
|
+
* dispatch. A consumer's call outlives the surface it points at too, so the span is
|
|
720
|
+
* asked on the way in as well as after the await.
|
|
721
|
+
*/
|
|
722
|
+
async function revealLeaf(field: DocPath): Promise<FieldController | undefined> {
|
|
723
|
+
if (!span.alive) return undefined;
|
|
724
|
+
const key = leafKeyForHit(field);
|
|
725
|
+
const leaf = key == null ? undefined : leaves.get(key);
|
|
726
|
+
if (!leaf) return undefined;
|
|
727
|
+
mainCard?.revealLeaf(key!);
|
|
728
|
+
for (const card of cardRefs) card?.revealLeaf(key!);
|
|
729
|
+
if (!(await span.resumes(tick()))) return undefined;
|
|
730
|
+
return leaf;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ── The verbs, as instance exports ──────────────────────────────────────────
|
|
734
|
+
// The same functions the card's own chrome calls, reached through `bind:this`: a
|
|
735
|
+
// host toolbar, command palette or shortcut wants the door the card header gets,
|
|
736
|
+
// and every one of them reports through `onChange` exactly as the click does.
|
|
737
|
+
// They speak the public vocabulary — a `DocPath` for a place, a `CardId` for a
|
|
738
|
+
// card — so a host drives them with what the hooks handed it.
|
|
739
|
+
//
|
|
740
|
+
// A target the surface does not hold is a NO-OP that reports `target-unknown` at
|
|
741
|
+
// `dev`: the chrome cannot mint a bad one, so it only ever fires on a host holding
|
|
742
|
+
// a key from a previous session or a card already removed.
|
|
743
|
+
|
|
744
|
+
/** Reveal and focus the leaf at `field`, without placing a caret inside it. */
|
|
745
|
+
export async function focusField(field: DocPath): Promise<void> {
|
|
746
|
+
const leaf = await revealLeaf(field);
|
|
747
|
+
if (!leaf) return missed(`no mounted leaf at ${field}`, field);
|
|
748
|
+
leaf.focus();
|
|
749
|
+
bloomInside(leaf.el);
|
|
750
|
+
}
|
|
751
|
+
/** Seed a card of `kind` and insert it at `at` (default: the end). Returns the new
|
|
752
|
+
* card's session key, or `undefined` when the quill seeds no card of that kind. */
|
|
753
|
+
export function insertCard(kind: string, at: number = cardIds.length): CardId | undefined {
|
|
754
|
+
return addCard(Math.min(Math.max(at, 0), cardIds.length), kind);
|
|
755
|
+
}
|
|
756
|
+
export function removeCard(cardId: CardId): void {
|
|
757
|
+
if (!holds(cardId)) return;
|
|
758
|
+
removeCardById(cardId);
|
|
759
|
+
}
|
|
760
|
+
/** Move a card one slot. The step the reorder control takes, and the one the
|
|
761
|
+
* surface animates; at either edge it is a no-op. */
|
|
762
|
+
export function moveCard(cardId: CardId, dir: -1 | 1): void {
|
|
763
|
+
if (!holds(cardId)) return;
|
|
764
|
+
moveCardById(cardId, dir);
|
|
765
|
+
}
|
|
766
|
+
export function setKind(cardId: CardId, kind: string): void {
|
|
767
|
+
if (!holds(cardId)) return;
|
|
768
|
+
retypeCardById(cardId, kind);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function holds(cardId: CardId): boolean {
|
|
772
|
+
if (cardIndexOf(cardId) >= 0) return true;
|
|
773
|
+
missed(`no card ${cardId} in this session`);
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
function missed(message: string, path?: DocPath): void {
|
|
777
|
+
reportError(onError, { code: 'target-unknown', severity: 'dev', message, path });
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Map a `ContentHit.field` (a canonical `DocPath`) to a mounted leaf key: the
|
|
781
|
+
* same `addrForFieldPath` route the diagnostics take, the absolute card index
|
|
782
|
+
* resolved to its live stable id, then the shared `fieldKeyToString` form. */
|
|
783
|
+
function leafKeyForHit(field: string): string | undefined {
|
|
784
|
+
const key = parsePath(field);
|
|
785
|
+
if (!key) return undefined;
|
|
786
|
+
const resolved = resolveCardKey(key, cardIds);
|
|
787
|
+
return resolved ? fieldKeyToString(resolved) : undefined;
|
|
788
|
+
}
|
|
789
|
+
</script>
|
|
790
|
+
|
|
791
|
+
<div class="qm-editor {className ?? ''}" {style} data-qm-root bind:this={rootEl}>
|
|
792
|
+
<!-- `main` and the tips card are ONE block in the stack: the tips card tucks under
|
|
793
|
+
`main`'s bottom corners, so the two share a seam rather than a gutter and the
|
|
794
|
+
wrapper is what holds them to it. -->
|
|
795
|
+
<div class="qm-primary">
|
|
796
|
+
<Card
|
|
797
|
+
bind:this={mainCard}
|
|
798
|
+
card={model.main}
|
|
799
|
+
{doc}
|
|
800
|
+
{quill}
|
|
801
|
+
index={-1}
|
|
802
|
+
isFirst={true}
|
|
803
|
+
isLast={true}
|
|
804
|
+
{kinds}
|
|
805
|
+
ops={opsFor('main', true)}
|
|
806
|
+
onFocus={handleFocus}
|
|
807
|
+
onCaretMove={handleCaret}
|
|
808
|
+
onChange={proseChanged}
|
|
809
|
+
{onError}
|
|
810
|
+
{register}
|
|
811
|
+
{unregister}
|
|
812
|
+
/>
|
|
813
|
+
|
|
814
|
+
<!-- The tips card: a fixed slot after `main`, ahead of the cards, so
|
|
815
|
+
document-level guidance reads as document-level and never displaces a field.
|
|
816
|
+
Absent when the channel is empty; which is what dismissal makes it, so the
|
|
817
|
+
card leaves for good (VISUAL_EDITOR §"Card operations"). -->
|
|
818
|
+
{#if model.tips.length}
|
|
819
|
+
<TipsCard tips={model.tips} onDismiss={dismissTips} {onError} />
|
|
820
|
+
{/if}
|
|
821
|
+
</div>
|
|
822
|
+
|
|
823
|
+
{@render addAffordance(0)}
|
|
824
|
+
<!-- A card and the gap under it are one SLOT, which is what a reorder moves: the
|
|
825
|
+
strip below a card is the same strip wherever the card lands, so it rides along
|
|
826
|
+
rather than being slid across. It is also the shape `animate:` asks for, being the
|
|
827
|
+
keyed block's only child. -->
|
|
828
|
+
{#each model.cards as c, i (c.id)}
|
|
829
|
+
<div class="qm-card-slot" animate:reorder={isReordering}>
|
|
830
|
+
<Card
|
|
831
|
+
bind:this={cardRefs[i]}
|
|
832
|
+
card={c}
|
|
833
|
+
{doc}
|
|
834
|
+
{quill}
|
|
835
|
+
index={i}
|
|
836
|
+
isFirst={i === 0}
|
|
837
|
+
isLast={i === model.cards.length - 1}
|
|
838
|
+
{kinds}
|
|
839
|
+
ops={opsFor(c.id, false)}
|
|
840
|
+
onFocus={handleFocus}
|
|
841
|
+
onCaretMove={handleCaret}
|
|
842
|
+
onChange={proseChanged}
|
|
843
|
+
{onError}
|
|
844
|
+
{register}
|
|
845
|
+
{unregister}
|
|
846
|
+
/>
|
|
847
|
+
{@render addAffordance(i + 1)}
|
|
848
|
+
</div>
|
|
849
|
+
{/each}
|
|
850
|
+
</div>
|
|
851
|
+
|
|
852
|
+
<FormatPopover {getActiveLeaf} />
|
|
853
|
+
|
|
854
|
+
<!-- Cards always render; the ADD affordance is gated on the schema declaring
|
|
855
|
+
`card_kinds`, since there is nothing to seed otherwise. A card already in the
|
|
856
|
+
document shows regardless of its kind: a kind with no schema (foreign, or a
|
|
857
|
+
schema with no `card_kinds` at all) degrades to a recovery shell inside <Card>
|
|
858
|
+
(retype + delete), never gated away, so its content is neither dropped nor
|
|
859
|
+
trapped. The gate lives HERE rather than at each call site: the strip is one
|
|
860
|
+
decision, and two copies of it drift into a stack with a gap at one end. -->
|
|
861
|
+
{#snippet addAffordance(atIndex: number)}
|
|
862
|
+
{#if kinds.length}
|
|
863
|
+
<!-- The strip past the last card is the APPEND point, the one a reader looks for
|
|
864
|
+
rather than finds by position, so it alone states itself in words at rest. The
|
|
865
|
+
gaps between cards stay bare: the strip IS the gap, and the pill each fills on
|
|
866
|
+
hover draws what a label would state, the space the new card takes. -->
|
|
867
|
+
{@const marked = atIndex === model.cards.length}
|
|
868
|
+
<div class="qm-add-card" class:qm-add-card-marked={marked}>
|
|
869
|
+
<!-- Marked, the words ARE the accessible name and no `aria-label` doubles
|
|
870
|
+
them; bare, the kind is the whole reading, the one with no geometry to
|
|
871
|
+
carry it. -->
|
|
872
|
+
{#if kinds.length === 1}
|
|
873
|
+
<button
|
|
874
|
+
type="button"
|
|
875
|
+
class="qm-add-btn qm-add-affordance"
|
|
876
|
+
aria-label={marked ? undefined : merged.addCardOfKind(humanize(kinds[0]))}
|
|
877
|
+
onclick={() => addCard(atIndex, kinds[0])}
|
|
878
|
+
>{marked ? merged.addCardMark(humanize(kinds[0])) : ''}</button
|
|
879
|
+
>
|
|
880
|
+
{:else}
|
|
881
|
+
<!-- Multi-kind add: pick the kind, then seed + insert. A MENU rather than a
|
|
882
|
+
disclosure: it floats out of the stack, so raising it moves no card, and
|
|
883
|
+
it dismisses on pick, on Escape and on a click outside, none of which a
|
|
884
|
+
`<details>` does. The trigger is bits-ui's `<button>`, which is why the
|
|
885
|
+
rules below reach it through `:global`. -->
|
|
886
|
+
<DropdownMenu.Root>
|
|
887
|
+
<DropdownMenu.Trigger
|
|
888
|
+
class="qm-add-btn qm-add-affordance"
|
|
889
|
+
aria-label={marked ? undefined : merged.addCard}
|
|
890
|
+
>{marked ? merged.addCardMarkAny : ''}</DropdownMenu.Trigger
|
|
891
|
+
>
|
|
892
|
+
<DropdownMenu.Portal to={rootEl}>
|
|
893
|
+
<DropdownMenu.Content sideOffset={4}>
|
|
894
|
+
<!-- Portalled out of the row but INTO the stack's root, and carrying the
|
|
895
|
+
marker itself: floating is still a detached subtree to the
|
|
896
|
+
derivation, like FormatPopover and the enum listbox. -->
|
|
897
|
+
<div class="qm-menu-surface" data-qm-root>
|
|
898
|
+
{#each kinds as k (k)}
|
|
899
|
+
<DropdownMenu.Item class="qm-menu-item" onSelect={() => addCard(atIndex, k)}
|
|
900
|
+
>{humanize(k)}</DropdownMenu.Item
|
|
901
|
+
>
|
|
902
|
+
{/each}
|
|
903
|
+
</div>
|
|
904
|
+
</DropdownMenu.Content>
|
|
905
|
+
</DropdownMenu.Portal>
|
|
906
|
+
</DropdownMenu.Root>
|
|
907
|
+
{/if}
|
|
908
|
+
</div>
|
|
909
|
+
{/if}
|
|
910
|
+
{/snippet}
|
|
911
|
+
|
|
912
|
+
<style>
|
|
913
|
+
/* The private scale lands via `data-qm-root` on the root element above: this is
|
|
914
|
+
a DETACHED root, one of those core/theme.css applies the derivation to. The
|
|
915
|
+
root rule also carries the baseline font and colour, so nothing here restates
|
|
916
|
+
them. Nothing here mints; `check:style` enforces that. */
|
|
917
|
+
/* The COLUMN, not only the cards in it: the gutter the stack sits in and the tone
|
|
918
|
+
behind it are the surface's own, so a bare `<div>` is a mounting site and
|
|
919
|
+
nothing is owed before the editor looks right. Consumer CSS is unlayered and
|
|
920
|
+
beats all of it, which is how a host that wants the column back takes it.
|
|
921
|
+
`border-box`, so a height from the caller is the height this draws. */
|
|
922
|
+
.qm-editor {
|
|
923
|
+
box-sizing: border-box;
|
|
924
|
+
display: flex;
|
|
925
|
+
flex-direction: column;
|
|
926
|
+
gap: var(--_qm-space-2);
|
|
927
|
+
padding: var(--_qm-space-2);
|
|
928
|
+
background: var(--_qm-surface);
|
|
929
|
+
color: var(--_qm-ink);
|
|
930
|
+
}
|
|
931
|
+
/* A fixed-height pane, which the stack is NOT by default, and the four popovers
|
|
932
|
+
are the reason: each resolves its portal target with
|
|
933
|
+
`closest('[data-qm-root]')`, which is this element, so an `overflow` here
|
|
934
|
+
clips the menu a leaf raised. Mounted in a page that scrolls, nothing clips;
|
|
935
|
+
mounted in a pane that does, the host says so and takes the clipping it would
|
|
936
|
+
have had from its own scrolling frame either way. The TAIL rides with the
|
|
937
|
+
scroller because it is only meaningful under one: dead space below the last
|
|
938
|
+
card, so it can be read mid-pane rather than against the bottom edge.
|
|
939
|
+
|
|
940
|
+
The HEIGHT is what makes the rest of the rule mean anything, and it is the
|
|
941
|
+
pane's rather than a number of ours: `overflow` on a box that grows to its
|
|
942
|
+
content never has anything to scroll, so the stack would run past the pane
|
|
943
|
+
and be clipped by whatever the host frames it with. Taking the pane's height
|
|
944
|
+
is the whole of what `.qm-pane` claims (THEMING §"Drop it in": the surface
|
|
945
|
+
becomes its own scroll container), and it resolves to `auto` — today's page
|
|
946
|
+
behaviour, so a class on a mounting site with no height of its own changes
|
|
947
|
+
nothing. The cards below do not shrink to fit it: a flex item's automatic
|
|
948
|
+
minimum floors each at its own height, so the stack overflows and scrolls
|
|
949
|
+
rather than squashing. `.qm-preview` states the same thing for the same
|
|
950
|
+
reason, unconditionally, having no page form to keep. */
|
|
951
|
+
.qm-editor.qm-pane {
|
|
952
|
+
height: 100%;
|
|
953
|
+
overflow: auto;
|
|
954
|
+
overscroll-behavior: contain;
|
|
955
|
+
padding-block-end: var(--_qm-tail);
|
|
956
|
+
}
|
|
957
|
+
/* The stack's one gapless seam, which `TipsCard` draws and this holds the two
|
|
958
|
+
blocks to. `main` has to paint OVER the tip: a later sibling paints over an earlier
|
|
959
|
+
one otherwise, and the tip's background would cover the corners it is there to
|
|
960
|
+
fill. `isolation` keeps that z-index local, so one wrapper owns the ordering and no
|
|
961
|
+
card below inherits an argument about it. The wrapper is a single flex item, so the
|
|
962
|
+
pair takes the editor's gap once, together, exactly as one card does. */
|
|
963
|
+
.qm-primary {
|
|
964
|
+
isolation: isolate;
|
|
965
|
+
}
|
|
966
|
+
.qm-primary > :global(.qm-card) {
|
|
967
|
+
position: relative;
|
|
968
|
+
z-index: 1;
|
|
969
|
+
}
|
|
970
|
+
/* A slot restates the stack's own column and rhythm, so grouping a card with the
|
|
971
|
+
strip under it changes no geometry: the strip takes the same gap back on both
|
|
972
|
+
sides whether the gap above it is the slot's or the stack's, and a slot's own
|
|
973
|
+
height is short by exactly the gap the strip absorbed at its foot. */
|
|
974
|
+
.qm-card-slot {
|
|
975
|
+
display: flex;
|
|
976
|
+
flex-direction: column;
|
|
977
|
+
gap: var(--_qm-space-2);
|
|
978
|
+
}
|
|
979
|
+
/* The strip between two blocks, and the whole of it is the target: a gap is found
|
|
980
|
+
by POSITION, so reveal and hit region are the full-bleed row rather than a word
|
|
981
|
+
to aim at in the middle of it. It takes the editor's gap back on BOTH sides, so
|
|
982
|
+
it is not a control sitting in the gutter; it IS the gutter: what separates two
|
|
983
|
+
cards is the trigger's own height at the tap floor and nothing else, and the
|
|
984
|
+
pill it fills on hover is edge to edge the space the new card opens into. No
|
|
985
|
+
band is held back as miss-tolerance: gutter that reads as the trigger and
|
|
986
|
+
inserts nothing unsays what the fill claims, so a press anywhere between two
|
|
987
|
+
cards inserts. Absorbed rather than removed, because `gap` is also what separates
|
|
988
|
+
the one seam no strip sits in: every card from the next under a quill declaring no
|
|
989
|
+
kinds, where the affordance does not render at all. */
|
|
990
|
+
.qm-add-card {
|
|
991
|
+
display: flex;
|
|
992
|
+
margin-top: calc(var(--_qm-space-2) * -1);
|
|
993
|
+
margin-bottom: calc(var(--_qm-space-2) * -1);
|
|
994
|
+
}
|
|
995
|
+
/* Unboxed, like every button, and not dashed: a
|
|
996
|
+
dashed edge is the PLACEHOLDER idiom ("nothing is here yet") which on a button
|
|
997
|
+
reads as disabled or as a drop target. It stays honest in one place, the
|
|
998
|
+
un-schemable card (`Card.svelte`), which is a state rather than a control. A bare
|
|
999
|
+
trigger rests invisible, so the FILL is the whole of what it shows: the pill fills
|
|
1000
|
+
the strip because the strip is what was pressed, and the strip is the gap, so the
|
|
1001
|
+
fill is the card-to-be drawn where it will land. Every gap is an equal entry point,
|
|
1002
|
+
reached by the name rather than by a mark: a card goes anywhere in the stack, not
|
|
1003
|
+
just after the last one.
|
|
1004
|
+
|
|
1005
|
+
`:global`, because the multi-kind trigger is bits-ui's own element and a `class`
|
|
1006
|
+
passed to a primitive is a plain string that never picks up the scoping hash:
|
|
1007
|
+
the same seam the enum trigger is styled through. */
|
|
1008
|
+
.qm-add-card :global(.qm-add-btn) {
|
|
1009
|
+
/* No inset of its own: the pill and the gap are the same rectangle, and the tap
|
|
1010
|
+
floor is the whole of the height, which is what the marked strip's words
|
|
1011
|
+
centre in. */
|
|
1012
|
+
display: flex;
|
|
1013
|
+
align-items: center;
|
|
1014
|
+
justify-content: center;
|
|
1015
|
+
width: 100%;
|
|
1016
|
+
}
|
|
1017
|
+
/* Words at rest, so the marked strip restates the rung they take: the LABEL rung,
|
|
1018
|
+
chrome's size, off the body size the family's type rule hands every button
|
|
1019
|
+
(`controls.css`), which is the size the fields above are READ at. */
|
|
1020
|
+
.qm-add-card-marked :global(.qm-add-btn) {
|
|
1021
|
+
font-size: var(--_qm-text-label);
|
|
1022
|
+
}
|
|
1023
|
+
/* The two engaged states the shared `:hover` rung does not cover, both filling the
|
|
1024
|
+
same pill it does. Keyboard focus, because the fill is the only thing that reads
|
|
1025
|
+
as the trigger on a bare strip; and an open menu, because it portals out of the
|
|
1026
|
+
strip, so a pointer moving onto an item has left the row whose hover was drawing
|
|
1027
|
+
the pill the menu hangs from. */
|
|
1028
|
+
.qm-add-card :global(.qm-add-btn:focus-visible),
|
|
1029
|
+
.qm-add-card :global(.qm-add-btn[data-state='open']) {
|
|
1030
|
+
background: var(--_qm-surface-hover);
|
|
1031
|
+
}
|
|
1032
|
+
</style>
|