@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 +12 -0
- package/package.json +1 -1
- package/src/cli/serve.ts +1 -1
- package/src/core/chrome.ts +3 -1
- package/src/core/document.ts +17 -2
- package/src/core/html.ts +99 -14
- package/src/core/page.ts +1 -0
- package/src/runtime/annotate-notes.ts +207 -0
- package/src/runtime/annotate-pick.ts +148 -0
- package/src/runtime/annotate-place.ts +46 -0
- package/src/runtime/annotate-view.ts +738 -0
- package/src/runtime/annotate.ts +15 -0
- package/src/runtime/player.ts +15 -7
- package/src/server/dev.ts +2 -1
- package/src/server/pages.ts +2 -1
- package/src/server/routes.ts +9 -3
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
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
|
}
|
package/src/core/chrome.ts
CHANGED
|
@@ -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); }
|
package/src/core/document.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
117
|
+
const file =
|
|
104
118
|
isSafeSlideSlug(slug) && isInside(path, paths.slides)
|
|
105
119
|
? readDeckFile(deck.dir, path)
|
|
106
120
|
: undefined;
|
|
107
|
-
|
|
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`,
|
|
148
|
-
*
|
|
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
|
-
/**
|
|
193
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
+
}
|