@quario/editor 0.1.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/lib/index.js ADDED
@@ -0,0 +1,820 @@
1
+ /**
2
+ * @quario/editor — the embeddable banded document designer. Not a target: it
3
+ * compiles nothing itself and never touches the event stream. `<quario-editor>`
4
+ * takes a starter `schema` plus the host's engine and html target through
5
+ * properties, displays the report rendered on sample data as its design
6
+ * surface, and lets the author rearrange it structurally — every edit
7
+ * recompiles and re-renders through the real engine, so what the author sees
8
+ * is the report, not an approximation of it. The edited document reaches the
9
+ * host on every committed change, deep-frozen, with its problem list.
10
+ *
11
+ * Importing this module defines nothing: the element class is the export, and
12
+ * `@quario/editor/register` performs the one-line define
13
+ * (docs/adr/0005-the-surfaces-are-custom-elements.md).
14
+ *
15
+ * The engine and the renderer arrive as properties, not imports (`instance`,
16
+ * `target`), so the editor forces no runtime dependency on either and the
17
+ * licence stays on the instance where the §6 mechanics put it. The unlicensed
18
+ * marking is left exactly as the fragment carries it: it depends on no grant,
19
+ * and the editor adds no wording of its own.
20
+ */
21
+ import { Task, TaskStatus } from "@lit/task";
22
+ import { LitElement, html, nothing } from "lit";
23
+ import { tinykeys } from "tinykeys";
24
+ import { CHROME, SURFACE } from "./chrome.js";
25
+ import {
26
+ addGroupBand,
27
+ bandOfItem,
28
+ checkFunctions,
29
+ checkInstance,
30
+ checkPage,
31
+ checkScheme,
32
+ checkSchema,
33
+ checkTarget,
34
+ crumbsOf,
35
+ duplicateColumn,
36
+ duplicateItem,
37
+ indexOfItem,
38
+ insertColumn,
39
+ insertItem,
40
+ legalSlots,
41
+ moveColumn,
42
+ moveGroup,
43
+ moveItem,
44
+ nodeKindAt,
45
+ placeholders,
46
+ removeColumn,
47
+ removeGroup,
48
+ removeGroupBand,
49
+ removeItem,
50
+ resolve,
51
+ widenHeader,
52
+ writeField,
53
+ } from "./document.js";
54
+ import { history } from "./history.js";
55
+ import { groupsSection, problemsSection, propertiesSection } from "./rail.js";
56
+ import { stage } from "./stage.js";
57
+ import { REPORT } from "./style.js";
58
+
59
+ /** The tag `@quario/editor/register` defines the element under. */
60
+ export const TAG = "quario-editor";
61
+
62
+ /** @type {(value: any) => any} */
63
+ let deepFreeze = (value) => {
64
+ if (value && typeof value === "object")
65
+ for (let key of Object.keys(value)) deepFreeze(value[key]);
66
+ return Object.freeze(value);
67
+ };
68
+
69
+ /** A column header's own key, or one of the keys inside its object form. */
70
+ /** @type {(key: string) => boolean} */
71
+ let isHeaderKey = (key) => key === "header" || key.startsWith("header.");
72
+ /** @type {(key: string) => string} */
73
+ let headerKey = (key) => (key === "header" ? "value" : key.slice("header.".length));
74
+
75
+ /** One step up a schema path: `detail[0].value` -> `detail[0]` -> `detail`.
76
+ * A path with nowhere left to climb answers "" and ends the walk. */
77
+ /** @type {(path: string) => string} */
78
+ let up = (path) => {
79
+ let above = path.replace(/(\.[^.[\]]+|\[\d+\])$/, "");
80
+ return above === path ? "" : above;
81
+ };
82
+
83
+ /** The error's own message: what the panel prints beside its label, and what
84
+ * `#checkOptions` compares by, since each check mints a fresh TypeError and
85
+ * the wording is the identity there. */
86
+ /** @type {(error: unknown) => string} */
87
+ let wording = (error) => String(/** @type {any} */ (error)?.message || error);
88
+
89
+ /** Whether an announcement says what the standing one already says. The same
90
+ * failure re-reported is the same failure: it owes the host no second event,
91
+ * and it must not re-open a panel the author has dismissed. Wording is the
92
+ * identity for the same reason `#checkOptions` compares by it — each failure
93
+ * mints a fresh error object, so the objects never match. */
94
+ /** @type {(failure: { label: string, error: unknown } | undefined, label: string, error: unknown) => boolean} */
95
+ let repeats = (failure, label, error) =>
96
+ !!failure && failure.label === label && wording(failure.error) === wording(error);
97
+
98
+ /** How long typing has to stop before it counts as a finished gesture. Yjs's
99
+ * `captureTimeout` default: short enough that one sentence is one entry, long
100
+ * enough that a thinking pause mid-expression is not five. */
101
+ let PAUSE = 500;
102
+
103
+ /** Whether two documents say the same thing. Every write hands back a fresh
104
+ * clone, so identity cannot tell a gesture that changed something from one
105
+ * that changed nothing — and the pause that commits typing is followed by the
106
+ * blur that would otherwise commit it again. Cheap beside the recompile every
107
+ * edit already pays. */
108
+ /** @type {(a: any, b: any) => boolean} */
109
+ let same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
110
+
111
+ /** Whether this write is a column's own header template, on a header still in
112
+ * its shorthand string form. */
113
+ /** @type {(base: any, path: string, key: string) => boolean} */
114
+ let shorthand = (base, path, key) =>
115
+ key === "header" && typeof resolve(base, path)?.header === "string";
116
+
117
+ /** A column's shorthand string header stays shorthand for its own template;
118
+ * anything else about it widens the header first, and nothing narrows it back
119
+ * — `{ value: "Product" }` is a legal resting state. */
120
+ /** @type {(base: any, path: string, key: string, value: any) => any} */
121
+ let headerWritten = (base, path, key, value) =>
122
+ shorthand(base, path, key)
123
+ ? writeField(base, path, "header", value)
124
+ : writeField(widenHeader(base, path), path + ".header", headerKey(key), value);
125
+
126
+ /** One field of `base` written, wherever that field actually lives. */
127
+ /** @type {(base: any, path: string, key: string, value: any) => any} */
128
+ let written = (base, path, key, value) =>
129
+ isHeaderKey(key) ? headerWritten(base, path, key, value) : writeField(base, path, key, value);
130
+
131
+ /** Where a dropped item lands, mirroring `moveItem`'s own arithmetic. */
132
+ /** @type {(path: string, slot: { band: string, index: number }) => string} */
133
+ let landedAt = (path, slot) => {
134
+ let shifts = bandOfItem(path) === slot.band && indexOfItem(path) < slot.index;
135
+ return slot.band + "[" + (shifts ? slot.index - 1 : slot.index) + "]";
136
+ };
137
+
138
+ /**
139
+ * The editor element. Hosts assign `schema` (the starter document — the
140
+ * editor is uncontrolled and owns the document thereafter), `instance` (a
141
+ * quario instance), `target` (the html target, made with `paths: true`),
142
+ * plus `functions`, `data`, `page` and `colorScheme`, and listen for `change`
143
+ * and `error`; `renderComplete` is the one awaitable.
144
+ *
145
+ * Reassignment semantics differ per property, deliberately: only a **new**
146
+ * `schema` object resets the document, history and selection — assigning back
147
+ * the object the last `change` carried (or the one already adopted) is a
148
+ * no-op, which is the write-back guard a naive persist-and-restore loop
149
+ * needs. A new `instance`, `target`, `functions`, `data` or `page` recompiles
150
+ * and re-renders while leaving the author's work untouched; `colorScheme`
151
+ * repaints chrome without re-rendering.
152
+ *
153
+ * Removal is not destruction: disconnecting abandons in-flight work, the
154
+ * document and history persist, and reconnecting re-renders from them.
155
+ */
156
+ export class QuarioEditor extends LitElement {
157
+ static styles = [CHROME, SURFACE, REPORT];
158
+
159
+ static properties = {
160
+ schema: { attribute: false },
161
+ instance: { attribute: false },
162
+ target: { attribute: false },
163
+ functions: { attribute: false },
164
+ data: { attribute: false },
165
+ page: { attribute: false },
166
+ colorScheme: { attribute: false },
167
+ };
168
+
169
+ /** The working document: what the sheet and the problem list answer to. It
170
+ * follows the author continuously — per keystroke, per colour-picker step —
171
+ * so correctness is reported live. May be invalid; that is legitimate and
172
+ * undoable. */
173
+ /** @type {any} */
174
+ #doc;
175
+ /** The live edit in flight: the field it is writing — both halves, since a
176
+ * `value` on one node is not a `value` on another — and the pause that will
177
+ * commit it. Undefined when the working and committed documents agree. */
178
+ /** @type {{ path: string, key: string, timer: ReturnType<typeof setTimeout> } | undefined} */
179
+ #pending;
180
+ /** @type {ReturnType<typeof history> | undefined} */
181
+ #history;
182
+ /** @type {string | null} */
183
+ #selection = null;
184
+ #revealed = false;
185
+ /** @type {readonly any[]} */
186
+ #problems = [];
187
+ /** @type {Record<string, readonly string[]>} */
188
+ #anchors = {};
189
+ /** Bumped per edit, live or committed; the task's argument for document
190
+ * changes. */
191
+ #epoch = 0;
192
+ /** The schema identities that mean "the document already here". */
193
+ /** @type {any} */
194
+ #adopted;
195
+ /** @type {any} */
196
+ #emitted;
197
+ /** Whether the next settled validation owes the host a change event. */
198
+ #owes = false;
199
+ /** Whether a fragment ever reached the sheet. */
200
+ #landed = false;
201
+ #stale = false;
202
+ /** @type {unknown} */
203
+ #invalid;
204
+ /** @type {{ label: string, error: unknown } | undefined} */
205
+ #failure;
206
+ #dismissed = false;
207
+ /** @type {(() => void) | undefined} */
208
+ #unbind;
209
+
210
+ #stage = stage({
211
+ select: (path, el) => this.#select(path, el),
212
+ slots: (path) => legalSlots(this.#doc, this.#anchors, path),
213
+ drop: (path, slot) => {
214
+ // The moved item stays selected, at the index `moveItem` actually put
215
+ // it: removing from an earlier position in the same band shifts the
216
+ // insertion point left, and the selection has to shift with it.
217
+ this.#commit(moveItem(this.#doc, path, slot), landedAt(path, slot));
218
+ },
219
+ dropColumn: (at, to) => {
220
+ let next = moveColumn(this.#doc, at, to);
221
+ this.#commit(next, "detail.columns[" + (at < to ? to - 1 : to) + "]");
222
+ },
223
+ insert: (band, index) => {
224
+ this.#commit(insertItem(this.#doc, band, index), band + "[" + index + "]");
225
+ },
226
+ remove: (path) => {
227
+ this.#commit(removeItem(this.#doc, path), null);
228
+ },
229
+ duplicate: (path) => {
230
+ // The copy lands right after its original, and takes the selection.
231
+ let index = indexOfItem(path) + 1;
232
+ this.#commit(duplicateItem(this.#doc, path), bandOfItem(path) + "[" + index + "]");
233
+ },
234
+ });
235
+
236
+ // The rail's three seams, built once rather than per render: they close over
237
+ // nothing but `this`, and the panel re-renders on every keystroke now.
238
+ #properties = {
239
+ edit: {
240
+ touch: (/** @type {string} */ key, /** @type {any} */ value) => this.#touch(key, value),
241
+ settle: (/** @type {string} */ key, /** @type {any} */ value) => this.#settle(key, value),
242
+ write: (/** @type {string} */ key, /** @type {any} */ value) => this.#write(key, value),
243
+ },
244
+ insertColumn: () => this.#columns((at) => insertColumn(this.#doc, at + 1), 1),
245
+ removeColumn: () => this.#columns((at) => removeColumn(this.#doc, at), 0),
246
+ duplicateColumn: () => this.#columns((at) => duplicateColumn(this.#doc, at), 1),
247
+ };
248
+
249
+ #groups = {
250
+ select: (/** @type {string} */ path) => this.#select(path, null),
251
+ move: (/** @type {number} */ at, /** @type {number} */ to) =>
252
+ this.#commit(moveGroup(this.#doc, at, to), null),
253
+ remove: (/** @type {number} */ at, /** @type {any} */ mode) =>
254
+ this.#commit(removeGroup(this.#doc, at, mode), null),
255
+ band: (/** @type {number} */ at, /** @type {any} */ which, /** @type {boolean} */ add) =>
256
+ this.#band(at, which, add),
257
+ };
258
+
259
+ #faults = { select: (/** @type {string} */ path) => this.#selectProblem(path) };
260
+
261
+ // The whole plan/render pipeline: one task, keyed on the host properties
262
+ // plus the document epoch, coalesced by Lit — validation runs once per
263
+ // element update cycle, no hand-rolled debounce. Side effects live only in
264
+ // the callbacks, which the task's call-id guard restricts to the newest
265
+ // run.
266
+ #task = new Task(this, {
267
+ args: () => [this.instance, this.target, this.functions, this.data, this.#epoch],
268
+ task: async ([instance, target, functions, data], { signal }) => {
269
+ if (!this.#ready(instance, target)) return this.#idle();
270
+ let planned = /** @type {any} */ (instance).plan(this.#doc, functions);
271
+ // A document with problems compiles to nothing; the sheet keeps the
272
+ // last version that did.
273
+ let fragment = planned.report ? await planned.report.render(target, data) : null;
274
+ // The engine takes no signal, so abandonment is the guard around this
275
+ // body; it only spares the swap when the answer arrives after a
276
+ // disconnect mid-render.
277
+ return this.#dropped(signal) ? this.#abandon() : { planned, fragment };
278
+ },
279
+ onComplete: (result) => {
280
+ if (result === null || !this.isConnected) return;
281
+ this.#problems = result.planned.problems;
282
+ this.#anchors = result.planned.anchors;
283
+ if (result.fragment != null) this.#land(result.fragment);
284
+ this.#placeholders();
285
+ this.#deliver();
286
+ this.requestUpdate();
287
+ },
288
+ onError: (error) => {
289
+ if (!this.isConnected) return;
290
+ this.#announce(error, this.#landed ? "update-render" : "mount-render");
291
+ this.#deliver();
292
+ },
293
+ });
294
+
295
+ constructor() {
296
+ super();
297
+ // Constructor assignment, never class fields: the source ships verbatim,
298
+ // so a declared field would shadow the accessor `static properties`
299
+ // installs and reactivity would go quiet.
300
+ /** @type {any} */
301
+ this.schema = undefined;
302
+ /** @type {any} */
303
+ this.instance = undefined;
304
+ /** @type {any} */
305
+ this.target = undefined;
306
+ /** @type {Record<string, Function> | undefined} */
307
+ this.functions = undefined;
308
+ /** @type {unknown} */
309
+ this.data = undefined;
310
+ /** @type {import('./index.d.ts').EditorPage | undefined} */
311
+ this.page = undefined;
312
+ /** @type {"light" | "dark" | "auto" | undefined} */
313
+ this.colorScheme = undefined;
314
+ // The stage receives keyboard focus on interaction, so the shortcuts are
315
+ // live exactly while focus is inside the element.
316
+ this.#stage.element.tabIndex = -1;
317
+ this.#stage.element.addEventListener("pointerdown", () => this.#stage.element.focus());
318
+ }
319
+
320
+ /**
321
+ * The newest render settling: `true` when it reached the sheet. Never
322
+ * rejects — a failed render is handled, on the panel and through the error
323
+ * event.
324
+ *
325
+ * @returns {Promise<boolean>}
326
+ */
327
+ get renderComplete() {
328
+ return this.updateComplete.then(() =>
329
+ this.#task.status === TaskStatus.INITIAL
330
+ ? this.#landed
331
+ : this.#task.taskComplete.then(
332
+ () => this.#landed,
333
+ () => false,
334
+ ),
335
+ );
336
+ }
337
+
338
+ /** The properties whose validity is re-derived per update cycle. */
339
+ static #CHECKED = ["instance", "target", "functions", "page", "colorScheme"];
340
+
341
+ /** @param {Map<string, unknown>} changed */
342
+ willUpdate(changed) {
343
+ let touched = (/** @type {string[]} */ keys) =>
344
+ !this.hasUpdated || keys.some((key) => changed.has(key));
345
+ if (touched(["schema"])) this.#adopt();
346
+ if (touched(QuarioEditor.#CHECKED)) this.#checkOptions(changed);
347
+ }
348
+
349
+ // The breadcrumb is load-bearing, not decoration: clicking a group header in
350
+ // the preview selects the item inside it, so a container needs another route.
351
+ #crumbs() {
352
+ let crumbs = this.#selection ? crumbsOf(this.#doc, this.#selection) : [];
353
+ return crumbs.map((path, i) =>
354
+ i === crumbs.length - 1
355
+ ? html`<code class="qe-crumb qe-here">${path}</code>`
356
+ : html`<button class="qe-button qe-crumb" @click=${() => this.#select(path, null)}>
357
+ ${path}</button
358
+ ><span>›</span>`,
359
+ );
360
+ }
361
+
362
+ #bar() {
363
+ return html`<div class="qe-bar">
364
+ <button
365
+ class="qe-button"
366
+ title="Undo"
367
+ ?disabled=${!this.#history?.canUndo}
368
+ @click=${() => this.#undo()}
369
+ >
370
+
371
+ </button>
372
+ <button
373
+ class="qe-button"
374
+ title="Redo"
375
+ ?disabled=${!this.#history?.canRedo}
376
+ @click=${() => this.#redo()}
377
+ >
378
+
379
+ </button>
380
+ <span class="qe-crumbs">${this.#crumbs()}</span>
381
+ <button
382
+ class="qe-button"
383
+ title="Reveal what the render leaves out"
384
+ aria-pressed=${this.#revealed ? "true" : "false"}
385
+ @click=${() => this.#reveal()}
386
+ >
387
+ Reveal
388
+ </button>
389
+ </div>`;
390
+ }
391
+
392
+ #rail() {
393
+ return html`<div class="qe-rail">
394
+ ${propertiesSection(this.#doc, this.#selection, this.#problems, this.#properties)}
395
+ ${groupsSection(this.#doc, this.#groups)}
396
+ ${problemsSection(this.#problems, this.#faults)}
397
+ </div>`;
398
+ }
399
+
400
+ /** Add or remove a group's header or footer band — the explicit verbs the
401
+ * absent-is-not-empty rule leaves as the only way to move one. */
402
+ /** @type {(at: number, which: "header" | "footer", add: boolean) => void} */
403
+ #band(at, which, add) {
404
+ let next = add ? addGroupBand(this.#doc, at, which) : removeGroupBand(this.#doc, at, which);
405
+ this.#commit(next, "groups[" + at + "]");
406
+ }
407
+
408
+ render() {
409
+ let busy = this.#task.status === TaskStatus.PENDING;
410
+ return html`
411
+ <div class="qe-editor" aria-busy=${String(busy)}>
412
+ ${this.#bar()}
413
+ <div class="qe-body">${this.#panel()} ${this.#stage.element} ${this.#rail()}</div>
414
+ </div>
415
+ `;
416
+ }
417
+
418
+ connectedCallback() {
419
+ super.connectedCallback();
420
+ // Mod+Z / Shift+Mod+Z / Escape, live only while focus is inside the
421
+ // element; tinykeys' default ignore rule keeps them inert inside text
422
+ // fields, so the browser's own undo owns typing.
423
+ this.#unbind ??= tinykeys(this, {
424
+ "$mod+KeyZ": (event) => {
425
+ if (this.#typing(event)) return;
426
+ event.preventDefault();
427
+ this.#undo();
428
+ },
429
+ "$mod+Shift+KeyZ": (event) => {
430
+ if (this.#typing(event)) return;
431
+ event.preventDefault();
432
+ this.#redo();
433
+ },
434
+ Escape: () => {
435
+ if (!this.#stage.cancel() && this.#selection) this.#select(null, null);
436
+ },
437
+ });
438
+ this.#wake();
439
+ }
440
+
441
+ disconnectedCallback() {
442
+ super.disconnectedCallback();
443
+ // A pending live edit is deliberately left alone: its pause is an ordinary
444
+ // timer that keeps running across the disconnect, and removal is not
445
+ // destruction — the document, history and the edit in flight all persist.
446
+ this.#task.abort();
447
+ this.#unbind?.();
448
+ this.#unbind = undefined;
449
+ }
450
+
451
+ // tinykeys ignores key events retargeted from inputs only when they carry
452
+ // the elements themselves as targets; composed shadow events retarget to
453
+ // the host, so the guard re-derives the real target.
454
+ /** @param {KeyboardEvent} event */
455
+ #typing(event) {
456
+ let target = /** @type {any} */ (event.composedPath?.()[0] ?? event.target);
457
+ return !!target?.closest?.("input, textarea, select, [contenteditable]");
458
+ }
459
+
460
+ #abandon() {
461
+ this.#stale = true;
462
+ return null;
463
+ }
464
+
465
+ /**
466
+ * Whether there is anything to plan: a host mistake fails loudly even with
467
+ * nothing assigned yet, so a misconfigured host is caught in development.
468
+ *
469
+ * @param {unknown} instance
470
+ * @param {unknown} target
471
+ */
472
+ #ready(instance, target) {
473
+ if (this.#invalid) throw this.#invalid;
474
+ return instance !== undefined && target !== undefined && this.#doc !== undefined;
475
+ }
476
+
477
+ /** @param {AbortSignal} signal */
478
+ #dropped(signal) {
479
+ return signal.aborted && !this.isConnected;
480
+ }
481
+
482
+ /** Nothing to show — and a disconnected element abandons rather than hides. */
483
+ #idle() {
484
+ return this.isConnected ? null : this.#abandon();
485
+ }
486
+
487
+ /** A fragment that compiled reaches the sheet, and takes the panel down. */
488
+ /** @param {string} fragment */
489
+ #land(fragment) {
490
+ this.#stage.swap(fragment);
491
+ this.#landed = true;
492
+ this.#stale = false;
493
+ this.#failure = undefined;
494
+ this.#dismissed = false;
495
+ this.#checkIdentity();
496
+ this.#stage.select(this.#selection);
497
+ }
498
+
499
+ #wake() {
500
+ if (this.#stale || this.#task.status === TaskStatus.PENDING) {
501
+ this.#epoch++;
502
+ this.requestUpdate();
503
+ }
504
+ }
505
+
506
+ /** Only a new schema identity resets; the write-back guard lives here. */
507
+ #adopt() {
508
+ let value = checkSchemaSafe(this.schema, (error) => {
509
+ this.#invalid = error;
510
+ });
511
+ if (value === undefined) return;
512
+ if (value === this.#adopted || value === this.#emitted) return;
513
+ this.#adopted = value;
514
+ this.#calm();
515
+ this.#doc = structuredClone(value);
516
+ this.#history = history(this.#doc, null);
517
+ this.#selection = null;
518
+ this.#problems = [];
519
+ this.#epoch++;
520
+ }
521
+
522
+ /** @param {Map<string, unknown>} changed */
523
+ #checkOptions(changed) {
524
+ let was = this.#invalid;
525
+ this.#invalid = undefined;
526
+ /** @type {(run: () => void) => void} */
527
+ let take = (run) => {
528
+ try {
529
+ run();
530
+ } catch (error) {
531
+ this.#invalid ??= error;
532
+ }
533
+ };
534
+ take(() => checkInstance(this.instance));
535
+ take(() => checkTarget(this.target));
536
+ take(() => checkFunctions(this.functions));
537
+ take(() => {
538
+ let box = checkPage(this.page);
539
+ if (!this.hasUpdated || changed.has("page")) this.#stage.resize(box);
540
+ });
541
+ take(() => {
542
+ let used = checkScheme(this.colorScheme);
543
+ if (!this.hasUpdated || changed.has("colorScheme")) this.style.colorScheme = used;
544
+ });
545
+ if (wording(was) !== wording(this.#invalid)) this.#epoch++;
546
+ }
547
+
548
+ /** One committed gesture, from anywhere but the field being typed into: a
549
+ * live edit still pending belongs to a gesture of its own and lands first.
550
+ * An operation that refused — `removeColumn` on the last column — hands
551
+ * back the document it was given, so nothing happened and the pending edit
552
+ * stays pending; that is the one case this identity guard still earns, since
553
+ * `#enter` answers the equal-content question on its own. */
554
+ /** @type {(next: any, selection: string | null) => void} */
555
+ #commit(next, selection) {
556
+ if (next === this.#doc) return;
557
+ this.#flush();
558
+ this.#enter(next, selection);
559
+ }
560
+
561
+ /** One history entry, one change event owed — unless the gesture said what
562
+ * the document already says, which is what a pause followed by a blur says
563
+ * twice. The working document still moves, so a live edit that wandered
564
+ * away and came back lands back in step. */
565
+ /** @type {(next: any, selection: string | null) => void} */
566
+ #enter(next, selection) {
567
+ this.#doc = next;
568
+ this.#selection = selection === undefined ? this.#selection : selection;
569
+ this.#epoch++;
570
+ this.requestUpdate();
571
+ if (same(next, this.#committed)) return;
572
+ this.#history?.commit(next, this.#selection);
573
+ this.#owes = true;
574
+ }
575
+
576
+ /** The committed document: the last thing that entered history, which is
577
+ * where history already keeps it (ADR 0031) — a second copy beside it would
578
+ * be two places to remember. This is the base every live write derives
579
+ * from, never the previous live document: `writeField` removes an optional
580
+ * key on an empty value and re-appends it later, so chaining live writes
581
+ * would reorder `style` the moment a field is selected-all and retyped. */
582
+ get #committed() {
583
+ return this.#history?.current.schema;
584
+ }
585
+
586
+ /** Stop the pause and forget what it was going to commit. */
587
+ #calm() {
588
+ if (this.#pending) clearTimeout(this.#pending.timer);
589
+ this.#pending = undefined;
590
+ }
591
+
592
+ /** The pending live edit becomes its own history entry — because the author
593
+ * stopped typing, or because something else is about to commit. */
594
+ #flush() {
595
+ if (!this.#pending) return;
596
+ this.#calm();
597
+ this.#enter(this.#doc, this.#selection);
598
+ }
599
+
600
+ #undo() {
601
+ // Typing then undoing undoes the typing: the pending edit lands first, so
602
+ // the step back is over it rather than over the gesture before it.
603
+ this.#flush();
604
+ let entry = this.#history?.undo();
605
+ if (!entry) return;
606
+ this.#restore(entry);
607
+ }
608
+
609
+ #redo() {
610
+ // Typing after an undo ends the redo road, as it does in every editor:
611
+ // the flush commits, and committing is what clears the redo stack.
612
+ this.#flush();
613
+ let entry = this.#history?.redo();
614
+ if (!entry) return;
615
+ this.#restore(entry);
616
+ }
617
+
618
+ /** @param {{ schema: any, selection: string | null }} entry */
619
+ #restore(entry) {
620
+ this.#doc = structuredClone(entry.schema);
621
+ this.#selection = entry.selection;
622
+ this.#stage.select(entry.selection);
623
+ this.#owes = true;
624
+ this.#epoch++;
625
+ this.requestUpdate();
626
+ }
627
+
628
+ /** The committed document reaches the host frozen, with its problems. */
629
+ #deliver() {
630
+ if (!this.#owes) return;
631
+ this.#owes = false;
632
+ let schema = deepFreeze(structuredClone(this.#doc));
633
+ this.#emitted = schema;
634
+ this.dispatchEvent(new CustomEvent("change", { detail: { schema, problems: this.#problems } }));
635
+ }
636
+
637
+ /** @type {(path: string | null, el: Element | null) => void} */
638
+ #select(path, el) {
639
+ // Selecting elsewhere ends the gesture: the pending edit lands under the
640
+ // path it was made on, not the one about to replace it. Clicking the
641
+ // sheet blurs the field and commits it first, but a rail button — a
642
+ // problem, a group — need not, and on some browsers does not.
643
+ this.#flush();
644
+ this.#selection = path;
645
+ this.#stage.select(path, el ?? null);
646
+ this.requestUpdate();
647
+ }
648
+
649
+ /** A problem's path selects the nearest selectable node behind it. */
650
+ /** @type {(path: string) => void} */
651
+ #selectProblem(path) {
652
+ let at = this.#selectable(path);
653
+ if (at) this.#select(at, null);
654
+ }
655
+
656
+ /** Climb a problem's path until it names a node the panel can edit. */
657
+ /** @type {(path: string) => string | null} */
658
+ #selectable(path) {
659
+ let at = path;
660
+ while (at && !nodeKindAt(this.#doc, at)) at = up(at);
661
+ return at || null;
662
+ }
663
+
664
+ #reveal() {
665
+ this.#revealed = !this.#revealed;
666
+ this.#placeholders();
667
+ this.requestUpdate();
668
+ }
669
+
670
+ #placeholders() {
671
+ this.#stage.revealPlaceholders(
672
+ placeholders(this.#doc ?? {}, this.#stage.rendered()),
673
+ this.#revealed,
674
+ );
675
+ }
676
+
677
+ /** Whether the live edit in flight is this very field, so ending its gesture
678
+ * settles what it already wrote rather than entering that first. */
679
+ /** @type {(path: string, key: string) => boolean} */
680
+ #mine(path, key) {
681
+ return this.#pending?.path === path && this.#pending.key === key;
682
+ }
683
+
684
+ /** A typed control's own gesture ending: a text field blurred or Entered, a
685
+ * colour picker released, a select changed. When it is the field with a live
686
+ * edit pending, the `touch`es before it were building toward this and settle
687
+ * into it; otherwise it is an ordinary commit. */
688
+ /** @type {(key: string, value: any) => void} */
689
+ #settle(key, value) {
690
+ let path = this.#selection;
691
+ if (!path) return;
692
+ if (!this.#mine(path, key)) return this.#commit(written(this.#doc, path, key, value), path);
693
+ // The pending edits were building toward this one, so they settle into it
694
+ // rather than landing ahead of it — that is what makes a burst one entry.
695
+ this.#calm();
696
+ this.#enter(written(this.#committed, path, key, value), path);
697
+ }
698
+
699
+ /** A discrete gesture in one act — a checkbox, an alignment button, an fx
700
+ * toggle. Always a commit, never a settle, even when it carries the key of
701
+ * the field being typed into: interrupting typing to press fx must leave the
702
+ * typing behind as its own entry rather than swallow it. */
703
+ /** @type {(key: string, value: any) => void} */
704
+ #write(key, value) {
705
+ let path = this.#selection;
706
+ if (!path) return;
707
+ this.#commit(written(this.#doc, path, key, value), path);
708
+ }
709
+
710
+ /** One live field edit from the panel: the working document follows the
711
+ * author and the pause will commit it — no history entry, no change event,
712
+ * since only commits are observable outside the element. Always derived
713
+ * from the committed document, so the intermediate states of one gesture
714
+ * never compound. */
715
+ /** @type {(key: string, value: any) => void} */
716
+ #touch(key, value) {
717
+ let path = this.#selection;
718
+ if (!path) return;
719
+ this.#calm();
720
+ this.#doc = written(this.#committed, path, key, value);
721
+ this.#epoch++;
722
+ this.#pending = { path, key, timer: setTimeout(() => this.#flush(), PAUSE) };
723
+ this.requestUpdate();
724
+ }
725
+
726
+ /** Column verbs act on the selected column (or the table's end). */
727
+ /** @type {(run: (at: number) => any, land: number) => void} */
728
+ #columns(run, land) {
729
+ let at = this.#columnAt();
730
+ let next = run(at);
731
+ let count = next?.detail?.columns?.length ?? 0;
732
+ let index = Math.min(at + land, Math.max(count - 1, 0));
733
+ this.#commit(next, count ? "detail.columns[" + index + "]" : null);
734
+ }
735
+
736
+ /** The column a verb acts on: the selected one, or the table's last. */
737
+ #columnAt() {
738
+ let selected = Number(this.#selection?.match(/^detail\.columns\[(\d+)\]$/)?.[1] ?? NaN);
739
+ return Number.isNaN(selected) ? this.#lastColumn() : selected;
740
+ }
741
+
742
+ #lastColumn() {
743
+ return (this.#doc?.detail?.columns?.length ?? 1) - 1;
744
+ }
745
+
746
+ /** A fragment with content but no identity cannot be edited by clicking —
747
+ * a host that passed `html()` without `paths` hears it here, near the
748
+ * mistake, instead of wondering why selection is dead. */
749
+ #checkIdentity() {
750
+ if (this.#stage.identified() || this.#failure || !this.#renders()) return;
751
+ this.#announce(
752
+ new TypeError("quario-editor: target: pass html({ paths: true }) so output maps to schema"),
753
+ "target",
754
+ );
755
+ }
756
+
757
+ /** Whether the document has anything that should have rendered at all. */
758
+ #renders() {
759
+ let filled = (/** @type {string} */ key) => {
760
+ let value = this.#doc?.[key];
761
+ return Array.isArray(value) && value.length > 0;
762
+ };
763
+ return this.#doc?.detail != null || ["header", "footer", "groups"].some(filled);
764
+ }
765
+
766
+ #dismiss() {
767
+ this.#dismissed = true;
768
+ this.requestUpdate();
769
+ }
770
+
771
+ #panel() {
772
+ if (!this.#failure || this.#dismissed) return nothing;
773
+ let { label, error } = this.#failure;
774
+ return html`<div class="qe-panel" role="alert">
775
+ <strong>${label}</strong>
776
+ <code>${wording(error)}</code>
777
+ <button
778
+ class="qe-button"
779
+ title="Dismiss"
780
+ style="margin-left:auto"
781
+ @click=${() => this.#dismiss()}
782
+ >
783
+ ×
784
+ </button>
785
+ </div>`;
786
+ }
787
+
788
+ /**
789
+ * Report one failure: the panel for the author, the event for the host —
790
+ * once per distinct failure, not once per attempt at it.
791
+ *
792
+ * @param {unknown} error
793
+ * @param {string} kind
794
+ */
795
+ #announce(error, kind) {
796
+ let label = LABELS[kind] ?? kind;
797
+ if (repeats(this.#failure, label, error)) return;
798
+ this.#failure = { error, label };
799
+ this.#dismissed = false;
800
+ this.requestUpdate();
801
+ this.dispatchEvent(new CustomEvent("error", { detail: { error, kind } }));
802
+ }
803
+ }
804
+
805
+ /** @type {Record<string, string>} */
806
+ let LABELS = {
807
+ "mount-render": "The report could not be displayed.",
808
+ "update-render": "The report could not be updated.",
809
+ target: "The editor cannot map this fragment to the schema.",
810
+ };
811
+
812
+ /** @type {(value: any, invalid: (error: unknown) => void) => any} */
813
+ let checkSchemaSafe = (value, invalid) => {
814
+ try {
815
+ return checkSchema(value);
816
+ } catch (error) {
817
+ invalid(error);
818
+ return undefined;
819
+ }
820
+ };