@hajimism/dek 0.2.1 → 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 (44) hide show
  1. package/CHANGELOG.md +20 -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/show.ts +17 -2
  14. package/src/cli/text.ts +43 -0
  15. package/src/cli/theme.ts +7 -1
  16. package/src/core/annotation-rows.ts +176 -0
  17. package/src/core/annotations.ts +362 -0
  18. package/src/core/diagnostic.ts +1 -0
  19. package/src/core/html-scan.ts +54 -0
  20. package/src/core/html.ts +53 -8
  21. package/src/core/lint/slide-html.ts +92 -2
  22. package/src/core/lint.ts +8 -1
  23. package/src/core/live-protocol.ts +4 -1
  24. package/src/core/page.ts +6 -0
  25. package/src/core/playwright-module.d.ts +4 -1
  26. package/src/core/playwright-visual.ts +74 -1
  27. package/src/core/playwright.ts +51 -1
  28. package/src/core/pptx-package.ts +312 -0
  29. package/src/core/pptx-text.ts +363 -0
  30. package/src/core/pptx.ts +75 -0
  31. package/src/core/slide-measure.ts +1 -0
  32. package/src/core/sync.ts +13 -5
  33. package/src/core/theme-excerpt.ts +61 -9
  34. package/src/core/theme-facts.ts +12 -2
  35. package/src/core/zip.ts +77 -0
  36. package/src/runtime/annotate-pick.ts +2 -6
  37. package/src/runtime/annotate-view.ts +115 -101
  38. package/src/runtime/annotate.ts +5 -1
  39. package/src/runtime/browser.ts +5 -1
  40. package/src/runtime/live.ts +1 -0
  41. package/src/runtime/routes.ts +3 -0
  42. package/src/server/auth.ts +3 -1
  43. package/src/server/routes.ts +68 -1
  44. package/src/runtime/annotate-notes.ts +0 -207
@@ -0,0 +1,77 @@
1
+ /** One file of an archive: its path inside it, with `/` between folders, and its bytes. */
2
+ export type ZipFile = { path: string; data: Uint8Array };
3
+
4
+ /**
5
+ * A ZIP archive of `files`, in order, each stored as it is. What dek packs is pictures, already
6
+ * compressed, and a little XML, so deflating would buy little and cost a dependency or a
7
+ * compressor to keep. Paths are UTF-8, and the time is fixed, so the same files make the same
8
+ * bytes.
9
+ */
10
+ export function zipStored(files: readonly ZipFile[]): Uint8Array {
11
+ const encoder = new TextEncoder();
12
+ const locals: Uint8Array[] = [];
13
+ const centrals: Uint8Array[] = [];
14
+ let offset = 0;
15
+ for (const file of files) {
16
+ const name = encoder.encode(file.path);
17
+ const crc = Bun.hash.crc32(file.data);
18
+ const size = file.data.length;
19
+
20
+ const local = new Uint8Array(30 + name.length);
21
+ const head = new DataView(local.buffer);
22
+ head.setUint32(0, 0x04034b50, true);
23
+ writeCommon(head, 4, { crc, size });
24
+ head.setUint16(26, name.length, true);
25
+ local.set(name, 30);
26
+ locals.push(local, file.data);
27
+
28
+ const central = new Uint8Array(46 + name.length);
29
+ const entry = new DataView(central.buffer);
30
+ entry.setUint32(0, 0x02014b50, true);
31
+ entry.setUint16(4, 20, true);
32
+ writeCommon(entry, 6, { crc, size });
33
+ entry.setUint16(28, name.length, true);
34
+ entry.setUint32(42, offset, true);
35
+ central.set(name, 46);
36
+ centrals.push(central);
37
+
38
+ offset += local.length + size;
39
+ }
40
+ const directorySize = centrals.reduce((sum, part) => sum + part.length, 0);
41
+ const end = new Uint8Array(22);
42
+ const tail = new DataView(end.buffer);
43
+ tail.setUint32(0, 0x06054b50, true);
44
+ tail.setUint16(8, files.length, true);
45
+ tail.setUint16(10, files.length, true);
46
+ tail.setUint32(12, directorySize, true);
47
+ tail.setUint32(16, offset, true);
48
+ return concat([...locals, ...centrals, end]);
49
+ }
50
+
51
+ /** 1980-01-01 00:00, the earliest time a ZIP can say. */
52
+ const DOS_DATE = (0 << 9) | (1 << 5) | 1;
53
+
54
+ /**
55
+ * What the local header and the central directory entry share, from the version needed on: no
56
+ * flags but UTF-8 names, stored, the fixed time, the CRC, and the size twice.
57
+ */
58
+ function writeCommon(view: DataView, at: number, { crc, size }: { crc: number; size: number }) {
59
+ view.setUint16(at, 20, true);
60
+ view.setUint16(at + 2, 1 << 11, true);
61
+ view.setUint16(at + 4, 0, true);
62
+ view.setUint16(at + 6, 0, true);
63
+ view.setUint16(at + 8, DOS_DATE, true);
64
+ view.setUint32(at + 10, crc, true);
65
+ view.setUint32(at + 14, size, true);
66
+ view.setUint32(at + 18, size, true);
67
+ }
68
+
69
+ function concat(parts: readonly Uint8Array[]): Uint8Array {
70
+ const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
71
+ let at = 0;
72
+ for (const part of parts) {
73
+ out.set(part, at);
74
+ at += part.length;
75
+ }
76
+ return out;
77
+ }
@@ -3,9 +3,9 @@
3
3
  // each is called, and where it is on the slide. Only elements the dev server stamped with where
