@hajimism/dek 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.ja.md +75 -0
- package/README.md +75 -0
- package/package.json +65 -0
- package/src/cli/address.ts +53 -0
- package/src/cli/build.ts +85 -0
- package/src/cli/check.ts +97 -0
- package/src/cli/commands.ts +851 -0
- package/src/cli/contract.ts +444 -0
- package/src/cli/cues.ts +18 -0
- package/src/cli/files.ts +309 -0
- package/src/cli/flags.ts +295 -0
- package/src/cli/format.ts +156 -0
- package/src/cli/goto.ts +96 -0
- package/src/cli/init.ts +136 -0
- package/src/cli/keep-alive.ts +72 -0
- package/src/cli/lint.ts +84 -0
- package/src/cli/ls.ts +119 -0
- package/src/cli/main.ts +231 -0
- package/src/cli/marks.ts +16 -0
- package/src/cli/mv.ts +37 -0
- package/src/cli/new.ts +64 -0
- package/src/cli/pdf.ts +16 -0
- package/src/cli/qr.ts +466 -0
- package/src/cli/ref.ts +165 -0
- package/src/cli/rehearse.ts +42 -0
- package/src/cli/result.ts +161 -0
- package/src/cli/scope.ts +308 -0
- package/src/cli/serve.ts +125 -0
- package/src/cli/shot.ts +134 -0
- package/src/cli/show.ts +103 -0
- package/src/cli/sync.ts +37 -0
- package/src/cli/text.ts +479 -0
- package/src/cli/theme.ts +56 -0
- package/src/cli/tty.ts +81 -0
- package/src/cli/usage.ts +198 -0
- package/src/cli/video.ts +17 -0
- package/src/cli/voice.ts +113 -0
- package/src/cli.ts +4 -0
- package/src/core/assets.ts +239 -0
- package/src/core/build.ts +77 -0
- package/src/core/bundled-theme.ts +15 -0
- package/src/core/capture-go.ts +72 -0
- package/src/core/chrome.ts +128 -0
- package/src/core/config-keys.ts +82 -0
- package/src/core/config.ts +95 -0
- package/src/core/css-scan.ts +158 -0
- package/src/core/css-transform.ts +140 -0
- package/src/core/css-value.ts +127 -0
- package/src/core/css.ts +545 -0
- package/src/core/cue.ts +187 -0
- package/src/core/deck-paths.ts +34 -0
- package/src/core/diagnostic.ts +114 -0
- package/src/core/document.ts +218 -0
- package/src/core/error.ts +31 -0
- package/src/core/escape.ts +16 -0
- package/src/core/fill.ts +100 -0
- package/src/core/finish-beat.ts +30 -0
- package/src/core/github.ts +234 -0
- package/src/core/html-scan.ts +253 -0
- package/src/core/html.ts +234 -0
- package/src/core/in-page-go.ts +128 -0
- package/src/core/lines.ts +43 -0
- package/src/core/lint/asset-refs.ts +109 -0
- package/src/core/lint/context.ts +116 -0
- package/src/core/lint/files.ts +117 -0
- package/src/core/lint/keys.ts +48 -0
- package/src/core/lint/morph.ts +91 -0
- package/src/core/lint/script.ts +102 -0
- package/src/core/lint/shared-styles.ts +165 -0
- package/src/core/lint/slide-html.ts +466 -0
- package/src/core/lint/slide-script.ts +179 -0
- package/src/core/lint/theme-css.ts +295 -0
- package/src/core/lint/timing.ts +66 -0
- package/src/core/lint/tokens.ts +520 -0
- package/src/core/lint/voice.ts +76 -0
- package/src/core/lint.ts +137 -0
- package/src/core/live-protocol.ts +130 -0
- package/src/core/marks.ts +198 -0
- package/src/core/mv.ts +293 -0
- package/src/core/ogp.ts +99 -0
- package/src/core/optional.ts +24 -0
- package/src/core/overflow.ts +220 -0
- package/src/core/page-pool.ts +53 -0
- package/src/core/page.ts +66 -0
- package/src/core/parse.ts +307 -0
- package/src/core/path.ts +58 -0
- package/src/core/pdf.ts +71 -0
- package/src/core/playwright-module.d.ts +43 -0
- package/src/core/playwright-motion.ts +42 -0
- package/src/core/playwright-sheet.ts +32 -0
- package/src/core/playwright-visual.ts +391 -0
- package/src/core/playwright-worker.ts +5 -0
- package/src/core/playwright.ts +431 -0
- package/src/core/position.ts +146 -0
- package/src/core/presenter-state.ts +43 -0
- package/src/core/presenter.ts +131 -0
- package/src/core/prose.ts +16 -0
- package/src/core/pseudo-text.ts +67 -0
- package/src/core/rail-width.ts +4 -0
- package/src/core/ref-name.ts +110 -0
- package/src/core/ref.ts +238 -0
- package/src/core/resolve.ts +228 -0
- package/src/core/rumdl.ts +99 -0
- package/src/core/safe-fs.ts +196 -0
- package/src/core/sarif.ts +135 -0
- package/src/core/schema.ts +89 -0
- package/src/core/script-lines.ts +53 -0
- package/src/core/sheet.ts +223 -0
- package/src/core/shot/morph.ts +80 -0
- package/src/core/shot/motion.ts +187 -0
- package/src/core/shot/still.ts +216 -0
- package/src/core/shot-cache.ts +140 -0
- package/src/core/size.ts +11 -0
- package/src/core/skeleton-record.ts +51 -0
- package/src/core/skeleton.ts +89 -0
- package/src/core/slide-draw.ts +165 -0
- package/src/core/slide-eval-worker.ts +58 -0
- package/src/core/slide-measure.ts +272 -0
- package/src/core/slide-script-eval.ts +258 -0
- package/src/core/slide-script.ts +252 -0
- package/src/core/spawn.ts +128 -0
- package/src/core/step.ts +94 -0
- package/src/core/still-page.ts +70 -0
- package/src/core/suggest.ts +33 -0
- package/src/core/sync.ts +316 -0
- package/src/core/text-contrast.ts +466 -0
- package/src/core/theme-excerpt.ts +187 -0
- package/src/core/theme-facts.ts +51 -0
- package/src/core/timeline.ts +216 -0
- package/src/core/timing.ts +88 -0
- package/src/core/toml-keys.ts +349 -0
- package/src/core/url-attributes.ts +103 -0
- package/src/core/video-document.ts +15 -0
- package/src/core/visual-cache.ts +100 -0
- package/src/core/visual.ts +558 -0
- package/src/core/voice.ts +321 -0
- package/src/core/zod.ts +79 -0
- package/src/runtime/browser.ts +203 -0
- package/src/runtime/clone.ts +52 -0
- package/src/runtime/deck-control.ts +43 -0
- package/src/runtime/fit.ts +32 -0
- package/src/runtime/go.ts +56 -0
- package/src/runtime/input.ts +128 -0
- package/src/runtime/laser-view.ts +110 -0
- package/src/runtime/laser.ts +145 -0
- package/src/runtime/live-host.ts +33 -0
- package/src/runtime/live-reload.ts +29 -0
- package/src/runtime/live.ts +88 -0
- package/src/runtime/marks-view.ts +74 -0
- package/src/runtime/motion.ts +113 -0
- package/src/runtime/navigator.ts +189 -0
- package/src/runtime/player.ts +28 -0
- package/src/runtime/presenter-view.ts +289 -0
- package/src/runtime/presenter.ts +52 -0
- package/src/runtime/rail-view.ts +229 -0
- package/src/runtime/rail.ts +52 -0
- package/src/runtime/rehearse.ts +264 -0
- package/src/runtime/routes.ts +142 -0
- package/src/runtime/settle.ts +45 -0
- package/src/runtime/slide.d.ts +28 -0
- package/src/runtime/socket.ts +143 -0
- package/src/runtime/stage.ts +259 -0
- package/src/runtime/step.ts +139 -0
- package/src/runtime/window.ts +38 -0
- package/src/server/auth.ts +210 -0
- package/src/server/deck-snapshot.ts +146 -0
- package/src/server/dev.ts +251 -0
- package/src/server/hub.ts +104 -0
- package/src/server/lan.ts +51 -0
- package/src/server/lock.ts +97 -0
- package/src/server/pages.ts +71 -0
- package/src/server/pairing.ts +40 -0
- package/src/server/project-watch.ts +142 -0
- package/src/server/rooms.ts +133 -0
- package/src/server/routes.ts +286 -0
- package/src/server/serial.ts +25 -0
- package/src/server/watch.ts +308 -0
- package/src/theme/default.css +175 -0
- package/src/video/bake.ts +152 -0
- package/src/video/mux.ts +103 -0
- package/src/video/recorder.ts +115 -0
- package/src/video/sidecar.ts +60 -0
- package/src/video/worker.ts +51 -0
- package/src/voice/engine.ts +177 -0
- package/src/voice/synth.ts +127 -0
- package/src/voice/wav.ts +115 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slide scripts draw as a function of time: `draw(slide, { index, step, t })`
|
|
3
|
+
* where `t` is milliseconds since the beat began. The runtime owns the clock,
|
|
4
|
+
* so live playback advances `t` per frame while video capture seeks it.
|
|
5
|
+
*/
|
|
6
|
+
import { drawFrame, stepMotionMs } from "../core/slide-draw.ts";
|
|
7
|
+
import { stepKey } from "../core/step.ts";
|
|
8
|
+
|
|
9
|
+
export { drawAtEnd } from "../core/slide-draw.ts";
|
|
10
|
+
|
|
11
|
+
// The shapes live in slide.d.ts, which `dekc sync` also hands to deck authors.
|
|
12
|
+
export type MotionFrame = DekMotionFrame;
|
|
13
|
+
export type SlideModule = DekSlide;
|
|
14
|
+
|
|
15
|
+
/** animate: run t from 0 to the motion. final: jump to the end. hold: t=0, then `seek`. */
|
|
16
|
+
export type MotionMode = "animate" | "final" | "hold";
|
|
17
|
+
|
|
18
|
+
export function motionMs(
|
|
19
|
+
module: SlideModule | undefined,
|
|
20
|
+
beats: Array<{ id?: string }>,
|
|
21
|
+
index: number,
|
|
22
|
+
): number {
|
|
23
|
+
return stepMotionMs(module, stepKey(beats, index));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createMotion(clock: {
|
|
27
|
+
now: () => number;
|
|
28
|
+
requestFrame: (fn: () => void) => number;
|
|
29
|
+
cancelFrame: (id: number) => void;
|
|
30
|
+
/** Backstop for hidden or throttled pages, where animation frames stop arriving. */
|
|
31
|
+
setTimer: (fn: () => void, ms: number) => number;
|
|
32
|
+
clearTimer: (id: number) => void;
|
|
33
|
+
}) {
|
|
34
|
+
let current:
|
|
35
|
+
| { slide: HTMLElement; module: SlideModule; index: number; step: string; ms: number }
|
|
36
|
+
| undefined;
|
|
37
|
+
let frame: number | undefined;
|
|
38
|
+
let timer: number | undefined;
|
|
39
|
+
|
|
40
|
+
const stop = (): void => {
|
|
41
|
+
if (frame !== undefined) {
|
|
42
|
+
clock.cancelFrame(frame);
|
|
43
|
+
frame = undefined;
|
|
44
|
+
}
|
|
45
|
+
if (timer !== undefined) {
|
|
46
|
+
clock.clearTimer(timer);
|
|
47
|
+
timer = undefined;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const draw = (t: number): void => {
|
|
52
|
+
if (!current) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
drawFrame(current.module, current.slide, {
|
|
56
|
+
index: current.index,
|
|
57
|
+
step: current.step,
|
|
58
|
+
t: Math.min(Math.max(t, 0), current.ms),
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
show(
|
|
64
|
+
slide: HTMLElement,
|
|
65
|
+
module: SlideModule | undefined,
|
|
66
|
+
beats: Array<{ id?: string }>,
|
|
67
|
+
index: number,
|
|
68
|
+
mode: MotionMode,
|
|
69
|
+
): void {
|
|
70
|
+
stop();
|
|
71
|
+
current = module
|
|
72
|
+
? { slide, module, index, step: stepKey(beats, index), ms: motionMs(module, beats, index) }
|
|
73
|
+
: undefined;
|
|
74
|
+
if (!current) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (mode === "final" || current.ms === 0) {
|
|
78
|
+
draw(current.ms);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
draw(0);
|
|
82
|
+
if (mode === "hold") {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const start = clock.now();
|
|
86
|
+
const step = (): void => {
|
|
87
|
+
const t = clock.now() - start;
|
|
88
|
+
draw(t);
|
|
89
|
+
if (current && t < current.ms) {
|
|
90
|
+
frame = clock.requestFrame(step);
|
|
91
|
+
} else {
|
|
92
|
+
stop();
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
frame = clock.requestFrame(step);
|
|
96
|
+
timer = clock.setTimer(() => {
|
|
97
|
+
timer = undefined;
|
|
98
|
+
if (frame !== undefined) {
|
|
99
|
+
stop();
|
|
100
|
+
draw(current?.ms ?? 0);
|
|
101
|
+
}
|
|
102
|
+
}, current.ms + 50);
|
|
103
|
+
},
|
|
104
|
+
seek(t: number): void {
|
|
105
|
+
stop();
|
|
106
|
+
draw(t);
|
|
107
|
+
},
|
|
108
|
+
duration(): number {
|
|
109
|
+
return current?.ms ?? 0;
|
|
110
|
+
},
|
|
111
|
+
stop,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
decodePointer,
|
|
5
|
+
decodePosition,
|
|
6
|
+
encodePointer,
|
|
7
|
+
encodePosition,
|
|
8
|
+
type Pointer,
|
|
9
|
+
} from "../core/live-protocol.ts";
|
|
10
|
+
import {
|
|
11
|
+
clampPosition,
|
|
12
|
+
type DeckStops,
|
|
13
|
+
formatHash,
|
|
14
|
+
hashChangeTarget,
|
|
15
|
+
historyMode,
|
|
16
|
+
positionFromHash,
|
|
17
|
+
positionsEqual,
|
|
18
|
+
} from "../core/position.ts";
|
|
19
|
+
import type { Position } from "../core/step.ts";
|
|
20
|
+
import { applyIncomingPosition, createGuardedGo } from "./go.ts";
|
|
21
|
+
import { createPositionSocket, type PositionSocket } from "./socket.ts";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Where a move came from. Local moves, and the rehearsal clock's, are published to peers; remote
|
|
25
|
+
* ones arrived from one; a hash move came from the URL, which already holds its history entry.
|
|
26
|
+
* Capture (the video recorder and morph shots, through `window.dekGo`) drives this page alone.
|
|
27
|
+
*/
|
|
28
|
+
export type GoOrigin = "local" | "rehearse" | "remote" | "hash" | "capture";
|
|
29
|
+
|
|
30
|
+
export type Navigator = {
|
|
31
|
+
/** What is on screen. */
|
|
32
|
+
position(): Position;
|
|
33
|
+
/**
|
|
34
|
+
* Where the deck is headed: `position()` once the moves asked for have run. Moves step from
|
|
35
|
+
* here, so every press counts even while a transition is still playing.
|
|
36
|
+
*/
|
|
37
|
+
target(): Position;
|
|
38
|
+
go(next: Position | null | undefined, origin?: GoOrigin): Promise<void>;
|
|
39
|
+
/** Write the position to the URL, e.g. once a hash past a slide's last beat was clamped. */
|
|
40
|
+
writeHash(mode: "push" | "replace"): void;
|
|
41
|
+
/** Tell the other windows and the server where this window's laser points, or that it went away. */
|
|
42
|
+
point(pointer: Pointer | null): void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type GoRequest = { position: Position; origin: GoOrigin };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Owns where the deck is: the position, the one it is headed to, and the lines that carry it
|
|
49
|
+
* (the URL, other windows, the dev server's socket), which also carry where a laser points.
|
|
50
|
+
* Drawing a move is left to `present`.
|
|
51
|
+
*/
|
|
52
|
+
export function createNavigator(options: {
|
|
53
|
+
deck: DeckStops;
|
|
54
|
+
channelName: string;
|
|
55
|
+
/** The dev server's position socket; a built file on a static host has none. */
|
|
56
|
+
socketUrl?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Draw the move from `from` to `to`. `apply` puts it on screen, perhaps inside a transition;
|
|
59
|
+
* the promise settles once the move has played.
|
|
60
|
+
*/
|
|
61
|
+
present: (from: Position, to: Position, apply: () => void) => Promise<void>;
|
|
62
|
+
/** A newer move is waiting: cut the one in flight short. */
|
|
63
|
+
hurry: () => void;
|
|
64
|
+
/** The deck now stands on `to`. */
|
|
65
|
+
onMove: (from: Position, to: Position) => void;
|
|
66
|
+
/** A move is about to run. */
|
|
67
|
+
beforeMove?: () => void;
|
|
68
|
+
/** A move has played out. */
|
|
69
|
+
afterMove?: () => void;
|
|
70
|
+
/** Where a peer's move goes, once clamped and new; left out, the deck goes there. */
|
|
71
|
+
route?: (next: Position) => void;
|
|
72
|
+
/** Where a peer's laser points, or null once it went away. */
|
|
73
|
+
onPointer?: (pointer: Pointer | null) => void;
|
|
74
|
+
}): Navigator {
|
|
75
|
+
const slugs = options.deck.map((slide) => slide.slug);
|
|
76
|
+
// One channel per deck: two decks' built files open side by side must not drive each other.
|
|
77
|
+
const channel = new BroadcastChannel(options.channelName);
|
|
78
|
+
let pos = positionFromHash(location.hash, options.deck);
|
|
79
|
+
let target = pos;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Write `pos` to the URL without a hashchange, so the handler only ever hears the reader:
|
|
83
|
+
* a typed hash, Back, or Forward.
|
|
84
|
+
*/
|
|
85
|
+
function writeHash(mode: "push" | "replace"): void {
|
|
86
|
+
const hash = formatHash(pos, slugs);
|
|
87
|
+
if (!hash || location.hash === hash) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
history[mode === "push" ? "pushState" : "replaceState"](null, "", hash);
|
|
92
|
+
} catch {
|
|
93
|
+
// A browser that refuses history on this URL still takes a hash; its echo matches `target`.
|
|
94
|
+
location.hash = hash;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const runGo = createGuardedGo(
|
|
99
|
+
async ({ position: next, origin }: GoRequest) => {
|
|
100
|
+
options.beforeMove?.();
|
|
101
|
+
await options.present(pos, next, () => {
|
|
102
|
+
const from = pos;
|
|
103
|
+
pos = next;
|
|
104
|
+
writeHash(origin === "hash" ? "replace" : historyMode(from, next));
|
|
105
|
+
options.onMove(from, next);
|
|
106
|
+
});
|
|
107
|
+
options.afterMove?.();
|
|
108
|
+
},
|
|
109
|
+
{ onQueue: options.hurry },
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
let remote: PositionSocket | undefined;
|
|
113
|
+
|
|
114
|
+
/** Tell the other windows and the server where the deck is headed. */
|
|
115
|
+
function publish(position: Position): void {
|
|
116
|
+
channel.postMessage(encodePosition(position));
|
|
117
|
+
remote?.publish(position);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Move to `next`. A local move is published at once, so a second window follows the key press
|
|
122
|
+
* rather than the end of this window's animation. A remote one came from a peer and is not
|
|
123
|
+
* sent back: a peer that had moved on would be rewound by the echo.
|
|
124
|
+
*/
|
|
125
|
+
function go(next: Position | null | undefined, origin: GoOrigin = "local"): Promise<void> {
|
|
126
|
+
if (next == null) {
|
|
127
|
+
return runGo(undefined);
|
|
128
|
+
}
|
|
129
|
+
target = next;
|
|
130
|
+
if (origin === "local" || origin === "rehearse") {
|
|
131
|
+
publish(next);
|
|
132
|
+
}
|
|
133
|
+
return runGo({ position: next, origin });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A position a peer sent: clamped to this deck, and dropped when the deck already heads there. */
|
|
137
|
+
function receive(next: Position): void {
|
|
138
|
+
const clamped = clampPosition(next, options.deck);
|
|
139
|
+
if (!clamped) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
applyIncomingPosition(options.route ?? ((incoming) => go(incoming, "remote")), clamped, {
|
|
143
|
+
equal: positionsEqual,
|
|
144
|
+
current: () => target,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
channel.addEventListener("message", (event: MessageEvent) => {
|
|
149
|
+
const laser = decodePointer(event.data);
|
|
150
|
+
if (laser) {
|
|
151
|
+
options.onPointer?.(laser.pointer);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const next = decodePosition(event.data);
|
|
155
|
+
if (next) {
|
|
156
|
+
receive(next);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
if (options.socketUrl) {
|
|
160
|
+
const url = options.socketUrl;
|
|
161
|
+
remote = createPositionSocket({
|
|
162
|
+
connect: () => new WebSocket(url),
|
|
163
|
+
onPosition: receive,
|
|
164
|
+
...(options.onPointer ? { onPointer: options.onPointer } : {}),
|
|
165
|
+
setTimer: (fn, ms) => window.setTimeout(fn, ms),
|
|
166
|
+
clearTimer: (id) => window.clearTimeout(id),
|
|
167
|
+
});
|
|
168
|
+
// A page that is really going away must not dial back in; one kept for Back reconnects.
|
|
169
|
+
window.addEventListener("pagehide", (event) => {
|
|
170
|
+
if (!event.persisted) {
|
|
171
|
+
remote?.close();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
window.addEventListener("hashchange", () => {
|
|
176
|
+
void go(hashChangeTarget(target, location.hash, options.deck), "hash");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
position: () => pos,
|
|
181
|
+
target: () => target,
|
|
182
|
+
go,
|
|
183
|
+
writeHash,
|
|
184
|
+
point(pointer) {
|
|
185
|
+
channel.postMessage(encodePointer(pointer));
|
|
186
|
+
remote?.point(pointer);
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
export { liveReloadScript } from "./live-reload.ts";
|
|
4
|
+
|
|
5
|
+
let compiled: Promise<string> | undefined;
|
|
6
|
+
|
|
7
|
+
export function playerScript(): Promise<string> {
|
|
8
|
+
compiled ??= compilePlayer();
|
|
9
|
+
return compiled;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function compilePlayer(): Promise<string> {
|
|
13
|
+
const result = await Bun.build({
|
|
14
|
+
entrypoints: [join(import.meta.dir, "browser.ts")],
|
|
15
|
+
target: "browser",
|
|
16
|
+
format: "iife",
|
|
17
|
+
minify: true,
|
|
18
|
+
});
|
|
19
|
+
if (!result.success) {
|
|
20
|
+
const details = result.logs.map((log) => String(log)).join("\n");
|
|
21
|
+
throw new Error(`failed to compile player\n${details}`);
|
|
22
|
+
}
|
|
23
|
+
const output = result.outputs[0];
|
|
24
|
+
if (!output) {
|
|
25
|
+
throw new Error("failed to compile player: no output");
|
|
26
|
+
}
|
|
27
|
+
return output.text();
|
|
28
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
import { PAGE_ID, type PageMode } from "../core/page.ts";
|
|
3
|
+
|
|
4
|
+
import { advance, type DeckStops } from "../core/position.ts";
|
|
5
|
+
import {
|
|
6
|
+
nextPresenterTitle,
|
|
7
|
+
type PresenterBeat,
|
|
8
|
+
type PresenterSlide,
|
|
9
|
+
presenterState,
|
|
10
|
+
} from "../core/presenter-state.ts";
|
|
11
|
+
import { type Position, stepValuesForBeat } from "../core/step.ts";
|
|
12
|
+
import { formatClock } from "../core/timing.ts";
|
|
13
|
+
import { deckSize, stillFrame, visualClone } from "./clone.ts";
|
|
14
|
+
import { fitStage } from "./fit.ts";
|
|
15
|
+
import { elapsedTone, presenterSearch, progressFill, totalBudgetSeconds } from "./presenter.ts";
|
|
16
|
+
import type { Stage } from "./stage.ts";
|
|
17
|
+
import { applyIsShown } from "./step.ts";
|
|
18
|
+
|
|
19
|
+
export type PresenterView = {
|
|
20
|
+
/** Whether the page can show the presenter at all. */
|
|
21
|
+
available(): boolean;
|
|
22
|
+
isOpen(): boolean;
|
|
23
|
+
/** Show or hide the presenter and keep `?presenter` in the URL to match. */
|
|
24
|
+
setOpen(open: boolean): void;
|
|
25
|
+
render(pos: Position): void;
|
|
26
|
+
/** Start the talk clock once, at the first move. */
|
|
27
|
+
startClock(): void;
|
|
28
|
+
/** Fit the next-beat preview to its stage. */
|
|
29
|
+
fit(): void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** What the panels say at a position, before any of it is written to the page. */
|
|
33
|
+
export type PresenterPanels = {
|
|
34
|
+
/** Undefined off the deck, where the panel keeps what it last said. */
|
|
35
|
+
script: string | undefined;
|
|
36
|
+
/** The title of what the next beat shows. */
|
|
37
|
+
next: string;
|
|
38
|
+
/** Null at the deck's last beat. */
|
|
39
|
+
nextPos: Position | null;
|
|
40
|
+
beats: PresenterBeat[] | undefined;
|
|
41
|
+
budget: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function presenterPanels(
|
|
45
|
+
slides: PresenterSlide[],
|
|
46
|
+
deck: DeckStops,
|
|
47
|
+
pos: Position,
|
|
48
|
+
): PresenterPanels {
|
|
49
|
+
const current = slides[pos.slideIndex];
|
|
50
|
+
const state = current ? presenterState(slides, pos) : undefined;
|
|
51
|
+
return {
|
|
52
|
+
script: state?.script,
|
|
53
|
+
next: state ? nextPresenterTitle(state) : "",
|
|
54
|
+
nextPos: advance(pos, deck),
|
|
55
|
+
beats: state?.current.beats,
|
|
56
|
+
budget: current?.budgetSeconds !== undefined ? formatClock(current.budgetSeconds) : "",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One bar segment per slide: done, current and filled as far as its beats go, or to come. */
|
|
61
|
+
export function progressSegments(
|
|
62
|
+
deck: DeckStops,
|
|
63
|
+
pos: Position,
|
|
64
|
+
): Array<{ className?: "is-done" | "is-current"; fill?: string }> {
|
|
65
|
+
return deck.map((slide, index) => {
|
|
66
|
+
if (index < pos.slideIndex) {
|
|
67
|
+
return { className: "is-done" };
|
|
68
|
+
}
|
|
69
|
+
if (index === pos.slideIndex) {
|
|
70
|
+
return {
|
|
71
|
+
className: "is-current",
|
|
72
|
+
fill: `${progressFill(pos.beatIndex, slide.stops) * 100}%`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return {};
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The presenter page opens with its panels shown, and so does any page asked `?presenter`. */
|
|
80
|
+
export function opensAsPresenter(search: string, mode: PageMode): boolean {
|
|
81
|
+
return new URLSearchParams(search).has("presenter") || mode === "presenter";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const setText = (id: string, text: string): void => {
|
|
85
|
+
const el = document.getElementById(id);
|
|
86
|
+
if (el) {
|
|
87
|
+
el.textContent = text;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** The script, what comes next, and the slide's budget. */
|
|
92
|
+
function renderNotes(panels: PresenterPanels, budgetEl: HTMLElement | null): void {
|
|
93
|
+
if (panels.script !== undefined) {
|
|
94
|
+
setText(PAGE_ID.script, panels.script);
|
|
95
|
+
}
|
|
96
|
+
setText(PAGE_ID.next, panels.next);
|
|
97
|
+
const nextEnd = document.getElementById(PAGE_ID.nextEnd);
|
|
98
|
+
if (nextEnd) {
|
|
99
|
+
nextEnd.hidden = Boolean(panels.nextPos);
|
|
100
|
+
}
|
|
101
|
+
if (budgetEl) {
|
|
102
|
+
budgetEl.textContent = panels.budget;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function renderPage(slideNumber: number, slideCount: number): void {
|
|
107
|
+
const pageEl = document.getElementById(PAGE_ID.page);
|
|
108
|
+
if (!pageEl) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const total = document.createElement("span");
|
|
112
|
+
total.className = "dek-page-total";
|
|
113
|
+
total.textContent = `/ ${slideCount}`;
|
|
114
|
+
pageEl.replaceChildren(document.createTextNode(`${slideNumber} `), total);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function beatItems(beats: PresenterBeat[]): HTMLLIElement[] {
|
|
118
|
+
return beats.map((beat, i) => {
|
|
119
|
+
const li = document.createElement("li");
|
|
120
|
+
li.setAttribute("data-beat-index", String(i + 1));
|
|
121
|
+
li.textContent = beat.title;
|
|
122
|
+
return li;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function highlightBeat(beatsEl: HTMLElement, beatIndex: number): void {
|
|
127
|
+
for (const el of beatsEl.querySelectorAll("[data-beat-index]")) {
|
|
128
|
+
el.classList.toggle(
|
|
129
|
+
"is-current-beat",
|
|
130
|
+
Number(el.getAttribute("data-beat-index")) === beatIndex,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function renderProgress(progressEl: HTMLElement, deck: DeckStops, pos: Position): void {
|
|
136
|
+
progressEl.replaceChildren(
|
|
137
|
+
...progressSegments(deck, pos).map(({ className, fill }) => {
|
|
138
|
+
const span = document.createElement("span");
|
|
139
|
+
if (className) {
|
|
140
|
+
span.className = className;
|
|
141
|
+
}
|
|
142
|
+
if (fill) {
|
|
143
|
+
span.style.setProperty("--dek-fill", fill);
|
|
144
|
+
}
|
|
145
|
+
return span;
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A still of the next beat as the move will show it, or nothing when there is none to show. */
|
|
151
|
+
function renderNextPreview(
|
|
152
|
+
stage: Stage,
|
|
153
|
+
next: { slide: PresenterSlide; beatIndex: number } | undefined,
|
|
154
|
+
): void {
|
|
155
|
+
const previewStage = document.getElementById(PAGE_ID.nextStage);
|
|
156
|
+
if (!previewStage) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const source = next ? stage.slideEl(next.slide.slug) : undefined;
|
|
160
|
+
const clone = source ? visualClone(source) : undefined;
|
|
161
|
+
const deckEl = document.getElementById(PAGE_ID.deck);
|
|
162
|
+
if (!next || !clone || !deckEl) {
|
|
163
|
+
previewStage.replaceChildren();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const { slide, beatIndex } = next;
|
|
167
|
+
applyIsShown(
|
|
168
|
+
[...clone.querySelectorAll("[data-step]")],
|
|
169
|
+
stepValuesForBeat(slide.beats, beatIndex),
|
|
170
|
+
);
|
|
171
|
+
const frame = stillFrame("dek-preview-frame", clone, deckSize(deckEl));
|
|
172
|
+
previewStage.replaceChildren(frame);
|
|
173
|
+
stage.drawStill(clone, slide, beatIndex);
|
|
174
|
+
fitStage(frame, previewStage);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function syncElapsed(
|
|
178
|
+
elapsedEl: HTMLElement,
|
|
179
|
+
startedAt: number,
|
|
180
|
+
talkBudget: number | undefined,
|
|
181
|
+
): void {
|
|
182
|
+
const elapsed = (Date.now() - startedAt) / 1000;
|
|
183
|
+
elapsedEl.textContent = formatClock(elapsed);
|
|
184
|
+
const tone = elapsedTone(elapsed, talkBudget);
|
|
185
|
+
elapsedEl.classList.toggle("is-warn", tone === "warn");
|
|
186
|
+
elapsedEl.classList.toggle("is-over", tone === "over");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Owns the presenter's panels: the script, the beats, what comes next, the clock, the progress. */
|
|
190
|
+
export function createPresenterView(options: {
|
|
191
|
+
slides: PresenterSlide[];
|
|
192
|
+
deck: DeckStops;
|
|
193
|
+
stage: Stage;
|
|
194
|
+
/** The page opens with the panels shown when it is the presenter page. */
|
|
195
|
+
mode: PageMode;
|
|
196
|
+
}): PresenterView {
|
|
197
|
+
const { slides, deck, stage } = options;
|
|
198
|
+
// Only a page with notes has the panels.
|
|
199
|
+
const root = document.getElementById(PAGE_ID.presenter);
|
|
200
|
+
const progressEl = document.getElementById(PAGE_ID.progress);
|
|
201
|
+
const elapsedEl = document.getElementById(PAGE_ID.elapsed);
|
|
202
|
+
const budgetEl = document.getElementById(PAGE_ID.budget);
|
|
203
|
+
const talkBudget = totalBudgetSeconds(slides);
|
|
204
|
+
let startedAt: number | undefined;
|
|
205
|
+
/** The slide the beat list was built for; beats within it only move the highlight. */
|
|
206
|
+
let beatsFor: number | undefined;
|
|
207
|
+
|
|
208
|
+
const isOpen = (): boolean => Boolean(root && !root.hidden);
|
|
209
|
+
|
|
210
|
+
function show(open: boolean): void {
|
|
211
|
+
if (!root) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
root.hidden = !open;
|
|
215
|
+
document.body.classList.toggle("is-presenter", open);
|
|
216
|
+
if (progressEl) {
|
|
217
|
+
progressEl.hidden = !open;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (root && opensAsPresenter(location.search, options.mode)) {
|
|
222
|
+
show(true);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function renderBeatList(pos: Position, beats: PresenterBeat[]): void {
|
|
226
|
+
const beatsEl = document.getElementById(PAGE_ID.beats);
|
|
227
|
+
if (!beatsEl) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (beatsFor !== pos.slideIndex) {
|
|
231
|
+
beatsFor = pos.slideIndex;
|
|
232
|
+
beatsEl.replaceChildren(...beatItems(beats));
|
|
233
|
+
}
|
|
234
|
+
highlightBeat(beatsEl, pos.beatIndex);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function render(pos: Position): void {
|
|
238
|
+
const panels = presenterPanels(slides, deck, pos);
|
|
239
|
+
renderNotes(panels, budgetEl);
|
|
240
|
+
if (panels.beats) {
|
|
241
|
+
renderBeatList(pos, panels.beats);
|
|
242
|
+
}
|
|
243
|
+
renderPage(pos.slideIndex + 1, slides.length);
|
|
244
|
+
if (progressEl) {
|
|
245
|
+
renderProgress(progressEl, deck, pos);
|
|
246
|
+
}
|
|
247
|
+
const { nextPos } = panels;
|
|
248
|
+
const nextSlide = nextPos && isOpen() ? slides[nextPos.slideIndex] : undefined;
|
|
249
|
+
renderNextPreview(
|
|
250
|
+
stage,
|
|
251
|
+
nextPos && nextSlide ? { slide: nextSlide, beatIndex: nextPos.beatIndex } : undefined,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
available: () => root !== null,
|
|
257
|
+
isOpen,
|
|
258
|
+
setOpen(open) {
|
|
259
|
+
if (!root) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
show(open);
|
|
263
|
+
const search = presenterSearch(location.search, open);
|
|
264
|
+
if (search !== location.search) {
|
|
265
|
+
history.replaceState(null, "", `${location.pathname}${search}${location.hash}`);
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
render,
|
|
269
|
+
startClock() {
|
|
270
|
+
if (startedAt !== undefined) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const started = Date.now();
|
|
274
|
+
startedAt = started;
|
|
275
|
+
if (elapsedEl) {
|
|
276
|
+
const tick = (): void => syncElapsed(elapsedEl, started, talkBudget);
|
|
277
|
+
window.setInterval(tick, 1000);
|
|
278
|
+
tick();
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
fit() {
|
|
282
|
+
const preview = document.querySelector(`#${PAGE_ID.nextStage} .dek-preview-frame`);
|
|
283
|
+
const nextStage = document.getElementById(PAGE_ID.nextStage);
|
|
284
|
+
if (preview instanceof HTMLElement && nextStage && isOpen()) {
|
|
285
|
+
fitStage(preview, nextStage);
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { isLetterKey } from "./step.ts";
|
|
2
|
+
|
|
3
|
+
export function isPresenterToggleKey(event: Parameters<typeof isLetterKey>[0]): boolean {
|
|
4
|
+
return isLetterKey(event, "p");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function presenterSearch(search: string, open: boolean): string {
|
|
8
|
+
const raw = search.startsWith("?") ? search.slice(1) : search;
|
|
9
|
+
const params = new URLSearchParams(raw);
|
|
10
|
+
if (open) {
|
|
11
|
+
params.set("presenter", "");
|
|
12
|
+
} else {
|
|
13
|
+
params.delete("presenter");
|
|
14
|
+
}
|
|
15
|
+
const encoded = params.toString().replace(/=(?=&|$)/g, "");
|
|
16
|
+
return encoded ? `?${encoded}` : "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ElapsedTone = "ok" | "warn" | "over";
|
|
20
|
+
|
|
21
|
+
export function elapsedTone(
|
|
22
|
+
elapsedSeconds: number,
|
|
23
|
+
budgetSeconds: number | undefined,
|
|
24
|
+
): ElapsedTone {
|
|
25
|
+
if (budgetSeconds === undefined || budgetSeconds <= 0) {
|
|
26
|
+
return "ok";
|
|
27
|
+
}
|
|
28
|
+
if (elapsedSeconds > budgetSeconds) {
|
|
29
|
+
return "over";
|
|
30
|
+
}
|
|
31
|
+
if (elapsedSeconds > budgetSeconds * 0.8) {
|
|
32
|
+
return "warn";
|
|
33
|
+
}
|
|
34
|
+
return "ok";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function totalBudgetSeconds(slides: Array<{ budgetSeconds?: number }>): number | undefined {
|
|
38
|
+
let any = false;
|
|
39
|
+
let sum = 0;
|
|
40
|
+
for (const slide of slides) {
|
|
41
|
+
if (slide.budgetSeconds !== undefined) {
|
|
42
|
+
any = true;
|
|
43
|
+
sum += slide.budgetSeconds;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return any ? sum : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function progressFill(beatIndex: number, stops: number): number {
|
|
50
|
+
const count = Math.max(stops, 1);
|
|
51
|
+
return Math.min(1, (beatIndex + 1) / count);
|
|
52
|
+
}
|