@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.
- package/CHANGELOG.md +20 -0
- package/README.ja.md +3 -1
- package/README.md +3 -1
- package/package.json +3 -1
- package/skills/dek/SKILL.md +44 -0
- package/src/cli/annotations.ts +26 -0
- package/src/cli/commands.ts +66 -0
- package/src/cli/contract.ts +75 -4
- package/src/cli/files.ts +3 -3
- package/src/cli/flags.ts +1 -1
- package/src/cli/format.ts +3 -0
- package/src/cli/pptx.ts +20 -0
- package/src/cli/show.ts +17 -2
- package/src/cli/text.ts +43 -0
- package/src/cli/theme.ts +7 -1
- package/src/core/annotation-rows.ts +176 -0
- package/src/core/annotations.ts +362 -0
- package/src/core/diagnostic.ts +1 -0
- package/src/core/html-scan.ts +54 -0
- package/src/core/html.ts +53 -8
- package/src/core/lint/slide-html.ts +92 -2
- package/src/core/lint.ts +8 -1
- package/src/core/live-protocol.ts +4 -1
- package/src/core/page.ts +6 -0
- package/src/core/playwright-module.d.ts +4 -1
- package/src/core/playwright-visual.ts +74 -1
- package/src/core/playwright.ts +51 -1
- package/src/core/pptx-package.ts +312 -0
- package/src/core/pptx-text.ts +363 -0
- package/src/core/pptx.ts +75 -0
- package/src/core/slide-measure.ts +1 -0
- package/src/core/sync.ts +13 -5
- package/src/core/theme-excerpt.ts +61 -9
- package/src/core/theme-facts.ts +12 -2
- package/src/core/zip.ts +77 -0
- package/src/runtime/annotate-pick.ts +2 -6
- package/src/runtime/annotate-view.ts +115 -101
- package/src/runtime/annotate.ts +5 -1
- package/src/runtime/browser.ts +5 -1
- package/src/runtime/live.ts +1 -0
- package/src/runtime/routes.ts +3 -0
- package/src/server/auth.ts +3 -1
- package/src/server/routes.ts +68 -1
- package/src/runtime/annotate-notes.ts +0 -207
package/src/core/zip.ts
ADDED
|
@@ -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:
|
|
95
|
+
text: el === slide ? "" : cutText(el.textContent ?? ""),
|
|
100
96
|
};
|
|
101
97
|
}
|
|
102
98
|
|
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
/// <reference lib="dom" />
|
|
2
|
-
import {
|
|
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: {
|
|
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?:
|
|
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 =
|
|
213
|
+
let notes: AnnotationRow[] = [];
|
|
200
214
|
/** What the last Clear took away, while Undo can still bring it back. */
|
|
201
|
-
let cleared:
|
|
215
|
+
let cleared: unknown[] | undefined;
|
|
202
216
|
let picks: Pick[] = [];
|
|
203
217
|
let choiceButtons = new Map<Element, HTMLButtonElement>();
|
|
204
|
-
let editing:
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
354
|
-
|
|
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.
|
|
401
|
+
if (!note || note.slug !== slug || !first?.found || !slide) {
|
|
358
402
|
return null;
|
|
359
403
|
}
|
|
360
|
-
|
|
404
|
+
const source = `${first.line}:${first.column}`;
|
|
405
|
+
return slide.getAttribute(SOURCE_ATTR) === source
|
|
361
406
|
? slide
|
|
362
|
-
: slide.querySelector(`[${SOURCE_ATTR}="${
|
|
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
|
|
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
|
-
?
|
|
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 =
|
|
399
|
-
marker.textContent = String(
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
718
|
-
|
|
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
|
-
|
|
731
|
-
|
|
744
|
+
if (replaced) {
|
|
745
|
+
void sync();
|
|
732
746
|
}
|
|
733
747
|
}).observe(deckEl, { childList: true });
|
|
734
|
-
|
|
735
|
-
|
|
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
|
}
|
package/src/runtime/annotate.ts
CHANGED
|
@@ -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:
|
|
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
|
}
|
package/src/runtime/browser.ts
CHANGED
|
@@ -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();
|
package/src/runtime/live.ts
CHANGED
package/src/runtime/routes.ts
CHANGED
|
@@ -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. */
|
package/src/server/auth.ts
CHANGED
|
@@ -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,
|
|
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. */
|
package/src/server/routes.ts
CHANGED
|
@@ -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
|
}
|