4
4
  // they are written count, so every one can be named by its file and line.
5
5
 
6
+ import { type Box, cutText } from "../core/annotation-rows.ts";
6
7
  import { deckStops, positionFromHash } from "../core/position.ts";
7
8
  import { type BeatRef, stepKey } from "../core/step.ts";
8
- import type { Box } from "./annotate-notes.ts";
9
9
 
10
10
  /** The attribute the dev server writes on each start tag of a slide: `<line>:<column>`. */
11
11
  export const SOURCE_ATTR = "data-dek-source";
@@ -13,9 +13,6 @@ export const SOURCE_ATTR = "data-dek-source";
13
13
  /** The classes the dev server saw written on each stamped tag, before any script added more. */
14
14
  const CLASS_ATTR = "data-dek-class";
15
15
 
16
- /** How long a text may be before it is cut short. */
17
- const TEXT_MAX = 40;
18
-
19
16
  type Point = { x: number; y: number };
20
17
 
21
18
  /**
@@ -92,11 +89,10 @@ export function describeElement(
92
89
  slide: Element,
93
90
  ): { source: string; name: string; text: string } {
94
91
  const classes = (el.getAttribute(CLASS_ATTR) ?? "").split(" ").filter(Boolean);
95
- const text = el === slide ? "" : (el.textContent ?? "").replace(/\s+/g, " ").trim();
96
92
  return {
97
93
  source: el.getAttribute(SOURCE_ATTR) ?? "",
98
94
  name: [el.tagName.toLowerCase(), ...classes].join("."),
99
- text: text.length > TEXT_MAX ? `${text.slice(0, TEXT_MAX)}…` : text,
95
+ text: el === slide ? "" : cutText(el.textContent ?? ""),
100
96
  };
101
97
  }
102
98
 
@@ -1,15 +1,7 @@
1
1
  /// <reference lib="dom" />
2
- import { PAGE_ID } from "../core/page.ts";
2
+ import { type AnnotationRow, type Box, formatNotes, type Point } from "../core/annotation-rows.ts";
3
+ import { ANNOTATIONS_CHANGED, PAGE_ID } from "../core/page.ts";
3
4
  import type { PresenterSlide } from "../core/presenter-state.ts";
4
- import {
5
- formatNotes,
6
- type Note,
7
- type NoteTarget,
8
- readNotes,
9
- reanchor,
10
- sortNotes,
11
- writeNotes,
12
- } from "./annotate-notes.ts";
13
5
  import {
14
6
  candidatesAt,
15
7
  currentStop,
@@ -23,8 +15,6 @@ import { placePopup } from "./annotate-place.ts";
23
15
  import { slideSelector } from "./live.ts";
24
16
  import { isLetterKey, isTextEntry } from "./step.ts";
25
17
 
26
- type Point = { x: number; y: number };
27
-
28
18
  /** One element picked for the note being written, and where the click that picked it was. */
29
19
  type Pick = { el: Element; at: Point };
30
20
 
