@hajimism/dek 0.3.0 → 0.3.2
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 +27 -0
- package/package.json +5 -5
- package/src/cli/commands.ts +2 -1
- package/src/core/document.ts +0 -1
- package/src/core/page.ts +1 -5
- package/src/core/pdf-text.ts +191 -0
- package/src/core/playwright-module.d.ts +6 -1
- package/src/core/playwright-visual.ts +40 -4
- package/src/core/playwright.ts +4 -1
- 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,33 @@
|
|
|
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.2](https://github.com/hajimism/dek/compare/v0.3.1...v0.3.2) (2026-10-20)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **pdf:** print each slide as a picture with its text laid over it ([02f49a8](https://github.com/hajimism/dek/commit/02f49a855209e1c4430613fd9358699305bcbb9b))
|
|
11
|
+
* **pdf:** print each slide as a picture with its text laid over it ([b88259d](https://github.com/hajimism/dek/commit/b88259dbcc80ed0a1450cc6bf6422c71120c5802))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Performance
|
|
15
|
+
|
|
16
|
+
* **pdf:** keep a slide of photographs as a JPEG ([19a9092](https://github.com/hajimism/dek/commit/19a909214b256c8bbcf66a443f00c54c2b43766e))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
* record the PDF as pictures in ADR 14 and the presenting guide ([c889781](https://github.com/hajimism/dek/commit/c88978175a9b3812f611d8af4de94b2de57556c1))
|
|
22
|
+
* say which slides the PDF keeps as JPEGs, in ADR 14 and the guide ([de733fb](https://github.com/hajimism/dek/commit/de733fb4589d5f6f45f6f9a83b60673d2feab706))
|
|
23
|
+
|
|
24
|
+
## [0.3.1](https://github.com/hajimism/dek/compare/v0.3.0...v0.3.1) (2026-10-08)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Performance
|
|
28
|
+
|
|
29
|
+
* **build:** leave the dev server's line out of built files ([1e74ba0](https://github.com/hajimism/dek/commit/1e74ba0fb2512eeecf8ca4ba13a747374b1a651c))
|
|
30
|
+
* **build:** leave the dev server's line out of built files ([93bb4fc](https://github.com/hajimism/dek/commit/93bb4fc56105940cc5b19dc86f890dc9246df850))
|
|
31
|
+
|
|
5
32
|
## [0.3.0](https://github.com/hajimism/dek/compare/v0.2.2...v0.3.0) (2026-10-07)
|
|
6
33
|
|
|
7
34
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hajimism/dek",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "A build system for talks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Hajime Nakagawa (https://github.com/hajimism)",
|
|
@@ -61,11 +61,11 @@
|
|
|
61
61
|
"zod": "^4.6.5"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"@biomejs/biome": "2.5.
|
|
65
|
-
"@happy-dom/global-registrator": "
|
|
64
|
+
"@biomejs/biome": "2.5.15",
|
|
65
|
+
"@happy-dom/global-registrator": "21.0.1",
|
|
66
66
|
"@types/bun": "^1.4.2",
|
|
67
|
-
"happy-dom": "
|
|
68
|
-
"knip": "6.
|
|
67
|
+
"happy-dom": "21.0.1",
|
|
68
|
+
"knip": "6.41.0",
|
|
69
69
|
"playwright": "^1.63.0",
|
|
70
70
|
"typescript": "^7.0.2",
|
|
71
71
|
"vitepress": "^1.6.4"
|
package/src/cli/commands.ts
CHANGED
|
@@ -774,7 +774,8 @@ export const COMMANDS = {
|
|
|
774
774
|
args: [],
|
|
775
775
|
flags: ["root-dist"],
|
|
776
776
|
usage: ["dekc pdf [deck] [--root-dist]"],
|
|
777
|
-
summary:
|
|
777
|
+
summary:
|
|
778
|
+
"Write dist/<deck>.pdf, one page per slide at its last beat: a picture of it with its text\nlaid over it, unseen, to select and search. Needs Playwright.",
|
|
778
779
|
group: "Output",
|
|
779
780
|
overview: [["dekc pdf [--root-dist]", "write a PDF"]],
|
|
780
781
|
agent: ["dekc pdf [--root-dist]"],
|
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
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
// What a PDF prints of a slide: a picture of the slide as the browser drew it, with its text laid
|
|
3
|
+
// over it, unseen, so a reader can still select, search, and copy it. Chromium writes a blurred
|
|
4
|
+
// box-shadow and `background-clip: text` into a PDF as soft masks, which Apple's viewers (Preview,
|
|
5
|
+
// Quick Look, Safari, iOS) draw as flat boxes; a picture looks the same in every viewer.
|
|
6
|
+
|
|
7
|
+
/** The slides of the print document, one PDF page each. */
|
|
8
|
+
export const PDF_SLIDES = "#deck > .slide";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Leaves each slide's text where it was laid out and paints nothing else: no color, no
|
|
12
|
+
* background, no border, no shadow, no picture. Only paint changes, never layout, so the text lies
|
|
13
|
+
* where the picture under it shows it. In a layer, so that these `!important` declarations win
|
|
14
|
+
* over any the theme or a slide writes, whatever their selectors weigh.
|
|
15
|
+
*/
|
|
16
|
+
export const PDF_TEXT_CSS = `@layer dek-pdf-text {
|
|
17
|
+
#deck > .slide,
|
|
18
|
+
#deck > .slide::before,
|
|
19
|
+
#deck > .slide::after,
|
|
20
|
+
#deck > .slide *,
|
|
21
|
+
#deck > .slide *::before,
|
|
22
|
+
#deck > .slide *::after,
|
|
23
|
+
#deck > .slide *::marker,
|
|
24
|
+
#deck > .slide *::first-letter,
|
|
25
|
+
#deck > .slide *::first-line {
|
|
26
|
+
color: transparent !important;
|
|
27
|
+
-webkit-text-fill-color: transparent !important;
|
|
28
|
+
-webkit-text-stroke-color: transparent !important;
|
|
29
|
+
text-decoration-color: transparent !important;
|
|
30
|
+
text-emphasis-color: transparent !important;
|
|
31
|
+
text-shadow: none !important;
|
|
32
|
+
border-color: transparent !important;
|
|
33
|
+
border-image: none !important;
|
|
34
|
+
outline-color: transparent !important;
|
|
35
|
+
column-rule-color: transparent !important;
|
|
36
|
+
box-shadow: none !important;
|
|
37
|
+
transition: none !important;
|
|
38
|
+
}
|
|
39
|
+
#deck > .slide::before,
|
|
40
|
+
#deck > .slide::after,
|
|
41
|
+
#deck > .slide *,
|
|
42
|
+
#deck > .slide *::before,
|
|
43
|
+
#deck > .slide *::after,
|
|
44
|
+
#deck > .slide *::first-letter,
|
|
45
|
+
#deck > .slide *::first-line {
|
|
46
|
+
background: none !important;
|
|
47
|
+
}
|
|
48
|
+
#deck > .slide {
|
|
49
|
+
opacity: 1 !important;
|
|
50
|
+
mix-blend-mode: normal !important;
|
|
51
|
+
}
|
|
52
|
+
#deck > .slide :is(img, video, canvas, iframe, embed, object, input, select, textarea, button, progress, meter),
|
|
53
|
+
#deck > .slide svg image {
|
|
54
|
+
opacity: 0 !important;
|
|
55
|
+
}
|
|
56
|
+
#deck > .slide svg * {
|
|
57
|
+
fill: transparent !important;
|
|
58
|
+
stroke: transparent !important;
|
|
59
|
+
}
|
|
60
|
+
}`;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Where each slide lies on the whole page, for its picture to be shot.
|
|
64
|
+
*
|
|
65
|
+
* Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
|
|
66
|
+
*/
|
|
67
|
+
export function slideBoxesInPage(
|
|
68
|
+
selector: string,
|
|
69
|
+
): { x: number; y: number; width: number; height: number }[] {
|
|
70
|
+
return [...document.querySelectorAll(selector)].map((slide) => {
|
|
71
|
+
const box = slide.getBoundingClientRect();
|
|
72
|
+
return {
|
|
73
|
+
x: box.left + window.scrollX,
|
|
74
|
+
y: box.top + window.scrollY,
|
|
75
|
+
width: box.width,
|
|
76
|
+
height: box.height,
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Takes every paint but the text away from the page's slides, once their pictures are shot. A
|
|
83
|
+
* filter or a backdrop filter would draw over the picture, or turn the text under it into pixels,
|
|
84
|
+
* so each goes; `will-change` keeps the box it made for its positioned descendants, so they stay
|
|
85
|
+
* where they were laid out.
|
|
86
|
+
*
|
|
87
|
+
* Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
|
|
88
|
+
*/
|
|
89
|
+
export function keepOnlyTextInPage({ selector, css }: { selector: string; css: string }): void {
|
|
90
|
+
for (const slide of document.querySelectorAll(selector)) {
|
|
91
|
+
for (const el of [slide, ...slide.querySelectorAll("*")]) {
|
|
92
|
+
if (!(el instanceof HTMLElement || el instanceof SVGElement)) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const style = getComputedStyle(el);
|
|
96
|
+
const effects = (["filter", "backdrop-filter"] as const).filter(
|
|
97
|
+
(property) => style.getPropertyValue(property) !== "none",
|
|
98
|
+
);
|
|
99
|
+
if (effects.length === 0) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const hints = style.willChange === "auto" ? [] : [style.willChange];
|
|
103
|
+
for (const property of effects) {
|
|
104
|
+
el.style.setProperty(property, "none", "important");
|
|
105
|
+
}
|
|
106
|
+
el.style.setProperty("will-change", [...hints, ...effects].join(", "), "important");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const sheet = document.createElement("style");
|
|
110
|
+
sheet.textContent = css;
|
|
111
|
+
document.head.append(sheet);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** How good a JPEG a photograph's slide is kept as: near the PNG to the eye, at under half its size. */
|
|
115
|
+
export const PDF_JPEG_QUALITY = 90;
|
|
116
|
+
|
|
117
|
+
/** How much of a slide its photographs must cover for the slide to be kept as a JPEG. */
|
|
118
|
+
const PHOTO_SHARE = 0.1;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Which of a slide's two pictures the PDF keeps: the JPEG for a slide that shows photographs,
|
|
122
|
+
* where it is smaller, and the PNG for the rest. Type and flat shapes keep their edges in a PNG;
|
|
123
|
+
* a JPEG blurs them, so a slide of them is never kept as one, however much smaller it would be.
|
|
124
|
+
*/
|
|
125
|
+
export function pdfPictureType(
|
|
126
|
+
sizes: { png: number; jpeg: number },
|
|
127
|
+
photoShare: number,
|
|
128
|
+
): "png" | "jpeg" {
|
|
129
|
+
return photoShare >= PHOTO_SHARE && sizes.jpeg < sizes.png ? "jpeg" : "png";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* How much of each slide raster pictures cover, 0 to 1: images, video, and canvases, and
|
|
134
|
+
* backgrounds drawn from a picture's URL. SVG is shapes, not a photograph, and does not count.
|
|
135
|
+
*
|
|
136
|
+
* Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
|
|
137
|
+
*/
|
|
138
|
+
export function photoSharesInPage(selector: string): number[] {
|
|
139
|
+
const vector = (url: string) => /^data:image\/svg|\.svg(?:[?#]|$)/i.test(url);
|
|
140
|
+
return [...document.querySelectorAll(selector)].map((slide) => {
|
|
141
|
+
const frame = slide.getBoundingClientRect();
|
|
142
|
+
let covered = 0;
|
|
143
|
+
for (const el of [slide, ...slide.querySelectorAll("*")]) {
|
|
144
|
+
const style = getComputedStyle(el);
|
|
145
|
+
if (style.visibility !== "visible" || style.display === "none") {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const raster =
|
|
149
|
+
el instanceof HTMLImageElement
|
|
150
|
+
? !vector(el.currentSrc || el.src)
|
|
151
|
+
: el instanceof HTMLVideoElement ||
|
|
152
|
+
el instanceof HTMLCanvasElement ||
|
|
153
|
+
[...style.backgroundImage.matchAll(/url\("?([^")]*)"?\)/g)].some(
|
|
154
|
+
([, url]) => url !== undefined && !vector(url),
|
|
155
|
+
);
|
|
156
|
+
if (!raster) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const box = el.getBoundingClientRect();
|
|
160
|
+
const width = Math.min(box.right, frame.right) - Math.max(box.left, frame.left);
|
|
161
|
+
const height = Math.min(box.bottom, frame.bottom) - Math.max(box.top, frame.top);
|
|
162
|
+
covered += Math.max(0, width) * Math.max(0, height);
|
|
163
|
+
}
|
|
164
|
+
return Math.min(1, covered / (frame.width * frame.height));
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Sets a slide's picture, a `data:` URL, under its text: the `index`th of the page's slides.
|
|
170
|
+
* One slide a call, so no message to the page carries more than one picture.
|
|
171
|
+
*
|
|
172
|
+
* Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
|
|
173
|
+
*/
|
|
174
|
+
export function setSlidePictureInPage({
|
|
175
|
+
selector,
|
|
176
|
+
index,
|
|
177
|
+
picture,
|
|
178
|
+
}: {
|
|
179
|
+
selector: string;
|
|
180
|
+
index: number;
|
|
181
|
+
picture: string;
|
|
182
|
+
}): void {
|
|
183
|
+
const slide = document.querySelectorAll(selector)[index];
|
|
184
|
+
if (slide instanceof HTMLElement) {
|
|
185
|
+
slide.style.setProperty(
|
|
186
|
+
"background",
|
|
187
|
+
`url("${picture}") 0 0 / 100% 100% no-repeat border-box`,
|
|
188
|
+
"important",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -3,13 +3,18 @@ declare module "playwright" {
|
|
|
3
3
|
export type Page = {
|
|
4
4
|
goto(url: string): Promise<unknown>;
|
|
5
5
|
setContent(html: string, options?: { waitUntil?: string }): Promise<void>;
|
|
6
|
-
emulateMedia(options: {
|
|
6
|
+
emulateMedia(options: {
|
|
7
|
+
media?: "screen" | "print";
|
|
8
|
+
reducedMotion?: "reduce" | "no-preference";
|
|
9
|
+
}): Promise<void>;
|
|
7
10
|
setViewportSize(size: Viewport): Promise<void>;
|
|
8
11
|
evaluate<T, A = unknown>(fn: (arg: A) => T | Promise<T>, arg?: A): Promise<T>;
|
|
9
12
|
screenshot(options: {
|
|
10
13
|
path?: string;
|
|
11
14
|
fullPage?: boolean;
|
|
15
|
+
clip?: { x: number; y: number; width: number; height: number };
|
|
12
16
|
type?: "png" | "jpeg";
|
|
17
|
+
quality?: number;
|
|
13
18
|
}): Promise<Buffer>;
|
|
14
19
|
addScriptTag(options: { content: string }): Promise<unknown>;
|
|
15
20
|
pdf(options: {
|
|
@@ -11,6 +11,16 @@ import {
|
|
|
11
11
|
samePlace,
|
|
12
12
|
} from "./overflow.ts";
|
|
13
13
|
import { visitInPages } from "./page-pool.ts";
|
|
14
|
+
import {
|
|
15
|
+
keepOnlyTextInPage,
|
|
16
|
+
PDF_JPEG_QUALITY,
|
|
17
|
+
PDF_SLIDES,
|
|
18
|
+
PDF_TEXT_CSS,
|
|
19
|
+
pdfPictureType,
|
|
20
|
+
photoSharesInPage,
|
|
21
|
+
setSlidePictureInPage,
|
|
22
|
+
slideBoxesInPage,
|
|
23
|
+
} from "./pdf-text.ts";
|
|
14
24
|
import type {
|
|
15
25
|
ContrastFinding,
|
|
16
26
|
ContrastOrigin,
|
|
@@ -50,12 +60,13 @@ export async function runVisualRequest<R extends VisualRequest>(
|
|
|
50
60
|
browser: Browser,
|
|
51
61
|
request: R,
|
|
52
62
|
): Promise<ResponseTo<R>> {
|
|
53
|
-
// A PPTX's pictures are shown full screen, so they are drawn at twice the slide's
|
|
54
|
-
// self-contained (DEK020), so the page needs no network, and a ref's slide
|
|
63
|
+
// A PPTX's and a PDF's pictures are shown full screen, so they are drawn at twice the slide's
|
|
64
|
+
// size. A deck is self-contained (DEK020), so the page needs no network, and a ref's slide
|
|
65
|
+
// scripts get none.
|
|
55
66
|
const context = await browser.newContext({
|
|
56
67
|
viewport: request.viewport,
|
|
57
68
|
offline: true,
|
|
58
|
-
...(request.kind === "pptx" ? { deviceScaleFactor: 2 } : {}),
|
|
69
|
+
...(request.kind === "pptx" || request.kind === "pdf" ? { deviceScaleFactor: 2 } : {}),
|
|
59
70
|
});
|
|
60
71
|
try {
|
|
61
72
|
return (await answer(context, request)) as ResponseTo<R>;
|
|
@@ -83,10 +94,35 @@ function answer(context: BrowserContext, request: VisualRequest) {
|
|
|
83
94
|
}
|
|
84
95
|
}
|
|
85
96
|
|
|
86
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* The whole print document as one PDF, a page per slide: each slide shot as print draws it, then
|
|
99
|
+
* printed as that picture with its text laid over it, unseen.
|
|
100
|
+
*/
|
|
87
101
|
async function printPdf(context: BrowserContext, request: PdfRequest): Promise<DoneResponse> {
|
|
88
102
|
const page = await context.newPage();
|
|
89
103
|
await page.setContent(request.html, { waitUntil: "load" });
|
|
104
|
+
await page.evaluate(() => document.fonts.ready.then(() => undefined));
|
|
105
|
+
await page.emulateMedia({ media: "print" });
|
|
106
|
+
// A slide that shows photographs is shot as a JPEG too, and kept as one where it is smaller.
|
|
107
|
+
const shares = await page.evaluate(photoSharesInPage, PDF_SLIDES);
|
|
108
|
+
const pictures: string[] = [];
|
|
109
|
+
for (const [index, clip] of (await page.evaluate(slideBoxesInPage, PDF_SLIDES)).entries()) {
|
|
110
|
+
const share = shares[index] ?? 0;
|
|
111
|
+
const png = await page.screenshot({ clip, fullPage: true });
|
|
112
|
+
const jpeg =
|
|
113
|
+
pdfPictureType({ png: png.length, jpeg: 0 }, share) === "jpeg"
|
|
114
|
+
? await page.screenshot({ clip, fullPage: true, type: "jpeg", quality: PDF_JPEG_QUALITY })
|
|
115
|
+
: undefined;
|
|
116
|
+
const kept =
|
|
117
|
+
jpeg && pdfPictureType({ png: png.length, jpeg: jpeg.length }, share) === "jpeg"
|
|
118
|
+
? { type: "jpeg", data: jpeg }
|
|
119
|
+
: { type: "png", data: png };
|
|
120
|
+
pictures.push(`data:image/${kept.type};base64,${kept.data.toString("base64")}`);
|
|
121
|
+
}
|
|
122
|
+
await page.evaluate(keepOnlyTextInPage, { selector: PDF_SLIDES, css: PDF_TEXT_CSS });
|
|
123
|
+
for (const [index, picture] of pictures.entries()) {
|
|
124
|
+
await page.evaluate(setSlidePictureInPage, { selector: PDF_SLIDES, index, picture });
|
|
125
|
+
}
|
|
90
126
|
await page.pdf({
|
|
91
127
|
path: request.pdfPath,
|
|
92
128
|
width: `${request.viewport.width}px`,
|
package/src/core/playwright.ts
CHANGED
|
@@ -55,7 +55,10 @@ export type PagesRequest = {
|
|
|
55
55
|
sheets?: SheetSpec[];
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* One page printed to `pdfPath`, a PDF page per slide: each slide a picture at twice its size,
|
|
60
|
+
* with its text laid over it, unseen, to select and search.
|
|
61
|
+
*/
|
|
59
62
|
export type PdfRequest = {
|
|
60
63
|
kind: "pdf";
|
|
61
64
|
viewport: Viewport;
|
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
|
};
|