@hajimism/dek 0.1.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 +43 -0
- package/README.ja.md +6 -2
- package/README.md +6 -2
- package/package.json +11 -3
- package/src/cli/files.ts +14 -7
- package/src/cli/ls.ts +3 -5
- package/src/cli/new.ts +3 -2
- package/src/cli/serve.ts +1 -1
- package/src/core/build.ts +0 -1
- package/src/core/chrome.ts +3 -1
- package/src/core/config.ts +10 -16
- package/src/core/document.ts +20 -7
- package/src/core/html.ts +99 -14
- package/src/core/lint/keys.ts +47 -4
- package/src/core/lint/slide-html.ts +32 -8
- package/src/core/lint/theme-css.ts +1 -1
- package/src/core/lint/timing.ts +3 -7
- package/src/core/page.ts +1 -0
- package/src/core/presenter.ts +1 -3
- package/src/core/schema.ts +24 -0
- package/src/core/sync.ts +1 -1
- package/src/core/timing.ts +17 -12
- package/src/core/video-document.ts +1 -3
- 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/src/core/lint/keys.ts
CHANGED
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
unknownTomlKeys,
|
|
6
6
|
} from "../config-keys.ts";
|
|
7
7
|
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
8
|
+
import { readFrontmatterYaml } from "../parse.ts";
|
|
8
9
|
import { type Project, readTextIfExists } from "../resolve.ts";
|
|
10
|
+
import type { DeckSettings } from "../schema.ts";
|
|
9
11
|
import type { LintContext } from "./context.ts";
|
|
10
12
|
|
|
11
13
|
/*
|
|
@@ -21,10 +23,51 @@ export function tomlKeyDiagnostics(project: Project): Diagnostic[] {
|
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
/** DEK008 for the deck's frontmatter. */
|
|
24
|
-
export function frontmatterKeyDiagnostics(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
export function frontmatterKeyDiagnostics(ctx: LintContext): Diagnostic[] {
|
|
27
|
+
const { deck, script } = ctx;
|
|
28
|
+
return [
|
|
29
|
+
...(script ? unknownFrontmatterKeys(script.yaml, 2) : []).map((key) =>
|
|
30
|
+
unknownKeyDiagnostic(key, { file: "frontmatter", path: deck.scriptPath }),
|
|
31
|
+
),
|
|
32
|
+
...unfollowedSeedDiagnostics(ctx),
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* DEK008 for a deck setting dek.toml sets and the deck leaves out. dek.toml only seeds `dekc new`,
|
|
38
|
+
* so a deck made before it, or without the key, keeps the default: said where the key would go,
|
|
39
|
+
* and only when the two differ, since otherwise the verdict is the same either way.
|
|
40
|
+
*/
|
|
41
|
+
function unfollowedSeedDiagnostics({ config, deck, script }: LintContext): Diagnostic[] {
|
|
42
|
+
if (!script || !config.seed) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const written = readFrontmatterKeys(script.yaml);
|
|
46
|
+
return Object.entries(config.seed).flatMap(([key, value]) => {
|
|
47
|
+
const used = deck.deck[key as keyof DeckSettings];
|
|
48
|
+
if (written.has(key) || value === used) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
return [
|
|
52
|
+
diag("DEK008", {
|
|
53
|
+
message: `dek.toml sets ${key} = ${value}, which this deck does not follow; it uses ${used}`,
|
|
54
|
+
path: deck.scriptPath,
|
|
55
|
+
line: script.bodyStartLine - 1,
|
|
56
|
+
hint: `to keep ${value}, add \`${key}: ${value}\` to the frontmatter; dek.toml only seeds \`dekc new\``,
|
|
57
|
+
data: { file: "frontmatter", key, value },
|
|
58
|
+
}),
|
|
59
|
+
];
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The top-level keys the frontmatter writes; none when its YAML does not parse. */
|
|
64
|
+
function readFrontmatterKeys(yaml: string): Set<string> {
|
|
65
|
+
try {
|
|
66
|
+
const parsed = readFrontmatterYaml(yaml);
|
|
67
|
+
return new Set(parsed && typeof parsed === "object" ? Object.keys(parsed) : []);
|
|
68
|
+
} catch {
|
|
69
|
+
return new Set();
|
|
70
|
+
}
|
|
28
71
|
}
|
|
29
72
|
|
|
30
73
|
function unknownKeyDiagnostic(
|
|
@@ -272,7 +272,7 @@ function morphDiagnostics({ section, path, scan }: SlideHtml): Diagnostic[] {
|
|
|
272
272
|
/**
|
|
273
273
|
* DEK011: style or script inside the markup, which belongs in the slide's own .css or .ts. Every
|
|
274
274
|
* occurrence is its own finding: a `<style>` or `<script>` element, a `style` attribute, an event
|
|
275
|
-
* handler attribute, and a
|
|
275
|
+
* handler attribute, and a URL that runs script.
|
|
276
276
|
*/
|
|
277
277
|
function inlineCodeDiagnostics({ section, path, scan }: SlideHtml): Diagnostic[] {
|
|
278
278
|
return scan.elements
|
|
@@ -346,8 +346,11 @@ function inlineAttribute(
|
|
|
346
346
|
hint: `remove it; a slide takes no input, and motion goes in slides/${slug}.ts as a draw(t) function`,
|
|
347
347
|
};
|
|
348
348
|
}
|
|
349
|
-
|
|
350
|
-
|
|
349
|
+
const scheme = isUrlAttribute(tag, attribute.name)
|
|
350
|
+
? scriptUrlScheme(tag, attribute.value)
|
|
351
|
+
: undefined;
|
|
352
|
+
if (scheme) {
|
|
353
|
+
return { message: `slide contains a ${scheme} URL`, hint: motionHint(slug) };
|
|
351
354
|
}
|
|
352
355
|
return undefined;
|
|
353
356
|
}
|
|
@@ -358,13 +361,34 @@ function motionHint(slug: string): string {
|
|
|
358
361
|
|
|
359
362
|
/** `onclick`, `onload`, and every other attribute the browser runs as script. */
|
|
360
363
|
const EVENT_HANDLER_RE = /^on[a-z]+$/;
|
|
361
|
-
/**
|
|
362
|
-
|
|
363
|
-
|
|
364
|
+
/** Tags that load a URL as a document of its own, so an SVG there runs its scripts. */
|
|
365
|
+
const DOCUMENT_TAGS = new Set(["iframe", "frame", "object", "embed"]);
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The scheme of a URL that runs script, or undefined: `javascript:` and `vbscript:` always, and a
|
|
369
|
+
* `data:` URL holding HTML, or SVG where a tag loads it as a document. An image's `data:` stays.
|
|
370
|
+
* Read the way the URL parser reads it: tabs and newlines dropped, leading space trimmed, any case.
|
|
371
|
+
*/
|
|
372
|
+
function scriptUrlScheme(tag: string, value: string): string | undefined {
|
|
373
|
+
const url = value
|
|
364
374
|
.replace(/[\t\n\r]/g, "")
|
|
365
375
|
.trimStart()
|
|
366
|
-
.toLowerCase()
|
|
367
|
-
|
|
376
|
+
.toLowerCase();
|
|
377
|
+
if (url.startsWith("javascript:")) {
|
|
378
|
+
return "javascript:";
|
|
379
|
+
}
|
|
380
|
+
if (url.startsWith("vbscript:")) {
|
|
381
|
+
return "vbscript:";
|
|
382
|
+
}
|
|
383
|
+
if (url.startsWith("data:")) {
|
|
384
|
+
const type = url.slice("data:".length).split(/[;,]/, 1)[0]?.trim();
|
|
385
|
+
const runs =
|
|
386
|
+
type === "text/html" ||
|
|
387
|
+
type === "application/xhtml+xml" ||
|
|
388
|
+
(type === "image/svg+xml" && DOCUMENT_TAGS.has(tag));
|
|
389
|
+
return runs ? "data:" : undefined;
|
|
390
|
+
}
|
|
391
|
+
return undefined;
|
|
368
392
|
}
|
|
369
393
|
|
|
370
394
|
/**
|
|
@@ -290,6 +290,6 @@ function themeScopeHint(selector: string): string {
|
|
|
290
290
|
/** The theme's own findings, or DEK018 when there is no theme. */
|
|
291
291
|
export function themeDiagnostics(ctx: LintContext): Diagnostic[] {
|
|
292
292
|
return ctx.theme
|
|
293
|
-
? lintTheme(ctx.theme, { maxClasses: ctx.
|
|
293
|
+
? lintTheme(ctx.theme, { maxClasses: ctx.deck.deck.max_classes, deckDir: ctx.deck.dir })
|
|
294
294
|
: [missingThemeDiagnostic(ctx)];
|
|
295
295
|
}
|
package/src/core/lint/timing.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { DekConfig } from "../config.ts";
|
|
2
1
|
import { type Diagnostic, diag } from "../diagnostic.ts";
|
|
3
2
|
import type { ProjectDeck } from "../resolve.ts";
|
|
4
3
|
import type { Timeline } from "../timeline.ts";
|
|
@@ -13,7 +12,7 @@ const ESTIMATE_DRIFT_RATIO = 0.35;
|
|
|
13
12
|
/** DEK041 against the voice timeline when there is one, else against the reading-time estimate. */
|
|
14
13
|
export function timingDiagnostics(ctx: LintContext): Diagnostic[] {
|
|
15
14
|
const timeline = tryLoadCachedTimeline(ctx.deck.dir);
|
|
16
|
-
return timeline ? lintDuration(ctx.deck, timeline) : lintEstimate(ctx.deck
|
|
15
|
+
return timeline ? lintDuration(ctx.deck, timeline) : lintEstimate(ctx.deck);
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
function lintDuration(deck: ProjectDeck, timeline: Timeline): Diagnostic[] {
|
|
@@ -37,15 +36,12 @@ function lintDuration(deck: ProjectDeck, timeline: Timeline): Diagnostic[] {
|
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
/** DEK041 before any voice: the reading-time estimate `dekc ls` shows, against the budget. */
|
|
40
|
-
function lintEstimate(deck: ProjectDeck
|
|
39
|
+
function lintEstimate(deck: ProjectDeck): Diagnostic[] {
|
|
41
40
|
const budget = parseDurationSeconds(deck.deck.duration);
|
|
42
41
|
if (budget === undefined || budget <= 0) {
|
|
43
42
|
return [];
|
|
44
43
|
}
|
|
45
|
-
const estimate = sectionTiming(deck.deck.
|
|
46
|
-
(sum, row) => sum + row.estimateSeconds,
|
|
47
|
-
0,
|
|
48
|
-
);
|
|
44
|
+
const estimate = sectionTiming(deck.deck).reduce((sum, row) => sum + row.estimateSeconds, 0);
|
|
49
45
|
if (Math.abs(estimate - budget) / budget < ESTIMATE_DRIFT_RATIO) {
|
|
50
46
|
return [];
|
|
51
47
|
}
|
package/src/core/page.ts
CHANGED
package/src/core/presenter.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { DekConfig } from "./config.ts";
|
|
2
1
|
import type { PresenterSlide } from "./presenter-state.ts";
|
|
3
2
|
import type { ProjectDeck } from "./resolve.ts";
|
|
4
3
|
import { type ScriptLine, scriptLines } from "./script-lines.ts";
|
|
@@ -18,10 +17,9 @@ export type ScriptReader = "speaker" | "audience";
|
|
|
18
17
|
|
|
19
18
|
export function presenterSlides(
|
|
20
19
|
deck: ProjectDeck,
|
|
21
|
-
config: DekConfig,
|
|
22
20
|
reader: ScriptReader = "speaker",
|
|
23
21
|
): PresenterSlide[] {
|
|
24
|
-
const timing = sectionTiming(deck.deck
|
|
22
|
+
const timing = sectionTiming(deck.deck);
|
|
25
23
|
const budgetBySlug = new Map(timing.map((row) => [row.slug, row.budgetSeconds]));
|
|
26
24
|
return deck.deck.sections.map((section) => {
|
|
27
25
|
const script = formatSectionScript(section);
|
package/src/core/schema.ts
CHANGED
|
@@ -52,6 +52,25 @@ export function inferLang(text: string): string {
|
|
|
52
52
|
return "en";
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* How a deck is judged: the theme's class budget and the speaking rate the estimate reads. A deck
|
|
57
|
+
* owns them in its frontmatter, so a project that changes later leaves a finished talk as it was;
|
|
58
|
+
* dek.toml may set the same keys, and `dekc new` copies them into the deck it makes.
|
|
59
|
+
*/
|
|
60
|
+
export const DeckSettings = z.object({
|
|
61
|
+
max_classes: z.number().describe("The most classes theme.css may define (DEK013)."),
|
|
62
|
+
cjk_per_minute: z.number().describe("Speaking rate for CJK text, in characters a minute."),
|
|
63
|
+
latin_per_minute: z.number().describe("Speaking rate for other text, in words a minute."),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export type DeckSettings = z.infer<typeof DeckSettings>;
|
|
67
|
+
|
|
68
|
+
export const DECK_SETTING_DEFAULTS: DeckSettings = {
|
|
69
|
+
max_classes: 40,
|
|
70
|
+
cjk_per_minute: 300,
|
|
71
|
+
latin_per_minute: 130,
|
|
72
|
+
};
|
|
73
|
+
|
|
55
74
|
/** Frontmatter fields written to `.dek/schema.json` for yaml-language-server. */
|
|
56
75
|
export const Frontmatter = z.object({
|
|
57
76
|
title: z.string(),
|
|
@@ -69,6 +88,11 @@ export const Frontmatter = z.object({
|
|
|
69
88
|
lang: Lang.optional().describe(
|
|
70
89
|
"BCP 47 tag for the page. Omit it to follow the script: kana → ja, Hangul → ko, Han → zh, else en.",
|
|
71
90
|
),
|
|
91
|
+
max_classes: DeckSettings.shape.max_classes.default(DECK_SETTING_DEFAULTS.max_classes),
|
|
92
|
+
cjk_per_minute: DeckSettings.shape.cjk_per_minute.default(DECK_SETTING_DEFAULTS.cjk_per_minute),
|
|
93
|
+
latin_per_minute: DeckSettings.shape.latin_per_minute.default(
|
|
94
|
+
DECK_SETTING_DEFAULTS.latin_per_minute,
|
|
95
|
+
),
|
|
72
96
|
});
|
|
73
97
|
|
|
74
98
|
export type Frontmatter = z.infer<typeof Frontmatter>;
|
package/src/core/sync.ts
CHANGED
|
@@ -252,7 +252,7 @@ A build system for talks. Write what you will say; dek builds, measures, and shi
|
|
|
252
252
|
- One \`##\` heading is one slide. HTML lives in \`slides/<id>.html\`.
|
|
253
253
|
- Each deck owns its \`theme.css\`. Before writing a slide, run \`dekc theme\` in the deck for the classes, tokens, and layouts it defines, and \`dekc theme <layout>\` for a layout's markup.
|
|
254
254
|
- Shared look lives in \`theme.css\`. Decoration only one slide uses lives in \`slides/<id>.css\`, which is scoped to that slide.
|
|
255
|
-
- Use only classes defined in \`theme.css\` or in that slide's own \`slides/<id>.css\`. The theme holds at most \`max_classes\` in \`
|
|
255
|
+
- Use only classes defined in \`theme.css\` or in that slide's own \`slides/<id>.css\`. The theme holds at most \`max_classes\` in the frontmatter of \`script.md\`, 40 by default (\`DEK013\`); classes in \`slides/<id>.css\` do not count, so keep a class only one slide uses there.
|
|
256
256
|
- A rule in \`slides/<id>.css\` weighs as if it were written at the end of \`theme.css\`: it beats the theme's \`.slide .x\`, but not a more specific rule such as a layout's \`.slide[data-layout="split"] .x\` or the beat state \`.slide.is-current [data-step]\`. To override one of those, write the same selector.
|
|
257
257
|
- Color, type, space, radius, and motion in either stylesheet use token \`var()\` only. A value only one slide uses can be a token of its own on that slide's \`.slide\` rule in \`slides/<id>.css\`.
|
|
258
258
|
- Do not add \`<style>\`, \`style=\`, \`<script>\`, event handler attributes (\`onclick=\` and the like), or \`javascript:\` URLs inside slide HTML.
|
package/src/core/timing.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Deck } from "./schema.ts";
|
|
2
2
|
|
|
3
3
|
type SectionBodies = {
|
|
4
4
|
body: string;
|
|
@@ -28,14 +28,23 @@ function speechChars(text: string): number {
|
|
|
28
28
|
return stripBlockquotes(text).replace(/\s+/g, "").length;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
/** The rate a deck is spoken at, from its own frontmatter. */
|
|
32
|
+
type SpeakingRate = Pick<Deck, "cjk_per_minute" | "latin_per_minute">;
|
|
33
|
+
|
|
34
|
+
/** What timing reads of a deck: its sections, its budget, and the rate it is spoken at. */
|
|
35
|
+
type TimedDeck = SpeakingRate & {
|
|
36
|
+
sections: Array<{ slug: string } & SectionBodies>;
|
|
37
|
+
duration?: string | undefined;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function estimateSeconds(text: string, rate: SpeakingRate): number {
|
|
32
41
|
const stripped = stripBlockquotes(text);
|
|
33
42
|
const cjk = [...stripped.matchAll(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/gu)]
|
|
34
43
|
.length;
|
|
35
44
|
const rest = stripped.replace(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/gu, " ");
|
|
36
45
|
const words = rest.split(/\s+/).filter(Boolean).length;
|
|
37
|
-
const cjkMinutes =
|
|
38
|
-
const latinMinutes =
|
|
46
|
+
const cjkMinutes = rate.cjk_per_minute > 0 ? cjk / rate.cjk_per_minute : 0;
|
|
47
|
+
const latinMinutes = rate.latin_per_minute > 0 ? words / rate.latin_per_minute : 0;
|
|
39
48
|
return Math.round((cjkMinutes + latinMinutes) * 60);
|
|
40
49
|
}
|
|
41
50
|
|
|
@@ -62,21 +71,17 @@ export type TimedSection = {
|
|
|
62
71
|
budgetSeconds?: number;
|
|
63
72
|
};
|
|
64
73
|
|
|
65
|
-
export function sectionTiming(
|
|
66
|
-
|
|
67
|
-
duration: string | undefined,
|
|
68
|
-
config: DekConfig,
|
|
69
|
-
): TimedSection[] {
|
|
70
|
-
const rows = sections.map((section) => {
|
|
74
|
+
export function sectionTiming(deck: TimedDeck): TimedSection[] {
|
|
75
|
+
const rows = deck.sections.map((section) => {
|
|
71
76
|
const text = speechText(section);
|
|
72
77
|
return {
|
|
73
78
|
slug: section.slug,
|
|
74
79
|
chars: speechChars(text),
|
|
75
|
-
estimateSeconds: estimateSeconds(text,
|
|
80
|
+
estimateSeconds: estimateSeconds(text, deck),
|
|
76
81
|
};
|
|
77
82
|
});
|
|
78
83
|
const totalChars = rows.reduce((sum, row) => sum + row.chars, 0);
|
|
79
|
-
const durationSeconds = parseDurationSeconds(duration);
|
|
84
|
+
const durationSeconds = parseDurationSeconds(deck.duration);
|
|
80
85
|
|
|
81
86
|
return rows.map((row) => ({
|
|
82
87
|
slug: row.slug,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { loadConfig } from "./config.ts";
|
|
2
1
|
import { renderDeckDocument } from "./document.ts";
|
|
3
2
|
import type { ResolvedDeck } from "./resolve.ts";
|
|
4
3
|
|
|
@@ -6,9 +5,8 @@ import type { ResolvedDeck } from "./resolve.ts";
|
|
|
6
5
|
* The whole deck as `dekc video` plays it: every slide, assets inlined, no notes, and the player
|
|
7
6
|
* runtime that walks it. Motion stills, morph frames and the video itself all run on this page.
|
|
8
7
|
*/
|
|
9
|
-
export function videoDocument({
|
|
8
|
+
export function videoDocument({ deck }: ResolvedDeck, playerScript: string): string {
|
|
10
9
|
return renderDeckDocument(deck, {
|
|
11
|
-
config: loadConfig(project.configPath),
|
|
12
10
|
playerScript,
|
|
13
11
|
target: { kind: "video" },
|
|
14
12
|
});
|
|
@@ -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
|
+
}
|