@@ -45,6 +35,7 @@ const LABELS = {
45
35
  notes: (n: number) => `${n} ${n === 1 ? "note" : "notes"}`,
46
36
  copied: (n: number) => `Copied ${n} ${n === 1 ? "note" : "notes"}`,
47
37
  manual: "Press ⌘C or Ctrl+C to copy",
38
+ failed: (why: string) => `Not saved: ${why}`,
48
39
  };
49
40
 
50
41
  /** Line icons, drawn in the text's color. */
@@ -111,20 +102,43 @@ textarea::placeholder { color: var(--faint); }
111
102
  [data-part=undo] { margin-left: auto; padding: 3px 10px; color: var(--ink); background: var(--raised); }
112
103
  `;
113
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
+
114
121
  /**
115
122
  * Annotate mode: point at elements of the slide on screen, write what should change, and copy
116
123
  * the notes as one block for an agent, each element named by the file and line it is written
117
124
  * at. It runs only on the dev server's pages for the speaker, beside the player, and shows
118
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.
119
128
  */
120
- export function createAnnotateView(options: { deck: string; slides: PresenterSlide[] }): void {
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 {
121
136
  const { deck, slides } = options;
122
137
  const deckEl = document.getElementById(PAGE_ID.deck);
123
138
  const stageEl = document.getElementById(PAGE_ID.currentStage);
124
139
  if (!deckEl || !stageEl) {
125
140
  return;
126
141
  }
127
- const storageKey = `dek-annotate:${deck}`;
128
142
  const toggleEl = document.getElementById(PAGE_ID.annotateToggle);
129
143
 
130
144
  const host = document.createElement("div");
@@ -182,7 +196,7 @@ export function createAnnotateView(options: { deck: string; slides: PresenterSli
182
196
  * and with Undo after Clear. Clear is undone rather than confirmed, and only until something
183
197
  * else is said here, a note is written, or the mode is left.
184
198
  */
185
- function report(text: string, extra: { byHand?: string; undo?: Note[] } = {}): void {
199
+ function report(text: string, extra: { byHand?: string; undo?: unknown[] } = {}): void {
186
200
  status.textContent = text;
187
201
  manual.hidden = extra.byHand === undefined;
188
202
  manual.value = extra.byHand ?? "";
@@ -196,31 +210,58 @@ export function createAnnotateView(options: { deck: string; slides: PresenterSli
196
210
  hitStyle.textContent = `#${PAGE_ID.deck} [${SOURCE_ATTR}] { pointer-events: auto !important; }`;
197
211
 
198
212
  let on = false;
199
- let notes = load();
213
+ let notes: AnnotationRow[] = [];
200
214
  /** What the last Clear took away, while Undo can still bring it back. */
201
- let cleared: Note[] | undefined;
215
+ let cleared: unknown[] | undefined;
202
216
  let picks: Pick[] = [];
203
217
  let choiceButtons = new Map<Element, HTMLButtonElement>();
204
- let editing: Note | undefined;
218
+ let editing: AnnotationRow | undefined;
205
219
  let hovered: Element | undefined;
206
220
  let frame: number | undefined;
207
221
  // Hit testing tries dozens of points; the pointer is followed at most once a frame.
208
222
  let hoverTested = false;
209
223
  let hoverPending: Point | undefined;
210
224
 
