@hajimism/dek 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.ja.md +3 -1
- package/README.md +3 -1
- package/package.json +4 -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/env-file.ts +78 -0
- 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/cli.ts +5 -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/github.ts +27 -24
- 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 +7 -3
- package/src/core/playwright-visual.ts +76 -1
- package/src/core/playwright.ts +65 -2
- 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/slide-script-eval.ts +2 -1
- package/src/core/spawn.ts +8 -1
- package/src/core/sync.ts +33 -6
- 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/video/worker.ts +2 -0
- package/tsconfig.json +26 -0
- package/src/runtime/annotate-notes.ts +0 -207
|
@@ -6,10 +6,9 @@ import {
|
|
|
6
6
|
selectorLayouts,
|
|
7
7
|
splitSelectorList,
|
|
8
8
|
} from "./css.ts";
|
|
9
|
+
import { blankStringsAndComments } from "./css-scan.ts";
|
|
9
10
|
import { escapeRegExp } from "./escape.ts";
|
|
10
|
-
|
|
11
|
-
/** Classes the player adds at runtime, so a slide's markup never names them. */
|
|
12
|
-
const RUNTIME_CLASSES = ["is-current", "is-shown"];
|
|
11
|
+
import { RUNTIME_CLASSES } from "./theme-facts.ts";
|
|
13
12
|
|
|
14
13
|
export type SlideUsage = {
|
|
15
14
|
/** Classes the slide's markup uses. */
|
|
@@ -18,8 +17,17 @@ export type SlideUsage = {
|
|
|
18
17
|
layout?: string;
|
|
19
18
|
/** The slide's own stylesheet, whose `var()` and animations reach into the theme too. */
|
|
20
19
|
css?: Stylesheet;
|
|
20
|
+
/**
|
|
21
|
+
* The element tags and attribute names the slide's markup has. Given, a rule that needs an
|
|
22
|
+
* element or an attribute the slide lacks, as `.slide ul` or `[data-step]`, is left out; left
|
|
23
|
+
* out, every element and attribute rule under `.slide` is kept.
|
|
24
|
+
*/
|
|
25
|
+
markup?: { tags: Iterable<string>; attributes: Iterable<string> };
|
|
21
26
|
};
|
|
22
27
|
|
|
28
|
+
/** The markup as a selector meets it: lowercased, with what the build stamps on every slide. */
|
|
29
|
+
type Markup = { tags: Set<string>; attributes: Set<string> };
|
|
30
|
+
|
|
23
31
|
type ExcerptEntry = {
|
|
24
32
|
atPath: string[];
|
|
25
33
|
selector: string;
|
|
@@ -29,9 +37,9 @@ type ExcerptEntry = {
|
|
|
29
37
|
|
|
30
38
|
/**
|
|
31
39
|
* The part of a theme one slide depends on, as CSS that reads on its own: the
|
|
32
|
-
* rules its classes and layout select, element and state rules under `.slide
|
|
33
|
-
* the
|
|
34
|
-
* through `var()`. It errs toward keeping a rule, since a rule left out misleads
|
|
40
|
+
* rules its classes and layout select, element and state rules under `.slide`
|
|
41
|
+
* for the elements and attributes it has, the keyframes those rules animate
|
|
42
|
+
* with, and only the tokens they reach through `var()`. It errs toward keeping a rule, since a rule left out misleads
|
|
35
43
|
* a reader silently while an extra one only costs a line.
|
|
36
44
|
*/
|
|
37
45
|
export function themeExcerpt(sheet: Stylesheet, usage: SlideUsage): string {
|
|
@@ -66,6 +74,10 @@ export function themeExcerpt(sheet: Stylesheet, usage: SlideUsage): string {
|
|
|
66
74
|
*/
|
|
67
75
|
function excerptEntries(sheet: Stylesheet, usage: SlideUsage): ExcerptEntry[] {
|
|
68
76
|
const used = new Set([...usage.classes, "slide", ...RUNTIME_CLASSES]);
|
|
77
|
+
const markup = usage.markup && {
|
|
78
|
+
tags: new Set([...usage.markup.tags].map((tag) => tag.toLowerCase())),
|
|
79
|
+
attributes: new Set([...usage.markup.attributes].map((name) => name.toLowerCase())),
|
|
80
|
+
};
|
|
69
81
|
const entries: ExcerptEntry[] = [];
|
|
70
82
|
for (const rule of sheet.rules) {
|
|
71
83
|
// An at-rule's block reads here only where it styles its parent's elements.
|
|
@@ -82,7 +94,7 @@ function excerptEntries(sheet: Stylesheet, usage: SlideUsage): ExcerptEntry[] {
|
|
|
82
94
|
}
|
|
83
95
|
const parts = splitSelectorList(selector)
|
|
84
96
|
.map((part) => part.trim())
|
|
85
|
-
.filter((part) => part && selectorPartApplies(part, used, usage.layout));
|
|
97
|
+
.filter((part) => part && selectorPartApplies(part, used, usage.layout, markup));
|
|
86
98
|
if (parts.length > 0) {
|
|
87
99
|
entries.push({ atPath, selector: parts.join(", "), decls: rule.decls });
|
|
88
100
|
}
|
|
@@ -104,13 +116,53 @@ function animatedKeyframes(styled: CssDeclaration[], entries: ExcerptEntry[]): S
|
|
|
104
116
|
);
|
|
105
117
|
}
|
|
106
118
|
|
|
107
|
-
function selectorPartApplies(
|
|
119
|
+
function selectorPartApplies(
|
|
120
|
+
part: string,
|
|
121
|
+
used: Set<string>,
|
|
122
|
+
layout: string | undefined,
|
|
123
|
+
markup: Markup | undefined,
|
|
124
|
+
): boolean {
|
|
108
125
|
if (part.startsWith("::view-transition")) {
|
|
109
126
|
return false;
|
|
110
127
|
}
|
|
111
128
|
return (
|
|
112
129
|
selectorClasses(part).every((name) => used.has(name)) &&
|
|
113
|
-
selectorLayouts(part).every((name) => name === layout)
|
|
130
|
+
selectorLayouts(part).every((name) => name === layout) &&
|
|
131
|
+
(markup === undefined || selectorFitsMarkup(part, markup))
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Attributes the build sets on every slide, or that the layout check already decides. */
|
|
136
|
+
const STAMPED_ATTRIBUTES = new Set(["data-layout", "data-slug", "style"]);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether the markup has every element and attribute a selector needs. Only what the selector
|
|
140
|
+
* requires outright counts: a name inside `:not()`, `:is()`, `:has()`, or any other parenthesis
|
|
141
|
+
* may be absent, so it keeps the rule.
|
|
142
|
+
*/
|
|
143
|
+
function selectorFitsMarkup(part: string, markup: Markup): boolean {
|
|
144
|
+
let outside = "";
|
|
145
|
+
let depth = 0;
|
|
146
|
+
for (const ch of blankStringsAndComments(part)) {
|
|
147
|
+
if (ch === "(") {
|
|
148
|
+
depth++;
|
|
149
|
+
} else if (ch === ")") {
|
|
150
|
+
depth = Math.max(0, depth - 1);
|
|
151
|
+
} else if (depth === 0) {
|
|
152
|
+
outside += ch;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const attributes = [...outside.matchAll(/\[\s*([-\w]+)/g)].map((m) => (m[1] ?? "").toLowerCase());
|
|
156
|
+
const bare = outside.replace(/\[[^\]]*\]/g, "").replace(/::?[-\w]+/g, "");
|
|
157
|
+
const tags = [...bare.matchAll(/(?:^|[\s>+~])([A-Za-z][-\w]*)/g)].map((m) =>
|
|
158
|
+
(m[1] ?? "").toLowerCase(),
|
|
159
|
+
);
|
|
160
|
+
return (
|
|
161
|
+
tags.every((tag) => markup.tags.has(tag)) &&
|
|
162
|
+
attributes.every(
|
|
163
|
+
(name) =>
|
|
164
|
+
markup.attributes.has(name) || STAMPED_ATTRIBUTES.has(name) || name.startsWith("data-dek-"),
|
|
165
|
+
)
|
|
114
166
|
);
|
|
115
167
|
}
|
|
116
168
|
|
package/src/core/theme-facts.ts
CHANGED
|
@@ -8,13 +8,21 @@ import {
|
|
|
8
8
|
|
|
9
9
|
export type ThemeLayout = { name: string; example?: string };
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* The state classes the player sets at runtime: `is-current` on the slide on screen, `is-shown`
|
|
13
|
+
* on each beat element that has appeared. A theme selects them; a slide's markup never writes them.
|
|
14
|
+
*/
|
|
15
|
+
export const RUNTIME_CLASSES: ReadonlySet<string> = new Set(["is-current", "is-shown"]);
|
|
16
|
+
|
|
11
17
|
/**
|
|
12
18
|
* What a theme offers a slide, read once: AGENTS.md, `dekc theme`, and lint all say the same
|
|
13
19
|
* thing about it because they all read it here.
|
|
14
20
|
*/
|
|
15
21
|
export type ThemeFacts = {
|
|
16
|
-
/** Every class the theme's selectors name. */
|
|
22
|
+
/** Every class the theme's selectors name for markup to use: its vocabulary. */
|
|
17
23
|
classes: Set<string>;
|
|
24
|
+
/** The runtime state classes its selectors name, which the player sets and markup never does. */
|
|
25
|
+
stateClasses: Set<string>;
|
|
18
26
|
/** The layouts it styles, by name, each with its example markup when it has one. */
|
|
19
27
|
layouts: ThemeLayout[];
|
|
20
28
|
/** The tokens every slide can `var()`, with their values, in source order. */
|
|
@@ -22,8 +30,10 @@ export type ThemeFacts = {
|
|
|
22
30
|
};
|
|
23
31
|
|
|
24
32
|
export function themeFacts(sheet: Stylesheet): ThemeFacts {
|
|
33
|
+
const named = [...cssClassNames(sheet)];
|
|
25
34
|
return {
|
|
26
|
-
classes:
|
|
35
|
+
classes: new Set(named.filter((name) => !RUNTIME_CLASSES.has(name))),
|
|
36
|
+
stateClasses: new Set(named.filter((name) => RUNTIME_CLASSES.has(name))),
|
|
27
37
|
layouts: themeLayouts(sheet),
|
|
28
38
|
tokens: publishedTokens(sheet),
|
|
29
39
|
};
|
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
|
}
|