@quillmark/svelte 0.2.0 → 0.3.1
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/NOTICE +6 -6
- package/README.md +3 -3
- package/THEMING.md +12 -8
- package/dist/core/address.d.ts +7 -7
- package/dist/core/address.js +6 -6
- package/dist/core/bloom.d.ts +3 -32
- package/dist/core/bloom.js +19 -51
- package/dist/core/codec/anchor.d.ts +1 -1
- package/dist/core/codec/anchor.js +1 -1
- package/dist/core/codec/atoms.d.ts +10 -0
- package/dist/core/codec/atoms.js +78 -0
- package/dist/core/codec/clipboard.d.ts +59 -0
- package/dist/core/codec/clipboard.js +202 -0
- package/dist/core/codec/code.d.ts +1 -1
- package/dist/core/codec/code.js +4 -4
- package/dist/core/codec/decode.d.ts +1 -1
- package/dist/core/codec/decode.js +8 -8
- package/dist/core/codec/encode.d.ts +4 -4
- package/dist/core/codec/encode.js +10 -10
- package/dist/core/codec/field.d.ts +21 -26
- package/dist/core/codec/field.js +33 -32
- package/dist/core/codec/index.js +5 -8
- package/dist/core/codec/inputrules.js +56 -18
- package/dist/core/codec/islands.d.ts +11 -7
- package/dist/core/codec/islands.js +16 -10
- package/dist/core/codec/keymap.d.ts +5 -6
- package/dist/core/codec/keymap.js +8 -7
- package/dist/core/codec/lists.d.ts +3 -3
- package/dist/core/codec/lists.js +32 -13
- package/dist/core/codec/marks.d.ts +1 -1
- package/dist/core/codec/marks.js +1 -1
- package/dist/core/codec/positions.js +1 -1
- package/dist/core/codec/prose.css +235 -221
- package/dist/core/codec/schema.js +4 -4
- package/dist/core/codec/slash.d.ts +39 -41
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/table-view.d.ts +12 -16
- package/dist/core/codec/table-view.js +488 -292
- package/dist/core/codec/table.d.ts +42 -15
- package/dist/core/codec/table.js +70 -26
- package/dist/core/errors.d.ts +5 -5
- package/dist/core/index.js +2 -2
- package/dist/core/lifecycle.js +1 -1
- package/dist/core/rebind.svelte.d.ts +2 -2
- package/dist/core/rebind.svelte.js +4 -4
- package/dist/core/teardown.d.ts +1 -1
- package/dist/core/teardown.js +1 -1
- package/dist/core/theme.css +170 -64
- package/dist/preset/index.css +1 -1
- package/dist/preset/recipes.css +31 -29
- package/dist/preset/scale.css +37 -26
- package/dist/preview/Preview.svelte +17 -27
- package/dist/preview/Preview.svelte.d.ts +4 -5
- package/dist/preview/bridge.js +6 -6
- package/dist/preview/controller.d.ts +8 -10
- package/dist/preview/controller.js +19 -44
- package/dist/preview/geometry.d.ts +8 -10
- package/dist/preview/geometry.js +8 -10
- package/dist/preview/index.js +4 -4
- package/dist/preview/paint.d.ts +1 -1
- package/dist/preview/paint.js +17 -19
- package/dist/visual/ArrayField.svelte +80 -66
- package/dist/visual/ArrayField.svelte.d.ts +1 -1
- package/dist/visual/BooleanField.svelte +8 -6
- package/dist/visual/BooleanField.svelte.d.ts +2 -2
- package/dist/visual/Card.svelte +160 -329
- package/dist/visual/Card.svelte.d.ts +2 -7
- package/dist/visual/CardControls.svelte +1 -1
- package/dist/visual/DateField.svelte +19 -19
- package/dist/visual/DateField.svelte.d.ts +3 -3
- package/dist/visual/DiagnosticList.svelte +1 -1
- package/dist/visual/EnumField.svelte +16 -16
- package/dist/visual/EnumField.svelte.d.ts +2 -2
- package/dist/visual/Field.svelte +128 -115
- package/dist/visual/Field.svelte.d.ts +1 -1
- package/dist/visual/FieldHint.svelte +24 -42
- package/dist/visual/FieldHint.svelte.d.ts +1 -1
- package/dist/visual/FieldLabel.svelte +8 -8
- package/dist/visual/FormatPopover.svelte +80 -35
- package/dist/visual/NumberField.svelte +5 -5
- package/dist/visual/NumberField.svelte.d.ts +1 -1
- package/dist/visual/ObjectField.svelte +9 -9
- package/dist/visual/ObjectField.svelte.d.ts +1 -1
- package/dist/visual/ProseArrayElement.svelte +11 -13
- package/dist/visual/ProseArrayElement.svelte.d.ts +1 -1
- package/dist/visual/ProseField.svelte +53 -87
- package/dist/visual/ProseField.svelte.d.ts +13 -19
- package/dist/visual/SlashMenu.svelte +42 -16
- package/dist/visual/TextField.svelte +5 -5
- package/dist/visual/TextField.svelte.d.ts +1 -1
- package/dist/visual/TipsCard.svelte +68 -56
- package/dist/visual/VisualEditor.svelte +6 -6
- package/dist/visual/VisualEditorInner.svelte +203 -290
- package/dist/visual/controls.css +74 -30
- package/dist/visual/diagnostics.d.ts +5 -5
- package/dist/visual/diagnostics.js +3 -3
- package/dist/visual/domid.js +3 -3
- package/dist/visual/ext.d.ts +1 -1
- package/dist/visual/ext.js +1 -1
- package/dist/visual/icons/nodes.d.ts +19 -0
- package/dist/visual/icons/nodes.js +13 -1
- package/dist/visual/index.js +4 -4
- package/dist/visual/leaves.d.ts +3 -3
- package/dist/visual/leaves.js +1 -1
- package/dist/visual/links.d.ts +24 -0
- package/dist/visual/links.js +67 -0
- package/dist/visual/props.d.ts +8 -8
- package/dist/visual/signals.d.ts +6 -6
- package/dist/visual/strings.d.ts +14 -19
- package/dist/visual/strings.js +5 -7
- package/dist/visual/structure.d.ts +24 -17
- package/dist/visual/structure.js +8 -8
- package/dist/visual/synced.svelte.d.ts +1 -1
- package/dist/visual/synced.svelte.js +4 -4
- package/dist/visual/tips.d.ts +2 -2
- package/dist/visual/tips.js +3 -3
- package/package.json +2 -2
- package/dist/preview/overlay.d.ts +0 -11
- package/dist/preview/overlay.js +0 -97
|
@@ -1,32 +1,19 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
The federated WYSIWYG surface (VISUAL_EDITOR). A
|
|
3
|
-
small editors;
|
|
2
|
+
The federated WYSIWYG surface (VISUAL_EDITOR). A thin composition over many
|
|
3
|
+
small editors; not one PM document spanning the page.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Mounted once per document. `VisualEditor` keys this component on `doc`, so
|
|
6
6
|
everything below seeds from a handle that cannot change under it: `cardIds`, the
|
|
7
7
|
id `seq`, the leaf registry, the commit-error map, the active address and the
|
|
8
8
|
card refs are all mount-scoped, and the prose leaves close over the `doc` they
|
|
9
9
|
mounted against. That is what makes them plain state rather than something
|
|
10
10
|
threaded through a generation token.
|
|
11
11
|
|
|
12
|
-
|
|
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(at)` 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
|
|
12
|
+
Reactivity across the WASM handle. The `Document` is opaque to Svelte, so a
|
|
26
13
|
`revision` counter is bumped after every scalar/structure mutation and the card
|
|
27
|
-
tree is `$derived` by
|
|
28
|
-
leaves are mounted
|
|
29
|
-
they commit to the doc directly and do
|
|
14
|
+
tree is `$derived` by re-reading `doc.main`/`doc.cards`/`quill.schema`. Prose
|
|
15
|
+
leaves are mounted once per stable leaf key (keyed `<Card>`/`<ProseField>`);
|
|
16
|
+
they commit to the doc directly and do not bump `revision`, so a re-derive never
|
|
30
17
|
remounts them or drops a caret. `doc.main`/`doc.cards` allocate per read: read
|
|
31
18
|
once per derive.
|
|
32
19
|
-->
|
|
@@ -66,7 +53,6 @@
|
|
|
66
53
|
stringifyGhost,
|
|
67
54
|
resolveBodyGhost,
|
|
68
55
|
NO_RESOLVED_ROWS,
|
|
69
|
-
type BodyPlaceholder,
|
|
70
56
|
type CardModel,
|
|
71
57
|
type ResolvedCardRows
|
|
72
58
|
} from './structure.js';
|
|
@@ -122,7 +108,7 @@
|
|
|
122
108
|
// ── Reactivity + session identity ───────────────────────────────────────────
|
|
123
109
|
let revision = $state(0);
|
|
124
110
|
// This editor's own id, prefixed onto every field's DOM ids (`domid.ts`). The
|
|
125
|
-
// leaf-key space is unique per
|
|
111
|
+
// leaf-key space is unique per editor, not per page: two editors mounted
|
|
126
112
|
// side-by-side both hold `main:subject`, and a duplicate `id` makes `for` resolve
|
|
127
113
|
// to whichever mounted first. `$props.id()` is stable across SSR and hydration,
|
|
128
114
|
// which a module counter is not.
|
|
@@ -132,10 +118,9 @@
|
|
|
132
118
|
// svelte-ignore state_referenced_locally
|
|
133
119
|
let cardIds = $state<string[]>(seq.take(doc.cardCount));
|
|
134
120
|
|
|
135
|
-
// Local commit-error diagnostics (VISUAL_EDITOR §Diagnostics, producer #2)
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
// error stays pinned to its field across a card reorder.
|
|
121
|
+
// Local commit-error diagnostics (VISUAL_EDITOR §Diagnostics, producer #2),
|
|
122
|
+
// id-keyed rather than positional so an error stays pinned to its field across a
|
|
123
|
+
// card reorder.
|
|
139
124
|
let commitErrors = $state(new Map<string, RoutedDiagnostic>());
|
|
140
125
|
/** Edit the commit-error map copy-on-write: a `$state` Map is not deeply
|
|
141
126
|
* reactive, so a mutation in place re-derives nothing. */
|
|
@@ -150,16 +135,12 @@
|
|
|
150
135
|
/**
|
|
151
136
|
* Re-derive the card tree and report the edit. Called for the two lanes that go
|
|
152
137
|
* through this component; a prose leaf commits itself and reports through
|
|
153
|
-
* `proseChanged`, which does
|
|
138
|
+
* `proseChanged`, which does not bump the revision (bumping it would re-derive
|
|
154
139
|
* and remount every leaf, costing the caret on every keystroke).
|
|
155
140
|
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* anything inside it; either way the path is minted HERE, after the bump, because
|
|
160
|
-
* an op's own index is only addressable against the tree the bump re-derived (an
|
|
161
|
-
* insert's card does not exist in the previous one, and a retype's kind is the
|
|
162
|
-
* previous kind).
|
|
141
|
+
* The path is minted here, after the bump, because an op's own index is only
|
|
142
|
+
* addressable against the tree the bump re-derived: an insert's card does not exist
|
|
143
|
+
* in the previous one, and a retype's kind is the previous kind.
|
|
163
144
|
*/
|
|
164
145
|
function bump(source: ChangeSource, cardId?: CardId, at?: Addr | number): void {
|
|
165
146
|
revision++;
|
|
@@ -167,21 +148,19 @@
|
|
|
167
148
|
at == null ? undefined : typeof at === 'number' ? cardPath(at, liveKinds()) : pathFor(at);
|
|
168
149
|
onChange?.({ source, cardId, path });
|
|
169
150
|
}
|
|
170
|
-
/**
|
|
171
|
-
*
|
|
151
|
+
/** Read at the mutation boundary, never cached (VISUAL_EDITOR §"The address is the
|
|
152
|
+
* spine"). */
|
|
172
153
|
function cardIndexOf(id: string): number {
|
|
173
154
|
return cardIds.indexOf(id);
|
|
174
155
|
}
|
|
175
|
-
/** The inverse, for the two signals that arrive as an address and nothing else
|
|
176
|
-
*
|
|
177
|
-
* that index. Resolved at the emit site off the same array the mutation boundary
|
|
178
|
-
* reads, never cached. */
|
|
156
|
+
/** The inverse, for the two signals that arrive as an address and nothing else (a
|
|
157
|
+
* focus, a prose commit), off the same array the mutation boundary reads. */
|
|
179
158
|
function cardIdOf(addr: Addr): CardId {
|
|
180
159
|
return addr.card == null ? 'main' : cardIds[addr.card];
|
|
181
160
|
}
|
|
182
161
|
|
|
183
162
|
// ── Teardown (core/teardown.ts: unregister, cancel, then free) ──────────────
|
|
184
|
-
// A document swap is a
|
|
163
|
+
// A document swap is a remount (see the remount contract above), so a destroy is
|
|
185
164
|
// the only way this surface ends and one span covers both. Cancellers register
|
|
186
165
|
// below in the order they run: the registry that resolves new work first, then
|
|
187
166
|
// the work already scheduled. The document handle is the consumer's to free, so
|
|
@@ -207,7 +186,7 @@
|
|
|
207
186
|
let mainCard = $state<CardHandle | undefined>(undefined);
|
|
208
187
|
let cardRefs = $state<(CardHandle | undefined)[]>([]);
|
|
209
188
|
/** The stack's own element: it carries `data-qm-root`, so it is what the kind
|
|
210
|
-
* menu portals
|
|
189
|
+
* menu portals into, the way each leaf's surface resolves its nearest root. */
|
|
211
190
|
let rootEl = $state<HTMLElement | undefined>(undefined);
|
|
212
191
|
|
|
213
192
|
// ── Focus + bridge outputs ──────────────────────────────────────────────────
|
|
@@ -234,7 +213,7 @@
|
|
|
234
213
|
* common case allocates nothing. */
|
|
235
214
|
const NO_KINDS: readonly string[] = [];
|
|
236
215
|
/**
|
|
237
|
-
* The live kinds by content index, off the
|
|
216
|
+
* The live kinds by content index, off the derived card tree rather than
|
|
238
217
|
* `doc.cards`: they are already in hand, and `doc.cards` serializes every card on
|
|
239
218
|
* each read, which is not a thing to do per keystroke.
|
|
240
219
|
*/
|
|
@@ -248,13 +227,13 @@
|
|
|
248
227
|
function pathFor(addr: Addr): DocPath | undefined {
|
|
249
228
|
return fieldPathForAddr(addr, addr.card == null ? NO_KINDS : liveKinds());
|
|
250
229
|
}
|
|
251
|
-
// The last
|
|
230
|
+
// The last place reported, so a caret that did not move is not news. A leaf fires
|
|
252
231
|
// one caret signal per transaction, and a transaction need not have moved the
|
|
253
232
|
// caret to exist: a mark toggle over the same range, an anchor op, a plugin's own
|
|
254
233
|
// bookkeeping. The memo is the editor's rather than each leaf's, because a place
|
|
255
234
|
// spans leaves: a caret that leaves `main.body` at 5 for a card leaf and comes
|
|
256
235
|
// back to 5 made two moves, and a per-leaf memo would swallow the second. An edit
|
|
257
|
-
// that reflows the page
|
|
236
|
+
// that reflows the page under a caret holding its offset is the case this drops,
|
|
258
237
|
// and a preview does not need the signal to answer it: the place is unchanged, so
|
|
259
238
|
// the one it already followed is the one its recompile re-locates.
|
|
260
239
|
let lastCaretField: DocPath | undefined;
|
|
@@ -275,35 +254,28 @@
|
|
|
275
254
|
}
|
|
276
255
|
|
|
277
256
|
// ── Commit routing ──────────────────────────────────────────────────────────
|
|
278
|
-
// Scalars / arrays / objects → the typed writer (schema-checked)
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
// below (`doc.removeField`), not a write.
|
|
257
|
+
// Scalars / arrays / objects → the typed writer (schema-checked); a cleared entry
|
|
258
|
+
// arrives as `undefined` and takes the unset lane instead. Prose leaves commit
|
|
259
|
+
// themselves via the codec (applyChange) and do not pass through here.
|
|
282
260
|
//
|
|
283
|
-
// A bad value makes `writer.set`
|
|
284
|
-
//
|
|
285
|
-
// `
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
// producer #2). It is stashed in `commitErrors`; a subsequent SUCCESSFUL commit
|
|
291
|
-
// for the same field clears it. Nothing here gates: the value is not written
|
|
292
|
-
// (the document is unchanged on throw, per the boundary's own transactional
|
|
293
|
-
// contract) and editing continues.
|
|
261
|
+
// A bad value makes `writer.set` throw a `QuillmarkError` whose `diagnostics[0]`
|
|
262
|
+
// carries a `code` and a canonical `path` (`edit::field_coercion_failed` at
|
|
263
|
+
// `main.font_size`, `edit::unknown_field`). That diagnostic is surfaced verbatim,
|
|
264
|
+
// under a key minted from the address being committed rather than parsed from the
|
|
265
|
+
// positional path, so it survives a later card reorder. Nothing gates: the document
|
|
266
|
+
// is unchanged on throw, per the boundary's transactional contract, and editing
|
|
267
|
+
// continues.
|
|
294
268
|
function commitScalar(id: string, isMain: boolean, name: string, value: unknown): void {
|
|
295
269
|
const key: FieldKey = { card: isMain ? undefined : id, field: name };
|
|
296
270
|
const keyStr = fieldKeyToString(key);
|
|
297
271
|
try {
|
|
298
272
|
if (value === undefined) {
|
|
299
|
-
// The
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
// it goes through the quill-free `doc.removeField` (bare string = main
|
|
306
|
-
// `{ field }`; `{ card, field }` for a card), NOT the typed writer.
|
|
273
|
+
// The unset rung of the commitment ladder (VISUAL_EDITOR §"Structure mirrors
|
|
274
|
+
// the schema"): removing the field leaves the engine's authored › `default:`
|
|
275
|
+
// › zero-fill resolve to render the default, where writing one bakes a
|
|
276
|
+
// snapshot the schema cannot track (canon SCHEMAS.md: the engine never
|
|
277
|
+
// persists a default; nor do we). Removal writes no value, so there is
|
|
278
|
+
// nothing for a schema to conform and the lane is the quill-free one.
|
|
307
279
|
if (isMain) {
|
|
308
280
|
doc.removeField(name);
|
|
309
281
|
} else {
|
|
@@ -350,8 +322,6 @@
|
|
|
350
322
|
* Coalesced on a single pending id: two quick adds resolve their `tick()` in
|
|
351
323
|
* order, the earlier one sees a newer pending id and drops out, so one smooth
|
|
352
324
|
* scroll runs to the last card rather than two fighting over the same scroller.
|
|
353
|
-
* The two guards below answer different questions: the span, whether this surface
|
|
354
|
-
* is still there; the pending id, whether this continuation is still the current one.
|
|
355
325
|
*/
|
|
356
326
|
let pendingScrollId: string | null = null;
|
|
357
327
|
async function scrollCardIntoView(id: string, block: ScrollLogicalPosition): Promise<void> {
|
|
@@ -373,8 +343,8 @@
|
|
|
373
343
|
const id = seq.next();
|
|
374
344
|
cardIds = [...cardIds.slice(0, atIndex), id, ...cardIds.slice(atIndex)];
|
|
375
345
|
bump('structure', id, atIndex);
|
|
376
|
-
//
|
|
377
|
-
//
|
|
346
|
+
// Centred: the new card is the subject, and the neighbours it landed between
|
|
347
|
+
// are what say where it went.
|
|
378
348
|
void scrollCardIntoView(id, 'center');
|
|
379
349
|
return id;
|
|
380
350
|
} catch (e) {
|
|
@@ -413,8 +383,8 @@
|
|
|
413
383
|
w.splice(to, 0, x);
|
|
414
384
|
cardIds = w;
|
|
415
385
|
bump('structure', id, to);
|
|
416
|
-
// `nearest
|
|
417
|
-
//
|
|
386
|
+
// `nearest`: the card was already in view and only needs to stay there, so one
|
|
387
|
+
// that never left the viewport moves it not at all.
|
|
418
388
|
void scrollCardIntoView(id, 'nearest');
|
|
419
389
|
}
|
|
420
390
|
function removeCardById(id: string): void {
|
|
@@ -426,18 +396,15 @@
|
|
|
426
396
|
activeCardId = undefined;
|
|
427
397
|
activeAddr = undefined;
|
|
428
398
|
}
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
// otherwise sit in the map forever (ids are never reused).
|
|
399
|
+
// Ids are never reused, so an entry keyed to the gone card is unreachable rather
|
|
400
|
+
// than mis-attributed to whichever card takes this position: it needs dropping
|
|
401
|
+
// because nothing else will ever clear it.
|
|
433
402
|
if ([...commitErrors.keys()].some((k) => k.startsWith(`${id}:`)))
|
|
434
403
|
editCommitErrors((m) => {
|
|
435
404
|
for (const k of [...m.keys()]) if (k.startsWith(`${id}:`)) m.delete(k);
|
|
436
405
|
});
|
|
437
|
-
// No addr: the removed card has
|
|
438
|
-
//
|
|
439
|
-
// WHICH card went, which is the one thing a host keying on it needs, and the
|
|
440
|
-
// only handle the removal leaves it.
|
|
406
|
+
// No addr: the removed card has none left and every survivor's shifted. The
|
|
407
|
+
// stack changed, not a leaf, and the id is the only handle the removal leaves.
|
|
441
408
|
bump('structure', id);
|
|
442
409
|
}
|
|
443
410
|
function retypeCardById(id: string, kind: string): void {
|
|
@@ -461,11 +428,8 @@
|
|
|
461
428
|
patchEditorExt(doc, { card: i }, { title });
|
|
462
429
|
bump('structure', id, i);
|
|
463
430
|
}
|
|
464
|
-
/**
|
|
465
|
-
*
|
|
466
|
-
* tips make. `undefined` drops the key while `title` and any later sibling ride
|
|
467
|
-
* through; `ext.ts` holds why that matters.
|
|
468
|
-
*/
|
|
431
|
+
/** The only write tips make. `undefined` drops the key while `title` and any later
|
|
432
|
+
* sibling ride through; `ext.ts` holds why that matters. */
|
|
469
433
|
function dismissTips(): void {
|
|
470
434
|
patchEditorExt(doc, MAIN_CARD_ADDR, { tips: undefined });
|
|
471
435
|
// Document-level chrome, not a leaf's: no addr, as for a card removal, and no
|
|
@@ -475,7 +439,7 @@
|
|
|
475
439
|
}
|
|
476
440
|
|
|
477
441
|
// ── Addressing + per-card op bundle ─────────────────────────────────────────
|
|
478
|
-
/** A
|
|
442
|
+
/** A live card address: `card` is a getter, so a reorder re-targets in place. */
|
|
479
443
|
function cardAddr(id: string, field?: string): Addr {
|
|
480
444
|
return field != null
|
|
481
445
|
? ({
|
|
@@ -496,9 +460,7 @@
|
|
|
496
460
|
}
|
|
497
461
|
|
|
498
462
|
// ── Diagnostics routing (VISUAL_EDITOR §Diagnostics) ────────────────────────
|
|
499
|
-
// Producer #1: quill.validate(doc), re-run every revision
|
|
500
|
-
// `[]` for usaf_memo (no field in the fixture carries a `!must_fill` marker)
|
|
501
|
-
// but the routing exists for the general contract, not just this fixture).
|
|
463
|
+
// Producer #1: quill.validate(doc), re-run every revision.
|
|
502
464
|
const validation = $derived.by(() => {
|
|
503
465
|
revision; // re-run on every mutation, per VISUAL_EDITOR §Diagnostics
|
|
504
466
|
try {
|
|
@@ -514,18 +476,16 @@
|
|
|
514
476
|
}
|
|
515
477
|
});
|
|
516
478
|
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
// (mergeDiagnostics sorts; nothing is dropped: diagnostics never gate, so
|
|
521
|
-
// nothing here hides one either).
|
|
479
|
+
// All three producers. The two positional ones resolve their `.path` to the live
|
|
480
|
+
// stable-id keying; commit errors are id-keyed already. Nothing is dropped:
|
|
481
|
+
// diagnostics never gate, so nothing here hides one either.
|
|
522
482
|
const diagByKey = $derived.by(() => {
|
|
523
483
|
const fromValidate = routeAndResolve(validation, cardIds);
|
|
524
484
|
const fromExternal = routeAndResolve(diagnostics, cardIds);
|
|
525
485
|
return mergeDiagnostics(fromValidate, fromExternal, [...commitErrors.values()]);
|
|
526
486
|
});
|
|
527
487
|
function opsFor(id: string, isMain: boolean) {
|
|
528
|
-
//
|
|
488
|
+
// One identity per field: the leaf registry, the DOM's three names, and the
|
|
529
489
|
// diagnostics map all key off this string, so a leaf, its label and its
|
|
530
490
|
// diagnostics can only ever resolve together.
|
|
531
491
|
const leafKey = (field?: string) => fieldKeyToString({ card: isMain ? undefined : id, field });
|
|
@@ -540,40 +500,11 @@
|
|
|
540
500
|
retype: (kind: string) => retypeCardById(id, kind),
|
|
541
501
|
rename: (title: string) => renameCardById(id, title),
|
|
542
502
|
diagFor: (field?: string) => diagByKey.get(leafKey(field)),
|
|
543
|
-
// Bind the consumer policy hook to this field's resolved addr;
|
|
544
|
-
// no hook → every option allowed.
|
|
545
503
|
enumAllowed: (field: string, value: string) =>
|
|
546
504
|
enumOptionAllowed?.(makeAddr(id, isMain, field), value) ?? true
|
|
547
505
|
};
|
|
548
506
|
}
|
|
549
507
|
|
|
550
|
-
// ── The empty-body ghost's consumer wording ─────────────────────────────────
|
|
551
|
-
// The hook is consulted once per KIND and its answer kept for the session, which
|
|
552
|
-
// is the whole determinism guarantee: a hook that samples a witty set at random
|
|
553
|
-
// is impure by design, and this cache is what makes its answer look chosen:
|
|
554
|
-
// same string for every card of a kind, and the same one after a remount or any
|
|
555
|
-
// re-derive. Keyed by kind rather than by card id deliberately: two empty cards
|
|
556
|
-
// that are the same kind ARE the same invitation, and disagreeing ghosts read as
|
|
557
|
-
// a glitch. Retyping a card crosses to another key and re-asks, which is right:
|
|
558
|
-
// it is a different card now. Plain (non-`$state`) on purpose: memoization, so
|
|
559
|
-
// filling it during a derive must not feed back into one.
|
|
560
|
-
let ghostHook: BodyPlaceholder | undefined;
|
|
561
|
-
const ghostByKind = new Map<string, string | undefined>();
|
|
562
|
-
function customBodyGhost(kind: string, isMain: boolean): string | undefined {
|
|
563
|
-
const bodyPlaceholder = merged.bodyPlaceholder;
|
|
564
|
-
// A swapped hook invalidates every answer the old one gave.
|
|
565
|
-
if (bodyPlaceholder !== ghostHook) {
|
|
566
|
-
ghostByKind.clear();
|
|
567
|
-
ghostHook = bodyPlaceholder;
|
|
568
|
-
}
|
|
569
|
-
if (!bodyPlaceholder) return undefined;
|
|
570
|
-
// `main` is not a `card_kinds` key, so a kind that spells it collides without
|
|
571
|
-
// the flag in the key.
|
|
572
|
-
const key = `${isMain ? '1' : '0'}\0${kind}`;
|
|
573
|
-
if (!ghostByKind.has(key)) ghostByKind.set(key, bodyPlaceholder({ kind, isMain }));
|
|
574
|
-
return ghostByKind.get(key);
|
|
575
|
-
}
|
|
576
|
-
|
|
577
508
|
// ── The derived card tree (schema × payload join) ───────────────────────────
|
|
578
509
|
function buildCard(
|
|
579
510
|
id: string,
|
|
@@ -599,7 +530,6 @@
|
|
|
599
530
|
id,
|
|
600
531
|
isMain,
|
|
601
532
|
kind,
|
|
602
|
-
// No schema for this kind → a recovery shell, not a field list.
|
|
603
533
|
// `main` always resolves `schema.main`, so it is never unschemable.
|
|
604
534
|
unschemable: !isMain && !cardSchema,
|
|
605
535
|
titleOverride: extEditor?.title ?? '',
|
|
@@ -608,16 +538,15 @@
|
|
|
608
538
|
provenance: provenanceMap(rows.fields),
|
|
609
539
|
sections,
|
|
610
540
|
hasBody,
|
|
611
|
-
// The body ghosts its resolved `default:` exactly as a scalar does
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
// is
|
|
615
|
-
//
|
|
616
|
-
// about one that renders none.
|
|
541
|
+
// The body ghosts its resolved `default:` exactly as a scalar does, and falls
|
|
542
|
+
// back to an invitation where a scalar shows nothing: an empty body is a
|
|
543
|
+
// surface to write on where an empty control is a value not yet given. The
|
|
544
|
+
// hook is pure by contract, so its answer is kept nowhere — a ghost holds
|
|
545
|
+
// still across a re-derive because the function does.
|
|
617
546
|
bodyGhost: hasBody
|
|
618
547
|
? resolveBodyGhost(
|
|
619
548
|
stringifyGhost(ghostDefault(rows.body ?? undefined)),
|
|
620
|
-
|
|
549
|
+
merged.bodyPlaceholder?.({ cardId: id, kind, isMain }),
|
|
621
550
|
merged.bodyGhost
|
|
622
551
|
)
|
|
623
552
|
: undefined
|
|
@@ -627,8 +556,8 @@
|
|
|
627
556
|
const model = $derived.by(() => {
|
|
628
557
|
revision; // re-derive on every mutation
|
|
629
558
|
const schema = quill.schema;
|
|
630
|
-
const main = doc.main;
|
|
631
|
-
const cards = doc.cards;
|
|
559
|
+
const main = doc.main;
|
|
560
|
+
const cards = doc.cards;
|
|
632
561
|
// The provenance channel (FIELD_PROVENANCE): one whole-doc resolve per
|
|
633
562
|
// derive, feeding the ghosted `default:` only. Guarded: provenance is
|
|
634
563
|
// chrome, so a resolve failure degrades to no ghosts, never a blank form.
|
|
@@ -645,10 +574,10 @@
|
|
|
645
574
|
}
|
|
646
575
|
const byCard = resolvedByCardIndex(resolved);
|
|
647
576
|
return {
|
|
648
|
-
// Tips are
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
577
|
+
// Tips are document-level, not a property of the main card: they hang off
|
|
578
|
+
// `main`'s `$ext` because that is where a document-scoped `$ext` lives, and
|
|
579
|
+
// the model says so at the root rather than making every card carry a field
|
|
580
|
+
// one card renders.
|
|
652
581
|
tips: tipsChannel((main.ext?.editor as { tips?: unknown } | undefined)?.tips),
|
|
653
582
|
main: buildCard('main', true, 'main', main, schema.main, {
|
|
654
583
|
fields: resolved?.main.fields ?? [],
|
|
@@ -671,32 +600,26 @@
|
|
|
671
600
|
/**
|
|
672
601
|
* Resolve a preview {@link Landing} to a mounted leaf and land in it.
|
|
673
602
|
*
|
|
674
|
-
* Async because the reveal has to
|
|
603
|
+
* Async because the reveal has to render before the landing: a collapsed group
|
|
675
604
|
* is `inert`, which swallows a focus silently, so a caret placed in the same
|
|
676
605
|
* tick as the reveal would go nowhere and report nothing. The consumer's
|
|
677
606
|
* `onPick` ignores the promise: awaiting it is for a caller that wants to
|
|
678
607
|
* observe where the caret went.
|
|
679
|
-
*
|
|
680
|
-
* A destroy lands INSIDE this one: `leaf` is looked up before the flush and
|
|
681
|
-
* dispatched into after it, and a PM view destroyed in that window throws on the
|
|
682
|
-
* dispatch. A consumer's `onPick` outlives the surface it points at too, so the
|
|
683
|
-
* span is asked on the way in as well as after the await.
|
|
684
608
|
*/
|
|
685
609
|
export async function setCaret(at: Landing): Promise<void> {
|
|
686
610
|
const found = await revealLeaf(at.field);
|
|
687
611
|
if (!found) return missed(`no mounted field at ${at.field}`, at.field);
|
|
688
612
|
// A `'segment'` hit landed on origin-less ink (list markers, a code fence's
|
|
689
|
-
// interior): `pos` is the segment
|
|
613
|
+
// interior): `pos` is the segment start, not a cluster-exact caret
|
|
690
614
|
// (HitGranularity), so it is dropped rather than snapped to a spot the click
|
|
691
615
|
// did not resolve. `'cluster'` (and an absent granularity: the backend did not
|
|
692
|
-
// report it, treat as exact) places the caret. An absent `pos` is the
|
|
616
|
+
// report it, treat as exact) places the caret. An absent `pos` is the placement
|
|
693
617
|
// rung and reaches the same floor.
|
|
694
618
|
land(found, at.granularity === 'segment' ? undefined : at.pos);
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
// the caret to be hunted for in a long form.
|
|
619
|
+
// Unconditional: a preview click is one discrete act, and its commonest target
|
|
620
|
+
// is the leaf already focused (where landing a caret changes nothing on screen)
|
|
621
|
+
// or one off-screen, where the browser's focus-scroll moves the page and leaves
|
|
622
|
+
// the caret to be hunted for.
|
|
700
623
|
bloomInside(found.control.el);
|
|
701
624
|
}
|
|
702
625
|
/** The active leaf's controller: the formatting popover's observation seam.
|
|
@@ -709,7 +632,7 @@
|
|
|
709
632
|
|
|
710
633
|
/**
|
|
711
634
|
* Reveal the field at `field` and hand back its landing handle plus the target it
|
|
712
|
-
* resolved to, once the reveal has
|
|
635
|
+
* resolved to, once the reveal has rendered. The shared half of the two landing
|
|
713
636
|
* verbs: a collapsed group is clipped to zero height and sits inside an `inert`
|
|
714
637
|
* panel, which swallows a focus silently, so a caret placed in the same tick as the
|
|
715
638
|
* reveal goes nowhere and reports nothing. Exactly one card holds the key; the rest
|
|
@@ -717,13 +640,11 @@
|
|
|
717
640
|
*
|
|
718
641
|
* The target comes back with the handle because the caller needs its key to ask for
|
|
719
642
|
* the codec lane (`leaves.prose`), and minting it twice would be two parses of one
|
|
720
|
-
* path.
|
|
721
|
-
* address opens the same group its array sits in.
|
|
643
|
+
* path.
|
|
722
644
|
*
|
|
723
|
-
* A destroy lands
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
* asked on the way in as well as after the await.
|
|
645
|
+
* A destroy lands inside the flush, and a PM view destroyed in that window throws on
|
|
646
|
+
* the dispatch that follows. A consumer's call outlives the surface it points at
|
|
647
|
+
* too, so the span is asked on the way in as well as after the await.
|
|
727
648
|
*/
|
|
728
649
|
async function revealLeaf(field: DocPath): Promise<Landed | undefined> {
|
|
729
650
|
if (!span.alive) return undefined;
|
|
@@ -737,15 +658,11 @@
|
|
|
737
658
|
}
|
|
738
659
|
|
|
739
660
|
/**
|
|
740
|
-
* Put the caret in a revealed target, at the finest grain it can take
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
*
|
|
744
|
-
*
|
|
745
|
-
* - Everything else takes the focus alone: the offset is a position in RENDERED
|
|
746
|
-
* content and a form control has no coordinate to spend it in (an
|
|
747
|
-
* `<input type="number">` refuses a selection outright), which is also the whole
|
|
748
|
-
* of what a click on plate-placed ink can mean.
|
|
661
|
+
* Put the caret in a revealed target, at the finest grain it can take: a USV offset
|
|
662
|
+
* in a prose leaf, a bare focus everywhere else. An array element is no
|
|
663
|
+
* `createField` leaf, and a form control has no coordinate to spend an offset in (an
|
|
664
|
+
* `<input type="number">` refuses a selection outright), which is also the whole of
|
|
665
|
+
* what a click on plate-placed ink can mean.
|
|
749
666
|
*/
|
|
750
667
|
function land(found: Landed, pos: number | undefined): void {
|
|
751
668
|
if (found.element != null && found.control.focusElement) {
|
|
@@ -759,26 +676,18 @@
|
|
|
759
676
|
}
|
|
760
677
|
|
|
761
678
|
// ── The verbs, as instance exports ──────────────────────────────────────────
|
|
762
|
-
// The same functions the card's own chrome calls, reached through `bind:this
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
// They speak the public vocabulary — a `DocPath` for a place, a `CardId` for a
|
|
766
|
-
// card — so a host drives them with what the hooks handed it.
|
|
679
|
+
// The same functions the card's own chrome calls, reached through `bind:this`, and
|
|
680
|
+
// they speak the public vocabulary — a `DocPath` for a place, a `CardId` for a card
|
|
681
|
+
// — so a host drives them with what the hooks handed it.
|
|
767
682
|
//
|
|
768
|
-
// A target the surface does not hold is a
|
|
769
|
-
// `dev`: a key from a previous session
|
|
770
|
-
//
|
|
771
|
-
//
|
|
772
|
-
//
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
/**
|
|
777
|
-
* Reveal and focus the field at `field`, without placing a caret inside it. Any
|
|
778
|
-
* mounted field: a prose leaf takes its view's focus, a form control takes the same
|
|
779
|
-
* handoff a click on its label does (`Field`, `leaves.ts`), and an element address
|
|
780
|
-
* takes its own row.
|
|
781
|
-
*/
|
|
683
|
+
// A target the surface does not hold is a no-op that reports `target-unknown` at
|
|
684
|
+
// `dev`: a key from a previous session, a card already removed, a path naming no
|
|
685
|
+
// declared field. `setCaret` reports it too, being the verb a preview click drives:
|
|
686
|
+
// a landing that resolved nothing is indistinguishable from one that landed, and a
|
|
687
|
+
// consumer wiring the bridge reads the difference off nothing else.
|
|
688
|
+
|
|
689
|
+
/** Reveal and focus the field at `field`, placing no caret inside it: a form control
|
|
690
|
+
* takes the same handoff a click on its label does (`Field`, `leaves.ts`). */
|
|
782
691
|
export async function focusField(field: DocPath): Promise<void> {
|
|
783
692
|
const found = await revealLeaf(field);
|
|
784
693
|
if (!found) return missed(`no mounted field at ${field}`, field);
|
|
@@ -794,8 +703,7 @@
|
|
|
794
703
|
if (!holds(cardId)) return;
|
|
795
704
|
removeCardById(cardId);
|
|
796
705
|
}
|
|
797
|
-
/**
|
|
798
|
-
* surface animates; at either edge it is a no-op. */
|
|
706
|
+
/** One slot: the step the reorder control takes, and the one the surface animates. */
|
|
799
707
|
export function moveCard(cardId: CardId, dir: -1 | 1): void {
|
|
800
708
|
if (!holds(cardId)) return;
|
|
801
709
|
moveCardById(cardId, dir);
|
|
@@ -815,7 +723,7 @@
|
|
|
815
723
|
}
|
|
816
724
|
|
|
817
725
|
/** What a `DocPath` resolves to in the mounted tree: a leaf key, and the array
|
|
818
|
-
*
|
|
726
|
+
* element within it when the address names one. */
|
|
819
727
|
interface LeafTarget {
|
|
820
728
|
key: string;
|
|
821
729
|
element?: number;
|
|
@@ -824,17 +732,16 @@
|
|
|
824
732
|
type Landed = LeafTarget & { control: FieldControl };
|
|
825
733
|
|
|
826
734
|
/**
|
|
827
|
-
* Map a landing's `field`
|
|
828
|
-
*
|
|
829
|
-
* to its live stable id, then the shared `fieldKeyToString` form.
|
|
735
|
+
* Map a landing's `field` to a mounted target, by the `addrForFieldPath` route the
|
|
736
|
+
* diagnostics take.
|
|
830
737
|
*
|
|
831
|
-
*
|
|
738
|
+
* Two rungs, because the boundary mints addresses at a finer granularity than
|
|
832
739
|
* `Addr` can name: a `richtext[]` element surfaces as `main.references.0`, which
|
|
833
740
|
* the grammar reads as a field literally named `"0"`. The second rung reads that
|
|
834
|
-
* trailing segment as an
|
|
741
|
+
* trailing segment as an index, under a field the schema declares an array — which
|
|
835
742
|
* is why the ladder is the editor's (VISUAL_EDITOR.md §Surface): the preview
|
|
836
743
|
* carries no schema, and truncating the address there is worse than guessing, since
|
|
837
|
-
* `pos` is an offset into the
|
|
744
|
+
* `pos` is an offset into the element's own content.
|
|
838
745
|
*/
|
|
839
746
|
function leafTargetFor(field: DocPath): LeafTarget | undefined {
|
|
840
747
|
const direct = addrForFieldPath(field);
|
|
@@ -848,8 +755,7 @@
|
|
|
848
755
|
return resolved ? { key: fieldKeyToString(resolved), element: element.index } : undefined;
|
|
849
756
|
}
|
|
850
757
|
|
|
851
|
-
/**
|
|
852
|
-
* guard the element rung stands on. Asked of `controlKind`, so what the ladder
|
|
758
|
+
/** The guard the element rung stands on. Asked of `controlKind`, so what the ladder
|
|
853
759
|
* tests is the control the tree mounted — the one holding a `focusElement` — and
|
|
854
760
|
* not a second reading of the schema beside it. */
|
|
855
761
|
function isArrayField(addr: Addr): boolean {
|
|
@@ -863,7 +769,7 @@
|
|
|
863
769
|
</script>
|
|
864
770
|
|
|
865
771
|
<div class="qm-editor {className ?? ''}" {style} data-qm-root bind:this={rootEl}>
|
|
866
|
-
<!-- `main` and the tips card are
|
|
772
|
+
<!-- `main` and the tips card are one block in the stack: the tips card tucks under
|
|
867
773
|
`main`'s bottom corners, so the two share a seam rather than a gutter and the
|
|
868
774
|
wrapper is what holds them to it. -->
|
|
869
775
|
<div class="qm-primary">
|
|
@@ -872,7 +778,6 @@
|
|
|
872
778
|
card={model.main}
|
|
873
779
|
{doc}
|
|
874
780
|
{quill}
|
|
875
|
-
index={-1}
|
|
876
781
|
isFirst={true}
|
|
877
782
|
isLast={true}
|
|
878
783
|
{kinds}
|
|
@@ -885,17 +790,16 @@
|
|
|
885
790
|
{leaves}
|
|
886
791
|
/>
|
|
887
792
|
|
|
888
|
-
<!--
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
card leaves for good (VISUAL_EDITOR §"Card operations"). -->
|
|
793
|
+
<!-- A fixed slot after `main`, ahead of the cards, so document-level guidance
|
|
794
|
+
reads as document-level and never displaces a field. Dismissal empties the
|
|
795
|
+
channel, so the card leaves for good (VISUAL_EDITOR §"Card operations"). -->
|
|
892
796
|
{#if model.tips.length}
|
|
893
797
|
<TipsCard tips={model.tips} onDismiss={dismissTips} {onError} />
|
|
894
798
|
{/if}
|
|
895
799
|
</div>
|
|
896
800
|
|
|
897
801
|
{@render addAffordance(0)}
|
|
898
|
-
<!-- A card and the gap under it are one
|
|
802
|
+
<!-- A card and the gap under it are one slot, which is what a reorder moves: the
|
|
899
803
|
strip below a card is the same strip wherever the card lands, so it rides along
|
|
900
804
|
rather than being slid across. It is also the shape `animate:` asks for, being the
|
|
901
805
|
keyed block's only child. -->
|
|
@@ -906,7 +810,6 @@
|
|
|
906
810
|
card={c}
|
|
907
811
|
{doc}
|
|
908
812
|
{quill}
|
|
909
|
-
index={i}
|
|
910
813
|
isFirst={i === 0}
|
|
911
814
|
isLast={i === model.cards.length - 1}
|
|
912
815
|
{kinds}
|
|
@@ -925,47 +828,45 @@
|
|
|
925
828
|
|
|
926
829
|
<FormatPopover {getActiveLeaf} />
|
|
927
830
|
|
|
928
|
-
<!--
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
decision, and two copies of it drift into a stack with a gap at one end. -->
|
|
831
|
+
<!-- The add affordance is gated on the schema declaring `card_kinds`, there being
|
|
832
|
+
nothing to seed otherwise. Cards render regardless: a kind with no schema degrades to
|
|
833
|
+
a recovery shell inside <Card> (retype + delete), never gated away, so its content is
|
|
834
|
+
neither dropped nor trapped. The gate lives here rather than at each call site: the
|
|
835
|
+
strip is one decision, and two copies of it drift into a stack with a gap at one
|
|
836
|
+
end. -->
|
|
935
837
|
{#snippet addAffordance(atIndex: number)}
|
|
936
838
|
{#if kinds.length}
|
|
937
|
-
<!-- The strip past the last card is the
|
|
839
|
+
<!-- The strip past the last card is the append point, the one a reader looks for
|
|
938
840
|
rather than finds by position, so it alone states itself in words at rest. The
|
|
939
|
-
gaps between cards stay bare: the strip
|
|
841
|
+
gaps between cards stay bare: the strip is the gap, and the pill each fills on
|
|
940
842
|
hover draws what a label would state, the space the new card takes. -->
|
|
941
843
|
{@const marked = atIndex === model.cards.length}
|
|
942
844
|
<div class="qm-add-card" class:qm-add-card-marked={marked}>
|
|
943
|
-
<!-- Marked, the words
|
|
944
|
-
them; bare, the
|
|
945
|
-
carry
|
|
845
|
+
<!-- Marked, the words are the accessible name and no `aria-label` doubles
|
|
846
|
+
them; bare, the same words land as the label, the strip having no
|
|
847
|
+
geometry to carry them. -->
|
|
946
848
|
{#if kinds.length === 1}
|
|
947
849
|
<button
|
|
948
850
|
type="button"
|
|
949
|
-
class="qm-add-btn qm-add-affordance"
|
|
950
|
-
aria-label={marked ? undefined : merged.
|
|
951
|
-
onclick={() => addCard(atIndex, kinds[0])}
|
|
952
|
-
>{marked ? merged.addCardMark(humanize(kinds[0])) : ''}</button
|
|
851
|
+
class="qm-add-btn qm-add-affordance qm-tap-floor"
|
|
852
|
+
aria-label={marked ? undefined : merged.addCard}
|
|
853
|
+
onclick={() => addCard(atIndex, kinds[0])}>{marked ? merged.addCard : ''}</button
|
|
953
854
|
>
|
|
954
855
|
{:else}
|
|
955
|
-
<!-- Multi-kind add: pick the kind, then seed + insert. A
|
|
856
|
+
<!-- Multi-kind add: pick the kind, then seed + insert. A menu rather than a
|
|
956
857
|
disclosure: it floats out of the stack, so raising it moves no card, and
|
|
957
858
|
it dismisses on pick, on Escape and on a click outside, none of which a
|
|
958
859
|
`<details>` does. The trigger is bits-ui's `<button>`, which is why the
|
|
959
860
|
rules below reach it through `:global`. -->
|
|
960
861
|
<DropdownMenu.Root>
|
|
961
862
|
<DropdownMenu.Trigger
|
|
962
|
-
class="qm-add-btn qm-add-affordance"
|
|
863
|
+
class="qm-add-btn qm-add-affordance qm-tap-floor"
|
|
963
864
|
aria-label={marked ? undefined : merged.addCard}
|
|
964
|
-
>{marked ? merged.
|
|
865
|
+
>{marked ? merged.addCard : ''}</DropdownMenu.Trigger
|
|
965
866
|
>
|
|
966
867
|
<DropdownMenu.Portal to={rootEl}>
|
|
967
868
|
<DropdownMenu.Content sideOffset={4}>
|
|
968
|
-
<!-- Portalled out of the row but
|
|
869
|
+
<!-- Portalled out of the row but into the stack's root, and carrying the
|
|
969
870
|
marker itself: floating is still a detached subtree to the
|
|
970
871
|
derivation, like FormatPopover and the enum listbox. -->
|
|
971
872
|
<div class="qm-menu-surface" data-qm-root>
|
|
@@ -984,44 +885,46 @@
|
|
|
984
885
|
{/snippet}
|
|
985
886
|
|
|
986
887
|
<style>
|
|
987
|
-
/*
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
888
|
+
/* A detached root, carrying `data-qm-root` for the derivation core/theme.css applies
|
|
889
|
+
to one — the baseline font and colour included, so nothing here restates them.
|
|
890
|
+
|
|
891
|
+
The column, not only the cards in it: the gutter the stack sits in and the tone
|
|
892
|
+
behind it are the surface's own, so a bare `<div>` is a mounting site and nothing
|
|
893
|
+
is owed before the editor looks right. Consumer CSS is unlayered and beats all of
|
|
894
|
+
it, which is how a host that wants the column back takes it. `border-box`, so a
|
|
895
|
+
height from the caller is the height this draws.
|
|
896
|
+
|
|
897
|
+
The sunken rung, which is what makes a card an island: the cards are the base
|
|
898
|
+
plane and this is the one they float on, so the gutter reads as ground rather
|
|
899
|
+
than as more card (ARCHITECTURE §Styling). The inset is the widest rung on the
|
|
900
|
+
ramp, which is what gives the ground enough of itself to read as ground: at a
|
|
901
|
+
narrower one the stack meets whatever frames the surface and the islands are
|
|
902
|
+
back to being one block clipped at its edges. */
|
|
996
903
|
.qm-editor {
|
|
997
904
|
box-sizing: border-box;
|
|
998
905
|
display: flex;
|
|
999
906
|
flex-direction: column;
|
|
1000
907
|
gap: var(--_qm-space-2);
|
|
1001
|
-
padding: var(--_qm-space-
|
|
1002
|
-
background: var(--_qm-surface);
|
|
908
|
+
padding: var(--_qm-space-5);
|
|
909
|
+
background: var(--_qm-surface-sunken);
|
|
1003
910
|
color: var(--_qm-ink);
|
|
1004
911
|
}
|
|
1005
|
-
/*
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
nothing. The cards below do not shrink to fit it: a flex item's automatic
|
|
1022
|
-
minimum floors each at its own height, so the stack overflows and scrolls
|
|
1023
|
-
rather than squashing. `.qm-preview` states the same thing for the same
|
|
1024
|
-
reason, unconditionally, having no page form to keep. */
|
|
912
|
+
/* Opt-in, and the four popovers are the reason: each resolves its portal target with
|
|
913
|
+
`closest('[data-qm-root]')`, which is this element, so an `overflow` here clips the
|
|
914
|
+
menu a leaf raised. Mounted in a page that scrolls, nothing clips; mounted in a
|
|
915
|
+
pane that does, the host says so and takes the clipping it would have had from its
|
|
916
|
+
own scrolling frame either way. The tail rides with the scroller because it is only
|
|
917
|
+
meaningful under one: dead space below the last card, so it can be read mid-pane
|
|
918
|
+
rather than against the bottom edge.
|
|
919
|
+
|
|
920
|
+
The height is what makes the rest of the rule mean anything, and it is the pane's
|
|
921
|
+
rather than a number of ours: `overflow` on a box that grows to its content never
|
|
922
|
+
has anything to scroll, so the stack would run past the pane and be clipped by
|
|
923
|
+
whatever the host frames it with. Taking the pane's height is the whole of what
|
|
924
|
+
`.qm-pane` claims (THEMING §"Drop it in"), and it resolves to `auto` on a mounting
|
|
925
|
+
site with no height of its own, which is the page form kept. The cards do not
|
|
926
|
+
shrink to fit: a flex item's automatic minimum floors each at its own height, so
|
|
927
|
+
the stack overflows and scrolls rather than squashing. */
|
|
1025
928
|
.qm-editor.qm-pane {
|
|
1026
929
|
height: 100%;
|
|
1027
930
|
overflow: auto;
|
|
@@ -1029,7 +932,7 @@
|
|
|
1029
932
|
padding-block-end: var(--_qm-tail);
|
|
1030
933
|
}
|
|
1031
934
|
/* The stack's one gapless seam, which `TipsCard` draws and this holds the two
|
|
1032
|
-
blocks to. `main` has to paint
|
|
935
|
+
blocks to. `main` has to paint over the tip: a later sibling paints over an earlier
|
|
1033
936
|
one otherwise, and the tip's background would cover the corners it is there to
|
|
1034
937
|
fill. `isolation` keeps that z-index local, so one wrapper owns the ordering and no
|
|
1035
938
|
card below inherits an argument about it. The wrapper is a single flex item, so the
|
|
@@ -1050,57 +953,67 @@
|
|
|
1050
953
|
flex-direction: column;
|
|
1051
954
|
gap: var(--_qm-space-2);
|
|
1052
955
|
}
|
|
1053
|
-
/* The strip between two blocks, and the whole of it is the target: a gap is found
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
cards inserts. Absorbed rather than removed, because `gap` is also what separates
|
|
1062
|
-
the one seam no strip sits in: every card from the next under a quill declaring no
|
|
956
|
+
/* The strip between two blocks, and the whole of it is the target: a gap is found by
|
|
957
|
+
position, so reveal and hit region are the full-bleed row rather than a word to aim
|
|
958
|
+
at in the middle of it. Taking the editor's gap back on both sides is what makes it
|
|
959
|
+
the gutter rather than a control sitting in one, and the pill it fills on hover is
|
|
960
|
+
edge to edge the space the new card opens into. No band is held back as
|
|
961
|
+
miss-tolerance: gutter that reads as the trigger and inserts nothing unsays what the
|
|
962
|
+
fill claims. Absorbed rather than removed, because `gap` is also what separates the
|
|
963
|
+
one seam no strip sits in: every card from the next under a quill declaring no
|
|
1063
964
|
kinds, where the affordance does not render at all. */
|
|
1064
965
|
.qm-add-card {
|
|
1065
966
|
display: flex;
|
|
1066
967
|
margin-top: calc(var(--_qm-space-2) * -1);
|
|
1067
968
|
margin-bottom: calc(var(--_qm-space-2) * -1);
|
|
1068
969
|
}
|
|
1069
|
-
/* Unboxed, like every button, and not dashed: a
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
the strip
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
970
|
+
/* Unboxed, like every button, and not dashed: a dashed edge is the placeholder idiom
|
|
971
|
+
("nothing is here yet"), which on a button reads as disabled or as a drop target. It
|
|
972
|
+
stays honest in one place, the un-schemable card (`Card.svelte`), which is a state
|
|
973
|
+
rather than a control. A bare trigger rests invisible, so the fill is the whole of
|
|
974
|
+
what it shows: the pill fills the strip because the strip is what was pressed, and
|
|
975
|
+
the strip is the gap, so the fill is the card-to-be drawn where it will land.
|
|
976
|
+
|
|
977
|
+
No inset of its own: the pill and the gap are the same rectangle, and a bare strip is
|
|
978
|
+
a band of gutter, so its height is a rhythm rung. The press floor the box gives up is
|
|
979
|
+
`.qm-tap-floor`'s (controls.css); what it overhangs is the card's own inset above and
|
|
980
|
+
below, which holds no target, so the floor costs a neighbour nothing.
|
|
981
|
+
|
|
982
|
+
Ink is pinned to the label tone in every state, taking back the family's hover step:
|
|
983
|
+
the trigger arrives and leaves on opacity, and a tone moving under an opacity already
|
|
984
|
+
moving reads as two trips at different speeds.
|
|
1078
985
|
|
|
1079
986
|
`:global`, because the multi-kind trigger is bits-ui's own element and a `class`
|
|
1080
987
|
passed to a primitive is a plain string that never picks up the scoping hash:
|
|
1081
988
|
the same seam the enum trigger is styled through. */
|
|
1082
989
|
.qm-add-card :global(.qm-add-btn) {
|
|
1083
|
-
/* No inset of its own: the pill and the gap are the same rectangle, and the tap
|
|
1084
|
-
floor is the whole of the height, which is what the marked strip's words
|
|
1085
|
-
centre in. */
|
|
1086
990
|
display: flex;
|
|
1087
991
|
align-items: center;
|
|
1088
992
|
justify-content: center;
|
|
1089
993
|
width: 100%;
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
994
|
+
height: var(--_qm-space-4);
|
|
995
|
+
color: var(--_qm-ink-label);
|
|
996
|
+
opacity: 0;
|
|
997
|
+
transition:
|
|
998
|
+
opacity var(--_qm-reveal),
|
|
999
|
+
background-color var(--_qm-reveal);
|
|
1000
|
+
}
|
|
1001
|
+
/* The strip that holds words at rest takes the height that centres them, and rests dim
|
|
1002
|
+
rather than absent, having something to be seen. */
|
|
1094
1003
|
.qm-add-card-marked :global(.qm-add-btn) {
|
|
1004
|
+
height: var(--_qm-tap-min);
|
|
1095
1005
|
font-size: var(--_qm-text-label);
|
|
1006
|
+
font-weight: var(--_qm-weight-mid);
|
|
1007
|
+
opacity: var(--_qm-opacity-idle);
|
|
1096
1008
|
}
|
|
1097
|
-
/*
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
strip, so a pointer moving onto an item has left the row whose hover was drawing
|
|
1009
|
+
/* One arrival, three ways in. Keyboard focus, because the fill is the only thing that
|
|
1010
|
+
reads as the trigger on a bare strip; and an open menu, because it portals out of
|
|
1011
|
+
the strip, so a pointer moving onto an item has left the row whose hover was drawing
|
|
1101
1012
|
the pill the menu hangs from. */
|
|
1013
|
+
.qm-add-card :global(.qm-add-btn:hover),
|
|
1102
1014
|
.qm-add-card :global(.qm-add-btn:focus-visible),
|
|
1103
1015
|
.qm-add-card :global(.qm-add-btn[data-state='open']) {
|
|
1104
|
-
|
|
1016
|
+
opacity: 1;
|
|
1017
|
+
background: var(--_qm-add-fill);
|
|
1105
1018
|
}
|
|
1106
1019
|
</style>
|