211
- function load(): Note[] {
212
- try {
213
- return readNotes(sessionStorage.getItem(storageKey));
214
- } catch {
215
- return [];
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}`);
216
244
  }
217
- }
245
+ return { annotations: body.annotations, ...(body.removed ? { removed: body.removed } : {}) };
246
+ };
218
247
 
219
- function save(): void {
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> {
220
254
  try {
221
- sessionStorage.setItem(storageKey, writeNotes(notes));
222
- } catch {
223
- // Storage blocked: the notes last as long as the page.
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;
224
265
  }
225
266
  }
226
267
 
@@ -324,7 +365,7 @@ export function createAnnotateView(options: { deck: string; slides: PresenterSli
324
365
  }
325
366
  });
326
367
  for (const marker of part("markers").querySelectorAll<HTMLElement>("[data-part=marker]")) {
327
- const tip = pinAt(notes[Number(marker.dataset.note)]);
368
+ const tip = pinAt(notes.find((note) => note.id === marker.dataset.note));
328
369
  marker.hidden = !tip;
329
370
  if (tip) {
330
371
  marker.style.left = `${tip.x}px`;
@@ -337,7 +378,7 @@ export function createAnnotateView(options: { deck: string; slides: PresenterSli
337
378
  * Where a note's pin points: the top right corner of its first element, or the place clicked
338
379
  * when the note is about a place on the slide rather than an element.
339
380
  */
340
- function pinAt(note: Note | undefined): Point | undefined {
381
+ function pinAt(note: AnnotationRow | undefined): Point | undefined {
341
382
  const anchor = anchorOf(note);
342
383
  const point = note?.targets[0]?.point;
343
384
  if (!anchor) {
@@ -350,16 +391,20 @@ export function createAnnotateView(options: { deck: string; slides: PresenterSli
350
391
  return { x: rect.right, y: rect.top };
351
392
  }
352
393
 
353
- /** The element a note's marker sits on: its first element, on the slide on screen. */
354
- function anchorOf(note: Note | undefined): Element | null {
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 {
355
399
  const { slug, slide } = onScreen();
356
400
  const first = note?.targets[0];
357
- if (!note || note.stale || note.slug !== slug || !first || !slide) {
401
+ if (!note || note.slug !== slug || !first?.found || !slide) {
358
402
  return null;
359
403
  }
360
- return slide.getAttribute(SOURCE_ATTR) === first.source
404
+ const source = `${first.line}:${first.column}`;
405
+ return slide.getAttribute(SOURCE_ATTR) === source
361
406
  ? slide
362
- : slide.querySelector(`[${SOURCE_ATTR}="${first.source}"]`);
407
+ : slide.querySelector(`[${SOURCE_ATTR}="${source}"]`);
363
408
  }
364
409
 
365
410
  /** Redraw what the state is made of: the count, the picks, the choices, and the markers. */
@@ -378,7 +423,13 @@ export function createAnnotateView(options: { deck: string; slides: PresenterSli
378
423
  for (const [el, button] of choiceButtons) {
379
424
  button.setAttribute("aria-pressed", String(el === last));
380
425
  }
381
- const picked = editing ? editing.targets : picks.map((pick) => describe(pick.el));
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));
382
433
  part("picked").replaceChildren(
383
434
  ...picked.map((target) => {
384
435
  const item = document.createElement("li");
@@ -388,15 +439,15 @@ export function createAnnotateView(options: { deck: string; slides: PresenterSli
388
439
  );
389
440
  // Numbered as Copy numbers them, so a marker and its note in the chat say the same number.
390
441
  const markers = on
391
- ? sortNotes(slides, notes).flatMap((note, index) => {
442
+ ? notes.flatMap((note) => {
392
443
  if (!anchorOf(note)) {
393
444
  return [];
394
445
  }
395
446
  const marker = document.createElement("button");
396
447
  marker.type = "button";
397
448
  marker.dataset.part = "marker";
398
- marker.dataset.note = String(notes.indexOf(note));
399
- marker.textContent = String(index + 1);
449
+ marker.dataset.note = note.id;
450
+ marker.textContent = String(note.number);
400
451
  marker.addEventListener("click", () => edit(note));
401
452
  return [marker];
402
453
  })
@@ -528,16 +579,17 @@ export function createAnnotateView(options: { deck: string; slides: PresenterSli
528
579
  openPopup();
529
580
  }
530
581
 
531
- function edit(note: Note): void {
582
+ function edit(note: AnnotationRow): void {
532
583
  closePopup();
533
584
  editing = note;
534
585
  field.value = note.text;
535
586
  openPopup();
536
587
  }
537
588
 
538
- function snapshot(pick: Pick, slide: Element): NoteTarget {
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 {
539
591
  const box = slideBox(pick.el, deckEl as HTMLElement);
540
- const target: NoteTarget = { ...describeElement(pick.el, slide), box };
592
+ const target: NewTarget = { source: describeElement(pick.el, slide).source, box };
541
593
  return pick.el === slide
542
594
  ? { ...target, point: slidePoint(pick.at, deckEl as HTMLElement) }
543
595
  : target;
@@ -546,26 +598,25 @@ export function createAnnotateView(options: { deck: string; slides: PresenterSli
546
598
  function submit(): void {
547
599
  const text = field.value;
548
600
  if (editing) {
549
- const target = editing;
550
- notes = notes.map((note) => (note === target ? { ...note, text } : note));
601
+ void sync({ op: "edit", id: editing.id, text });
551
602
  } else {
552
603
  const { slug, step, slide } = onScreen();
553
604
  if (!slide || picks.length === 0) {
554
605
  return;
555
606
  }
556
- notes = [...notes, { slug, step, targets: picks.map((pick) => snapshot(pick, slide)), text }];
607
+ const targets = picks.map((pick) => snapshot(pick, slide));
608
+ void sync({ op: "add", slug, step, text, targets });
557
609
  }
558
610
  if (cleared) {
559
611
  report("");
560
612
  }
561
- save();
562
613
  closePopup();
563
614
  }
564
615
 
565
616
  function remove(): void {
566
- const target = editing;
567
- notes = notes.filter((note) => note !== target);
568
- save();
617
+ if (editing) {
618
+ void sync({ op: "remove", id: editing.id });
619
+ }
569
620
  closePopup();
570
621
  }
571
622
 
@@ -585,36 +636,6 @@ export function createAnnotateView(options: { deck: string; slides: PresenterSli
585
636
  }
586
637
  }
587
638
 
588
- /**
589
- * Match the notes on a slide an edit replaced to its elements as they are now; a note that
590
- * cannot be matched keeps what it said, marked as written before the edit.
591
- */
592
- function follow(slug: string): void {
593
- const slide = document.querySelector(slideSelector(slug));
594
- if (!slide) {
595
- return;
596
- }
597
- const present = [slide, ...slide.querySelectorAll(`[${SOURCE_ATTR}]`)].map((el) =>
598
- describeElement(el, slide),
599
- );
600
- notes = notes.map((note) => {
601
- if (note.slug !== slug) {
602
- return note;
603
- }
604
- const found = note.targets.map((target) => reanchor(target, present));
605
- if (found.some((el) => el === undefined)) {
606
- return { ...note, stale: true };
607
- }
608
- const { stale: _, ...rest } = note;
609
- return {
610
- ...rest,
611
- targets: note.targets.map((target, index) => ({ ...target, ...found[index] })),
612
- };
613
- });
614
- save();
615
- rebuild();
616
- }
617
-
618
639
  // Holding Cmd or Ctrl to add an element lets clicks through the popup, which may lie over it.
619
640
  // Heard on the way down, before a field of the view keeps its keys to itself.
620
641
  const through = (event: KeyboardEvent): void => {
@@ -692,17 +713,15 @@ export function createAnnotateView(options: { deck: string; slides: PresenterSli
692
713
  act("delete", remove);
693
714
  act("copy", () => void copy());
694
715
  act("clear", () => {
695
- const gone = notes;
696
- notes = [];
697
- save();
698
- rebuild();
699
- report(LABELS.cleared(gone.length), { undo: gone });
716
+ void sync({ op: "clear" }).then((answer) => {
717
+ const gone = answer?.removed ?? [];
718
+ report(LABELS.cleared(gone.length), { undo: gone });
719
+ });
700
720
  });
701
721
  act("undo", () => {
702
- notes = cleared ?? notes;
703
- save();
704
- rebuild();
722
+ const back = cleared ?? [];
705
723
  report("");
724
+ void sync({ op: "restore", annotations: back });
706
725
  });
707
726
  act("close", () => setOn(false));
708
727
  toggleEl?.addEventListener("click", () => setOn(!on));
@@ -712,27 +731,22 @@ export function createAnnotateView(options: { deck: string; slides: PresenterSli
712
731
  }
713
732
  rebuild();
714
733
  });
715
- // A live update replaces a slide's section whole; its notes are matched to the new one.
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.
716
736
  new MutationObserver((records) => {
717
- const slugs = new Set(
718
- records.flatMap((record) =>
719
- [...record.addedNodes].flatMap((node) =>
720
- node instanceof Element && node.matches(".slide")
721
- ? [node.getAttribute("data-slug") ?? ""]
722
- : [],
723
- ),
724
- ),
737
+ const replaced = records.some((record) =>
738
+ [...record.addedNodes].some((node) => node instanceof Element && node.matches(".slide")),
725
739
  );
726
740
  // What was picked for the note being written went with the old section.
727
741
  if (picks.some((pick) => !pick.el.isConnected)) {
728
742
  closePopup();
729
743
  }
730
- for (const slug of slugs) {
731
- follow(slug);
744
+ if (replaced) {
745
+ void sync();
732
746
  }
733
747
  }).observe(deckEl, { childList: true });
734
- for (const slug of new Set(notes.map((note) => note.slug))) {
735
- follow(slug);
736
- }
748
+ // Another tab, or another device, changed the notes.
749
+ document.addEventListener(ANNOTATIONS_CHANGED, () => void sync());
737
750
  rebuild();
751
+ void sync();
738
752
  }
@@ -5,11 +5,15 @@
5
5
  import { PAGE_ID, readPageConfig } from "../core/page.ts";
6
6
  import type { PresenterSlide } from "../core/presenter-state.ts";
7
7
  import { createAnnotateView } from "./annotate-view.ts";
8
+ import { deckUrl, liveTokenQuery } from "./routes.ts";
8
9
 
9
10
  const dataEl = document.getElementById(PAGE_ID.data);
10
11
  if (dataEl?.textContent) {
12
+ const page = readPageConfig(document.body);
11
13
  createAnnotateView({
12
- deck: readPageConfig(document.body).deck,
14
+ deck: page.deck,
13
15
  slides: JSON.parse(dataEl.textContent) as PresenterSlide[],
16
+ url: `${deckUrl(location.pathname, { kind: "annotations" })}${liveTokenQuery(page.liveToken)}`,
17
+ fetch: (input, init) => fetch(input, init),
14
18
  });
15
19
  }
@@ -1,7 +1,7 @@
1
1
  /// <reference lib="dom" />
2
2
 
3
3
  import { isLiveEvent } from "../core/live-protocol.ts";
4
- import { PAGE_ID, readPageConfig } from "../core/page.ts";
4
+ import { ANNOTATIONS_CHANGED, PAGE_ID, readPageConfig } from "../core/page.ts";
5
5
  import { deckStops, moveTarget, positionsEqual } from "../core/position.ts";
6
6
  import type { PresenterSlide } from "../core/presenter-state.ts";
7
7
  import type { Position } from "../core/step.ts";
@@ -185,6 +185,10 @@ if (dataEl?.textContent) {
185
185
  }
186
186
  return;
187
187
  }
188
+ if (raw.type === "annotations") {
189
+ document.dispatchEvent(new Event(ANNOTATIONS_CHANGED));
190
+ return;
191
+ }
188
192
  const event = await hydrateLiveEvent(raw, location.pathname);
189
193
  if (!event) {
190
194
  location.reload();
@@ -62,6 +62,7 @@ export function applyLiveEvent(event: HydratedLiveEvent, host: LiveHost): { relo
62
62
  host.setDiagnostics(formatLiveDiagnostics(event.diagnostics));
63
63
  return { reload: false };
64
64
  case "timeline":
65
+ case "annotations":
65
66
  return { reload: false };
66
67
  }
67
68
  }
@@ -20,6 +20,8 @@ export type DeckRoute =
20
20
  | { kind: "goto" }
21
21
  /** The beats the presenter marked to rewrite: listed, and one marked or unmarked. */
22
22
  | { kind: "marks" }
23
+ /** The notes annotate mode keeps on elements of the slides: listed, and one added or changed. */
24
+ | { kind: "annotations" }
23
25
  /** A file under the deck's `assets/`, decoded. */
24
26
  | { kind: "asset"; path: string };
25
27
 
@@ -34,6 +36,7 @@ const FIXED: Record<Fixed, string> = {
34
36
  current: "/current",
35
37
  goto: "/goto",
36
38
  marks: "/marks",
39
+ annotations: "/annotations",
37
40
  };
38
41
 
39
42
  /** The path of `route` below its deck's prefix. */
@@ -86,7 +86,7 @@ export type Exposure = "audience" | "presenter";
86
86
 
87
87
  /**
88
88
  * What each dev server route reveals. The script, its notes, the voice read from it, moving the
89
- * deck, and the marks left on its beats belong to the presenter. The server routes only by these names, so a new route does
89
+ * deck, the marks left on its beats, and the annotations on its slides belong to the presenter. The server routes only by these names, so a new route does
90
90
  * not compile until it is placed here.
91
91
  */
92
92
  export const ROUTE_EXPOSURE = {
@@ -101,6 +101,7 @@ export const ROUTE_EXPOSURE = {
101
101
  voice: "presenter",
102
102
  control: "presenter",
103
103
  marks: "presenter",
104
+ annotations: "presenter",
104
105
  missing: "audience",
105
106
  } as const satisfies Record<string, Exposure>;
106
107
 
@@ -117,6 +118,7 @@ export const EVENT_EXPOSURE: Record<LiveEventType, Exposure> = {
117
118
  "reload-script": "audience",
118
119
  diagnostics: "presenter",
119
120
  timeline: "presenter",
121
+ annotations: "presenter",
120
122
  };
121
123
 
122
124
  /** True when a request cleared for `seen` may read what `exposure` names. */
@@ -1,5 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, relative } from "node:path";
3
+ import type { AnnotationRow } from "../core/annotation-rows.ts";
4
+ import { AnnotationOp, applyAnnotationOp, listAnnotations } from "../core/annotations.ts";
3
5
  import { fileInside, readTheme } from "../core/assets.ts";
4
6
  import { DekError, errorFields } from "../core/error.ts";
5
7
  import { escapeAttr, escapeHtml } from "../core/escape.ts";
@@ -116,6 +118,16 @@ export function routeRequest(req: Request, ctx: RouteContext): Routed {
116
118
  respond: () =>
117
119
  deck ? marksResponse(req, ctx.project().root, deck) : jsonNotFound(deckName),
118
120
  };
121
+ case "annotations":
122
+ return {
123
+ route: "annotations",
124
+ respond: () =>
125
+ deck
126
+ ? annotationsResponse(req, ctx.project().root, deck, () =>
127
+ ctx.hub.emit({ type: "annotations", deck: deckName }),
128
+ )
129
+ : jsonNotFound(deckName),
130
+ };
119
131
  case "asset": {
120
132
  const asset = dir && safeDeckAsset(dir, route.path);
121
133
  return asset
@@ -217,6 +229,61 @@ async function marksResponse(req: Request, root: string, deck: ProjectDeck): Pro
217
229
  }
218
230
  }
219
231
 
232
+ /**
233
+ * The deck's annotations: GET lists them as the files have them now, and a POST of an operation
234
+ * adds, edits, removes, clears, or puts back. Every change is told to the deck's other pages,
235
+ * so tabs and devices annotating one deck show the same notes. One that names nothing on the
236
+ * slide as it is now, sent from a page an edit left behind, is refused.
237
+ */
238
+ async function annotationsResponse(
239
+ req: Request,
240
+ root: string,
241
+ deck: ProjectDeck,
242
+ changed: () => void,
243
+ ): Promise<Response> {
244
+ const rows = (annotations: AnnotationRow[]) =>
245
+ annotations.map((row) => ({
246
+ ...row,
247
+ targets: row.targets.map((target) => ({ ...target, path: relative(root, target.path) })),
248
+ changed: row.changed.map((path) => relative(root, path)),
249
+ }));
250
+ try {
251
+ if (req.method === "GET") {
252
+ return jsonResponse({ ok: true, annotations: rows(listAnnotations(root, deck)) });
253
+ }
254
+ if (req.method !== "POST") {
255
+ return new Response("Method not allowed", { status: 405 });
256
+ }
257
+ let body: unknown;
258
+ try {
259
+ body = await req.json();
260
+ } catch {
261
+ body = undefined;
262
+ }
263
+ const op = AnnotationOp.safeParse(body);
264
+ if (!op.success) {
265
+ return jsonResponse(
266
+ {
267
+ ok: false,
268
+ error: {
269
+ message:
270
+ 'the request names no operation: send {"op": "add" | "edit" | "remove" | "clear" | "restore", …}',
271
+ },
272
+ },
273
+ 400,
274
+ );
275
+ }
276
+ const done = applyAnnotationOp(root, deck, op.data);
277
+ changed();
278
+ return jsonResponse({ ok: true, ...done, annotations: rows(done.annotations) });
279
+ } catch (error) {
280
+ if (!(error instanceof DekError)) {
281
+ throw error;
282
+ }
283
+ return jsonResponse({ ok: false, error: errorFields(error) }, 409);
284
+ }
285
+ }
286
+
220
287
  function jsonNotFound(deckName: string): Response {
221
288
  return jsonResponse({ ok: false, error: { message: `deck "${deckName}" not found` } }, 404);
222
289
  }