@hajimism/dek 0.3.0 → 0.3.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 +8 -0
- package/package.json +1 -1
- package/src/core/document.ts +0 -1
- package/src/core/page.ts +1 -5
- package/src/runtime/browser-live.ts +6 -0
- package/src/runtime/browser.ts +3 -205
- package/src/runtime/deck-control.ts +4 -3
- package/src/runtime/dev-line.ts +110 -0
- package/src/runtime/navigator.ts +5 -9
- package/src/runtime/player.ts +11 -1
- package/src/runtime/socket.ts +15 -9
- package/src/runtime/start.ts +171 -0
- package/src/server/dev.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Release notes for `@hajimism/dek`. From 0.1.0 on, release-please writes this file from the Conventional Commits on `main`.
|
|
4
4
|
|
|
5
|
+
## [0.3.1](https://github.com/hajimism/dek/compare/v0.3.0...v0.3.1) (2026-10-08)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Performance
|
|
9
|
+
|
|
10
|
+
* **build:** leave the dev server's line out of built files ([1e74ba0](https://github.com/hajimism/dek/commit/1e74ba0fb2512eeecf8ca4ba13a747374b1a651c))
|
|
11
|
+
* **build:** leave the dev server's line out of built files ([93bb4fc](https://github.com/hajimism/dek/commit/93bb4fc56105940cc5b19dc86f890dc9246df850))
|
|
12
|
+
|
|
5
13
|
## [0.3.0](https://github.com/hajimism/dek/compare/v0.2.2...v0.3.0) (2026-10-07)
|
|
6
14
|
|
|
7
15
|
|
package/package.json
CHANGED
package/src/core/document.ts
CHANGED
package/src/core/page.ts
CHANGED
|
@@ -45,8 +45,6 @@ export type PageMode = "player" | "presenter" | "video";
|
|
|
45
45
|
export type PageConfig = {
|
|
46
46
|
mode: PageMode;
|
|
47
47
|
deck: string;
|
|
48
|
-
/** The dev server's page: it follows the server's socket and events. */
|
|
49
|
-
live: boolean;
|
|
50
48
|
/** What a presenter page sends as `?token=` on its live channels. */
|
|
51
49
|
liveToken?: string;
|
|
52
50
|
};
|
|
@@ -56,18 +54,16 @@ export function pageConfigAttrs(config: PageConfig): string {
|
|
|
56
54
|
return [
|
|
57
55
|
` data-mode="${config.mode}"`,
|
|
58
56
|
` data-deck="${escapeAttr(config.deck)}"`,
|
|
59
|
-
config.live ? ` data-live="true"` : "",
|
|
60
57
|
config.liveToken ? ` data-live-token="${escapeAttr(config.liveToken)}"` : "",
|
|
61
58
|
].join("");
|
|
62
59
|
}
|
|
63
60
|
|
|
64
61
|
/** The settings `pageConfigAttrs` wrote on `body`. */
|
|
65
62
|
export function readPageConfig(body: HTMLElement): PageConfig {
|
|
66
|
-
const { mode, deck,
|
|
63
|
+
const { mode, deck, liveToken } = body.dataset;
|
|
67
64
|
return {
|
|
68
65
|
mode: mode === "presenter" || mode === "video" ? mode : "player",
|
|
69
66
|
deck: deck ?? "",
|
|
70
|
-
live: live === "true",
|
|
71
67
|
...(liveToken ? { liveToken } : {}),
|
|
72
68
|
};
|
|
73
69
|
}
|
package/src/runtime/browser.ts
CHANGED
|
@@ -1,207 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
// The player of a file that stands alone: a build, a video, a shot. It has no dev server to reach.
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import { ANNOTATIONS_CHANGED, PAGE_ID, readPageConfig } from "../core/page.ts";
|
|
5
|
-
import { deckStops, moveTarget, positionsEqual } from "../core/position.ts";
|
|
6
|
-
import type { PresenterSlide } from "../core/presenter-state.ts";
|
|
7
|
-
import type { Position } from "../core/step.ts";
|
|
8
|
-
import { createDeckControl } from "./deck-control.ts";
|
|
9
|
-
import { bindInput } from "./input.ts";
|
|
10
|
-
import { createLaserView, type LaserView } from "./laser-view.ts";
|
|
11
|
-
import { applyLiveEvent, hydrateLiveEvent } from "./live.ts";
|
|
12
|
-
import { documentLiveHost } from "./live-host.ts";
|
|
13
|
-
import { createMarksView } from "./marks-view.ts";
|
|
14
|
-
import { createNavigator } from "./navigator.ts";
|
|
15
|
-
import { createPresenterView } from "./presenter-view.ts";
|
|
16
|
-
import { createRailView } from "./rail-view.ts";
|
|
17
|
-
import { createRehearseController, fetchRehearsal } from "./rehearse.ts";
|
|
18
|
-
import { deckChannelName, deckUrl, liveTokenQuery } from "./routes.ts";
|
|
19
|
-
import { createStage } from "./stage.ts";
|
|
3
|
+
import { startPlayer } from "./start.ts";
|
|
20
4
|
|
|
21
|
-
|
|
22
|
-
if (dataEl?.textContent) {
|
|
23
|
-
const page = readPageConfig(document.body);
|
|
24
|
-
const slides = JSON.parse(dataEl.textContent) as PresenterSlide[];
|
|
25
|
-
const deck = deckStops(slides);
|
|
26
|
-
const stage = createStage({ slides, deck, videoMode: page.mode === "video" });
|
|
27
|
-
const presenter = createPresenterView({ slides, deck, stage, mode: page.mode });
|
|
28
|
-
|
|
29
|
-
function fitAll(): void {
|
|
30
|
-
stage.fit();
|
|
31
|
-
presenter.fit();
|
|
32
|
-
rail.fit();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Draw every view at `pos`. */
|
|
36
|
-
function render(pos: Position): void {
|
|
37
|
-
stage.render(pos);
|
|
38
|
-
presenter.render(pos);
|
|
39
|
-
rail.markCurrent(pos.slideIndex);
|
|
40
|
-
marks?.render(pos);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* The first key or move takes the key hint away. Removing it also drops its animation, which
|
|
45
|
-
* the first move would otherwise wait on before it settles.
|
|
46
|
-
*/
|
|
47
|
-
function dismissKeyHint(): void {
|
|
48
|
-
document.getElementById(PAGE_ID.hint)?.remove();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const rehearse = createRehearseController({
|
|
52
|
-
load: () =>
|
|
53
|
-
fetchRehearsal(location.pathname, {
|
|
54
|
-
fetch: (input, init) => fetch(input, init),
|
|
55
|
-
audio: (src) => new Audio(src),
|
|
56
|
-
}),
|
|
57
|
-
go: (position, origin) => nav.go(position, origin),
|
|
58
|
-
current: () => nav.target(),
|
|
59
|
-
});
|
|
60
|
-
const control = createDeckControl({ go: (next, origin) => nav.go(next, origin), rehearse });
|
|
61
|
-
|
|
62
|
-
// A video shows the talk as narrated; nobody points at it.
|
|
63
|
-
const laser: LaserView | undefined =
|
|
64
|
-
page.mode === "video"
|
|
65
|
-
? undefined
|
|
66
|
-
: createLaserView({
|
|
67
|
-
current: () => nav.position().slideIndex,
|
|
68
|
-
send: (pointer) => nav.point(pointer),
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
// Only the dev server has a socket to follow; a built file on a static host must not dial one.
|
|
72
|
-
const socketUrl = page.live
|
|
73
|
-
? `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}${deckUrl(location.pathname, { kind: "socket" })}${liveTokenQuery(page.liveToken)}`
|
|
74
|
-
: undefined;
|
|
75
|
-
const nav = createNavigator({
|
|
76
|
-
deck,
|
|
77
|
-
channelName: deckChannelName(
|
|
78
|
-
page.deck,
|
|
79
|
-
deck.map((slide) => slide.slug),
|
|
80
|
-
),
|
|
81
|
-
...(socketUrl ? { socketUrl } : {}),
|
|
82
|
-
present: stage.present,
|
|
83
|
-
hurry: stage.hurry,
|
|
84
|
-
beforeMove: dismissKeyHint,
|
|
85
|
-
onMove: (from, to) => {
|
|
86
|
-
render(to);
|
|
87
|
-
if (from.slideIndex !== to.slideIndex) {
|
|
88
|
-
stage.announce(to);
|
|
89
|
-
laser?.sync();
|
|
90
|
-
}
|
|
91
|
-
// A hashchange echoing this same position must not cut a running animation short.
|
|
92
|
-
if (!positionsEqual(from, to)) {
|
|
93
|
-
stage.showMove(from, to);
|
|
94
|
-
}
|
|
95
|
-
},
|
|
96
|
-
afterMove: presenter.startClock,
|
|
97
|
-
route: (next) => control.request(next, "remote"),
|
|
98
|
-
...(laser ? { onPointer: laser.receive } : {}),
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
const marks = createMarksView({
|
|
102
|
-
url: `${deckUrl(location.pathname, { kind: "marks" })}${liveTokenQuery(page.liveToken)}`,
|
|
103
|
-
current: () => nav.position(),
|
|
104
|
-
fetch: (input, init) => fetch(input, init),
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
const rail = createRailView({
|
|
108
|
-
slides,
|
|
109
|
-
stage,
|
|
110
|
-
onPick: (slideIndex) => control.request({ slideIndex, beatIndex: 0 }, "local"),
|
|
111
|
-
onResize: fitAll,
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
function setPresenterOpen(open: boolean): void {
|
|
115
|
-
presenter.setOpen(open);
|
|
116
|
-
fitAll();
|
|
117
|
-
render(nav.position());
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
bindInput({
|
|
121
|
-
onKey: dismissKeyHint,
|
|
122
|
-
togglePresenter: () => {
|
|
123
|
-
if (!presenter.available()) {
|
|
124
|
-
return false;
|
|
125
|
-
}
|
|
126
|
-
setPresenterOpen(!presenter.isOpen());
|
|
127
|
-
return true;
|
|
128
|
-
},
|
|
129
|
-
toggleFullscreen: () => {
|
|
130
|
-
if (document.fullscreenElement) {
|
|
131
|
-
void document.exitFullscreen?.().catch(() => undefined);
|
|
132
|
-
} else {
|
|
133
|
-
void document.documentElement.requestFullscreen?.().catch(() => undefined);
|
|
134
|
-
}
|
|
135
|
-
},
|
|
136
|
-
toggleRail: () => {
|
|
137
|
-
if (!rail.available() || presenter.isOpen()) {
|
|
138
|
-
return false;
|
|
139
|
-
}
|
|
140
|
-
rail.setOpen(!rail.isOpen());
|
|
141
|
-
return true;
|
|
142
|
-
},
|
|
143
|
-
togglePlay: control.togglePlay,
|
|
144
|
-
toggleLaser: () => laser?.toggle() ?? false,
|
|
145
|
-
toggleMark: () => marks?.toggle() ?? false,
|
|
146
|
-
pointing: () => laser?.isOn() ?? false,
|
|
147
|
-
move: (move) => control.request(moveTarget(move, nav.target(), deck), "local"),
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
window.addEventListener("resize", fitAll);
|
|
151
|
-
window.addEventListener("beforeprint", () => stage.setPrinting(true, nav.position()));
|
|
152
|
-
window.addEventListener("afterprint", () => stage.setPrinting(false, nav.position()));
|
|
153
|
-
matchMedia("print").addEventListener?.("change", (event) =>
|
|
154
|
-
stage.setPrinting(event.matches, nav.position()),
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
rail.fill();
|
|
158
|
-
fitAll();
|
|
159
|
-
render(nav.position());
|
|
160
|
-
// A hash that named a beat past the slide's last now says where the deck opened.
|
|
161
|
-
nav.writeHash("replace");
|
|
162
|
-
stage.showMotion(nav.position(), page.mode === "video" ? "hold" : "final");
|
|
163
|
-
// biome-ignore lint/complexity/useLiteralKeys: video recorder looks up window["dekGo"]
|
|
164
|
-
window["dekGo"] = (next) => nav.go(next, "capture");
|
|
165
|
-
// The video recorder seeks slide scripts the way it seeks Web Animations.
|
|
166
|
-
// biome-ignore lint/complexity/useLiteralKeys: video recorder looks up window["dekMotion"]
|
|
167
|
-
window["dekMotion"] = stage.motion;
|
|
168
|
-
|
|
169
|
-
const rehearseMode = new URLSearchParams(location.search).has("rehearse");
|
|
170
|
-
if (rehearseMode) {
|
|
171
|
-
void rehearse.load();
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const liveHost = documentLiveHost();
|
|
175
|
-
// Keep a string key so minify does not rename the hook liveReloadScript calls.
|
|
176
|
-
// biome-ignore lint/complexity/useLiteralKeys: liveReloadScript looks up window["dekLive"]
|
|
177
|
-
window["dekLive"] = async (raw: unknown) => {
|
|
178
|
-
if (!isLiveEvent(raw)) {
|
|
179
|
-
location.reload();
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
if (raw.type === "timeline") {
|
|
183
|
-
if (rehearseMode) {
|
|
184
|
-
await rehearse.load();
|
|
185
|
-
}
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
if (raw.type === "annotations") {
|
|
189
|
-
document.dispatchEvent(new Event(ANNOTATIONS_CHANGED));
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
const event = await hydrateLiveEvent(raw, location.pathname);
|
|
193
|
-
if (!event) {
|
|
194
|
-
location.reload();
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
if (applyLiveEvent(event, liveHost).reload) {
|
|
198
|
-
location.reload();
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
// Whatever changed, drawing the beat again puts it back as it stood.
|
|
202
|
-
const pos = nav.position();
|
|
203
|
-
rail.fill();
|
|
204
|
-
render(pos);
|
|
205
|
-
stage.showMotion(pos, "final");
|
|
206
|
-
};
|
|
207
|
-
}
|
|
5
|
+
startPlayer();
|
|
@@ -18,7 +18,8 @@ export type DeckControl = {
|
|
|
18
18
|
*/
|
|
19
19
|
export function createDeckControl(options: {
|
|
20
20
|
go: (next: Position, origin: RequestOrigin) => Promise<void>;
|
|
21
|
-
|
|
21
|
+
/** The rehearsal the dev server can load; a file that stands alone has none. */
|
|
22
|
+
rehearse?: Pick<RehearseController, "ready" | "move" | "toggle">;
|
|
22
23
|
}): DeckControl {
|
|
23
24
|
const { go, rehearse } = options;
|
|
24
25
|
return {
|
|
@@ -26,14 +27,14 @@ export function createDeckControl(options: {
|
|
|
26
27
|
if (!next) {
|
|
27
28
|
return;
|
|
28
29
|
}
|
|
29
|
-
if (rehearse
|
|
30
|
+
if (rehearse?.ready()) {
|
|
30
31
|
rehearse.move(next, origin);
|
|
31
32
|
} else {
|
|
32
33
|
void go(next, origin);
|
|
33
34
|
}
|
|
34
35
|
},
|
|
35
36
|
togglePlay() {
|
|
36
|
-
if (!rehearse
|
|
37
|
+
if (!rehearse?.ready()) {
|
|
37
38
|
return false;
|
|
38
39
|
}
|
|
39
40
|
rehearse.toggle();
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
|
|
3
|
+
import { isLiveEvent } from "../core/live-protocol.ts";
|
|
4
|
+
import { ANNOTATIONS_CHANGED, type PageConfig } from "../core/page.ts";
|
|
5
|
+
import type { Position } from "../core/step.ts";
|
|
6
|
+
import { applyLiveEvent, hydrateLiveEvent } from "./live.ts";
|
|
7
|
+
import { documentLiveHost } from "./live-host.ts";
|
|
8
|
+
import { createMarksView, type MarksView } from "./marks-view.ts";
|
|
9
|
+
import type { GoOrigin } from "./navigator.ts";
|
|
10
|
+
import { createRehearseController, fetchRehearsal, type RehearseController } from "./rehearse.ts";
|
|
11
|
+
import { deckUrl, liveTokenQuery } from "./routes.ts";
|
|
12
|
+
import { createPositionSocket, type PositionHandlers, type PositionSocket } from "./socket.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What the page has of the dev server: the position socket, the marks, the rehearsal, and the
|
|
16
|
+
* live updates. Only the player the dev server embeds is built with it; a file that stands alone
|
|
17
|
+
* has no server to reach, so its player never imports this module.
|
|
18
|
+
*/
|
|
19
|
+
export type DevLine = {
|
|
20
|
+
/** Open the position socket, for the navigator. */
|
|
21
|
+
connect: (handlers: PositionHandlers) => PositionSocket;
|
|
22
|
+
rehearse: RehearseController;
|
|
23
|
+
/** Only the presenter bar has the mark button. */
|
|
24
|
+
marks: MarksView | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Load the rehearsal the URL asks for, and take the server's live updates from here on.
|
|
27
|
+
* `redraw` puts the beat back as it stood once one has changed the page.
|
|
28
|
+
*/
|
|
29
|
+
start(redraw: () => void): void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type CreateDevLine = typeof createDevLine;
|
|
33
|
+
|
|
34
|
+
export function createDevLine(options: {
|
|
35
|
+
page: PageConfig;
|
|
36
|
+
/** Move the deck. */
|
|
37
|
+
go: (position: Position, origin: GoOrigin) => Promise<void>;
|
|
38
|
+
/** Where the deck is headed. */
|
|
39
|
+
target: () => Position;
|
|
40
|
+
/** What is on screen. */
|
|
41
|
+
position: () => Position;
|
|
42
|
+
}): DevLine {
|
|
43
|
+
const token = liveTokenQuery(options.page.liveToken);
|
|
44
|
+
const route = (kind: "socket" | "marks"): string =>
|
|
45
|
+
`${deckUrl(location.pathname, { kind })}${token}`;
|
|
46
|
+
|
|
47
|
+
const rehearse = createRehearseController({
|
|
48
|
+
load: () =>
|
|
49
|
+
fetchRehearsal(location.pathname, {
|
|
50
|
+
fetch: (input, init) => fetch(input, init),
|
|
51
|
+
audio: (src) => new Audio(src),
|
|
52
|
+
}),
|
|
53
|
+
go: options.go,
|
|
54
|
+
current: options.target,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const marks = createMarksView({
|
|
58
|
+
url: route("marks"),
|
|
59
|
+
current: options.position,
|
|
60
|
+
fetch: (input, init) => fetch(input, init),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function connect(handlers: PositionHandlers): PositionSocket {
|
|
64
|
+
const url = `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}${route("socket")}`;
|
|
65
|
+
return createPositionSocket({
|
|
66
|
+
connect: () => new WebSocket(url),
|
|
67
|
+
...handlers,
|
|
68
|
+
setTimer: (fn, ms) => window.setTimeout(fn, ms),
|
|
69
|
+
clearTimer: (id) => window.clearTimeout(id),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function start(redraw: () => void): void {
|
|
74
|
+
const rehearseMode = new URLSearchParams(location.search).has("rehearse");
|
|
75
|
+
if (rehearseMode) {
|
|
76
|
+
void rehearse.load();
|
|
77
|
+
}
|
|
78
|
+
const liveHost = documentLiveHost();
|
|
79
|
+
// Keep a string key so minify does not rename the hook liveReloadScript calls.
|
|
80
|
+
// biome-ignore lint/complexity/useLiteralKeys: liveReloadScript looks up window["dekLive"]
|
|
81
|
+
window["dekLive"] = async (raw: unknown) => {
|
|
82
|
+
if (!isLiveEvent(raw)) {
|
|
83
|
+
location.reload();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (raw.type === "timeline") {
|
|
87
|
+
if (rehearseMode) {
|
|
88
|
+
await rehearse.load();
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (raw.type === "annotations") {
|
|
93
|
+
document.dispatchEvent(new Event(ANNOTATIONS_CHANGED));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const event = await hydrateLiveEvent(raw, location.pathname);
|
|
97
|
+
if (!event) {
|
|
98
|
+
location.reload();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (applyLiveEvent(event, liveHost).reload) {
|
|
102
|
+
location.reload();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
redraw();
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { connect, rehearse, marks, start };
|
|
110
|
+
}
|
package/src/runtime/navigator.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from "../core/position.ts";
|
|
19
19
|
import type { Position } from "../core/step.ts";
|
|
20
20
|
import { applyIncomingPosition, createGuardedGo } from "./go.ts";
|
|
21
|
-
import {
|
|
21
|
+
import type { PositionHandlers, PositionSocket } from "./socket.ts";
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Where a move came from. Local moves, and the rehearsal clock's, are published to peers; remote
|
|
@@ -52,8 +52,8 @@ type GoRequest = { position: Position; origin: GoOrigin };
|
|
|
52
52
|
export function createNavigator(options: {
|
|
53
53
|
deck: DeckStops;
|
|
54
54
|
channelName: string;
|
|
55
|
-
/**
|
|
56
|
-
|
|
55
|
+
/** Open the dev server's position socket; a file that stands alone has none to open. */
|
|
56
|
+
connect?: (handlers: PositionHandlers) => PositionSocket;
|
|
57
57
|
/**
|
|
58
58
|
* Draw the move from `from` to `to`. `apply` puts it on screen, perhaps inside a transition;
|
|
59
59
|
* the promise settles once the move has played.
|
|
@@ -156,14 +156,10 @@ export function createNavigator(options: {
|
|
|
156
156
|
receive(next);
|
|
157
157
|
}
|
|
158
158
|
});
|
|
159
|
-
if (options.
|
|
160
|
-
|
|
161
|
-
remote = createPositionSocket({
|
|
162
|
-
connect: () => new WebSocket(url),
|
|
159
|
+
if (options.connect) {
|
|
160
|
+
remote = options.connect({
|
|
163
161
|
onPosition: receive,
|
|
164
162
|
...(options.onPointer ? { onPointer: options.onPointer } : {}),
|
|
165
|
-
setTimer: (fn, ms) => window.setTimeout(fn, ms),
|
|
166
|
-
clearTimer: (id) => window.clearTimeout(id),
|
|
167
163
|
});
|
|
168
164
|
// A page that is really going away must not dial back in; one kept for Back reconnects.
|
|
169
165
|
window.addEventListener("pagehide", (event) => {
|
package/src/runtime/player.ts
CHANGED
|
@@ -3,14 +3,24 @@ import { join } from "node:path";
|
|
|
3
3
|
export { liveReloadScript } from "./live-reload.ts";
|
|
4
4
|
|
|
5
5
|
let player: Promise<string> | undefined;
|
|
6
|
+
let livePlayer: Promise<string> | undefined;
|
|
6
7
|
let annotate: Promise<string> | undefined;
|
|
7
8
|
|
|
8
|
-
/** The player
|
|
9
|
+
/** The player a file that stands alone runs: a build, a video, a shot. */
|
|
9
10
|
export function playerScript(): Promise<string> {
|
|
10
11
|
player ??= compile("browser.ts", "player");
|
|
11
12
|
return player;
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The player the dev server's pages run: the same player, and its line to the server besides,
|
|
17
|
+
* which a file that stands alone has no server for.
|
|
18
|
+
*/
|
|
19
|
+
export function livePlayerScript(): Promise<string> {
|
|
20
|
+
livePlayer ??= compile("browser-live.ts", "live player");
|
|
21
|
+
return livePlayer;
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
/** Annotate mode, which only the dev server's pages for the speaker run, after the player. */
|
|
15
25
|
export function annotateScript(): Promise<string> {
|
|
16
26
|
annotate ??= compile("annotate.ts", "annotate mode");
|
package/src/runtime/socket.ts
CHANGED
|
@@ -46,6 +46,13 @@ export type PositionSocket = {
|
|
|
46
46
|
close(): void;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
/** Where what a peer sends over the line goes. */
|
|
50
|
+
export type PositionHandlers = {
|
|
51
|
+
onPosition: (position: Position) => void;
|
|
52
|
+
/** Where a presenter's laser points, or null once it went away. */
|
|
53
|
+
onPointer?: (pointer: Pointer | null) => void;
|
|
54
|
+
};
|
|
55
|
+
|
|
49
56
|
/**
|
|
50
57
|
* The deck's line to the server, which relays positions between the presenter, the audience,
|
|
51
58
|
* and a phone remote, and where the presenter's laser points. Like an EventSource, it reconnects by itself after a drop (a restarted
|
|
@@ -55,15 +62,14 @@ export type PositionSocket = {
|
|
|
55
62
|
* missed whatever moved meanwhile. A move this deck made while away is sent instead, and that
|
|
56
63
|
* greeting, if it is still where the deck was when the line dropped, is stale and passed over.
|
|
57
64
|
*/
|
|
58
|
-
export function createPositionSocket(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}): PositionSocket {
|
|
65
|
+
export function createPositionSocket(
|
|
66
|
+
options: PositionHandlers & {
|
|
67
|
+
connect: () => SocketLike;
|
|
68
|
+
setTimer: (fn: () => void, ms: number) => number;
|
|
69
|
+
clearTimer: (id: number) => void;
|
|
70
|
+
random?: () => number;
|
|
71
|
+
},
|
|
72
|
+
): PositionSocket {
|
|
67
73
|
let socket: SocketLike | undefined;
|
|
68
74
|
let timer: number | undefined;
|
|
69
75
|
let attempt = 0;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
|
|
3
|
+
import { PAGE_ID, readPageConfig } from "../core/page.ts";
|
|
4
|
+
import { deckStops, moveTarget, positionsEqual } from "../core/position.ts";
|
|
5
|
+
import type { PresenterSlide } from "../core/presenter-state.ts";
|
|
6
|
+
import type { Position } from "../core/step.ts";
|
|
7
|
+
import { createDeckControl } from "./deck-control.ts";
|
|
8
|
+
import type { CreateDevLine } from "./dev-line.ts";
|
|
9
|
+
import { bindInput } from "./input.ts";
|
|
10
|
+
import { createLaserView, type LaserView } from "./laser-view.ts";
|
|
11
|
+
import { createNavigator } from "./navigator.ts";
|
|
12
|
+
import { createPresenterView } from "./presenter-view.ts";
|
|
13
|
+
import { createRailView } from "./rail-view.ts";
|
|
14
|
+
import { deckChannelName } from "./routes.ts";
|
|
15
|
+
import { createStage } from "./stage.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Start the player on the page. The dev server's player passes `createDevLine`, its line to the
|
|
19
|
+
* server; a file that stands alone has no server, and its player is built without one.
|
|
20
|
+
*/
|
|
21
|
+
export function startPlayer(createDevLine?: CreateDevLine): void {
|
|
22
|
+
const dataEl = document.getElementById(PAGE_ID.data);
|
|
23
|
+
if (!dataEl?.textContent) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const page = readPageConfig(document.body);
|
|
27
|
+
const slides = JSON.parse(dataEl.textContent) as PresenterSlide[];
|
|
28
|
+
const deck = deckStops(slides);
|
|
29
|
+
const stage = createStage({ slides, deck, videoMode: page.mode === "video" });
|
|
30
|
+
const presenter = createPresenterView({ slides, deck, stage, mode: page.mode });
|
|
31
|
+
|
|
32
|
+
function fitAll(): void {
|
|
33
|
+
stage.fit();
|
|
34
|
+
presenter.fit();
|
|
35
|
+
rail.fit();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Draw every view at `pos`. */
|
|
39
|
+
function render(pos: Position): void {
|
|
40
|
+
stage.render(pos);
|
|
41
|
+
presenter.render(pos);
|
|
42
|
+
rail.markCurrent(pos.slideIndex);
|
|
43
|
+
marks?.render(pos);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The first key or move takes the key hint away. Removing it also drops its animation, which
|
|
48
|
+
* the first move would otherwise wait on before it settles.
|
|
49
|
+
*/
|
|
50
|
+
function dismissKeyHint(): void {
|
|
51
|
+
document.getElementById(PAGE_ID.hint)?.remove();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const dev = createDevLine?.({
|
|
55
|
+
page,
|
|
56
|
+
go: (position, origin) => nav.go(position, origin),
|
|
57
|
+
target: () => nav.target(),
|
|
58
|
+
position: () => nav.position(),
|
|
59
|
+
});
|
|
60
|
+
const control = createDeckControl({
|
|
61
|
+
go: (next, origin) => nav.go(next, origin),
|
|
62
|
+
...(dev ? { rehearse: dev.rehearse } : {}),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// A video shows the talk as narrated; nobody points at it.
|
|
66
|
+
const laser: LaserView | undefined =
|
|
67
|
+
page.mode === "video"
|
|
68
|
+
? undefined
|
|
69
|
+
: createLaserView({
|
|
70
|
+
current: () => nav.position().slideIndex,
|
|
71
|
+
send: (pointer) => nav.point(pointer),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const nav = createNavigator({
|
|
75
|
+
deck,
|
|
76
|
+
channelName: deckChannelName(
|
|
77
|
+
page.deck,
|
|
78
|
+
deck.map((slide) => slide.slug),
|
|
79
|
+
),
|
|
80
|
+
...(dev ? { connect: dev.connect } : {}),
|
|
81
|
+
present: stage.present,
|
|
82
|
+
hurry: stage.hurry,
|
|
83
|
+
beforeMove: dismissKeyHint,
|
|
84
|
+
onMove: (from, to) => {
|
|
85
|
+
render(to);
|
|
86
|
+
if (from.slideIndex !== to.slideIndex) {
|
|
87
|
+
stage.announce(to);
|
|
88
|
+
laser?.sync();
|
|
89
|
+
}
|
|
90
|
+
// A hashchange echoing this same position must not cut a running animation short.
|
|
91
|
+
if (!positionsEqual(from, to)) {
|
|
92
|
+
stage.showMove(from, to);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
afterMove: presenter.startClock,
|
|
96
|
+
route: (next) => control.request(next, "remote"),
|
|
97
|
+
...(laser ? { onPointer: laser.receive } : {}),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const marks = dev?.marks;
|
|
101
|
+
|
|
102
|
+
const rail = createRailView({
|
|
103
|
+
slides,
|
|
104
|
+
stage,
|
|
105
|
+
onPick: (slideIndex) => control.request({ slideIndex, beatIndex: 0 }, "local"),
|
|
106
|
+
onResize: fitAll,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
function setPresenterOpen(open: boolean): void {
|
|
110
|
+
presenter.setOpen(open);
|
|
111
|
+
fitAll();
|
|
112
|
+
render(nav.position());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
bindInput({
|
|
116
|
+
onKey: dismissKeyHint,
|
|
117
|
+
togglePresenter: () => {
|
|
118
|
+
if (!presenter.available()) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
setPresenterOpen(!presenter.isOpen());
|
|
122
|
+
return true;
|
|
123
|
+
},
|
|
124
|
+
toggleFullscreen: () => {
|
|
125
|
+
if (document.fullscreenElement) {
|
|
126
|
+
void document.exitFullscreen?.().catch(() => undefined);
|
|
127
|
+
} else {
|
|
128
|
+
void document.documentElement.requestFullscreen?.().catch(() => undefined);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
toggleRail: () => {
|
|
132
|
+
if (!rail.available() || presenter.isOpen()) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
rail.setOpen(!rail.isOpen());
|
|
136
|
+
return true;
|
|
137
|
+
},
|
|
138
|
+
togglePlay: control.togglePlay,
|
|
139
|
+
toggleLaser: () => laser?.toggle() ?? false,
|
|
140
|
+
toggleMark: () => marks?.toggle() ?? false,
|
|
141
|
+
pointing: () => laser?.isOn() ?? false,
|
|
142
|
+
move: (move) => control.request(moveTarget(move, nav.target(), deck), "local"),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
window.addEventListener("resize", fitAll);
|
|
146
|
+
window.addEventListener("beforeprint", () => stage.setPrinting(true, nav.position()));
|
|
147
|
+
window.addEventListener("afterprint", () => stage.setPrinting(false, nav.position()));
|
|
148
|
+
matchMedia("print").addEventListener?.("change", (event) =>
|
|
149
|
+
stage.setPrinting(event.matches, nav.position()),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
rail.fill();
|
|
153
|
+
fitAll();
|
|
154
|
+
render(nav.position());
|
|
155
|
+
// A hash that named a beat past the slide's last now says where the deck opened.
|
|
156
|
+
nav.writeHash("replace");
|
|
157
|
+
stage.showMotion(nav.position(), page.mode === "video" ? "hold" : "final");
|
|
158
|
+
// biome-ignore lint/complexity/useLiteralKeys: video recorder looks up window["dekGo"]
|
|
159
|
+
window["dekGo"] = (next) => nav.go(next, "capture");
|
|
160
|
+
// The video recorder seeks slide scripts the way it seeks Web Animations.
|
|
161
|
+
// biome-ignore lint/complexity/useLiteralKeys: video recorder looks up window["dekMotion"]
|
|
162
|
+
window["dekMotion"] = stage.motion;
|
|
163
|
+
|
|
164
|
+
// Whatever a live update changed, drawing the beat again puts it back as it stood.
|
|
165
|
+
dev?.start(() => {
|
|
166
|
+
const pos = nav.position();
|
|
167
|
+
rail.fill();
|
|
168
|
+
render(pos);
|
|
169
|
+
stage.showMotion(pos, "final");
|
|
170
|
+
});
|
|
171
|
+
}
|
package/src/server/dev.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { join } from "node:path";
|
|
|
2
2
|
import { DekError } from "../core/error.ts";
|
|
3
3
|
import type { PlaywrightRunner } from "../core/playwright.ts";
|
|
4
4
|
import { locateDeck, type Project, resolveProject } from "../core/resolve.ts";
|
|
5
|
-
import { annotateScript,
|
|
5
|
+
import { annotateScript, livePlayerScript, liveReloadScript } from "../runtime/player.ts";
|
|
6
6
|
import {
|
|
7
7
|
clearance,
|
|
8
8
|
mayRead,
|
|
@@ -69,7 +69,7 @@ export async function startDevServer(options: DevServerOptions): Promise<DevServ
|
|
|
69
69
|
const { scopedDeckName, deckDir } = serverScope(project, options);
|
|
70
70
|
const access = accessFor(options);
|
|
71
71
|
const embed = {
|
|
72
|
-
playerScript: await
|
|
72
|
+
playerScript: await livePlayerScript(),
|
|
73
73
|
liveReloadScript: liveReloadScript(),
|
|
74
74
|
annotateScript: await annotateScript(),
|
|
75
75
|
};
|