@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/stage.js ADDED
@@ -0,0 +1,654 @@
1
+ /**
2
+ * The design surface: the sheet the rendered fragment lands on, and the
3
+ * furniture drawn above it — selection outlines, the gutter grip, the drop
4
+ * caret, placeholders. Imperative on purpose, like the viewer's stage: the
5
+ * sheet's content must survive updates that fail, and lit-html leaves an
6
+ * interpolated node untouched.
7
+ *
8
+ * `swap()` is the editor's one markup edge: the html target's documented
9
+ * output injected the documented way, with `data-q-path` mapping every
10
+ * element back to its definition. One schema node renders many times, so one
11
+ * path has many rectangles; selecting any of them selects the definition, and
12
+ * dropping in any rectangle of a slot means the same edit.
13
+ *
14
+ * The gutter grip is a **hover region**, not an element summoned per item:
15
+ * the grip is drawn above the fragment, so the pointer must leave the item to
16
+ * reach its own handle — a pointer already over the grip must not clear it,
17
+ * and the grip is never rebuilt while it serves the same item (a rebuild
18
+ * swallows the pointerdown that lands between two frames). It is as tall as
19
+ * its item and overlaps its left edge, so the seam is not a dead zone.
20
+ */
21
+
22
+ import { bandOfItem, indexOfItem } from "./document.js";
23
+
24
+ /** How far a pointer may wobble before a press becomes a drag. */
25
+ let DRAG_SLOP = 4;
26
+
27
+ /** How far a placeholder looks for a sibling to sit against. Bounded because
28
+ * a band whose neighbours are all hidden has nothing to measure, and the
29
+ * stack under the sheet is the honest answer there. */
30
+ let SEARCH = 8;
31
+
32
+ /** The selectable element under an event target, innermost first. */
33
+ /** @type {(target: any) => any} */
34
+ let hit = (target) => target?.closest?.("[data-q-path]") ?? null;
35
+
36
+ /**
37
+ * @param {{
38
+ * select: (path: string | null, el: Element | null) => void,
39
+ * slots: (path: string) => { band: string, label: string, index: number, legal: boolean }[],
40
+ * drop: (path: string, slot: { band: string, index: number }) => void,
41
+ * dropColumn: (at: number, to: number) => void,
42
+ * insert: (band: string, index: number) => void,
43
+ * remove: (path: string) => void,
44
+ * duplicate: (path: string) => void,
45
+ * }} acts What each finished gesture means; the element owns the commits.
46
+ */
47
+ export function stage(acts) {
48
+ let root = document.createElement("div");
49
+ root.className = "qe-stage";
50
+ let canvas = document.createElement("div");
51
+ canvas.style.position = "relative";
52
+ let sheet = document.createElement("div");
53
+ sheet.className = "qe-sheet";
54
+ let overlay = document.createElement("div");
55
+ overlay.className = "qe-overlay";
56
+ canvas.append(sheet, overlay);
57
+ root.append(canvas);
58
+
59
+ /** @type {Map<string, Element[]>} path -> every rendered instance. */
60
+ let instances = new Map();
61
+ /** @type {string | null} */
62
+ let selected = null;
63
+ /** @type {string | null} */
64
+ let hovered = null;
65
+ /** @type {any} */
66
+ let drag = null;
67
+ /** @type {any} */
68
+ let column = null;
69
+ /** @type {{ el: HTMLElement, plus: HTMLElement, path: string } | null} */
70
+ let grip = null;
71
+ /** @type {HTMLElement | null} */
72
+ let menu = null;
73
+ /** @type {(
74
+ * { kind: "band", path: string } | { kind: "item", path: string, source: string }
75
+ * )[]} */
76
+ let ghosts = [];
77
+ let revealed = false;
78
+
79
+ let index = () => {
80
+ instances = new Map();
81
+ for (let el of sheet.querySelectorAll("[data-q-path]")) {
82
+ let path = /** @type {string} */ (el.getAttribute("data-q-path"));
83
+ let list = instances.get(path);
84
+ if (!list) instances.set(path, (list = []));
85
+ list.push(el);
86
+ }
87
+ };
88
+
89
+ /** @type {(el: Element) => { left: number, top: number, width: number, height: number }} */
90
+ let box = (el) => {
91
+ let r = el.getBoundingClientRect();
92
+ let c = canvas.getBoundingClientRect();
93
+ return { left: r.left - c.left, top: r.top - c.top, width: r.width, height: r.height };
94
+ };
95
+
96
+ /** @type {(cls: string, at: { left?: number, top?: number, width?: number, height?: number }) => HTMLElement} */
97
+ let add = (cls, at) => {
98
+ let el = document.createElement("div");
99
+ el.className = cls;
100
+ for (let key of /** @type {("left" | "top" | "width" | "height")[]} */ ([
101
+ "left",
102
+ "top",
103
+ "width",
104
+ "height",
105
+ ]))
106
+ if (at[key] != null) el.style.setProperty(key, at[key] + "px");
107
+ overlay.append(el);
108
+ return el;
109
+ };
110
+
111
+ let closeMenu = () => {
112
+ menu?.remove();
113
+ menu = null;
114
+ };
115
+
116
+ let dropGrip = () => {
117
+ grip?.el.remove();
118
+ grip?.plus.remove();
119
+ grip = null;
120
+ };
121
+
122
+ // --- selection -----------------------------------------------------------
123
+
124
+ /** @type {(cls: string, els: Element[]) => void} */
125
+ let paintClass = (cls, els) => {
126
+ for (let el of els) el.classList.add(cls);
127
+ };
128
+ /** @type {(cls: string) => void} */
129
+ let clearClass = (cls) => {
130
+ for (let el of sheet.querySelectorAll("." + cls)) el.classList.remove(cls);
131
+ };
132
+
133
+ /** The instance the pointer actually landed on, or the family's first. */
134
+ /** @type {(family: Element[], el?: Element | null) => Element | undefined} */
135
+ let struck = (family, el) => (el && family.includes(el) ? el : family[0]);
136
+
137
+ /** Definition, not instance: every rectangle of the path lights, the hit
138
+ * one strongly. Re-run after every swap so selection survives a re-render. */
139
+ /** @type {(path: string | null, el?: Element | null) => void} */
140
+ let paintSelection = (path, el) => {
141
+ clearClass("qe-hit");
142
+ clearClass("qe-hit-first");
143
+ selected = path;
144
+ if (!path) return;
145
+ let family = instances.get(path) ?? [];
146
+ paintClass("qe-hit", family);
147
+ struck(family, el)?.classList.add("qe-hit-first");
148
+ };
149
+
150
+ /** @type {(path: string | null) => void} */
151
+ let paintPeek = (path) => {
152
+ clearClass("qe-peek");
153
+ hovered = path;
154
+ if (path && path !== selected) paintClass("qe-peek", instances.get(path) ?? []);
155
+ };
156
+
157
+ // --- placeholders --------------------------------------------------------
158
+
159
+ /** What a placeholder says: a band names itself, a hidden item shows the
160
+ * template source the author wrote, so several are distinguishable. */
161
+ /** @type {(ghost: any) => string} */
162
+ let ghostText = (ghost) =>
163
+ ghost.kind === "band" ? "empty band" : "hidden: " + (ghost.source || "(empty)");
164
+
165
+ /** @type {(ghost: any) => string} */
166
+ let ghostLabel = (ghost) => ghost.path + " — " + ghostText(ghost);
167
+
168
+ /** @type {(ghost: any, seat: { left: number, top: number, width: number },
169
+ * stacked: boolean) => void} */
170
+ let drawGhost = (ghost, seat, stacked) => {
171
+ let el = add("qe-ghost" + (ghost.kind === "item" ? " qe-hidden-item" : ""), {
172
+ left: seat.left,
173
+ top: seat.top,
174
+ width: seat.width,
175
+ height: 24,
176
+ });
177
+ el.dataset.ghostPath = ghost.path;
178
+ el.dataset.ghostKind = ghost.kind;
179
+ // A placeholder in its own place needs no path to name it; one stacked
180
+ // under the sheet does, because nothing around it says where it belongs.
181
+ el.textContent = stacked ? ghostLabel(ghost) : ghostText(ghost);
182
+ };
183
+
184
+ // Where a placeholder sits: against the nearest rendered sibling in its own
185
+ // band, so it appears in document order rather than in a strip of its own —
186
+ // below the one before it, or above the one after when nothing precedes it.
187
+ // A band with nothing rendered at all has no sibling to measure, and falls
188
+ // back to the stack under the sheet.
189
+ /** @type {(ghost: any) => { left: number, top: number, width: number } | null} */
190
+ let ghostSeat = (ghost) => {
191
+ let at = indexOfItem(ghost.path);
192
+ if (at < 0) return null;
193
+ let band = bandOfItem(ghost.path);
194
+ let before = nearestSibling(band, at, -1);
195
+ if (before) return edgeOf(before, false);
196
+ let after = nearestSibling(band, at, 1);
197
+ return after ? edgeOf(after, true) : null;
198
+ };
199
+
200
+ /** The closest rendered item of a band, walking `step` away from `at`. */
201
+ /** @type {(band: string, at: number, step: number) => Element | null} */
202
+ let nearestSibling = (band, at, step) => {
203
+ for (let hop = 1; hop <= SEARCH; hop++) {
204
+ let found = siblingAt(band, at + hop * step);
205
+ if (found) return found;
206
+ }
207
+ return null;
208
+ };
209
+
210
+ /** @type {(band: string, i: number) => Element | null} */
211
+ let siblingAt = (band, i) => (i < 0 ? null : (instances.get(band + "[" + i + "]")?.[0] ?? null));
212
+
213
+ /** @type {(el: Element, above: boolean) => { left: number, top: number, width: number }} */
214
+ let edgeOf = (el, above) => {
215
+ let r = box(el);
216
+ return { left: r.left, top: above ? r.top - 26 : r.top + r.height + 2, width: r.width };
217
+ };
218
+
219
+ /** A hidden item's placeholder is a selection, never a drop target, so a
220
+ * drag shows only the empty bands an item could actually land in. */
221
+ /** @type {(ghost: any) => boolean} */
222
+ let shown = (ghost) => !(ghost.kind === "item" && drag);
223
+
224
+ /** @type {(base: any) => void} */
225
+ let drawGhosts = (base) => {
226
+ let stacked = base.top + base.height + 10;
227
+ for (let ghost of ghosts.filter(shown)) {
228
+ let seat = ghostSeat(ghost);
229
+ drawGhost(ghost, seat ?? { left: base.left, top: stacked, width: base.width }, !seat);
230
+ if (!seat) stacked += 30;
231
+ }
232
+ };
233
+
234
+ let ghostFurniture = () => {
235
+ for (let el of overlay.querySelectorAll(".qe-ghost")) el.remove();
236
+ if (revealed || drag) drawGhosts(box(sheet));
237
+ };
238
+
239
+ // --- the gutter grip -----------------------------------------------------
240
+
241
+ /** @type {(path: string, el: Element) => void} */
242
+ let showGrip = (path, el) => {
243
+ if (grip?.path === path) return;
244
+ dropGrip();
245
+ let r = box(el);
246
+ let handle = add("qe-grip", { left: r.left - 20, top: r.top, height: Math.max(r.height, 14) });
247
+ handle.textContent = "⠿";
248
+ handle.dataset.path = path;
249
+ let plus = add("qe-plus", { left: r.left - 20, top: r.top + Math.max(r.height, 14) + 1 });
250
+ plus.textContent = "+";
251
+ plus.title = "Add a text item after this one";
252
+ plus.dataset.path = path;
253
+ grip = { el: handle, plus, path };
254
+ };
255
+
256
+ /** @type {(path: string) => boolean} */
257
+ let grippable = (path) =>
258
+ /\[\d+\]$/.test(path) &&
259
+ !path.includes(".columns[") &&
260
+ !path.includes(".total[") &&
261
+ !path.startsWith("page.");
262
+
263
+ // --- item drag -----------------------------------------------------------
264
+
265
+ /** Every rectangle a slot offers: real insertion lines, or its ghost. */
266
+ /** @type {(slot: any) => { left: number, top: number, width: number }[]} */
267
+ /** The insertion line along each anchor: its top edge, or its bottom when
268
+ * the slot sits after the band's last item. */
269
+ /** @type {(anchors: Element[], useTop: boolean) => { left: number, top: number, width: number }[]} */
270
+ let edgeRects = (anchors, useTop) =>
271
+ anchors.map((anchor) => {
272
+ let r = box(anchor);
273
+ return { left: r.left, top: useTop ? r.top : r.top + r.height, width: r.width };
274
+ });
275
+
276
+ /** The strip standing in for a band with nothing on screen to aim at. */
277
+ /** @type {(band: string) => { left: number, top: number, width: number }[]} */
278
+ let ghostRect = (band) => {
279
+ let ghost = overlay.querySelector('[data-ghost-path="' + CSS.escape(band) + '"]');
280
+ if (!(ghost instanceof HTMLElement)) return [];
281
+ return [
282
+ {
283
+ left: parseFloat(ghost.style.left),
284
+ top: parseFloat(ghost.style.top),
285
+ width: parseFloat(ghost.style.width),
286
+ },
287
+ ];
288
+ };
289
+
290
+ /** Every rectangle a slot offers: real insertion lines, or its ghost. */
291
+ /** @type {(slot: any) => { left: number, top: number, width: number }[]} */
292
+ let slotRects = (slot) => {
293
+ /** @type {(path: string) => Element[]} */
294
+ let family = (path) => instances.get(path) ?? [];
295
+ let afters = family(slot.band + "[" + slot.index + "]");
296
+ let rects = edgeRects(
297
+ afters.length ? afters : family(slot.band + "[" + (slot.index - 1) + "]"),
298
+ afters.length > 0,
299
+ );
300
+ return rects.length ? rects : ghostRect(slot.band);
301
+ };
302
+
303
+ // How far a pointer is from one insertion line: vertical proximity
304
+ // dominates, and horizontal still discriminates between side-by-side bands.
305
+ /** @type {(r: { left: number, top: number, width: number }, px: number, py: number) => number} */
306
+ let distance = (r, px, py) =>
307
+ Math.max(0, Math.max(r.left - px, px - (r.left + r.width))) * 3 + Math.abs(r.top - py);
308
+
309
+ /** @type {(best: any, next: any) => any} */
310
+ let closer = (best, next) => (!best || next.d < best.d ? next : best);
311
+
312
+ /** @type {(slot: any, px: number, py: number, best: any) => any} */
313
+ let nearestIn = (slot, px, py, best) => {
314
+ for (let r of slotRects(slot)) best = closer(best, { d: distance(r, px, py), slot });
315
+ return best;
316
+ };
317
+
318
+ /** The nearest legal slot to a point, across every rectangle each offers. */
319
+ /** @type {(px: number, py: number) => any} */
320
+ let nearest = (px, py) => {
321
+ let best = null;
322
+ for (let slot of drag.legal) best = nearestIn(slot, px, py, best);
323
+ return best;
324
+ };
325
+
326
+ // The caret refuses to go anywhere illegal: only legal slots compete, and
327
+ // one schema slot's many rectangles all mean the same edit.
328
+ /** @type {(best: any) => void} */
329
+ let paintCaret = (best) => {
330
+ for (let el of overlay.querySelectorAll(".qe-caret")) el.remove();
331
+ for (let r of best ? slotRects(best.slot) : []) add("qe-caret", r);
332
+ };
333
+
334
+ /** @type {(x: number, y: number) => void} */
335
+ let paintDrag = (x, y) => {
336
+ let c = canvas.getBoundingClientRect();
337
+ let best = nearest(x - c.left, y - c.top);
338
+ drag.target = best?.slot ?? null;
339
+ paintCaret(best);
340
+ };
341
+
342
+ /** @type {(commit: boolean) => void} */
343
+ let endItemDrag = (commit) => {
344
+ let finished = drag;
345
+ drag = null;
346
+ paintCaret(null);
347
+ clearClass("qe-lifted");
348
+ ghostFurniture();
349
+ if (commit && finished?.target) acts.drop(finished.path, finished.target);
350
+ };
351
+
352
+ /** @type {(path: string) => void} */
353
+ let beginItemDrag = (path) => {
354
+ closeMenu();
355
+ let slots = acts.slots(path);
356
+ drag = { path, legal: slots.filter((slot) => slot.legal), target: null };
357
+ for (let el of instances.get(path) ?? []) el.classList.add("qe-lifted");
358
+ ghostFurniture();
359
+ };
360
+
361
+ // --- column drag ---------------------------------------------------------
362
+
363
+ /** @type {(x: number) => void} */
364
+ /** The insertion point a pointer names: the first header it sits left of,
365
+ * or past the last one. */
366
+ /** @type {(heads: Element[], x: number) => number} */
367
+ let columnSlot = (heads, x) => {
368
+ let at = heads.findIndex((head) => {
369
+ let r = head.getBoundingClientRect();
370
+ return x < r.left + r.width / 2;
371
+ });
372
+ return at < 0 ? heads.length : at;
373
+ };
374
+
375
+ /** Where the vertical caret is drawn: a header's left edge, or the table's
376
+ * right edge for the slot past the last column. */
377
+ /** @type {(heads: Element[], to: number) => number} */
378
+ let columnEdge = (heads, to) => {
379
+ if (to < heads.length) return box(heads[to]).left;
380
+ let last = box(heads[heads.length - 1]);
381
+ return last.left + last.width;
382
+ };
383
+
384
+ /** @type {(x: number) => void} */
385
+ let paintColumnDrag = (x) => {
386
+ clearVCaret();
387
+ let heads = [...column.table.querySelectorAll("th[data-q-path]")];
388
+ column.to = columnSlot(heads, x);
389
+ let table = box(column.table);
390
+ add("qe-vcaret", { left: columnEdge(heads, column.to), top: table.top, height: table.height });
391
+ };
392
+
393
+ /** A column that stayed put — dropped where it already is — is no edit. */
394
+ /** @type {(at: number, to: number) => boolean} */
395
+ let travelledColumn = (at, to) => at !== to && at + 1 !== to;
396
+
397
+ let clearVCaret = () => {
398
+ for (let el of overlay.querySelectorAll(".qe-vcaret")) el.remove();
399
+ };
400
+
401
+ let endColumnDrag = () => {
402
+ let finished = column;
403
+ column = null;
404
+ clearVCaret();
405
+ if (!finished?.moved) return false;
406
+ if (travelledColumn(finished.at, finished.to)) acts.dropColumn(finished.at, finished.to);
407
+ return true;
408
+ };
409
+
410
+ // --- the grip menu -------------------------------------------------------
411
+
412
+ /** @type {(path: string) => void} */
413
+ let openMenu = (path) => {
414
+ closeMenu();
415
+ if (!grip) return;
416
+ let r = box(grip.el);
417
+ menu = document.createElement("div");
418
+ menu.className = "qe-menu";
419
+ /** @type {(label: string, run: () => void) => void} */
420
+ let make = (label, run) => {
421
+ let button = document.createElement("button");
422
+ button.type = "button";
423
+ button.textContent = label;
424
+ button.addEventListener("click", () => {
425
+ closeMenu();
426
+ run();
427
+ });
428
+ menu?.append(button);
429
+ };
430
+ make("Duplicate", () => acts.duplicate(path));
431
+ make("Delete", () => acts.remove(path));
432
+ menu.style.left = r.left + 18 + "px";
433
+ menu.style.top = r.top + "px";
434
+ overlay.append(menu);
435
+ };
436
+
437
+ // --- wiring --------------------------------------------------------------
438
+
439
+ /** @type {any} */
440
+ let press = null;
441
+
442
+ /** The path an element carries, or null — the one place it is read. */
443
+ /** @type {(el: Element | null) => string | null} */
444
+ let pathOf = (el) => el?.getAttribute("data-q-path") ?? null;
445
+
446
+ /** Best-effort: a browser that refuses capture still drags, it just loses
447
+ * the pointer when it leaves the stage. */
448
+ /** @type {(event: PointerEvent) => void} */
449
+ let capture = (event) => {
450
+ try {
451
+ root.setPointerCapture(event.pointerId);
452
+ } catch {
453
+ /* capture is best-effort */
454
+ }
455
+ };
456
+
457
+ /** @type {(target: Element, event: PointerEvent) => boolean} */
458
+ let gripPress = (target, event) => {
459
+ let onGrip = target.closest(".qe-grip");
460
+ if (!(onGrip instanceof HTMLElement) || !onGrip.dataset.path) return false;
461
+ event.preventDefault();
462
+ press = { kind: "grip", path: onGrip.dataset.path, x: event.clientX, y: event.clientY };
463
+ capture(event);
464
+ return true;
465
+ };
466
+
467
+ /** The column a header cell belongs to, or null for anything else. */
468
+ /** @type {(cell: Element | null) => number | null} */
469
+ let columnIndex = (cell) => {
470
+ let at = pathOf(cell)?.match(/^detail\.columns\[(\d+)\]$/);
471
+ return at ? Number(at[1]) : null;
472
+ };
473
+
474
+ /** @type {(target: Element, event: PointerEvent) => void} */
475
+ let columnPress = (target, event) => {
476
+ let cell = target.closest("th[data-q-path]");
477
+ let at = columnIndex(cell);
478
+ if (at === null) return;
479
+ press = {
480
+ kind: "column",
481
+ at,
482
+ table: cell?.closest("table"),
483
+ el: cell,
484
+ path: "detail.columns[" + at + "]",
485
+ x: event.clientX,
486
+ y: event.clientY,
487
+ };
488
+ capture(event);
489
+ };
490
+
491
+ root.addEventListener("pointerdown", (event) => {
492
+ let target = /** @type {Element} */ (event.target);
493
+ if (menu && !menu.contains(target)) closeMenu();
494
+ if (!gripPress(target, event)) columnPress(target, event);
495
+ });
496
+
497
+ /** @type {(event: PointerEvent) => boolean} */
498
+ let travelled = (event) =>
499
+ Math.abs(event.clientX - press.x) > DRAG_SLOP || Math.abs(event.clientY - press.y) > DRAG_SLOP;
500
+
501
+ /** Whether a press is still eligible to become a drag. */
502
+ let pending = () => press && !drag && !column;
503
+
504
+ /** Promote a press that travelled into the drag its kind means. */
505
+ /** @type {(event: PointerEvent) => void} */
506
+ let maybeLift = (event) => {
507
+ if (!pending() || !travelled(event)) return;
508
+ if (press.kind === "grip") beginItemDrag(press.path);
509
+ else column = { at: press.at, to: press.at, table: press.table, moved: true };
510
+ };
511
+
512
+ /** @type {(el: Element | null, path: string | null) => boolean} */
513
+ let showable = (el, path) => !!el && !!path && grippable(path);
514
+
515
+ /** @type {(el: Element | null, path: string | null) => void} */
516
+ let gripFor = (el, path) => {
517
+ if (showable(el, path)) showGrip(/** @type {string} */ (path), /** @type {Element} */ (el));
518
+ else if (!menu) dropGrip();
519
+ };
520
+
521
+ // The hover region spans gutter and item together: a pointer on the grip or
522
+ // its + keeps it, a pointer on the item refreshes it, anything else clears
523
+ // it — the affordance is a region entered once and left once, never an
524
+ // element summoned per item.
525
+ /** @type {(target: Element) => void} */
526
+ let hover = (target) => {
527
+ if (target.closest(".qe-grip, .qe-plus, .qe-menu")) return;
528
+ let el = hit(target);
529
+ let path = pathOf(el);
530
+ if (path !== hovered) paintPeek(path);
531
+ gripFor(el, path);
532
+ };
533
+
534
+ root.addEventListener("pointermove", (event) => {
535
+ maybeLift(event);
536
+ if (drag) return paintDrag(event.clientX, event.clientY);
537
+ if (column) return paintColumnDrag(event.clientX);
538
+ hover(/** @type {Element} */ (event.target));
539
+ });
540
+
541
+ // A press that never travelled is a click: what it means is decided by what
542
+ // it began on — the grip opens its menu, a column header selects its column.
543
+ /** @type {Record<string, (pressed: any, target: Element) => any>} */
544
+ let FINISH = {
545
+ grip: (pressed, target) => target.closest(".qe-grip") && openMenu(pressed.path),
546
+ column: (pressed) => acts.select(pressed.path, pressed.el),
547
+ };
548
+
549
+ /** A finished drag consumes the release; anything else is a click. */
550
+ let endDrags = () => {
551
+ if (drag) {
552
+ endItemDrag(true);
553
+ return true;
554
+ }
555
+ return column ? endColumnDrag() : false;
556
+ };
557
+
558
+ root.addEventListener("pointerup", (event) => {
559
+ let pressed = press;
560
+ press = null;
561
+ if (endDrags()) return;
562
+ FINISH[pressed?.kind]?.(pressed, /** @type {Element} */ (event.target));
563
+ });
564
+
565
+ root.addEventListener("pointerleave", () => {
566
+ paintPeek(null);
567
+ });
568
+
569
+ /** The insertion point the gutter's `+` names: right after its own item. */
570
+ /** @type {(path: string) => number} */
571
+ let afterIndex = (path) => indexOfItem(path) + 1;
572
+
573
+ /** @type {(target: Element) => boolean} */
574
+ let plusInsert = (target) => {
575
+ let plus = target.closest(".qe-plus");
576
+ if (!(plus instanceof HTMLElement) || !plus.dataset.path) return false;
577
+ let path = plus.dataset.path;
578
+ acts.insert(bandOfItem(path), afterIndex(path));
579
+ return true;
580
+ };
581
+
582
+ // Two meanings, one affordance: a hidden item's placeholder is a real node
583
+ // to select, an empty band's is an insertion point.
584
+ /** @type {(target: Element) => boolean} */
585
+ let ghostClick = (target) => {
586
+ let ghost = target.closest(".qe-ghost");
587
+ if (!(ghost instanceof HTMLElement) || !ghost.dataset.ghostPath) return false;
588
+ if (ghost.dataset.ghostKind === "item") acts.select(ghost.dataset.ghostPath, null);
589
+ else acts.insert(ghost.dataset.ghostPath, 0);
590
+ return true;
591
+ };
592
+
593
+ root.addEventListener("click", (event) => {
594
+ let target = /** @type {Element} */ (event.target);
595
+ if (target.closest(".qe-grip, .qe-menu")) return;
596
+ if (plusInsert(target) || ghostClick(target)) return;
597
+ let el = hit(target);
598
+ acts.select(pathOf(el), el);
599
+ });
600
+
601
+ return {
602
+ element: root,
603
+ /** The one markup edge: the html target's documented output, injected
604
+ * the documented way. Selection is re-painted so it survives the swap. */
605
+ /** @type {(fragment: string) => void} */
606
+ swap(fragment) {
607
+ closeMenu();
608
+ dropGrip();
609
+ sheet.innerHTML = fragment;
610
+ index();
611
+ paintSelection(selected);
612
+ ghostFurniture();
613
+ },
614
+ /** The sheet is the page's shape: its width, and at least one page tall,
615
+ * so a letter sheet is letter-shaped and an A4 one A4-shaped. It grows
616
+ * with the report — the editor never paginates. */
617
+ /** @type {(page: { width: number, height: number, margin: number }) => void} */
618
+ resize(page) {
619
+ sheet.style.width = page.width + "px";
620
+ sheet.style.minHeight = page.height + "px";
621
+ sheet.style.padding = page.margin + "px";
622
+ sheet.style.paddingLeft = "var(--_gutter)";
623
+ },
624
+ /** @type {(path: string | null, el?: Element | null) => void} */
625
+ select: paintSelection,
626
+ /** Does the current fragment carry any identity to select by? */
627
+ identified: () => instances.size > 0,
628
+ /** The set of paths the render produced — placeholders() takes it. */
629
+ rendered: () => new Set(instances.keys()),
630
+ /** @type {(list: typeof ghosts, on: boolean) => void} */
631
+ revealPlaceholders(list, on) {
632
+ ghosts = list;
633
+ revealed = on;
634
+ ghostFurniture();
635
+ },
636
+ /** Escape cancels a live drag; true when one was cancelled. */
637
+ cancel() {
638
+ if (drag) {
639
+ endItemDrag(false);
640
+ return true;
641
+ }
642
+ if (column) {
643
+ column.moved = false;
644
+ endColumnDrag();
645
+ return true;
646
+ }
647
+ if (menu) {
648
+ closeMenu();
649
+ return true;
650
+ }
651
+ return false;
652
+ },
653
+ };
654
+ }