@hajimism/dek 0.2.0 → 0.2.2

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.ja.md +3 -1
  3. package/README.md +3 -1
  4. package/package.json +3 -1
  5. package/skills/dek/SKILL.md +44 -0
  6. package/src/cli/annotations.ts +26 -0
  7. package/src/cli/commands.ts +66 -0
  8. package/src/cli/contract.ts +75 -4
  9. package/src/cli/files.ts +3 -3
  10. package/src/cli/flags.ts +1 -1
  11. package/src/cli/format.ts +3 -0
  12. package/src/cli/pptx.ts +20 -0
  13. package/src/cli/serve.ts +1 -1
  14. package/src/cli/show.ts +17 -2
  15. package/src/cli/text.ts +43 -0
  16. package/src/cli/theme.ts +7 -1
  17. package/src/core/annotation-rows.ts +176 -0
  18. package/src/core/annotations.ts +362 -0
  19. package/src/core/chrome.ts +3 -1
  20. package/src/core/diagnostic.ts +1 -0
  21. package/src/core/document.ts +17 -2
  22. package/src/core/html-scan.ts +54 -0
  23. package/src/core/html.ts +144 -14
  24. package/src/core/lint/slide-html.ts +92 -2
  25. package/src/core/lint.ts +8 -1
  26. package/src/core/live-protocol.ts +4 -1
  27. package/src/core/page.ts +7 -0
  28. package/src/core/playwright-module.d.ts +4 -1
  29. package/src/core/playwright-visual.ts +74 -1
  30. package/src/core/playwright.ts +51 -1
  31. package/src/core/pptx-package.ts +312 -0
  32. package/src/core/pptx-text.ts +363 -0
  33. package/src/core/pptx.ts +75 -0
  34. package/src/core/slide-measure.ts +1 -0
  35. package/src/core/sync.ts +13 -5
  36. package/src/core/theme-excerpt.ts +61 -9
  37. package/src/core/theme-facts.ts +12 -2
  38. package/src/core/zip.ts +77 -0
  39. package/src/runtime/annotate-pick.ts +144 -0
  40. package/src/runtime/annotate-place.ts +46 -0
  41. package/src/runtime/annotate-view.ts +752 -0
  42. package/src/runtime/annotate.ts +19 -0
  43. package/src/runtime/browser.ts +5 -1
  44. package/src/runtime/live.ts +1 -0
  45. package/src/runtime/player.ts +15 -7
  46. package/src/runtime/routes.ts +3 -0
  47. package/src/server/auth.ts +3 -1
  48. package/src/server/dev.ts +2 -1
  49. package/src/server/pages.ts +2 -1
  50. package/src/server/routes.ts +77 -4
