@hajimism/dek 0.2.0 → 0.2.1

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 CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  Release notes for `@hajimism/dek`. From 0.1.0 on, release-please writes this file from the Conventional Commits on `main`.
4
4
 
5
+ ## [0.2.1](https://github.com/hajimism/dek/compare/v0.2.0...v0.2.1) (2026-10-06)
6
+
7
+
8
+ ### Features
9
+
10
+ * **dev:** annotate slide elements and copy the notes for an agent ([8565bd5](https://github.com/hajimism/dek/commit/8565bd5a8d385a84c10d4fa78cab1b7460a86389))
11
+
12
+
13
+ ### Documentation
14
+
15
+ * **sample:** show annotate mode on a roadmap in with-agents ([c1d9c4c](https://github.com/hajimism/dek/commit/c1d9c4c6ddd6306748bdd5544b868fbf3d170a6c))
16
+
5
17
  ## [0.2.0](https://github.com/hajimism/dek/compare/v0.1.0...v0.2.0) (2026-10-01)
6
18
 
7
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hajimism/dek",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A build system for talks.",
5
5
  "license": "MIT",
6
6
  "author": "Hajime Nakagawa (https://github.com/hajimism)",
package/src/cli/serve.ts CHANGED
@@ -121,5 +121,5 @@ export function devBanner(
121
121
  remoteUrls: string[],
122
122
  options: Parameters<typeof remoteBanner>[2],
123
123
  ): string {
124
- return `${remoteBanner(url, remoteUrls, options)}\n\np presenter view · s slide rail · Ctrl-C stops the server`;
124
+ return `${remoteBanner(url, remoteUrls, options)}\n\np presenter view · s slide rail · a annotate · Ctrl-C stops the server`;
125
125
  }
@@ -60,10 +60,12 @@ ${indent(printPageCss({ width, height }))}
60
60
  @media print { #dek-presenter, #dek-progress { display: none !important; } }
61
61
  body[data-mode="video"] #dek-presenter, body[data-mode="video"] #dek-progress { display: none !important; }
62
62
  #dek-presenter[hidden], #dek-progress[hidden], #dek-next-end[hidden] { display: none; }
63
- #dek-mark-toggle { flex: none; width: 2.25rem; height: 2.25rem; padding: 0; border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; background: transparent; color: rgba(255,255,255,0.45); cursor: pointer; font: 600 1rem/1 system-ui, sans-serif; }
63
+ #dek-mark-toggle, #dek-annotate-toggle { flex: none; width: 2.25rem; height: 2.25rem; padding: 0; border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; background: transparent; color: rgba(255,255,255,0.45); cursor: pointer; font: 600 1rem/1 system-ui, sans-serif; }
64
64
  #dek-mark-toggle::before { content: "✎"; }
65
65
  #dek-mark-toggle[aria-pressed="true"] { border-color: #eab308; color: #eab308; }
66
66
  #dek-mark-toggle[data-error] { border-color: #ef4444; }
67
+ #dek-annotate-toggle::before { content: "⌖"; }
68
+ #dek-annotate-toggle[aria-pressed="true"] { border-color: #3b82f6; color: #3b82f6; }
67
69
  #dek-beats li.is-marked::after { content: " ✎"; color: #eab308; }
68
70
  #dek-laser-toggle { flex: none; width: 2.25rem; height: 2.25rem; padding: 0; border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; background: transparent; cursor: pointer; }
69
71
  #dek-laser-toggle::before { content: ""; display: block; width: 10px; height: 10px; margin: auto; border-radius: 50%; background: rgba(255,255,255,0.45); }
@@ -24,6 +24,8 @@ export type PageTarget =
24
24
  mode: "player" | "presenter";
25
25
  includeNotes: boolean;
26
26
  liveReloadScript: string;
27
+ /** Annotate mode, run after the player on a page that is the speaker's: one with the notes. */
28
+ annotateScript: string;
27
29
  /**
28
30
  * What a presenter page sends as `?token=` on its live channels at `/decks/<name>/events`
29
31
  * and `/decks/<name>/ws`: neither an EventSource nor a WebSocket can set a header of its
@@ -70,7 +72,7 @@ export function renderDeckDocument(
70
72
  const data = presenterSlides(deck, scriptReader(target)).map((slide) =>
71
73
  includeNotes ? slide : { ...slide, script: "" },
72
74
  );
73
- const slidesHtml = collectSlidesHtml(deck, { inline: standalone });
75
+ const slidesHtml = collectSlidesHtml(deck, { inline: standalone, sources: live });
74
76
  const themeCss = readTheme(deck.dir, standalone);
75
77
  const slideScripts = readSlideScripts(deck.dir, { strict: strictScripts });
76
78
  const state = data[0] ? presenterState(data, { slideIndex: 0, beatIndex: 0 }) : undefined;
@@ -105,6 +107,7 @@ export function renderDeckDocument(
105
107
  <p id="${PAGE_ID.elapsed}">0:00</p>
106
108
  <p id="${PAGE_ID.budget}">${budget}</p>
107
109
  ${target.kind === "dev" ? markButton(deck.deck.lang) : ""}
110
+ ${target.kind === "dev" ? annotateButton(deck.deck.lang) : ""}
108
111
  <button id="${PAGE_ID.laserToggle}" type="button" aria-pressed="false" aria-label="${escapeAttr(laserLabel(deck.deck.lang))}" title="${escapeAttr(laserLabel(deck.deck.lang))}"></button>
109
112
  </footer>
110
113
  </aside>`
@@ -157,7 +160,8 @@ export function renderDeckDocument(
157
160
  <script type="application/json" id="${PAGE_ID.data}">${jsonForScript(data)}</script>
158
161
  ${slideScriptTags(slideScripts)}
159
162
  <script>${options.playerScript}</script>
160
- ${target.kind === "dev" ? `<script>${target.liveReloadScript}</script>` : ""}`,
163
+ ${target.kind === "dev" ? `<script>${target.liveReloadScript}</script>` : ""}
164
+ ${target.kind === "dev" && includeNotes ? `<script>${target.annotateScript}</script>` : ""}`,
161
165
  });
162
166
  }
163
167
 
@@ -197,6 +201,17 @@ function markButton(lang: string): string {
197
201
  return `<button id="${PAGE_ID.markToggle}" type="button" aria-pressed="false" aria-label="${escapeAttr(label)}" title="${escapeAttr(label)}"></button>`;
198
202
  }
199
203
 
204
+ /**
205
+ * The button that turns annotate mode on and off. Only the dev server's page has it: the notes
206
+ * name files and lines of a project on this machine, and a built file is someone else's to read.
207
+ */
208
+ function annotateButton(lang: string): string {
209
+ const label = lang.toLowerCase().startsWith("ja")
210
+ ? "スライドの要素に注釈を付ける (a)"
211
+ : "Annotate slide elements (a)";
212
+ return `<button id="${PAGE_ID.annotateToggle}" type="button" aria-pressed="false" aria-label="${escapeAttr(label)}" title="${escapeAttr(label)}"></button>`;
213
+ }
214
+
200
215
  /** The laser button's name, with the key that does the same. */
201
216
  function laserLabel(lang: string): string {
202
217
  return lang.toLowerCase().startsWith("ja") ? "レーザーポインター (l)" : "Laser pointer (l)";
package/src/core/html.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { assetInliner } from "./assets.ts";
2
2
  import { deckPaths } from "./deck-paths.ts";
3
3
  import { escapeAttr, escapeHtml } from "./escape.ts";
4
+ import { type LineColumn, lineLocator } from "./lines.ts";
4
5
  import { isInside } from "./path.ts";
5
6
  import { type ProjectDeck, readDeckFile } from "./resolve.ts";
6
7
  import { FALLBACK_LANG, type Section } from "./schema.ts";
@@ -41,6 +42,11 @@ export function hasSlideClass(className: string | null | undefined): boolean {
41
42
  * written. A slide that never closes runs to `</body>`, or to the end.
42
43
  */
43
44
  export function extractSlideSection(html: string): string | undefined {
45
+ return locateSlideSection(html)?.section;
46
+ }
47
+
48
+ /** The first slide section, as `extractSlideSection` reads it, and the offset it starts at. */
49
+ function locateSlideSection(html: string): { section: string; start: number } | undefined {
44
50
  const mark = uniqueMark(html);
45
51
  let found = false;
46
52
  let closed = false;
@@ -68,10 +74,13 @@ export function extractSlideSection(html: string): string | undefined {
68
74
  : undefined;
69
75
  // The parser closes an unclosed slide at the end of its parent, past `</body>`.
70
76
  if (slide !== undefined && /<\/section\s*>$/i.test(slide)) {
71
- return slide;
77
+ return { section: slide, start };
72
78
  }
73
79
  const bodyClose = html.slice(start).search(/<\/body>/i);
74
- return bodyClose >= 0 ? html.slice(start, start + bodyClose) : html.slice(start);
80
+ return {
81
+ section: bodyClose >= 0 ? html.slice(start, start + bodyClose) : html.slice(start),
82
+ start,
83
+ };
75
84
  }
76
85
 
77
86
  /** A deck's slide markup, each file read at most once. */
@@ -86,32 +95,43 @@ export type DeckSlides = {
86
95
  * stops on them, because at the venue a deck that shows beats one that does not.
87
96
  */
88
97
  section(slug: string): string;
98
+ /** Where the written section is in `slides/<slug>.html`; none for a skeleton. */
99
+ source(slug: string): SlideSource | undefined;
89
100
  };
90
101
 
102
+ /** A slide file and the offset its section starts at: the section is `file` from `start`. */
103
+ type SlideSource = { file: string; start: number };
104
+
91
105
  export function deckSlides(deck: ProjectDeck): DeckSlides {
92
106
  const paths = deckPaths(deck.dir);
93
- const read = new Map<string, string | undefined>();
107
+ const read = new Map<string, (SlideSource & { section: string }) | undefined>();
94
108
  let layouts: SkeletonLayouts | undefined;
95
109
  const skeleton = (slug: string): string | undefined => {
96
110
  layouts ??= deckLayouts(deck.dir);
97
111
  return skeletonHtml(deck.deck, slug, layouts);
98
112
  };
99
- const written = (slug: string): string | undefined => {
113
+ const located = (slug: string): (SlideSource & { section: string }) | undefined => {
100
114
  if (!read.has(slug)) {
101
115
  const path = paths.slide(slug, ".html");
102
116
  // A slug from a URL may try to climb out of slides/.
103
- const source =
117
+ const file =
104
118
  isSafeSlideSlug(slug) && isInside(path, paths.slides)
105
119
  ? readDeckFile(deck.dir, path)
106
120
  : undefined;
107
- read.set(slug, source === undefined ? undefined : extractSlideSection(source));
121
+ const found = file === undefined ? undefined : locateSlideSection(file);
122
+ read.set(slug, file !== undefined && found ? { file, ...found } : undefined);
108
123
  }
109
124
  return read.get(slug);
110
125
  };
126
+ const written = (slug: string): string | undefined => located(slug)?.section;
111
127
  return {
112
128
  has: (slug) => written(slug) !== undefined,
113
129
  written,
114
130
  section: (slug) => written(slug) ?? extractSlideSection(skeleton(slug) ?? "") ?? "",
131
+ source(slug) {
132
+ const found = located(slug);
133
+ return found && { file: found.file, start: found.start };
134
+ },
115
135
  };
116
136
  }
117
137
 
@@ -140,15 +160,24 @@ export type SlideStamp = {
140
160
  beat?: { index: number; step: string };
141
161
  /** Inline the deck's files as data: URIs, for a page that stands alone. */
142
162
  inline?: { deckDir: string };
163
+ /**
164
+ * Where the section is written, for the dev server's pages: every start tag then says where
165
+ * in the file it is, as `data-dek-source="<line>:<column>"`, and the classes it is written
166
+ * with, as `data-dek-class`, so annotate mode can name it as the file does whatever a script
167
+ * adds to it.
168
+ */
169
+ source?: SlideSource;
143
170
  };
144
171
 
145
172
  /**
146
173
  * One slide section with what the page needs on it, in one parser pass: its `data-slug` unless
147
- * it names its own, its place as `--dek-slide-number` and `--dek-slide-count`, and for a still
148
- * page the classes and beat the player would have set.
174
+ * it names its own, its place as `--dek-slide-number` and `--dek-slide-count`, for a still page
175
+ * the classes and beat the player would have set, and for the dev server where each tag is
176
+ * written.
149
177
  */
150
178
  export function stampSlide(html: string, stamp: SlideStamp): string {
151
179
  let done = false;
180
+ const spots = stamp.source && startTagSpots(html, stamp.source);
152
181
  return rewriteHtml(html, (rewriter) => {
153
182
  rewriter.on("section", {
154
183
  element(el) {
@@ -186,21 +215,77 @@ export function stampSlide(html: string, stamp: SlideStamp): string {
186
215
  if (stamp.inline) {
187
216
  rewriter.on("*", assetInliner(stamp.inline.deckDir));
188
217
  }
218
+ if (spots) {
219
+ // The same parser over the same markup meets the start tags in the same order.
220
+ let index = 0;
221
+ rewriter.on("*", {
222
+ element(el) {
223
+ const spot = spots[index++];
224
+ if (spot) {
225
+ el.setAttribute("data-dek-source", `${spot.line}:${spot.column}`);
226
+ if (spot.classes !== undefined) {
227
+ el.setAttribute("data-dek-class", spot.classes);
228
+ }
229
+ }
230
+ },
231
+ });
232
+ }
233
+ });
234
+ }
235
+
236
+ /** Where a start tag is written, and its classes as written; none when it has no `class`. */
237
+ type TagSpot = LineColumn & { classes?: string };
238
+
239
+ /**
240
+ * Where each start tag of `html` is written in the file it was taken from, in document order,
241
+ * read before anything else touches the markup. A marker goes in front of each tag; where the
242
+ * markers land, less their own length, is where the tags were.
243
+ */
244
+ function startTagSpots(html: string, source: SlideSource): TagSpot[] {
245
+ const mark = uniqueMark(html);
246
+ const classes: Array<string | null> = [];
247
+ const marked = rewriteHtml(html, (rewriter) => {
248
+ rewriter.on("*", {
249
+ element(el) {
250
+ el.before(mark, { html: true });
251
+ classes.push(el.getAttribute("class"));
252
+ },
253
+ });
189
254
  });
255
+ const spot = lineLocator(source.file);
256
+ let offset = source.start;
257
+ return marked
258
+ .split(mark)
259
+ .slice(0, -1)
260
+ .map((before, index) => {
261
+ offset += before.length;
262
+ const written = classes[index];
263
+ return written === null || written === undefined
264
+ ? spot(offset)
265
+ : { ...spot(offset), classes: written.split(/\s+/).filter(Boolean).join(" ") };
266
+ });
190
267
  }
191
268
 
192
- /** Every slide of the deck in script order, as the player pages hold them. */
193
- export function collectSlidesHtml(deck: ProjectDeck, options: { inline: boolean }): string {
269
+ /**
270
+ * Every slide of the deck in script order, as the player pages hold them. `sources` says where
271
+ * each tag is written, for the dev server's pages.
272
+ */
273
+ export function collectSlidesHtml(
274
+ deck: ProjectDeck,
275
+ options: { inline: boolean; sources?: boolean },
276
+ ): string {
194
277
  const slides = deckSlides(deck);
195
278
  const { sections } = deck.deck;
196
279
  return sections
197
- .map((section, index) =>
198
- stampSlide(slides.section(section.slug), {
280
+ .map((section, index) => {
281
+ const source = options.sources ? slides.source(section.slug) : undefined;
282
+ return stampSlide(slides.section(section.slug), {
199
283
  slug: section.slug,
200
284
  place: slidePlace(sections, index),
201
285
  ...(options.inline ? { inline: { deckDir: deck.dir } } : {}),
202
- }),
203
- )
286
+ ...(source ? { source } : {}),
287
+ });
288
+ })
204
289
  .join("");
205
290
  }
206
291
 
package/src/core/page.ts CHANGED
@@ -28,6 +28,7 @@ export const PAGE_ID = {
28
28
  laser: "dek-laser",
29
29
  laserToggle: "dek-laser-toggle",
30
30
  markToggle: "dek-mark-toggle",
31
+ annotateToggle: "dek-annotate-toggle",
31
32
  hint: "dek-hint",
32
33
  announce: "dek-announce",
33
34
  } as const;
@@ -0,0 +1,207 @@
1
+ // What annotate mode keeps and hands over: notes on elements of a slide, each a snapshot of what
2
+ // the page showed when it was written, and the Markdown an agent is given to act on them. No DOM
3
+ // here, so all of it is decided by these functions alone.
4
+
5
+ import { type BeatRef, resolveStop } from "../core/step.ts";
6
+
7
+ /** A box on the slide, in logical pixels. */
8
+ export type Box = { x: number; y: number; width: number; height: number };
9
+
10
+ /** One element a note is about, as the page showed it when the note was written. */
11
+ export type NoteTarget = {
12
+ /** `<line>:<column>` of its start tag in `slides/<slug>.html`. */
13
+ source: string;
14
+ /** Its tag and its own classes, as `div.chevron`. */
15
+ name: string;
16
+ /** Its text, whitespace collapsed and cut short; empty for a shape. */
17
+ text: string;
18
+ box: Box;
19
+ /** Where the slide itself was clicked, when the note is about a place rather than an element. */
20
+ point?: { x: number; y: number };
21
+ };
22
+
23
+ /** One note, on one slide at one beat. */
24
+ export type Note = {
25
+ slug: string;
26
+ /** The stop as `dekc shot --step` takes it: `0` as the slide arrives, else the beat. */
27
+ step: string;
28
+ targets: NoteTarget[];
29
+ /** What the human wrote; it may be empty, when the words go in the chat. */
30
+ text: string;
31
+ /** Written before an edit that took its element away, so its line is as it was then. */
32
+ stale?: true;
33
+ };
34
+
35
+ /** A slide as the page lists it. */
36
+ export type SlideInfo = { slug: string; title: string; beats: BeatRef[] };
37
+
38
+ /**
39
+ * The notes in the order they are handed over: by slide and beat in talk order, then as they
40
+ * were written. Markers on the page are numbered in this order too.
41
+ */
42
+ export function sortNotes<T extends Note>(slides: readonly SlideInfo[], notes: readonly T[]): T[] {
43
+ const order = (slug: string): number => slides.findIndex((slide) => slide.slug === slug);
44
+ const stop = (note: Note): number =>
45
+ resolveStop(slides[order(note.slug)]?.beats ?? [], note.step) ?? Number.MAX_SAFE_INTEGER;
46
+ return notes
47
+ .map((note, index) => ({ note, index }))
48
+ .sort(
49
+ (a, b) =>
50
+ order(a.note.slug) - order(b.note.slug) || stop(a.note) - stop(b.note) || a.index - b.index,
51
+ )
52
+ .map(({ note }) => note);
53
+ }
54
+
55
+ /**
56
+ * The notes as one Markdown block for an agent: grouped by slide and beat in talk order, each
57
+ * element with the file and line it is written at, and how to see the slide at that beat.
58
+ */
59
+ export function formatNotes(
60
+ deck: string,
61
+ slides: readonly SlideInfo[],
62
+ notes: readonly Note[],
63
+ ): string {
64
+ if (notes.length === 0) {
65
+ return "";
66
+ }
67
+ const groups = new Map<string, Note[]>();
68
+ for (const note of sortNotes(slides, notes)) {
69
+ const key = `${note.slug}\n${note.step}`;
70
+ groups.set(key, [...(groups.get(key) ?? []), note]);
71
+ }
72
+
73
+ const lines = [
74
+ `## Notes on decks/${deck}`,
75
+ "",
76
+ "Boxes and points are in the slide's own pixels, from its top left.",
77
+ ];
78
+ let number = 0;
79
+ for (const group of groups.values()) {
80
+ const first = group[0];
81
+ if (!first) {
82
+ continue;
83
+ }
84
+ const index = slides.findIndex((slide) => slide.slug === first.slug);
85
+ const title = slides[index]?.title;
86
+ lines.push(
87
+ "",
88
+ `### Slide ${index + 1} of ${slides.length}, ${first.slug}${title ? ` (${JSON.stringify(title)})` : ""}, ${beatLabel(first.step)}`,
89
+ );
90
+ for (const note of group) {
91
+ number++;
92
+ const where = (target: NoteTarget): string =>
93
+ `${describe(target)} at decks/${deck}/slides/${note.slug}.html:${target.source} (${measure(target)})${note.stale ? ", as written before an edit; the line may have moved" : ""}`;
94
+ lines.push("");
95
+ if (note.targets.length === 1 && note.targets[0]) {
96
+ lines.push(`${number}. ${where(note.targets[0])}`);
97
+ } else {
98
+ lines.push(`${number}. ${note.targets.length} elements:`);
99
+ lines.push(...note.targets.map((target) => ` - ${where(target)}`));
100
+ }
101
+ if (note.text.trim() !== "") {
102
+ lines.push(
103
+ ...note.text
104
+ .trim()
105
+ .split(/\r?\n/)
106
+ .map((line) => ` > ${line}`.trimEnd()),
107
+ );
108
+ }
109
+ }
110
+ lines.push("", `To see it: \`dekc shot ${deck} ${first.slug} --step ${first.step}\``);
111
+ }
112
+ return `${lines.join("\n")}\n`;
113
+ }
114
+
115
+ function beatLabel(step: string): string {
116
+ if (step === "0") {
117
+ return "as it arrives";
118
+ }
119
+ return /^\d+$/.test(step) ? `at beat ${step}` : `at beat \`${step}\``;
120
+ }
121
+
122
+ function describe(target: NoteTarget): string {
123
+ return target.text === "" ? target.name : `${target.name} ${JSON.stringify(target.text)}`;
124
+ }
125
+
126
+ function measure(target: NoteTarget): string {
127
+ if (target.point) {
128
+ return `point ${target.point.x},${target.point.y}`;
129
+ }
130
+ const { x, y, width, height } = target.box;
131
+ return `box ${x},${y} ${width}×${height}`;
132
+ }
133
+
134
+ /** An element on the slide as it is now, to match a note's target against. */
135
+ type Present = { source: string; name: string; text: string };
136
+
137
+ /**
138
+ * The element a note's target is now, after an edit replaced its slide: the one at the same
139
+ * place with the same name and text; else the only one with that name and text, wherever it
140
+ * moved; else the one at the same place with the same name, whose text was rewritten, as an
141
+ * agent does when it acts on the note. None when it cannot be told.
142
+ */
143
+ export function reanchor<T extends Present>(
144
+ target: NoteTarget,
145
+ present: readonly T[],
146
+ ): T | undefined {
147
+ const same = present.find(
148
+ (el) => el.source === target.source && el.name === target.name && el.text === target.text,
149
+ );
150
+ if (same) {
151
+ return same;
152
+ }
153
+ const moved = present.filter((el) => el.name === target.name && el.text === target.text);
154
+ if (moved.length === 1) {
155
+ return moved[0];
156
+ }
157
+ return present.find((el) => el.source === target.source && el.name === target.name);
158
+ }
159
+
160
+ /** The notes kept in storage, or none when what is there is not notes. */
161
+ export function readNotes(raw: string | null): Note[] {
162
+ let parsed: unknown;
163
+ try {
164
+ parsed = JSON.parse(raw ?? "");
165
+ } catch {
166
+ return [];
167
+ }
168
+ if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.notes)) {
169
+ return [];
170
+ }
171
+ return parsed.notes.filter(isNote);
172
+ }
173
+
174
+ /** The notes as storage keeps them. */
175
+ export function writeNotes(notes: readonly Note[]): string {
176
+ return JSON.stringify({ version: 1, notes });
177
+ }
178
+
179
+ function isNote(value: unknown): value is Note {
180
+ return (
181
+ isRecord(value) &&
182
+ typeof value.slug === "string" &&
183
+ typeof value.step === "string" &&
184
+ typeof value.text === "string" &&
185
+ (value.stale === undefined || value.stale === true) &&
186
+ Array.isArray(value.targets) &&
187
+ value.targets.length > 0 &&
188
+ value.targets.every(isTarget)
189
+ );
190
+ }
191
+
192
+ function isTarget(value: unknown): value is NoteTarget {
193
+ return (
194
+ isRecord(value) &&
195
+ typeof value.source === "string" &&
196
+ typeof value.name === "string" &&
197
+ typeof value.text === "string" &&
198
+ isRecord(value.box) &&
199
+ [value.box.x, value.box.y, value.box.width, value.box.height].every(Number.isFinite) &&
200
+ (value.point === undefined ||
201
+ (isRecord(value.point) && Number.isFinite(value.point.x) && Number.isFinite(value.point.y)))
202
+ );
203
+ }
204
+
205
+ function isRecord(value: unknown): value is Record<string, unknown> {
206
+ return typeof value === "object" && value !== null && !Array.isArray(value);
207
+ }
@@ -0,0 +1,148 @@
1
+ /// <reference lib="dom" />
2
+ // What annotate mode can point at: the elements of the slide on screen under the pointer, what
3
+ // each is called, and where it is on the slide. Only elements the dev server stamped with where
4
+ // they are written count, so every one can be named by its file and line.
5
+
6
+ import { deckStops, positionFromHash } from "../core/position.ts";
7
+ import { type BeatRef, stepKey } from "../core/step.ts";
8
+ import type { Box } from "./annotate-notes.ts";
9
+
10
+ /** The attribute the dev server writes on each start tag of a slide: `<line>:<column>`. */
11
+ export const SOURCE_ATTR = "data-dek-source";
12
+
13
+ /** The classes the dev server saw written on each stamped tag, before any script added more. */
14
+ const CLASS_ATTR = "data-dek-class";
15
+
16
+ /** How long a text may be before it is cut short. */
17
+ const TEXT_MAX = 40;
18
+
19
+ type Point = { x: number; y: number };
20
+
21
+ /**
22
+ * The points tried around the pointer: the pointer itself first, then rings out to a few pixels,
23
+ * so a line one pixel thick is found from beside it as well as on it.
24
+ */
25
+ export function ringPoints(at: Point): Point[] {
26
+ const points = [at];
27
+ for (let radius = 1; radius <= 5; radius++) {
28
+ for (let step = 0; step < 8; step++) {
29
+ const angle = (step * Math.PI) / 4;
30
+ points.push({ x: at.x + radius * Math.cos(angle), y: at.y + radius * Math.sin(angle) });
31
+ }
32
+ }
33
+ return points;
34
+ }
35
+
36
+ /**
37
+ * The elements of `slide` that can be picked at `at`: what is under the pointer, then what is
38
+ * only near it, each before anything it is inside, and the slide itself last. A line beside the
39
+ * pointer so comes before the drawing it is in, which is under the pointer and easy to hit.
40
+ * `elementsAt` is the document's `elementsFromPoint`, every element at a point, topmost first.
41
+ */
42
+ export function candidatesAt(
43
+ at: Point,
44
+ slide: Element,
45
+ elementsAt: (x: number, y: number) => readonly Element[],
46
+ ): Element[] {
47
+ const found: Element[] = [];
48
+ for (const point of ringPoints(at)) {
49
+ for (const el of elementsAt(point.x, point.y)) {
50
+ if (el === slide || found.includes(el) || !isCandidate(el, slide)) {
51
+ continue;
52
+ }
53
+ const outer = found.findIndex((other) => other.contains(el));
54
+ found.splice(outer < 0 ? found.length : outer, 0, el);
55
+ }
56
+ }
57
+ return isCandidate(slide, slide) ? [...found, slide] : found;
58
+ }
59
+
60
+ function isCandidate(el: Element, slide: Element): boolean {
61
+ return el.hasAttribute(SOURCE_ATTR) && slide.contains(el) && isShown(el, slide);
62
+ }
63
+
64
+ /** Whether `el` shows: neither it nor anything it is in on the slide is transparent or hidden. */
65
+ function isShown(el: Element, slide: Element): boolean {
66
+ const view = el.ownerDocument.defaultView;
67
+ if (!view) {
68
+ return true;
69
+ }
70
+ if (view.getComputedStyle(el).visibility === "hidden") {
71
+ return false;
72
+ }
73
+ for (let node: Element | null = el; node; node = node.parentElement) {
74
+ if (view.getComputedStyle(node).opacity === "0") {
75
+ return false;
76
+ }
77
+ if (node === slide) {
78
+ break;
79
+ }
80
+ }
81
+ return true;
82
+ }
83
+
84
+ /**
85
+ * What a note says an element is: where it is written, its tag and its classes as written, and
86
+ * its text cut short. The classes the player or a slide script added since, such as
87
+ * `is-current`, are in no file to find. The slide's text is the whole slide, which names
88
+ * nothing, so it has none.
89
+ */
90
+ export function describeElement(
91
+ el: Element,
92
+ slide: Element,
93
+ ): { source: string; name: string; text: string } {
94
+ const classes = (el.getAttribute(CLASS_ATTR) ?? "").split(" ").filter(Boolean);
95
+ const text = el === slide ? "" : (el.textContent ?? "").replace(/\s+/g, " ").trim();
96
+ return {
97
+ source: el.getAttribute(SOURCE_ATTR) ?? "",
98
+ name: [el.tagName.toLowerCase(), ...classes].join("."),
99
+ text: text.length > TEXT_MAX ? `${text.slice(0, TEXT_MAX)}…` : text,
100
+ };
101
+ }
102
+
103
+ /** Where `el` is on the slide, in the slide's own pixels, whatever the stage scales it to. */
104
+ export function slideBox(el: Element, deck: HTMLElement): Box {
105
+ const scale = deckScale(deck);
106
+ const origin = deck.getBoundingClientRect();
107
+ const rect = el.getBoundingClientRect();
108
+ return {
109
+ x: Math.round((rect.left - origin.left) / scale),
110
+ y: Math.round((rect.top - origin.top) / scale),
111
+ width: Math.round(rect.width / scale),
112
+ height: Math.round(rect.height / scale),
113
+ };
114
+ }
115
+
116
+ /** A point in the window as a point on the slide, in the slide's own pixels. */
117
+ export function slidePoint(at: Point, deck: HTMLElement): Point {
118
+ const scale = deckScale(deck);
119
+ const origin = deck.getBoundingClientRect();
120
+ return {
121
+ x: Math.round((at.x - origin.left) / scale),
122
+ y: Math.round((at.y - origin.top) / scale),
123
+ };
124
+ }
125
+
126
+ /** A point on the slide, in its own pixels, as a point in the window. */
127
+ export function windowPoint(at: Point, deck: HTMLElement): Point {
128
+ const scale = deckScale(deck);
129
+ const origin = deck.getBoundingClientRect();
130
+ return { x: origin.left + at.x * scale, y: origin.top + at.y * scale };
131
+ }
132
+
133
+ function deckScale(deck: HTMLElement): number {
134
+ const width = deck.getBoundingClientRect().width;
135
+ return deck.offsetWidth > 0 && width > 0 ? width / deck.offsetWidth : 1;
136
+ }
137
+
138
+ /** The slide on screen and its beat, as the hash says and `dekc shot --step` takes it. */
139
+ export function currentStop(
140
+ hash: string,
141
+ slides: ReadonlyArray<{ slug: string; beats: BeatRef[] }>,
142
+ ): { slideIndex: number; step: string } {
143
+ const pos = positionFromHash(hash, deckStops(slides));
144
+ return {
145
+ slideIndex: pos.slideIndex,
146
+ step: stepKey(slides[pos.slideIndex]?.beats ?? [], pos.beatIndex),
147
+ };
148
+ }