@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/CHANGELOG.md +39 -0
- package/LICENSE +219 -0
- package/README.md +89 -0
- package/lib/chrome.js +431 -0
- package/lib/document.js +674 -0
- package/lib/history.js +57 -0
- package/lib/index.d.ts +119 -0
- package/lib/index.js +820 -0
- package/lib/rail.js +506 -0
- package/lib/register.d.ts +9 -0
- package/lib/register.js +10 -0
- package/lib/stage.js +654 -0
- package/lib/style.js +85 -0
- package/package.json +77 -0
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
|
+
};
|