@@ -0,0 +1,752 @@
1
+ /// <reference lib="dom" />
2
+ import { type AnnotationRow, type Box, formatNotes, type Point } from "../core/annotation-rows.ts";
3
+ import { ANNOTATIONS_CHANGED, PAGE_ID } from "../core/page.ts";
4
+ import type { PresenterSlide } from "../core/presenter-state.ts";
5
+ import {
6
+ candidatesAt,
7
+ currentStop,
8
+ describeElement,
9
+ SOURCE_ATTR,
10
+ slideBox,
11
+ slidePoint,
12
+ windowPoint,
13
+ } from "./annotate-pick.ts";
14
+ import { placePopup } from "./annotate-place.ts";
15
+ import { slideSelector } from "./live.ts";
16
+ import { isLetterKey, isTextEntry } from "./step.ts";
17
+
18
+ /** One element picked for the note being written, and where the click that picked it was. */
19
+ type Pick = { el: Element; at: Point };
20
+
21
+ // In English whatever the deck's language, like the notes the view hands over.
22
+ const LABELS = {
23
+ placeholder: "What should change?",
24
+ add: "Add",
25
+ save: "Save",
26
+ cancel: "Cancel",
27
+ delete: "Delete",
28
+ copy: "Copy",
29
+ clear: "Clear all notes",
30
+ undo: "Undo",
31
+ cleared: (n: number) => `Cleared ${n} ${n === 1 ? "note" : "notes"}`,
32
+ close: "Leave annotate mode (a)",
33
+ near: "near",
34
+ addHint: "⌘/Ctrl-click adds more",
35
+ notes: (n: number) => `${n} ${n === 1 ? "note" : "notes"}`,
36
+ copied: (n: number) => `Copied ${n} ${n === 1 ? "note" : "notes"}`,
37
+ manual: "Press ⌘C or Ctrl+C to copy",
38
+ failed: (why: string) => `Not saved: ${why}`,
39
+ };
40
+
41
+ /** Line icons, drawn in the text's color. */
42
+ const ICON = {
43
+ copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h8"/>',
44
+ trash:
45
+ '<path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/>',
46
+ close: '<path d="M6 6l12 12M18 6 6 18"/>',
47
+ };
48
+
49
+ const icon = (name: keyof typeof ICON): string =>
50
+ `<svg viewBox="0 0 24 24" aria-hidden="true">${ICON[name]}</svg>`;
51
+
52
+ // An inspector's look: dark panels, a hairline outline, and names in monospace, as the notes
53
+ // write them. Over the deck and everything the page draws; the popup and the bar on top.
54
+ const STYLE = `
55
+ :host { all: initial; --accent: #378add; --ink: #f4f4f5; --muted: #a1a1aa; --faint: #71717a; --panel: #18181b; --raised: #27272a; --well: #09090b; --line: rgb(255 255 255 / 0.1); }
56
+ [hidden] { display: none !important; }
57
+ * { box-sizing: border-box; margin: 0; font: 12px/1.45 system-ui, -apple-system, sans-serif; }
58
+ .mono, .mono *, kbd { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
59
+ svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
60
+ button { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border: 0; border-radius: 6px; background: none; color: var(--muted); cursor: pointer; }
61
+ button:hover { background: var(--raised); color: var(--ink); }
62
+ button:disabled { opacity: 0.35; cursor: default; background: none; }
63
+ kbd { padding: 0 4px; border: 1px solid var(--line); border-radius: 4px; color: var(--faint); }
64
+ [data-part=layer] { position: fixed; z-index: 2147483000; cursor: crosshair; }
65
+ [data-part=hover], [data-part=pick] { position: fixed; z-index: 2147483001; pointer-events: none; outline: 1.5px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
66
+ [data-part=pick] { background: rgb(55 138 221 / 0.12); }
67
+ [data-part=hover] span { position: absolute; left: -2px; bottom: 100%; margin-bottom: 5px; padding: 2px 6px; border-radius: 4px; background: #0c447c; color: #e6f1fb; white-space: nowrap; }
68
+ [data-part=hover] i { font-style: normal; color: #85b7eb; }
69
+ [data-part=marker] { position: fixed; z-index: 2147483002; min-width: 24px; height: 24px; padding: 0 6px; justify-content: center; transform: translate(-2px, calc(-100% + 2px)); border: 2px solid #fff; border-radius: 12px 12px 12px 2px; background: var(--accent); color: #fff; font-weight: 600; font-size: 11px; box-shadow: 0 2px 6px rgb(0 0 0 / 0.25); }
70
+ [data-part=marker]:hover { background: #185fa5; color: #fff; }
71
+ [data-part=popup], [data-part=pill], [data-part=tray] { background: var(--panel); color: var(--ink); border: 1px solid var(--line); box-shadow: 0 12px 32px rgb(0 0 0 / 0.35); }
72
+ [data-part=popup] { position: fixed; z-index: 2147483003; width: 340px; padding: 10px; border-radius: 12px; }
73
+ [data-part=choices] { display: grid; gap: 4px; margin-bottom: 8px; }
74
+ [data-part=path], [data-part=near] { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; }
75
+ [data-part=path] > span, [data-part=near] > span { color: #52525b; }
76
+ [data-part=choices] button { padding: 2px 6px; border-radius: 5px; }
77
+ [data-part=choices] button[aria-pressed=true] { background: var(--raised); color: var(--ink); box-shadow: inset 0 0 0 1px var(--accent); }
78
+ [data-part=picked] { list-style: none; padding: 0 2px; margin-bottom: 8px; color: var(--faint); }
79
+ [data-part=picked] li { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
80
+ textarea { display: block; width: 100%; min-height: 3.6em; padding: 7px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--well); color: var(--ink); resize: vertical; outline: none; }
81
+ textarea:focus { border-color: var(--accent); }
82
+ textarea::placeholder { color: var(--faint); }
83
+ [data-part=actions] { display: flex; align-items: center; gap: 4px; margin-top: 8px; }
84
+ [data-part=hint] { margin-right: auto; padding-left: 2px; color: var(--faint); }
85
+ [data-part=delete] { margin-right: auto; color: #f09595; }
86
+ [data-part=popup] { transition: opacity 120ms; }
87
+ [data-part=popup][data-through] { opacity: 0.3; pointer-events: none; }
88
+ [data-part=add] { background: var(--raised); color: var(--ink); }
89
+ [data-part=add]:hover { background: #3f3f46; }
90
+ [data-part=bar] { position: fixed; z-index: 2147483003; display: grid; justify-items: center; gap: 8px; transform: translate(-50%, -100%); }
91
+ [data-part=pill] { display: flex; align-items: center; gap: 2px; padding: 4px; border-radius: 999px; }
92
+ [data-part=pill] button { border-radius: 999px; padding: 5px 10px; }
93
+ [data-part=pill] button[data-part=clear], [data-part=pill] button[data-part=close] { padding: 5px 7px; }
94
+ [data-part=count] { padding: 0 10px; color: var(--muted); white-space: nowrap; }
95
+ [data-part=clear]:hover { color: #f09595; }
96
+ [data-part=divider] { width: 1px; height: 16px; margin: 0 6px; background: var(--line); }
97
+ [data-part=copy] { background: var(--raised); color: var(--ink); }
98
+ [data-part=tray] { display: grid; gap: 6px; max-width: calc(100vw - 32px); padding: 8px; border-radius: 12px; }
99
+ [data-part=manual] { width: min(460px, calc(100vw - 48px)); min-height: 9em; resize: vertical; }
100
+ [data-part=report] { display: flex; align-items: center; gap: 8px; padding: 0 4px; }
101
+ [data-part=status] { color: var(--muted); }
102
+ [data-part=undo] { margin-left: auto; padding: 3px 10px; color: var(--ink); background: var(--raised); }
103
+ `;
104
+
105
+ /** A note as the page sends it: the elements by where each start tag is written. */
106
+ type NewTarget = { source: string; box: Box; point?: Point };
107
+
108
+ /**
109
+ * What the page asks of the dev server's notes; each answer is the deck's notes as they stand,
110
+ * and a clear also hands back what it took, to put back.
111
+ */
112
+ type Ask = (
113
+ op?:
114
+ | { op: "add"; slug: string; step: string; text: string; targets: NewTarget[] }
115
+ | { op: "edit"; id: string; text: string }
116
+ | { op: "remove"; id: string }
117
+ | { op: "clear" }
118
+ | { op: "restore"; annotations: unknown[] },
119
+ ) => Promise<{ annotations: AnnotationRow[]; removed?: unknown[] }>;
120
+
121
+ /**
122
+ * Annotate mode: point at elements of the slide on screen, write what should change, and copy
123
+ * the notes as one block for an agent, each element named by the file and line it is written
124
+ * at. It runs only on the dev server's pages for the speaker, beside the player, and shows
125
+ * nothing until `a` or the presenter bar's button turns it on: the page may be on the projector.
126
+ * The notes are the dev server's, kept in `.dek/annotations.json` for `dekc annotations` to
127
+ * read; every tab of the deck shows the same ones, and asks again whenever they or a slide change.
128
+ */
129
+ export function createAnnotateView(options: {
130
+ deck: string;
131
+ slides: PresenterSlide[];
132
+ /** The dev server's annotations endpoint for this deck, with the presenter's token. */
133
+ url: string;
134
+ fetch: (url: string, init?: RequestInit) => Promise<Response>;
135
+ }): void {
136
+ const { deck, slides } = options;
137
+ const deckEl = document.getElementById(PAGE_ID.deck);
138
+ const stageEl = document.getElementById(PAGE_ID.currentStage);
139
+ if (!deckEl || !stageEl) {
140
+ return;
141
+ }
142
+ const toggleEl = document.getElementById(PAGE_ID.annotateToggle);
143
+
144
+ const host = document.createElement("div");
145
+ host.id = "dek-annotate";
146
+ const root = host.attachShadow({ mode: "open" });
147
+ root.innerHTML = `<style>${STYLE}</style>
148
+ <div data-part="layer" hidden></div>
149
+ <div data-part="hover" class="mono" hidden></div>
150
+ <div data-part="picks"></div>
151
+ <div data-part="markers"></div>
152
+ <form data-part="popup" hidden>
153
+ <div data-part="choices" class="mono">
154
+ <nav data-part="path" aria-label="Elements under the click"></nav>
155
+ <div data-part="near" aria-label="Elements near the click"></div>
156
+ </div>
157
+ <ul data-part="picked" class="mono"></ul>
158
+ <textarea placeholder="${LABELS.placeholder}" aria-label="${LABELS.placeholder}"></textarea>
159
+ <div data-part="actions">
160
+ <span data-part="hint" class="mono">${LABELS.addHint}</span>
161
+ <button type="button" data-part="delete">${LABELS.delete}</button>
162
+ <button type="button" data-part="cancel">${LABELS.cancel} <kbd>esc</kbd></button>
163
+ <button type="submit" data-part="add"><span>${LABELS.add}</span> <kbd>↵</kbd></button>
164
+ </div>
165
+ </form>
166
+ <div data-part="bar" hidden>
167
+ <div data-part="tray" hidden>
168
+ <textarea data-part="manual" class="mono" readonly hidden></textarea>
169
+ <div data-part="report">
170
+ <p data-part="status" role="status"></p>
171
+ <button type="button" data-part="undo" hidden>${LABELS.undo}</button>
172
+ </div>
173
+ </div>
174
+ <div data-part="pill">
175
+ <button type="button" data-part="copy">${icon("copy")}${LABELS.copy}</button>
176
+ <span data-part="count"></span>
177
+ <button type="button" data-part="clear" aria-label="${LABELS.clear}" title="${LABELS.clear}">${icon("trash")}</button>
178
+ <span data-part="divider" aria-hidden="true"></span>
179
+ <button type="button" data-part="close" aria-label="${LABELS.close}" title="${LABELS.close}">${icon("close")}</button>
180
+ </div>
181
+ </div>`;
182
+ document.body.append(host);
183
+
184
+ const part = <T extends HTMLElement = HTMLElement>(name: string): T =>
185
+ root.querySelector(`[data-part=${name}]`) as T;
186
+ const layer = part("layer");
187
+ const hover = part("hover");
188
+ const popup = part<HTMLFormElement>("popup");
189
+ const field = popup.querySelector("textarea") as HTMLTextAreaElement;
190
+ const manual = part<HTMLTextAreaElement>("manual");
191
+ const status = part("status");
192
+ const tray = part("tray");
193
+
194
+ /**
195
+ * Say above the bar how Copy or Clear went: with the text to copy by hand when Copy could not,
196
+ * and with Undo after Clear. Clear is undone rather than confirmed, and only until something
197
+ * else is said here, a note is written, or the mode is left.
198
+ */
199
+ function report(text: string, extra: { byHand?: string; undo?: unknown[] } = {}): void {
200
+ status.textContent = text;
201
+ manual.hidden = extra.byHand === undefined;
202
+ manual.value = extra.byHand ?? "";
203
+ cleared = extra.undo;
204
+ part("undo").hidden = cleared === undefined;
205
+ tray.hidden = text === "";
206
+ }
207
+ const hitStyle = document.createElement("style");
208
+ // A decorative arrow often takes no pointer; while annotating, every element of the deck does.
209
+ // The rail's copies carry the attribute too, so the rule stays inside the deck.
210
+ hitStyle.textContent = `#${PAGE_ID.deck} [${SOURCE_ATTR}] { pointer-events: auto !important; }`;
211
+
212
+ let on = false;
213
+ let notes: AnnotationRow[] = [];
214
+ /** What the last Clear took away, while Undo can still bring it back. */
215
+ let cleared: unknown[] | undefined;
216
+ let picks: Pick[] = [];
217
+ let choiceButtons = new Map<Element, HTMLButtonElement>();
218
+ let editing: AnnotationRow | undefined;
219
+ let hovered: Element | undefined;
220
+ let frame: number | undefined;
221
+ // Hit testing tries dozens of points; the pointer is followed at most once a frame.
222
+ let hoverTested = false;
223
+ let hoverPending: Point | undefined;
224
+
225
+ const ask: Ask = async (op) => {
226
+ const response = await options.fetch(
227
+ options.url,
228
+ op === undefined
229
+ ? undefined
230
+ : {
231
+ method: "POST",
232
+ headers: { "content-type": "application/json" },
233
+ body: JSON.stringify(op),
234
+ },
235
+ );
236
+ const body = (await response.json()) as {
237
+ ok?: boolean;
238
+ annotations?: AnnotationRow[];
239
+ removed?: unknown[];
240
+ error?: { message?: string };
241
+ };
242
+ if (!response.ok || body.ok !== true || !body.annotations) {
243
+ throw new Error(body.error?.message ?? `the dev server answered ${response.status}`);
244
+ }
245
+ return { annotations: body.annotations, ...(body.removed ? { removed: body.removed } : {}) };
246
+ };
247
+
248
+ /**
249
+ * Send `op`, or ask for the notes when there is none, and show what the server says they are
250
+ * now. A refusal is said above the bar, and the notes are asked for again, since a refused
251
+ * note names a slide the page no longer shows as it is.
252
+ */
253
+ async function sync(op?: Parameters<Ask>[0]): Promise<{ removed?: unknown[] } | undefined> {
254
+ try {
255
+ const answer = await ask(op);
256
+ notes = answer.annotations;
257
+ rebuild();
258
+ return answer;
259
+ } catch (error) {
260
+ report(LABELS.failed(error instanceof Error ? error.message : String(error)));
261
+ if (op !== undefined) {
262
+ await sync();
263
+ }
264
+ return undefined;
265
+ }
266
+ }
267
+
268
+ /** The slide on screen, its beat, and its section in the deck. */
269
+ function onScreen(): { slug: string; step: string; slide: Element | null } {
270
+ const stop = currentStop(location.hash, slides);
271
+ const slug = slides[stop.slideIndex]?.slug ?? "";
272
+ return { slug, step: stop.step, slide: document.querySelector(slideSelector(slug)) };
273
+ }
274
+
275
+ function setOn(next: boolean): void {
276
+ on = next;
277
+ toggleEl?.setAttribute("aria-pressed", String(on));
278
+ layer.hidden = !on;
279
+ part("bar").hidden = !on;
280
+ report("");
281
+ if (on) {
282
+ document.head.append(hitStyle);
283
+ tick();
284
+ } else {
285
+ hitStyle.remove();
286
+ closePopup();
287
+ hovered = undefined;
288
+ if (frame !== undefined) {
289
+ cancelAnimationFrame(frame);
290
+ frame = undefined;
291
+ }
292
+ }
293
+ rebuild();
294
+ }
295
+
296
+ /** Follow what moves on the slide while the mode is on: entrances, morphs, a resized window. */
297
+ function tick(): void {
298
+ hoverTested = false;
299
+ if (hoverPending) {
300
+ hoverAt(hoverPending);
301
+ }
302
+ layout();
303
+ frame = requestAnimationFrame(tick);
304
+ }
305
+
306
+ function hoverAt(at: Point): void {
307
+ hoverTested = true;
308
+ hoverPending = undefined;
309
+ const [first] = candidates(at);
310
+ if (first !== hovered) {
311
+ hovered = first;
312
+ rebuild();
313
+ }
314
+ }
315
+
316
+ function elementsAt(x: number, y: number): Element[] {
317
+ return document.elementsFromPoint(x, y);
318
+ }
319
+
320
+ function candidates(at: Point): Element[] {
321
+ const { slide } = onScreen();
322
+ return slide ? candidatesAt(at, slide, elementsAt) : [];
323
+ }
324
+
325
+ /** An element as a note names it: its name, its text, and its line, as Copy writes them. */
326
+ function label(target: { name: string; text: string; source: string }): string {
327
+ const text = target.text === "" ? "" : ` ${JSON.stringify(target.text)}`;
328
+ return `${target.name}${text} :${target.source.split(":")[0]}`;
329
+ }
330
+
331
+ function describe(el: Element): { name: string; text: string; source: string } {
332
+ return describeElement(el, el.closest(".slide") ?? el);
333
+ }
334
+
335
+ function place(
336
+ el: HTMLElement,
337
+ rect: { left: number; top: number; width: number; height: number },
338
+ ): void {
339
+ el.style.left = `${rect.left}px`;
340
+ el.style.top = `${rect.top}px`;
341
+ el.style.width = `${rect.width}px`;
342
+ el.style.height = `${rect.height}px`;
343
+ }
344
+
345
+ /** Put every outline and marker where its element is now. */
346
+ function layout(): void {
347
+ if (!on) {
348
+ return;
349
+ }
350
+ const stage = stageEl?.getBoundingClientRect() ?? new DOMRect();
351
+ place(layer, stage);
352
+ // The bar sits at the foot of the slide, whatever the rail or the presenter view take.
353
+ const bar = part("bar");
354
+ bar.style.left = `${stage.left + stage.width / 2}px`;
355
+ bar.style.top = `${stage.bottom - 12}px`;
356
+ hover.hidden = !hovered;
357
+ if (hovered) {
358
+ place(hover, hovered.getBoundingClientRect());
359
+ }
360
+ const outlines = part("picks").children;
361
+ picks.forEach((pick, index) => {
362
+ const outline = outlines[index];
363
+ if (outline instanceof HTMLElement) {
364
+ place(outline, pick.el.getBoundingClientRect());
365
+ }
366
+ });
367
+ for (const marker of part("markers").querySelectorAll<HTMLElement>("[data-part=marker]")) {
368
+ const tip = pinAt(notes.find((note) => note.id === marker.dataset.note));
369
+ marker.hidden = !tip;
370
+ if (tip) {
371
+ marker.style.left = `${tip.x}px`;
372
+ marker.style.top = `${tip.y}px`;
373
+ }
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Where a note's pin points: the top right corner of its first element, or the place clicked
379
+ * when the note is about a place on the slide rather than an element.
380
+ */
381
+ function pinAt(note: AnnotationRow | undefined): Point | undefined {
382
+ const anchor = anchorOf(note);
383
+ const point = note?.targets[0]?.point;
384
+ if (!anchor) {
385
+ return undefined;
386
+ }
387
+ if (point && anchor.matches(".slide")) {
388
+ return windowPoint(point, deckEl as HTMLElement);
389
+ }
390
+ const rect = anchor.getBoundingClientRect();
391
+ return { x: rect.right, y: rect.top };
392
+ }
393
+
394
+ /**
395
+ * The element a note's marker sits on: its first element, on the slide on screen, where the
396
+ * server found it as the file is now.
397
+ */
398
+ function anchorOf(note: AnnotationRow | undefined): Element | null {
399
+ const { slug, slide } = onScreen();
400
+ const first = note?.targets[0];
401
+ if (!note || note.slug !== slug || !first?.found || !slide) {
402
+ return null;
403
+ }
404
+ const source = `${first.line}:${first.column}`;
405
+ return slide.getAttribute(SOURCE_ATTR) === source
406
+ ? slide
407
+ : slide.querySelector(`[${SOURCE_ATTR}="${source}"]`);
408
+ }
409
+
410
+ /** Redraw what the state is made of: the count, the picks, the choices, and the markers. */
411
+ function rebuild(): void {
412
+ part("count").textContent = LABELS.notes(notes.length);
413
+ part<HTMLButtonElement>("copy").disabled = notes.length === 0;
414
+ part<HTMLButtonElement>("clear").disabled = notes.length === 0;
415
+ part("picks").replaceChildren(
416
+ ...picks.map(() => {
417
+ const outline = document.createElement("div");
418
+ outline.dataset.part = "pick";
419
+ return outline;
420
+ }),
421
+ );
422
+ const last = picks.at(-1)?.el;
423
+ for (const [el, button] of choiceButtons) {
424
+ button.setAttribute("aria-pressed", String(el === last));
425
+ }
426
+ const picked = editing
427
+ ? editing.targets.map((target) => ({
428
+ name: target.name,
429
+ text: target.text ?? target.was,
430
+ source: `${target.line}:${target.column}`,
431
+ }))
432
+ : picks.map((pick) => describe(pick.el));
433
+ part("picked").replaceChildren(
434
+ ...picked.map((target) => {
435
+ const item = document.createElement("li");
436
+ item.textContent = label(target);
437
+ return item;
438
+ }),
439
+ );
440
+ // Numbered as Copy numbers them, so a marker and its note in the chat say the same number.
441
+ const markers = on
442
+ ? notes.flatMap((note) => {
443
+ if (!anchorOf(note)) {
444
+ return [];
445
+ }
446
+ const marker = document.createElement("button");
447
+ marker.type = "button";
448
+ marker.dataset.part = "marker";
449
+ marker.dataset.note = note.id;
450
+ marker.textContent = String(note.number);
451
+ marker.addEventListener("click", () => edit(note));
452
+ return [marker];
453
+ })
454
+ : [];
455
+ part("markers").replaceChildren(...markers);
456
+ hover.replaceChildren();
457
+ if (hovered) {
458
+ // Its name, its size on the slide, and its line, as a browser's inspector shows them.
459
+ const { name, source } = describe(hovered);
460
+ const { width, height } = slideBox(hovered, deckEl as HTMLElement);
461
+ const tag = document.createElement("span");
462
+ const size = document.createElement("i");
463
+ size.textContent = `${width}×${height}`;
464
+ tag.append(`${name} `, size, ` :${source.split(":")[0]}`);
465
+ hover.append(tag);
466
+ }
467
+ layout();
468
+ }
469
+
470
+ function openPopup(): void {
471
+ popup.hidden = false;
472
+ part("delete").hidden = editing === undefined;
473
+ part("hint").hidden = editing !== undefined;
474
+ (part("add").firstElementChild as HTMLElement).textContent = editing ? LABELS.save : LABELS.add;
475
+ placeOpenPopup();
476
+ field.focus();
477
+ }
478
+
479
+ /** Put the popup beside what the note is about, so it never hides what is pointed at. */
480
+ function placeOpenPopup(): void {
481
+ const rects = editing
482
+ ? [anchorOf(editing)?.getBoundingClientRect()].filter((rect) => rect !== undefined)
483
+ : picks.map((pick) => pick.el.getBoundingClientRect());
484
+ if (rects.length === 0) {
485
+ return;
486
+ }
487
+ const picked = {
488
+ left: Math.min(...rects.map((rect) => rect.left)),
489
+ top: Math.min(...rects.map((rect) => rect.top)),
490
+ right: Math.max(...rects.map((rect) => rect.right)),
491
+ bottom: Math.max(...rects.map((rect) => rect.bottom)),
492
+ };
493
+ const { width, height } = popup.getBoundingClientRect();
494
+ const at = placePopup(picked, { width, height }, { width: innerWidth, height: innerHeight });
495
+ popup.style.left = `${at.left}px`;
496
+ popup.style.top = `${at.top}px`;
497
+ }
498
+
499
+ function closePopup(): void {
500
+ popup.hidden = true;
501
+ picks = [];
502
+ showChoices([]);
503
+ editing = undefined;
504
+ field.value = "";
505
+ rebuild();
506
+ }
507
+
508
+ /**
509
+ * Offer the elements under a click: the one it hit and what that is inside, as a path from the
510
+ * slide in, and apart from it whatever else is under or near the click. None once put away.
511
+ */
512
+ function showChoices(found: Element[]): void {
513
+ const [first] = found;
514
+ const path: Element[] = [];
515
+ for (let el: Element | null = first ?? null; el; el = el.parentElement) {
516
+ if (found.includes(el)) {
517
+ path.unshift(el);
518
+ }
519
+ }
520
+ const near = found.filter((el) => !path.includes(el));
521
+ choiceButtons = new Map();
522
+ const chips = (elements: Element[], between: string): Node[] =>
523
+ elements.flatMap((el, index) => {
524
+ const button = document.createElement("button");
525
+ button.type = "button";
526
+ button.textContent = describe(el).name;
527
+ button.title = label(describe(el));
528
+ button.addEventListener("click", () => choose(el));
529
+ choiceButtons.set(el, button);
530
+ return index === 0 ? [button] : [separator(between), button];
531
+ });
532
+ // A path reads from the slide in; what is near is only a list.
533
+ part("path").replaceChildren(...chips(path, "›"));
534
+ part("near").replaceChildren(
535
+ ...(near.length > 0 ? [separator(LABELS.near), ...chips(near, "·")] : []),
536
+ );
537
+ part("near").hidden = near.length === 0;
538
+ }
539
+
540
+ function separator(text: string): HTMLElement {
541
+ const span = document.createElement("span");
542
+ span.textContent = text;
543
+ span.setAttribute("aria-hidden", "true");
544
+ return span;
545
+ }
546
+
547
+ /** Make `el`, one of the elements under the last click, the one that click picked. */
548
+ function choose(el: Element): void {
549
+ const last = picks.at(-1);
550
+ if (last) {
551
+ picks = [...picks.slice(0, -1), { el, at: last.at }];
552
+ }
553
+ rebuild();
554
+ placeOpenPopup();
555
+ field.focus();
556
+ }
557
+
558
+ function pickAt(at: Point, adding: boolean): void {
559
+ const found = candidates(at);
560
+ const [first] = found;
561
+ if (!first) {
562
+ return;
563
+ }
564
+ if (editing) {
565
+ closePopup();
566
+ }
567
+ if (adding && !popup.hidden) {
568
+ const already = picks.some((pick) => pick.el === first);
569
+ picks = already ? picks.filter((pick) => pick.el !== first) : [...picks, { el: first, at }];
570
+ if (picks.length === 0) {
571
+ closePopup();
572
+ return;
573
+ }
574
+ } else {
575
+ picks = [{ el: first, at }];
576
+ }
577
+ showChoices(found);
578
+ rebuild();
579
+ openPopup();
580
+ }
581
+
582
+ function edit(note: AnnotationRow): void {
583
+ closePopup();
584
+ editing = note;
585
+ field.value = note.text;
586
+ openPopup();
587
+ }
588
+
589
+ /** A picked element as the page sends it; the server reads its name and text from the file. */
590
+ function snapshot(pick: Pick, slide: Element): NewTarget {
591
+ const box = slideBox(pick.el, deckEl as HTMLElement);
592
+ const target: NewTarget = { source: describeElement(pick.el, slide).source, box };
593
+ return pick.el === slide
594
+ ? { ...target, point: slidePoint(pick.at, deckEl as HTMLElement) }
595
+ : target;
596
+ }
597
+
598
+ function submit(): void {
599
+ const text = field.value;
600
+ if (editing) {
601
+ void sync({ op: "edit", id: editing.id, text });
602
+ } else {
603
+ const { slug, step, slide } = onScreen();
604
+ if (!slide || picks.length === 0) {
605
+ return;
606
+ }
607
+ const targets = picks.map((pick) => snapshot(pick, slide));
608
+ void sync({ op: "add", slug, step, text, targets });
609
+ }
610
+ if (cleared) {
611
+ report("");
612
+ }
613
+ closePopup();
614
+ }
615
+
616
+ function remove(): void {
617
+ if (editing) {
618
+ void sync({ op: "remove", id: editing.id });
619
+ }
620
+ closePopup();
621
+ }
622
+
623
+ async function copy(): Promise<void> {
624
+ const text = formatNotes(deck, slides, notes);
625
+ try {
626
+ // Missing on a page served over plain HTTP, such as the LAN address.
627
+ if (!navigator.clipboard) {
628
+ throw new Error("no clipboard");
629
+ }
630
+ await navigator.clipboard.writeText(text);
631
+ report(LABELS.copied(notes.length));
632
+ } catch {
633
+ report(LABELS.manual, { byHand: text });
634
+ manual.focus();
635
+ manual.select();
636
+ }
637
+ }
638
+
639
+ // Holding Cmd or Ctrl to add an element lets clicks through the popup, which may lie over it.
640
+ // Heard on the way down, before a field of the view keeps its keys to itself.
641
+ const through = (event: KeyboardEvent): void => {
642
+ if (event.key === "Meta" || event.key === "Control") {
643
+ popup.toggleAttribute("data-through", event.type === "keydown");
644
+ }
645
+ };
646
+ addEventListener("keydown", through, true);
647
+ addEventListener("keyup", through, true);
648
+ addEventListener("blur", () => popup.removeAttribute("data-through"));
649
+
650
+ // The player hears every key too; `a` and Escape are not among its own.
651
+ document.addEventListener("keydown", (event) => {
652
+ if (isTextEntry(event.target)) {
653
+ return;
654
+ }
655
+ if (isLetterKey(event, "a")) {
656
+ event.preventDefault();
657
+ setOn(!on);
658
+ } else if (event.key === "Escape" && on) {
659
+ event.preventDefault();
660
+ if (popup.hidden) {
661
+ setOn(false);
662
+ } else {
663
+ closePopup();
664
+ }
665
+ }
666
+ });
667
+ // Keys typed into the note are the note's: the player hears a key from inside this view as
668
+ // one on the view's host, which is not a text field, and would move the deck on a space.
669
+ root.addEventListener("keydown", (event) => {
670
+ if (!(event instanceof KeyboardEvent) || !isTextEntry(event.target)) {
671
+ return;
672
+ }
673
+ event.stopPropagation();
674
+ if (event.target !== field) {
675
+ return;
676
+ }
677
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
678
+ event.preventDefault();
679
+ submit();
680
+ } else if (event.key === "Escape") {
681
+ event.preventDefault();
682
+ closePopup();
683
+ }
684
+ });
685
+ popup.addEventListener("submit", (event) => {
686
+ event.preventDefault();
687
+ submit();
688
+ });
689
+ layer.addEventListener("pointermove", (event) => {
690
+ const at = { x: event.clientX, y: event.clientY };
691
+ if (hoverTested) {
692
+ hoverPending = at;
693
+ } else {
694
+ hoverAt(at);
695
+ }
696
+ });
697
+ layer.addEventListener("pointerleave", () => {
698
+ hoverPending = undefined;
699
+ hovered = undefined;
700
+ rebuild();
701
+ });
702
+ layer.addEventListener("click", (event) => {
703
+ pickAt({ x: event.clientX, y: event.clientY }, event.metaKey || event.ctrlKey);
704
+ });
705
+ const act = (name: string, fn: () => void): void => {
706
+ part(name).addEventListener("click", (event) => {
707
+ // A button left focused would take the next space, which the deck is for.
708
+ (event.currentTarget as HTMLElement).blur();
709
+ fn();
710
+ });
711
+ };
712
+ act("cancel", closePopup);
713
+ act("delete", remove);
714
+ act("copy", () => void copy());
715
+ act("clear", () => {
716
+ void sync({ op: "clear" }).then((answer) => {
717
+ const gone = answer?.removed ?? [];
718
+ report(LABELS.cleared(gone.length), { undo: gone });
719
+ });
720
+ });
721
+ act("undo", () => {
722
+ const back = cleared ?? [];
723
+ report("");
724
+ void sync({ op: "restore", annotations: back });
725
+ });
726
+ act("close", () => setOn(false));
727
+ toggleEl?.addEventListener("click", () => setOn(!on));
728
+ addEventListener("hashchange", () => {
729
+ if (!popup.hidden && !editing) {
730
+ closePopup();
731
+ }
732
+ rebuild();
733
+ });
734
+ // A live update replaces a slide's section whole; the server matches its notes to the file
735
+ // as it is now, so the page asks for them again.
736
+ new MutationObserver((records) => {
737
+ const replaced = records.some((record) =>
738
+ [...record.addedNodes].some((node) => node instanceof Element && node.matches(".slide")),
739
+ );
740
+ // What was picked for the note being written went with the old section.
741
+ if (picks.some((pick) => !pick.el.isConnected)) {
742
+ closePopup();
743
+ }
744
+ if (replaced) {
745
+ void sync();
746
+ }
747
+ }).observe(deckEl, { childList: true });
748
+ // Another tab, or another device, changed the notes.
749
+ document.addEventListener(ANNOTATIONS_CHANGED, () => void sync());
750
+ rebuild();
751
+ void sync();
752
+ }