@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,229 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
import { PAGE_ID } from "../core/page.ts";
|
|
3
|
+
|
|
4
|
+
import type { PresenterSlide } from "../core/presenter-state.ts";
|
|
5
|
+
import { lastStop } from "../core/step.ts";
|
|
6
|
+
import { deckSize, stillFrame, visualClone } from "./clone.ts";
|
|
7
|
+
import { fitStage } from "./fit.ts";
|
|
8
|
+
import {
|
|
9
|
+
clampRailWidth,
|
|
10
|
+
pageStorage,
|
|
11
|
+
RAIL_VISIBLE_KEY,
|
|
12
|
+
RAIL_WIDTH_KEY,
|
|
13
|
+
RAIL_WIDTH_STEP,
|
|
14
|
+
readStoredRailVisible,
|
|
15
|
+
readStoredRailWidth,
|
|
16
|
+
} from "./rail.ts";
|
|
17
|
+
import type { Stage } from "./stage.ts";
|
|
18
|
+
|
|
19
|
+
export type RailView = {
|
|
20
|
+
/** Whether the page has a rail. */
|
|
21
|
+
available(): boolean;
|
|
22
|
+
isOpen(): boolean;
|
|
23
|
+
setOpen(open: boolean): void;
|
|
24
|
+
/** Draw every thumbnail again from its slide. */
|
|
25
|
+
fill(): void;
|
|
26
|
+
fit(): void;
|
|
27
|
+
/** Mark the thumbnail of slide `slideIndex` current and scroll it into view. */
|
|
28
|
+
markCurrent(slideIndex: number): void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Which way an arrow key on a thumbnail moves along the rail. */
|
|
32
|
+
export function thumbStep(key: string): 1 | -1 | undefined {
|
|
33
|
+
if (key === "ArrowDown") {
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
return key === "ArrowUp" ? -1 : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** How far an arrow key on the resize handle moves the rail's edge. */
|
|
40
|
+
export function resizeStep(key: string): number | undefined {
|
|
41
|
+
if (key === "ArrowRight") {
|
|
42
|
+
return RAIL_WIDTH_STEP;
|
|
43
|
+
}
|
|
44
|
+
return key === "ArrowLeft" ? -RAIL_WIDTH_STEP : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function persist(key: string, value: string): void {
|
|
48
|
+
try {
|
|
49
|
+
pageStorage()?.setItem(key, value);
|
|
50
|
+
} catch {
|
|
51
|
+
// file:// or private mode may reject storage
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function setWidth(handle: HTMLElement | null, px: number): number {
|
|
56
|
+
const width = clampRailWidth(px);
|
|
57
|
+
document.documentElement.style.setProperty("--dek-rail-w", `${width}px`);
|
|
58
|
+
handle?.setAttribute("aria-valuenow", String(width));
|
|
59
|
+
return width;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function fillThumbs(railEl: HTMLElement, slides: PresenterSlide[], stage: Stage): void {
|
|
63
|
+
const deckEl = document.getElementById(PAGE_ID.deck);
|
|
64
|
+
if (!deckEl) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const size = deckSize(deckEl);
|
|
68
|
+
for (const [index, slide] of slides.entries()) {
|
|
69
|
+
const frame = railEl.querySelector(`[data-slide-index="${index}"] .dek-thumb-frame`);
|
|
70
|
+
if (!(frame instanceof HTMLElement)) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const source = stage.slideEl(slide.slug);
|
|
74
|
+
const clone = source ? visualClone(source) : undefined;
|
|
75
|
+
if (!clone) {
|
|
76
|
+
frame.replaceChildren();
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const thumbStage = stillFrame("dek-thumb-stage", clone, size);
|
|
80
|
+
frame.replaceChildren(thumbStage);
|
|
81
|
+
// Drawn once in the document, so a script that measures its slide gets real boxes. The
|
|
82
|
+
// rail shows every beat's elements, so the script draws the last beat to match them.
|
|
83
|
+
stage.drawStill(clone, slide, lastStop(slide.beats));
|
|
84
|
+
fitStage(thumbStage, frame);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function fitThumbs(railEl: HTMLElement): void {
|
|
89
|
+
if (railEl.offsetParent === null) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
for (const frame of railEl.querySelectorAll(".dek-thumb-frame")) {
|
|
93
|
+
const thumbStage = frame.querySelector(".dek-thumb-stage");
|
|
94
|
+
if (frame instanceof HTMLElement && thumbStage instanceof HTMLElement) {
|
|
95
|
+
fitStage(thumbStage, frame);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function markCurrent(railEl: HTMLElement, slideIndex: number): void {
|
|
101
|
+
for (const el of railEl.querySelectorAll(".dek-thumb")) {
|
|
102
|
+
const current = Number(el.getAttribute("data-slide-index")) === slideIndex;
|
|
103
|
+
el.classList.toggle("is-current", current);
|
|
104
|
+
if (current) {
|
|
105
|
+
el.setAttribute("aria-current", "page");
|
|
106
|
+
el.scrollIntoView({ block: "nearest" });
|
|
107
|
+
} else {
|
|
108
|
+
el.removeAttribute("aria-current");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Arrow keys on a thumbnail pick the slide next to it, and focus follows. */
|
|
114
|
+
function bindThumbKeys(
|
|
115
|
+
railEl: HTMLElement,
|
|
116
|
+
slides: PresenterSlide[],
|
|
117
|
+
onPick: (slideIndex: number) => void,
|
|
118
|
+
): void {
|
|
119
|
+
railEl.addEventListener("keydown", (event) => {
|
|
120
|
+
const step = thumbStep(event.key);
|
|
121
|
+
const thumb = event.target;
|
|
122
|
+
if (!step || !(thumb instanceof HTMLElement) || !thumb.classList.contains("dek-thumb")) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
event.stopPropagation();
|
|
127
|
+
const nextIndex = Number(thumb.getAttribute("data-slide-index")) + step;
|
|
128
|
+
if (!slides[nextIndex]) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
onPick(nextIndex);
|
|
132
|
+
railEl
|
|
133
|
+
.querySelector<HTMLElement>(`[data-slide-index="${nextIndex}"]`)
|
|
134
|
+
?.focus({ preventScroll: true });
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function bindResizeKeys(handle: HTMLElement, onResize: () => void): void {
|
|
139
|
+
handle.addEventListener("keydown", (event) => {
|
|
140
|
+
const step = resizeStep(event.key);
|
|
141
|
+
if (!step) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// The arrows resize the rail here; they must not also move the deck.
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
event.stopPropagation();
|
|
147
|
+
const now = Number(handle.getAttribute("aria-valuenow"));
|
|
148
|
+
persist(RAIL_WIDTH_KEY, String(setWidth(handle, now + step)));
|
|
149
|
+
onResize();
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The rail follows the pointer while it drags, and remembers the width where it lets go. */
|
|
154
|
+
function bindResizeDrag(handle: HTMLElement, onResize: () => void): void {
|
|
155
|
+
handle.addEventListener("pointerdown", (event) => {
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
try {
|
|
158
|
+
handle.setPointerCapture(event.pointerId);
|
|
159
|
+
} catch {
|
|
160
|
+
// synthetic events may not support capture
|
|
161
|
+
}
|
|
162
|
+
handle.setAttribute("data-dragging", "");
|
|
163
|
+
let width = setWidth(handle, event.clientX);
|
|
164
|
+
const move = (ev: PointerEvent): void => {
|
|
165
|
+
width = setWidth(handle, ev.clientX);
|
|
166
|
+
onResize();
|
|
167
|
+
};
|
|
168
|
+
const up = (): void => {
|
|
169
|
+
handle.removeEventListener("pointermove", move);
|
|
170
|
+
handle.removeEventListener("pointerup", up);
|
|
171
|
+
handle.removeEventListener("pointercancel", up);
|
|
172
|
+
handle.removeAttribute("data-dragging");
|
|
173
|
+
persist(RAIL_WIDTH_KEY, String(width));
|
|
174
|
+
};
|
|
175
|
+
handle.addEventListener("pointermove", move);
|
|
176
|
+
handle.addEventListener("pointerup", up);
|
|
177
|
+
handle.addEventListener("pointercancel", up);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Owns the slide rail: its thumbnails, its width, and whether it shows. Arrow keys on a thumbnail
|
|
183
|
+
* pick the slide next to it; `onResize` hears each width change, since the deck fits around it.
|
|
184
|
+
*/
|
|
185
|
+
export function createRailView(options: {
|
|
186
|
+
slides: PresenterSlide[];
|
|
187
|
+
stage: Stage;
|
|
188
|
+
onPick: (slideIndex: number) => void;
|
|
189
|
+
onResize: () => void;
|
|
190
|
+
}): RailView {
|
|
191
|
+
const railEl = document.getElementById(PAGE_ID.rail);
|
|
192
|
+
const resizeEl = document.getElementById(PAGE_ID.railResize);
|
|
193
|
+
|
|
194
|
+
if (railEl) {
|
|
195
|
+
setWidth(resizeEl, readStoredRailWidth(pageStorage()));
|
|
196
|
+
// What was remembered; the deck is fitted once everything is built.
|
|
197
|
+
document.body.classList.toggle("is-rail-hidden", !readStoredRailVisible(pageStorage()));
|
|
198
|
+
bindThumbKeys(railEl, options.slides, options.onPick);
|
|
199
|
+
if (resizeEl) {
|
|
200
|
+
bindResizeKeys(resizeEl, options.onResize);
|
|
201
|
+
bindResizeDrag(resizeEl, options.onResize);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
available: () => railEl !== null,
|
|
207
|
+
isOpen: () => !document.body.classList.contains("is-rail-hidden"),
|
|
208
|
+
setOpen(open) {
|
|
209
|
+
document.body.classList.toggle("is-rail-hidden", !open);
|
|
210
|
+
persist(RAIL_VISIBLE_KEY, open ? "1" : "0");
|
|
211
|
+
options.onResize();
|
|
212
|
+
},
|
|
213
|
+
fill() {
|
|
214
|
+
if (railEl) {
|
|
215
|
+
fillThumbs(railEl, options.slides, options.stage);
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
fit() {
|
|
219
|
+
if (railEl) {
|
|
220
|
+
fitThumbs(railEl);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
markCurrent(slideIndex) {
|
|
224
|
+
if (railEl) {
|
|
225
|
+
markCurrent(railEl, slideIndex);
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { RAIL_WIDTH_DEFAULT, RAIL_WIDTH_MAX, RAIL_WIDTH_MIN } from "../core/rail-width.ts";
|
|
2
|
+
import { isLetterKey } from "./step.ts";
|
|
3
|
+
|
|
4
|
+
export const RAIL_WIDTH_KEY = "dek.railWidth";
|
|
5
|
+
export const RAIL_VISIBLE_KEY = "dek.railVisible";
|
|
6
|
+
|
|
7
|
+
export function isRailToggleKey(event: Parameters<typeof isLetterKey>[0]): boolean {
|
|
8
|
+
return isLetterKey(event, "s");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** How far one arrow key press moves the rail's resize handle. */
|
|
12
|
+
export const RAIL_WIDTH_STEP = 16;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The page's localStorage, or undefined where reading it throws: a file opened with storage
|
|
16
|
+
* blocked, or a sandboxed frame. The rail remembers less there; the deck still runs.
|
|
17
|
+
*/
|
|
18
|
+
export function pageStorage(): Storage | undefined {
|
|
19
|
+
try {
|
|
20
|
+
return window.localStorage;
|
|
21
|
+
} catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function clampRailWidth(px: number): number {
|
|
27
|
+
if (!Number.isFinite(px)) {
|
|
28
|
+
return RAIL_WIDTH_DEFAULT;
|
|
29
|
+
}
|
|
30
|
+
return Math.max(RAIL_WIDTH_MIN, Math.min(RAIL_WIDTH_MAX, Math.round(px)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readStoredRailWidth(
|
|
34
|
+
storage: { getItem(key: string): string | null } | undefined,
|
|
35
|
+
): number {
|
|
36
|
+
try {
|
|
37
|
+
const raw = storage?.getItem(RAIL_WIDTH_KEY);
|
|
38
|
+
return raw ? clampRailWidth(Number(raw)) : RAIL_WIDTH_DEFAULT;
|
|
39
|
+
} catch {
|
|
40
|
+
return RAIL_WIDTH_DEFAULT;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function readStoredRailVisible(
|
|
45
|
+
storage: { getItem(key: string): string | null } | undefined,
|
|
46
|
+
): boolean {
|
|
47
|
+
try {
|
|
48
|
+
return storage?.getItem(RAIL_VISIBLE_KEY) !== "0";
|
|
49
|
+
} catch {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { comparePositions, positionsEqual } from "../core/position.ts";
|
|
2
|
+
import type { Position } from "../core/step.ts";
|
|
3
|
+
import { playbackSchedule, type ScheduledGo, type Timeline } from "../core/timeline.ts";
|
|
4
|
+
import type { RequestOrigin } from "./deck-control.ts";
|
|
5
|
+
import type { GoOrigin } from "./navigator.ts";
|
|
6
|
+
import { deckUrl } from "./routes.ts";
|
|
7
|
+
|
|
8
|
+
export type RehearseDriver = {
|
|
9
|
+
play: () => void;
|
|
10
|
+
pause: () => void;
|
|
11
|
+
/** Move the clock to `position`; the caller moves the deck. */
|
|
12
|
+
seek: (position: Position) => void;
|
|
13
|
+
stop: () => void;
|
|
14
|
+
playing: () => boolean;
|
|
15
|
+
elapsed: () => number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** The latest entry whose time has come by `at` ms. */
|
|
19
|
+
export function lastDue(schedule: ScheduledGo[], at: number): ScheduledGo | undefined {
|
|
20
|
+
return schedule.findLast((entry) => entry.at <= at);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The entry for `position`, or the last one before it when the timeline skips that stop. */
|
|
24
|
+
export function entryFor(schedule: ScheduledGo[], position: Position): ScheduledGo | undefined {
|
|
25
|
+
return (
|
|
26
|
+
schedule.find((entry) => positionsEqual(entry.position, position)) ??
|
|
27
|
+
schedule.findLast((entry) => comparePositions(entry.position, position) < 0)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The rehearsal clock: it walks the deck through `schedule` as time passes. Only the clock's own
|
|
33
|
+
* moves go through `go`; a seek answers a move someone else already made.
|
|
34
|
+
*/
|
|
35
|
+
export function createRehearseDriver(options: {
|
|
36
|
+
schedule: ScheduledGo[];
|
|
37
|
+
go: (position: Position) => void | Promise<void>;
|
|
38
|
+
now?: () => number;
|
|
39
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
40
|
+
clearTimer?: (id: unknown) => void;
|
|
41
|
+
onPlay?: () => void;
|
|
42
|
+
onPause?: () => void;
|
|
43
|
+
onSeek?: (ms: number) => void;
|
|
44
|
+
}): RehearseDriver {
|
|
45
|
+
const now = options.now ?? (() => Date.now());
|
|
46
|
+
const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
47
|
+
const clearTimer =
|
|
48
|
+
options.clearTimer ?? ((id) => clearTimeout(id as ReturnType<typeof setTimeout>));
|
|
49
|
+
const timers = new Set<unknown>();
|
|
50
|
+
let playing = false;
|
|
51
|
+
let origin = 0;
|
|
52
|
+
let pausedElapsed = 0;
|
|
53
|
+
/** The entry the deck stands on as far as the clock knows. */
|
|
54
|
+
let shown: ScheduledGo | undefined;
|
|
55
|
+
|
|
56
|
+
const elapsed = (): number => (playing ? now() - origin : pausedElapsed);
|
|
57
|
+
|
|
58
|
+
const clear = (): void => {
|
|
59
|
+
for (const id of timers) {
|
|
60
|
+
clearTimer(id);
|
|
61
|
+
}
|
|
62
|
+
timers.clear();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const arm = (): void => {
|
|
66
|
+
clear();
|
|
67
|
+
const t = elapsed();
|
|
68
|
+
for (const event of options.schedule) {
|
|
69
|
+
const wait = event.at - t;
|
|
70
|
+
if (wait <= 0) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const id = setTimer(() => {
|
|
74
|
+
timers.delete(id);
|
|
75
|
+
shown = event;
|
|
76
|
+
void options.go(event.position);
|
|
77
|
+
}, wait);
|
|
78
|
+
timers.add(id);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
play() {
|
|
84
|
+
if (playing) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
playing = true;
|
|
88
|
+
origin = now() - pausedElapsed;
|
|
89
|
+
options.onPlay?.();
|
|
90
|
+
const due = lastDue(options.schedule, pausedElapsed);
|
|
91
|
+
if (due && due !== shown) {
|
|
92
|
+
shown = due;
|
|
93
|
+
void options.go(due.position);
|
|
94
|
+
}
|
|
95
|
+
arm();
|
|
96
|
+
},
|
|
97
|
+
pause() {
|
|
98
|
+
if (!playing) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
pausedElapsed = elapsed();
|
|
102
|
+
playing = false;
|
|
103
|
+
options.onPause?.();
|
|
104
|
+
clear();
|
|
105
|
+
},
|
|
106
|
+
seek(position) {
|
|
107
|
+
pausedElapsed = entryFor(options.schedule, position)?.at ?? 0;
|
|
108
|
+
shown = lastDue(options.schedule, pausedElapsed);
|
|
109
|
+
options.onSeek?.(pausedElapsed);
|
|
110
|
+
if (playing) {
|
|
111
|
+
origin = now() - pausedElapsed;
|
|
112
|
+
arm();
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
stop() {
|
|
116
|
+
playing = false;
|
|
117
|
+
pausedElapsed = 0;
|
|
118
|
+
shown = undefined;
|
|
119
|
+
clear();
|
|
120
|
+
},
|
|
121
|
+
playing: () => playing,
|
|
122
|
+
elapsed,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The slice of an audio element the rehearsal uses. */
|
|
127
|
+
export type RehearseAudio = {
|
|
128
|
+
currentTime: number;
|
|
129
|
+
play(): Promise<void> | void;
|
|
130
|
+
pause(): void;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** A loaded rehearsal: when each stop comes, and the voice track if there is one. */
|
|
134
|
+
export type Rehearsal = { schedule: ScheduledGo[]; audio?: RehearseAudio };
|
|
135
|
+
|
|
136
|
+
export type RehearseController = {
|
|
137
|
+
/** Whether a rehearsal is loaded and owns the keys. */
|
|
138
|
+
ready(): boolean;
|
|
139
|
+
/** Load the timeline, or load it again after it changed, and play from where the deck is. */
|
|
140
|
+
load(): Promise<void>;
|
|
141
|
+
toggle(): void;
|
|
142
|
+
/** Move the deck and the clock to `position`, asked for on this page or by a peer. */
|
|
143
|
+
move(position: Position, origin: RequestOrigin): void;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
type RehearseState =
|
|
147
|
+
| { kind: "off" }
|
|
148
|
+
| { kind: "loading" }
|
|
149
|
+
| { kind: "ready"; driver: RehearseDriver; audio?: RehearseAudio };
|
|
150
|
+
|
|
151
|
+
/** A clock for `rehearsal`; with a voice track, the track keeps the time and follows the clock. */
|
|
152
|
+
function rehearsalDriver(
|
|
153
|
+
{ schedule, audio }: Rehearsal,
|
|
154
|
+
go: (position: Position) => void | Promise<void>,
|
|
155
|
+
): RehearseDriver {
|
|
156
|
+
return createRehearseDriver({
|
|
157
|
+
schedule,
|
|
158
|
+
go,
|
|
159
|
+
...(audio ? { now: () => audio.currentTime * 1000 } : {}),
|
|
160
|
+
onPlay: () => {
|
|
161
|
+
void audio?.play();
|
|
162
|
+
},
|
|
163
|
+
onPause: () => {
|
|
164
|
+
audio?.pause();
|
|
165
|
+
},
|
|
166
|
+
onSeek: (ms) => {
|
|
167
|
+
if (audio) {
|
|
168
|
+
audio.currentTime = ms / 1000;
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Owns the rehearsal: one state at a time, and the voice track with it. A reload stops the old
|
|
176
|
+
* clock and silences its track before anything new plays.
|
|
177
|
+
*/
|
|
178
|
+
export function createRehearseController(options: {
|
|
179
|
+
load: () => Promise<Rehearsal | undefined>;
|
|
180
|
+
/** Move the deck; the clock's own moves come as "rehearse". */
|
|
181
|
+
go: (position: Position, origin: GoOrigin) => void | Promise<void>;
|
|
182
|
+
/** Where the deck is headed, so a reload plays on from there. */
|
|
183
|
+
current: () => Position;
|
|
184
|
+
}): RehearseController {
|
|
185
|
+
let state: RehearseState = { kind: "off" };
|
|
186
|
+
let generation = 0;
|
|
187
|
+
|
|
188
|
+
const stopCurrent = (): void => {
|
|
189
|
+
if (state.kind === "ready") {
|
|
190
|
+
state.driver.stop();
|
|
191
|
+
state.audio?.pause();
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
ready: () => state.kind === "ready",
|
|
197
|
+
async load() {
|
|
198
|
+
generation += 1;
|
|
199
|
+
const mine = generation;
|
|
200
|
+
stopCurrent();
|
|
201
|
+
state = { kind: "loading" };
|
|
202
|
+
let loaded: Rehearsal | undefined;
|
|
203
|
+
try {
|
|
204
|
+
loaded = await options.load();
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (mine === generation) {
|
|
207
|
+
state = { kind: "off" };
|
|
208
|
+
}
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
// A newer load started while this one waited; it owns the state now.
|
|
212
|
+
if (mine !== generation) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (!loaded) {
|
|
216
|
+
state = { kind: "off" };
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const { audio } = loaded;
|
|
220
|
+
const driver = rehearsalDriver(loaded, (position) => options.go(position, "rehearse"));
|
|
221
|
+
state = audio ? { kind: "ready", driver, audio } : { kind: "ready", driver };
|
|
222
|
+
// Set the clock first: playing from zero would send the deck to the first slide.
|
|
223
|
+
driver.seek(options.current());
|
|
224
|
+
driver.play();
|
|
225
|
+
},
|
|
226
|
+
toggle() {
|
|
227
|
+
if (state.kind !== "ready") {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (state.driver.playing()) {
|
|
231
|
+
state.driver.pause();
|
|
232
|
+
} else {
|
|
233
|
+
state.driver.play();
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
move(position, origin) {
|
|
237
|
+
if (state.kind !== "ready") {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
state.driver.seek(position);
|
|
241
|
+
void options.go(position, origin);
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Fetch the deck's timeline and, when the server has one, its voice track. */
|
|
247
|
+
export async function fetchRehearsal(
|
|
248
|
+
pathname: string,
|
|
249
|
+
deps: {
|
|
250
|
+
fetch: (input: string, init?: { method: string }) => Promise<Response>;
|
|
251
|
+
audio: (src: string) => RehearseAudio;
|
|
252
|
+
},
|
|
253
|
+
): Promise<Rehearsal | undefined> {
|
|
254
|
+
const timelinePath = deckUrl(pathname, { kind: "voice", file: "timeline.json" });
|
|
255
|
+
const audioPath = deckUrl(pathname, { kind: "voice", file: "audio.wav" });
|
|
256
|
+
const response = await deps.fetch(timelinePath);
|
|
257
|
+
if (!response.ok) {
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
const timeline = (await response.json()) as Timeline;
|
|
261
|
+
const audioHead = await deps.fetch(audioPath, { method: "HEAD" });
|
|
262
|
+
const schedule = playbackSchedule(timeline);
|
|
263
|
+
return audioHead.ok ? { schedule, audio: deps.audio(audioPath) } : { schedule };
|
|
264
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev server's URL scheme, shared by the server that matches paths and the page that builds
|
|
3
|
+
* them. Everything a deck serves sits under `/decks/:name`, or at the root of a server scoped to
|
|
4
|
+
* that deck; `DeckRoute` is what follows that prefix.
|
|
5
|
+
*/
|
|
6
|
+
export type VoiceFile = "timeline.json" | "audio.wav";
|
|
7
|
+
|
|
8
|
+
export type DeckRoute =
|
|
9
|
+
| { kind: "player" }
|
|
10
|
+
| { kind: "presenter" }
|
|
11
|
+
/** The position socket. */
|
|
12
|
+
| { kind: "socket" }
|
|
13
|
+
/** The live event stream. */
|
|
14
|
+
| { kind: "events" }
|
|
15
|
+
| { kind: "theme" }
|
|
16
|
+
| { kind: "slide"; slug: string }
|
|
17
|
+
| { kind: "voice"; file: VoiceFile }
|
|
18
|
+
/** `dekc current` and `dekc goto`. */
|
|
19
|
+
| { kind: "current" }
|
|
20
|
+
| { kind: "goto" }
|
|
21
|
+
/** The beats the presenter marked to rewrite: listed, and one marked or unmarked. */
|
|
22
|
+
| { kind: "marks" }
|
|
23
|
+
/** A file under the deck's `assets/`, decoded. */
|
|
24
|
+
| { kind: "asset"; path: string };
|
|
25
|
+
|
|
26
|
+
type Fixed = Exclude<DeckRoute, { kind: "slide" | "voice" | "asset" }>["kind"];
|
|
27
|
+
|
|
28
|
+
const FIXED: Record<Fixed, string> = {
|
|
29
|
+
player: "/",
|
|
30
|
+
presenter: "/presenter",
|
|
31
|
+
socket: "/ws",
|
|
32
|
+
events: "/events",
|
|
33
|
+
theme: "/theme",
|
|
34
|
+
current: "/current",
|
|
35
|
+
goto: "/goto",
|
|
36
|
+
marks: "/marks",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The path of `route` below its deck's prefix. */
|
|
40
|
+
export function formatDeckRoute(route: DeckRoute): string {
|
|
41
|
+
switch (route.kind) {
|
|
42
|
+
case "slide":
|
|
43
|
+
return `/slide/${encodeURIComponent(route.slug)}`;
|
|
44
|
+
case "voice":
|
|
45
|
+
return `/voice/${route.file}`;
|
|
46
|
+
case "asset":
|
|
47
|
+
return `/assets/${route.path.split("/").map(encodeURIComponent).join("/")}`;
|
|
48
|
+
default:
|
|
49
|
+
return FIXED[route.kind];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The route `rest` names below a deck's prefix; a fixed path may end in a slash. */
|
|
54
|
+
export function parseDeckRoute(rest: string): DeckRoute | undefined {
|
|
55
|
+
const bare = rest.length > 1 && rest.endsWith("/") ? rest.slice(0, -1) : rest || "/";
|
|
56
|
+
for (const [kind, path] of Object.entries(FIXED) as Array<[Fixed, string]>) {
|
|
57
|
+
if (bare === path) {
|
|
58
|
+
return { kind };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const slug = rest.match(/^\/slide\/([^/]+)\/?$/)?.[1];
|
|
63
|
+
if (slug) {
|
|
64
|
+
return { kind: "slide", slug: decodeURIComponent(slug) };
|
|
65
|
+
}
|
|
66
|
+
const file = rest.match(/^\/voice\/(timeline\.json|audio\.wav)$/)?.[1];
|
|
67
|
+
if (file) {
|
|
68
|
+
return { kind: "voice", file: file as VoiceFile };
|
|
69
|
+
}
|
|
70
|
+
const asset = rest.match(/^\/assets\/(.+)$/)?.[1];
|
|
71
|
+
if (asset) {
|
|
72
|
+
return { kind: "asset", path: decodeURIComponent(asset) };
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Malformed percent-encoding names nothing.
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Where `route` is on a server that serves every deck, for the deck named `deckName`. */
|
|
81
|
+
export function deckRoutePath(deckName: string, route: DeckRoute): string {
|
|
82
|
+
return `/decks/${encodeURIComponent(deckName)}${formatDeckRoute(route)}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `localPath` under the deck the page at `pathname` belongs to. `liveReloadScript` embeds this
|
|
87
|
+
* function's source, so it must stay self-contained.
|
|
88
|
+
*/
|
|
89
|
+
export function withDeckPrefix(pathname: string, localPath: string): string {
|
|
90
|
+
const deck = pathname.match(/^\/decks\/([^/]+)/)?.[1];
|
|
91
|
+
return deck ? `/decks/${deck}${localPath}` : localPath;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Where `route` is for the page at `pathname`. */
|
|
95
|
+
export function deckUrl(pathname: string, route: DeckRoute): string {
|
|
96
|
+
return withDeckPrefix(pathname, formatDeckRoute(route));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Inverse of `withDeckPrefix`: `/decks/:name/rest` or a scoped local path, the name decoded. */
|
|
100
|
+
export function splitDeckPath(
|
|
101
|
+
pathname: string,
|
|
102
|
+
scopedDeckName?: string,
|
|
103
|
+
): { deckName: string; rest: string } | undefined {
|
|
104
|
+
const prefixed = pathname.match(/^\/decks\/([^/]+)(\/.*)?$/);
|
|
105
|
+
if (prefixed?.[1]) {
|
|
106
|
+
const deckName = decodeSegment(prefixed[1]);
|
|
107
|
+
// A server scoped to one deck shares that deck, not the rest of the project.
|
|
108
|
+
if (deckName === undefined || (scopedDeckName !== undefined && deckName !== scopedDeckName)) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
return { deckName, rest: prefixed[2] ?? "/" };
|
|
112
|
+
}
|
|
113
|
+
if (!scopedDeckName) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
return { deckName: scopedDeckName, rest: pathname };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function decodeSegment(segment: string): string | undefined {
|
|
120
|
+
try {
|
|
121
|
+
return decodeURIComponent(segment);
|
|
122
|
+
} catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The query a live channel carries for the presenter's token, or nothing on an audience page.
|
|
129
|
+
* `liveReloadScript` embeds this function's source, so it must stay self-contained.
|
|
130
|
+
*/
|
|
131
|
+
export function liveTokenQuery(token: string | undefined): string {
|
|
132
|
+
return token ? `?token=${encodeURIComponent(token)}` : "";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The BroadcastChannel a deck's windows share. Built files share one origin under `file://`,
|
|
137
|
+
* so the name carries the deck and its slides: an audience view and a presenter view of one
|
|
138
|
+
* deck follow each other, and two decks open side by side do not.
|
|
139
|
+
*/
|
|
140
|
+
export function deckChannelName(deck: string | undefined, slugs: readonly string[]): string {
|
|
141
|
+
return `dek:${deck ?? ""}:${slugs.join(",")}`;
|
|
142
|
+
}
|