@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 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.0",
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.14",
65
- "@happy-dom/global-registrator": "20.14.5",
64
+ "@biomejs/biome": "2.5.15",
65
+ "@happy-dom/global-registrator": "21.0.1",
66
66
  "@types/bun": "^1.4.2",
67
- "happy-dom": "20.14.5",
68
- "knip": "6.38.0",
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"
@@ -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: "Write dist/<deck>.pdf, one page per slide at its last beat. Needs Playwright.",
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]"],
@@ -142,7 +142,6 @@ export function renderDeckDocument(
142
142
  bodyAttrs: `${presenterOpen ? ' class="is-presenter"' : ""}${pageConfigAttrs({
143
143
  mode,
144
144
  deck: deck.name,
145
- live,
146
145
  ...(liveToken ? { liveToken } : {}),
147
146
  })}`,
148
147
  body: `${progress}
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, live, liveToken } = body.dataset;
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: { reducedMotion?: "reduce" | "no-preference" }): Promise<void>;
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 size. A deck is
54
- // self-contained (DEK020), so the page needs no network, and a ref's slide scripts get none.
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
- /** The whole print document as one PDF, a page per slide. */
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`,
@@ -55,7 +55,10 @@ export type PagesRequest = {
55
55
  sheets?: SheetSpec[];
56
56
  };
57
57
 
58
- /** One page printed to `pdfPath`, a PDF page per slide. */
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;
@@ -0,0 +1,6 @@
1
+ // The player of the dev server's pages: the same player, with its line to the server.
2
+
3
+ import { createDevLine } from "./dev-line.ts";
4
+ import { startPlayer } from "./start.ts";
5
+
6
+ startPlayer(createDevLine);
@@ -1,207 +1,5 @@
1
- /// <reference lib="dom" />
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 { isLiveEvent } from "../core/live-protocol.ts";
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
- const dataEl = document.getElementById(PAGE_ID.data);
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
- rehearse: Pick<RehearseController, "ready" | "move" | "toggle">;
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.ready()) {
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.ready()) {
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
+ }
@@ -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 { createPositionSocket, type PositionSocket } from "./socket.ts";
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
- /** The dev server's position socket; a built file on a static host has none. */
56
- socketUrl?: string;
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.socketUrl) {
160
- const url = options.socketUrl;
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) => {
@@ -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 every deck page runs. */
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");
@@ -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(options: {
59
- connect: () => SocketLike;
60
- onPosition: (position: Position) => void;
61
- /** Where a presenter's laser points, or null once it went away. */
62
- onPointer?: (pointer: Pointer | null) => void;
63
- setTimer: (fn: () => void, ms: number) => number;
64
- clearTimer: (id: number) => void;
65
- random?: () => number;
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, liveReloadScript, playerScript } from "../runtime/player.ts";
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 playerScript(),
72
+ playerScript: await livePlayerScript(),
73
73
  liveReloadScript: liveReloadScript(),
74
74
  annotateScript: await annotateScript(),
75
75
  };