@hajimism/dek 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.ja.md +75 -0
- package/README.md +75 -0
- package/package.json +65 -0
- package/src/cli/address.ts +53 -0
- package/src/cli/build.ts +85 -0
- package/src/cli/check.ts +97 -0
- package/src/cli/commands.ts +851 -0
- package/src/cli/contract.ts +444 -0
- package/src/cli/cues.ts +18 -0
- package/src/cli/files.ts +309 -0
- package/src/cli/flags.ts +295 -0
- package/src/cli/format.ts +156 -0
- package/src/cli/goto.ts +96 -0
- package/src/cli/init.ts +136 -0
- package/src/cli/keep-alive.ts +72 -0
- package/src/cli/lint.ts +84 -0
- package/src/cli/ls.ts +119 -0
- package/src/cli/main.ts +231 -0
- package/src/cli/marks.ts +16 -0
- package/src/cli/mv.ts +37 -0
- package/src/cli/new.ts +64 -0
- package/src/cli/pdf.ts +16 -0
- package/src/cli/qr.ts +466 -0
- package/src/cli/ref.ts +165 -0
- package/src/cli/rehearse.ts +42 -0
- package/src/cli/result.ts +161 -0
- package/src/cli/scope.ts +308 -0
- package/src/cli/serve.ts +125 -0
- package/src/cli/shot.ts +134 -0
- package/src/cli/show.ts +103 -0
- package/src/cli/sync.ts +37 -0
- package/src/cli/text.ts +479 -0
- package/src/cli/theme.ts +56 -0
- package/src/cli/tty.ts +81 -0
- package/src/cli/usage.ts +198 -0
- package/src/cli/video.ts +17 -0
- package/src/cli/voice.ts +113 -0
- package/src/cli.ts +4 -0
- package/src/core/assets.ts +239 -0
- package/src/core/build.ts +77 -0
- package/src/core/bundled-theme.ts +15 -0
- package/src/core/capture-go.ts +72 -0
- package/src/core/chrome.ts +128 -0
- package/src/core/config-keys.ts +82 -0
- package/src/core/config.ts +95 -0
- package/src/core/css-scan.ts +158 -0
- package/src/core/css-transform.ts +140 -0
- package/src/core/css-value.ts +127 -0
- package/src/core/css.ts +545 -0
- package/src/core/cue.ts +187 -0
- package/src/core/deck-paths.ts +34 -0
- package/src/core/diagnostic.ts +114 -0
- package/src/core/document.ts +218 -0
- package/src/core/error.ts +31 -0
- package/src/core/escape.ts +16 -0
- package/src/core/fill.ts +100 -0
- package/src/core/finish-beat.ts +30 -0
- package/src/core/github.ts +234 -0
- package/src/core/html-scan.ts +253 -0
- package/src/core/html.ts +234 -0
- package/src/core/in-page-go.ts +128 -0
- package/src/core/lines.ts +43 -0
- package/src/core/lint/asset-refs.ts +109 -0
- package/src/core/lint/context.ts +116 -0
- package/src/core/lint/files.ts +117 -0
- package/src/core/lint/keys.ts +48 -0
- package/src/core/lint/morph.ts +91 -0
- package/src/core/lint/script.ts +102 -0
- package/src/core/lint/shared-styles.ts +165 -0
- package/src/core/lint/slide-html.ts +466 -0
- package/src/core/lint/slide-script.ts +179 -0
- package/src/core/lint/theme-css.ts +295 -0
- package/src/core/lint/timing.ts +66 -0
- package/src/core/lint/tokens.ts +520 -0
- package/src/core/lint/voice.ts +76 -0
- package/src/core/lint.ts +137 -0
- package/src/core/live-protocol.ts +130 -0
- package/src/core/marks.ts +198 -0
- package/src/core/mv.ts +293 -0
- package/src/core/ogp.ts +99 -0
- package/src/core/optional.ts +24 -0
- package/src/core/overflow.ts +220 -0
- package/src/core/page-pool.ts +53 -0
- package/src/core/page.ts +66 -0
- package/src/core/parse.ts +307 -0
- package/src/core/path.ts +58 -0
- package/src/core/pdf.ts +71 -0
- package/src/core/playwright-module.d.ts +43 -0
- package/src/core/playwright-motion.ts +42 -0
- package/src/core/playwright-sheet.ts +32 -0
- package/src/core/playwright-visual.ts +391 -0
- package/src/core/playwright-worker.ts +5 -0
- package/src/core/playwright.ts +431 -0
- package/src/core/position.ts +146 -0
- package/src/core/presenter-state.ts +43 -0
- package/src/core/presenter.ts +131 -0
- package/src/core/prose.ts +16 -0
- package/src/core/pseudo-text.ts +67 -0
- package/src/core/rail-width.ts +4 -0
- package/src/core/ref-name.ts +110 -0
- package/src/core/ref.ts +238 -0
- package/src/core/resolve.ts +228 -0
- package/src/core/rumdl.ts +99 -0
- package/src/core/safe-fs.ts +196 -0
- package/src/core/sarif.ts +135 -0
- package/src/core/schema.ts +89 -0
- package/src/core/script-lines.ts +53 -0
- package/src/core/sheet.ts +223 -0
- package/src/core/shot/morph.ts +80 -0
- package/src/core/shot/motion.ts +187 -0
- package/src/core/shot/still.ts +216 -0
- package/src/core/shot-cache.ts +140 -0
- package/src/core/size.ts +11 -0
- package/src/core/skeleton-record.ts +51 -0
- package/src/core/skeleton.ts +89 -0
- package/src/core/slide-draw.ts +165 -0
- package/src/core/slide-eval-worker.ts +58 -0
- package/src/core/slide-measure.ts +272 -0
- package/src/core/slide-script-eval.ts +258 -0
- package/src/core/slide-script.ts +252 -0
- package/src/core/spawn.ts +128 -0
- package/src/core/step.ts +94 -0
- package/src/core/still-page.ts +70 -0
- package/src/core/suggest.ts +33 -0
- package/src/core/sync.ts +316 -0
- package/src/core/text-contrast.ts +466 -0
- package/src/core/theme-excerpt.ts +187 -0
- package/src/core/theme-facts.ts +51 -0
- package/src/core/timeline.ts +216 -0
- package/src/core/timing.ts +88 -0
- package/src/core/toml-keys.ts +349 -0
- package/src/core/url-attributes.ts +103 -0
- package/src/core/video-document.ts +15 -0
- package/src/core/visual-cache.ts +100 -0
- package/src/core/visual.ts +558 -0
- package/src/core/voice.ts +321 -0
- package/src/core/zod.ts +79 -0
- package/src/runtime/browser.ts +203 -0
- package/src/runtime/clone.ts +52 -0
- package/src/runtime/deck-control.ts +43 -0
- package/src/runtime/fit.ts +32 -0
- package/src/runtime/go.ts +56 -0
- package/src/runtime/input.ts +128 -0
- package/src/runtime/laser-view.ts +110 -0
- package/src/runtime/laser.ts +145 -0
- package/src/runtime/live-host.ts +33 -0
- package/src/runtime/live-reload.ts +29 -0
- package/src/runtime/live.ts +88 -0
- package/src/runtime/marks-view.ts +74 -0
- package/src/runtime/motion.ts +113 -0
- package/src/runtime/navigator.ts +189 -0
- package/src/runtime/player.ts +28 -0
- package/src/runtime/presenter-view.ts +289 -0
- package/src/runtime/presenter.ts +52 -0
- package/src/runtime/rail-view.ts +229 -0
- package/src/runtime/rail.ts +52 -0
- package/src/runtime/rehearse.ts +264 -0
- package/src/runtime/routes.ts +142 -0
- package/src/runtime/settle.ts +45 -0
- package/src/runtime/slide.d.ts +28 -0
- package/src/runtime/socket.ts +143 -0
- package/src/runtime/stage.ts +259 -0
- package/src/runtime/step.ts +139 -0
- package/src/runtime/window.ts +38 -0
- package/src/server/auth.ts +210 -0
- package/src/server/deck-snapshot.ts +146 -0
- package/src/server/dev.ts +251 -0
- package/src/server/hub.ts +104 -0
- package/src/server/lan.ts +51 -0
- package/src/server/lock.ts +97 -0
- package/src/server/pages.ts +71 -0
- package/src/server/pairing.ts +40 -0
- package/src/server/project-watch.ts +142 -0
- package/src/server/rooms.ts +133 -0
- package/src/server/routes.ts +286 -0
- package/src/server/serial.ts +25 -0
- package/src/server/watch.ts +308 -0
- package/src/theme/default.css +175 -0
- package/src/video/bake.ts +152 -0
- package/src/video/mux.ts +103 -0
- package/src/video/recorder.ts +115 -0
- package/src/video/sidecar.ts +60 -0
- package/src/video/worker.ts +51 -0
- package/src/voice/engine.ts +177 -0
- package/src/voice/synth.ts +127 -0
- package/src/voice/wav.ts +115 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type AnimationLike = {
|
|
2
|
+
playState?: string;
|
|
3
|
+
finished?: Promise<unknown>;
|
|
4
|
+
effect?: { getComputedTiming(): { endTime?: number | CSSNumberish } } | null;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type ViewTransitionLike = {
|
|
8
|
+
finished: Promise<unknown>;
|
|
9
|
+
/** Rejects when the transition is skipped, which a hurried move does on purpose. */
|
|
10
|
+
ready?: Promise<unknown>;
|
|
11
|
+
/** Rejects when the update itself threw. */
|
|
12
|
+
updateCallbackDone?: Promise<unknown>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function waitForPlaybackSettle(options: {
|
|
16
|
+
animations?: AnimationLike[];
|
|
17
|
+
viewTransition?: ViewTransitionLike | null;
|
|
18
|
+
}): Promise<void> {
|
|
19
|
+
const tasks: Array<Promise<unknown>> = [];
|
|
20
|
+
const transition = options.viewTransition;
|
|
21
|
+
if (transition) {
|
|
22
|
+
// A skipped transition still ran its update; only the animation was dropped.
|
|
23
|
+
transition.ready?.catch(() => undefined);
|
|
24
|
+
tasks.push(transition.finished.catch(() => undefined));
|
|
25
|
+
// The update's own error is the move's, as it is when no transition wraps it.
|
|
26
|
+
if (transition.updateCallbackDone) {
|
|
27
|
+
tasks.push(transition.updateCallbackDone);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const animation of options.animations ?? []) {
|
|
31
|
+
if (animation.playState === "idle" || animation.playState === "finished") {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
// One that repeats forever never finishes; waiting on it would stall every later go.
|
|
35
|
+
if (animation.effect?.getComputedTiming().endTime === Number.POSITIVE_INFINITY) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (animation.finished) {
|
|
39
|
+
tasks.push(animation.finished.catch(() => undefined));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (tasks.length > 0) {
|
|
43
|
+
await Promise.all(tasks);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Types for slide scripts, slides/<id>.ts. Generated by dek; `dekc sync` rewrites it.
|
|
2
|
+
//
|
|
3
|
+
// export default { motion: { growth: 900 }, draw(slide, { t }) {} } satisfies DekSlide;
|
|
4
|
+
|
|
5
|
+
export {};
|
|
6
|
+
|
|
7
|
+
declare global {
|
|
8
|
+
/** What `draw` receives for one frame of a beat. */
|
|
9
|
+
interface DekMotionFrame {
|
|
10
|
+
/** Beats reached on the slide: 0 as it arrives, k at its k-th beat. */
|
|
11
|
+
index: number;
|
|
12
|
+
/** "0" as the slide arrives; at a beat, its id or 1-based position, the values `data-step` takes. */
|
|
13
|
+
step: string;
|
|
14
|
+
/** Milliseconds since the beat began, from 0 up to the beat's `motion`. */
|
|
15
|
+
t: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The default export of a slide script. */
|
|
19
|
+
interface DekSlide {
|
|
20
|
+
/** Milliseconds of motion per beat, keyed like `step`. A beat without an entry is drawn once, at t = 0. */
|
|
21
|
+
motion?: Record<string, number>;
|
|
22
|
+
/**
|
|
23
|
+
* Draws the slide at `t`. The runtime owns the clock: video and screenshots seek it,
|
|
24
|
+
* so draw from `t` alone and set everything you touch on every call.
|
|
25
|
+
*/
|
|
26
|
+
draw?(slide: HTMLElement, frame: DekMotionFrame): void;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodePointer,
|
|
3
|
+
decodePosition,
|
|
4
|
+
encodePointer,
|
|
5
|
+
encodePosition,
|
|
6
|
+
type Pointer,
|
|
7
|
+
} from "../core/live-protocol.ts";
|
|
8
|
+
import { positionsEqual } from "../core/position.ts";
|
|
9
|
+
import type { Position } from "../core/step.ts";
|
|
10
|
+
|
|
11
|
+
const RECONNECT_BASE_MS = 500;
|
|
12
|
+
export const RECONNECT_MAX_MS = 10_000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* How long to wait before reconnect `attempt` (from 0): doubling from half a second up to a cap,
|
|
16
|
+
* then cut by a random share of up to half, so the phones a Wi-Fi drop cut off together do not
|
|
17
|
+
* all come back in the same instant.
|
|
18
|
+
*/
|
|
19
|
+
export function reconnectDelay(attempt: number, random: () => number = Math.random): number {
|
|
20
|
+
const ceiling = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** attempt);
|
|
21
|
+
return ceiling / 2 + (random() * ceiling) / 2;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The part of a WebSocket the deck uses. */
|
|
25
|
+
export type SocketLike = {
|
|
26
|
+
readyState: number;
|
|
27
|
+
send(data: string): void;
|
|
28
|
+
close(): void;
|
|
29
|
+
addEventListener(
|
|
30
|
+
type: "open" | "message" | "close",
|
|
31
|
+
listener: (event: { data?: unknown }) => void,
|
|
32
|
+
): void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const OPEN = 1;
|
|
36
|
+
|
|
37
|
+
export type PositionSocket = {
|
|
38
|
+
/** Tell the server where the deck is headed; kept for the next connection while offline. */
|
|
39
|
+
publish(position: Position): void;
|
|
40
|
+
/**
|
|
41
|
+
* Tell the server where the laser points, or null once it went away. Dropped while offline:
|
|
42
|
+
* a point is stale by the time the line is back, and the next move of the hand sends a new one.
|
|
43
|
+
*/
|
|
44
|
+
point(pointer: Pointer | null): void;
|
|
45
|
+
/** Close for good: no reconnect follows. */
|
|
46
|
+
close(): void;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The deck's line to the server, which relays positions between the presenter, the audience,
|
|
51
|
+
* and a phone remote, and where the presenter's laser points. Like an EventSource, it reconnects by itself after a drop (a restarted
|
|
52
|
+
* server, a Wi-Fi blip), backing off with `reconnectDelay`, until `close`.
|
|
53
|
+
*
|
|
54
|
+
* On reconnect the server greets with the last position it holds, and the deck follows it, having
|
|
55
|
+
* missed whatever moved meanwhile. A move this deck made while away is sent instead, and that
|
|
56
|
+
* greeting, if it is still where the deck was when the line dropped, is stale and passed over.
|
|
57
|
+
*/
|
|
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 {
|
|
67
|
+
let socket: SocketLike | undefined;
|
|
68
|
+
let timer: number | undefined;
|
|
69
|
+
let attempt = 0;
|
|
70
|
+
let closed = false;
|
|
71
|
+
/** Where the server and this deck last agreed the deck was. */
|
|
72
|
+
let synced: Position | undefined;
|
|
73
|
+
/** The latest move made while the line was down. */
|
|
74
|
+
let unsent: Position | undefined;
|
|
75
|
+
/** A greeting to pass over: the server's position from before `unsent`. */
|
|
76
|
+
let stale: Position | undefined;
|
|
77
|
+
|
|
78
|
+
const connect = (): void => {
|
|
79
|
+
timer = undefined;
|
|
80
|
+
const current = options.connect();
|
|
81
|
+
socket = current;
|
|
82
|
+
current.addEventListener("open", () => {
|
|
83
|
+
attempt = 0;
|
|
84
|
+
if (unsent) {
|
|
85
|
+
current.send(encodePosition(unsent));
|
|
86
|
+
stale = synced;
|
|
87
|
+
synced = unsent;
|
|
88
|
+
unsent = undefined;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
current.addEventListener("message", (event) => {
|
|
92
|
+
const laser = decodePointer(event.data);
|
|
93
|
+
if (laser) {
|
|
94
|
+
options.onPointer?.(laser.pointer);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const position = decodePosition(event.data);
|
|
98
|
+
if (!position) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const skip = stale !== undefined && positionsEqual(stale, position);
|
|
102
|
+
stale = undefined;
|
|
103
|
+
if (skip) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
synced = position;
|
|
107
|
+
options.onPosition(position);
|
|
108
|
+
});
|
|
109
|
+
current.addEventListener("close", () => {
|
|
110
|
+
if (closed || socket !== current) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
socket = undefined;
|
|
114
|
+
timer = options.setTimer(connect, reconnectDelay(attempt++, options.random));
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
connect();
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
publish(position) {
|
|
121
|
+
if (socket?.readyState === OPEN) {
|
|
122
|
+
socket.send(encodePosition(position));
|
|
123
|
+
synced = position;
|
|
124
|
+
} else {
|
|
125
|
+
unsent = position;
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
point(pointer) {
|
|
129
|
+
if (socket?.readyState === OPEN) {
|
|
130
|
+
socket.send(encodePointer(pointer));
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
close() {
|
|
134
|
+
closed = true;
|
|
135
|
+
if (timer !== undefined) {
|
|
136
|
+
options.clearTimer(timer);
|
|
137
|
+
timer = undefined;
|
|
138
|
+
}
|
|
139
|
+
socket?.close();
|
|
140
|
+
socket = undefined;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
import { PAGE_ID } from "../core/page.ts";
|
|
3
|
+
|
|
4
|
+
import { advance, type DeckStops, positionsEqual } from "../core/position.ts";
|
|
5
|
+
import type { PresenterSlide } from "../core/presenter-state.ts";
|
|
6
|
+
import { lastStop, type Position, stepKey, stepValuesForBeat } from "../core/step.ts";
|
|
7
|
+
import { fitStage } from "./fit.ts";
|
|
8
|
+
import { slideSelector } from "./live.ts";
|
|
9
|
+
import { createMotion, drawAtEnd, type MotionMode, type SlideModule } from "./motion.ts";
|
|
10
|
+
import { waitForPlaybackSettle } from "./settle.ts";
|
|
11
|
+
import { applyIsShown, applyMorphNames, clearMorphNames, shouldUseViewTransition } from "./step.ts";
|
|
12
|
+
import type { DekMotionHandle } from "./window.ts";
|
|
13
|
+
|
|
14
|
+
export type Stage = {
|
|
15
|
+
slideEl(slug: string | undefined): HTMLElement | undefined;
|
|
16
|
+
/** Mark the slide at `pos` current and show its beats. */
|
|
17
|
+
render(pos: Position): void;
|
|
18
|
+
/** Draw the script of the slide at `pos`. Only the live element animates. */
|
|
19
|
+
showMotion(pos: Position, mode: MotionMode): void;
|
|
20
|
+
/** Play the step from `from` to `to` when it is one beat forward; else jump to its end. */
|
|
21
|
+
showMove(from: Position, to: Position): void;
|
|
22
|
+
/** Draw a clone of `slide` as it stands at `beatIndex`, with no animation. */
|
|
23
|
+
drawStill(clone: HTMLElement, slide: PresenterSlide, beatIndex: number): void;
|
|
24
|
+
/** Run `apply` inside a view transition when the slide changes, and wait until it plays out. */
|
|
25
|
+
present(from: Position, to: Position, apply: () => void): Promise<void>;
|
|
26
|
+
/** Cut the running move short: a newer one is waiting, and the presenter should not. */
|
|
27
|
+
hurry(): void;
|
|
28
|
+
/** Tell a screen reader which slide `pos` is on. */
|
|
29
|
+
announce(pos: Position): void;
|
|
30
|
+
/** Give each slide a page at its last beat while printing; `pos` is drawn again after. */
|
|
31
|
+
setPrinting(on: boolean, pos: Position): void;
|
|
32
|
+
/** Fit the deck to its stage. */
|
|
33
|
+
fit(): void;
|
|
34
|
+
/** What the video recorder seeks. */
|
|
35
|
+
motion: DekMotionHandle;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** How the script draws a move: one beat forward animates, anything else jumps to its end. */
|
|
39
|
+
export function motionModeFor(
|
|
40
|
+
from: Position,
|
|
41
|
+
to: Position,
|
|
42
|
+
deck: DeckStops,
|
|
43
|
+
env: { videoMode: boolean; reducedMotion: boolean },
|
|
44
|
+
): MotionMode {
|
|
45
|
+
if (env.videoMode) {
|
|
46
|
+
return "hold";
|
|
47
|
+
}
|
|
48
|
+
if (env.reducedMotion) {
|
|
49
|
+
return "final";
|
|
50
|
+
}
|
|
51
|
+
const stepped = advance(from, deck);
|
|
52
|
+
return stepped && positionsEqual(stepped, to) ? "animate" : "final";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What a screen reader hears on arriving at `pos`. */
|
|
56
|
+
export function announcement(
|
|
57
|
+
slides: ReadonlyArray<{ title: string }>,
|
|
58
|
+
pos: Position,
|
|
59
|
+
): string | undefined {
|
|
60
|
+
const slide = slides[pos.slideIndex];
|
|
61
|
+
return slide ? `Slide ${pos.slideIndex + 1} of ${slides.length}: ${slide.title}` : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Only a running animation with an end can be finished; an endless one would throw. */
|
|
65
|
+
export function isFinishable(animation: {
|
|
66
|
+
playState: string;
|
|
67
|
+
effect: { getComputedTiming(): { endTime?: unknown } } | null;
|
|
68
|
+
}): boolean {
|
|
69
|
+
const end = animation.effect?.getComputedTiming().endTime;
|
|
70
|
+
return animation.playState === "running" && typeof end === "number" && Number.isFinite(end);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const slideModules = (): Record<string, SlideModule> => window.__dekSlides ?? {};
|
|
74
|
+
|
|
75
|
+
function slideEl(slug: string | undefined): HTMLElement | undefined {
|
|
76
|
+
if (!slug) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
return document.querySelector<HTMLElement>(slideSelector(slug)) ?? undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function prefersReducedMotion(): boolean {
|
|
83
|
+
return matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const morphEls = (el: Element): HTMLElement[] => [
|
|
87
|
+
...el.querySelectorAll<HTMLElement>("[data-morph]"),
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
function drawStill(clone: HTMLElement, slide: PresenterSlide, beatIndex: number): void {
|
|
91
|
+
drawAtEnd(slideModules()[slide.slug], clone, beatIndex, stepKey(slide.beats, beatIndex));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Read the slides afresh on each draw: live reload may have swapped one. */
|
|
95
|
+
function renderSlides(slides: PresenterSlide[], pos: Position): void {
|
|
96
|
+
const slideEls = [...document.querySelectorAll("#deck > .slide")];
|
|
97
|
+
const current = slides[pos.slideIndex];
|
|
98
|
+
const currentEl = slideEl(current?.slug);
|
|
99
|
+
for (const el of slideEls) {
|
|
100
|
+
el.classList.toggle("is-current", el === currentEl);
|
|
101
|
+
}
|
|
102
|
+
const shown = current ? stepValuesForBeat(current.beats, pos.beatIndex) : new Set<string>();
|
|
103
|
+
if (currentEl) {
|
|
104
|
+
applyIsShown([...currentEl.querySelectorAll("[data-step]")], shown);
|
|
105
|
+
applyMorphNames(morphEls(currentEl));
|
|
106
|
+
}
|
|
107
|
+
for (const el of slideEls) {
|
|
108
|
+
if (el !== currentEl) {
|
|
109
|
+
clearMorphNames(morphEls(el));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Each slide at its last beat, as `dekc pdf` gives it a page. */
|
|
115
|
+
function drawPrintPages(slides: PresenterSlide[]): void {
|
|
116
|
+
for (const slide of slides) {
|
|
117
|
+
const el = slideEl(slide.slug);
|
|
118
|
+
if (el) {
|
|
119
|
+
const last = lastStop(slide.beats);
|
|
120
|
+
applyIsShown([...el.querySelectorAll("[data-step]")], stepValuesForBeat(slide.beats, last));
|
|
121
|
+
drawStill(el, slide, last);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Start a view transition that runs `apply` when the move changes slide and the browser and
|
|
128
|
+
* viewer allow one; else run `apply` now.
|
|
129
|
+
*/
|
|
130
|
+
function startTransition(
|
|
131
|
+
slides: PresenterSlide[],
|
|
132
|
+
from: Position,
|
|
133
|
+
to: Position,
|
|
134
|
+
apply: () => void,
|
|
135
|
+
): ViewTransition | undefined {
|
|
136
|
+
if (
|
|
137
|
+
!shouldUseViewTransition(from.slideIndex, to.slideIndex) ||
|
|
138
|
+
!("startViewTransition" in document) ||
|
|
139
|
+
prefersReducedMotion()
|
|
140
|
+
) {
|
|
141
|
+
apply();
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
const fromEl = slideEl(slides[from.slideIndex]?.slug);
|
|
145
|
+
if (fromEl) {
|
|
146
|
+
applyMorphNames(morphEls(fromEl));
|
|
147
|
+
}
|
|
148
|
+
return document.startViewTransition(apply);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function finishAnimations(): void {
|
|
152
|
+
for (const animation of document.getAnimations()) {
|
|
153
|
+
if (isFinishable(animation)) {
|
|
154
|
+
animation.finish();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function fitDeck(): void {
|
|
160
|
+
const deckEl = document.getElementById(PAGE_ID.deck);
|
|
161
|
+
const currentStage = document.getElementById(PAGE_ID.currentStage);
|
|
162
|
+
if (deckEl instanceof HTMLElement && currentStage) {
|
|
163
|
+
fitStage(deckEl, currentStage);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Owns the deck's slides on screen: which is current, its beats, morphs, script, and print. */
|
|
168
|
+
export function createStage(options: {
|
|
169
|
+
slides: PresenterSlide[];
|
|
170
|
+
deck: DeckStops;
|
|
171
|
+
videoMode: boolean;
|
|
172
|
+
}): Stage {
|
|
173
|
+
const { slides, videoMode } = options;
|
|
174
|
+
const motion = createMotion({
|
|
175
|
+
now: () => performance.now(),
|
|
176
|
+
requestFrame: (fn) => requestAnimationFrame(fn),
|
|
177
|
+
cancelFrame: (id) => cancelAnimationFrame(id),
|
|
178
|
+
setTimer: (fn, ms) => window.setTimeout(fn, ms),
|
|
179
|
+
clearTimer: (id) => window.clearTimeout(id),
|
|
180
|
+
});
|
|
181
|
+
let inFlight: { skipTransition(): void } | undefined;
|
|
182
|
+
let printing = false;
|
|
183
|
+
|
|
184
|
+
function showMotion(pos: Position, mode: MotionMode): void {
|
|
185
|
+
const current = slides[pos.slideIndex];
|
|
186
|
+
const el = slideEl(current?.slug);
|
|
187
|
+
if (!current || !el) {
|
|
188
|
+
motion.stop();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
motion.show(el, slideModules()[current.slug], current.beats, pos.beatIndex, mode);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function present(from: Position, to: Position, apply: () => void): Promise<void> {
|
|
195
|
+
const viewTransition = startTransition(slides, from, to, apply);
|
|
196
|
+
if (viewTransition) {
|
|
197
|
+
inFlight = viewTransition;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
await waitForPlaybackSettle({ animations: [...document.getAnimations()], viewTransition });
|
|
201
|
+
} finally {
|
|
202
|
+
inFlight = undefined;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Print gives each slide a page at its last beat, as `dekc pdf` does, so each slide's script
|
|
208
|
+
* draws that beat's end the way the PDF page draws it. Once printed, the stage draws its own
|
|
209
|
+
* beat again. The browser tells of a print twice (beforeprint and the print media query), and
|
|
210
|
+
* a PDF export only the second way; each change is drawn once.
|
|
211
|
+
*/
|
|
212
|
+
function setPrinting(on: boolean, pos: Position): void {
|
|
213
|
+
if (on === printing) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
printing = on;
|
|
217
|
+
if (on) {
|
|
218
|
+
motion.stop();
|
|
219
|
+
drawPrintPages(slides);
|
|
220
|
+
} else {
|
|
221
|
+
renderSlides(slides, pos);
|
|
222
|
+
showMotion(pos, "final");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
slideEl,
|
|
228
|
+
render: (pos) => renderSlides(slides, pos),
|
|
229
|
+
showMotion,
|
|
230
|
+
showMove: (from, to) =>
|
|
231
|
+
showMotion(
|
|
232
|
+
to,
|
|
233
|
+
motionModeFor(from, to, options.deck, { videoMode, reducedMotion: prefersReducedMotion() }),
|
|
234
|
+
),
|
|
235
|
+
drawStill,
|
|
236
|
+
present,
|
|
237
|
+
hurry() {
|
|
238
|
+
if (videoMode) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
inFlight?.skipTransition();
|
|
242
|
+
finishAnimations();
|
|
243
|
+
},
|
|
244
|
+
/** Beats within a slide pass quietly. */
|
|
245
|
+
announce(pos) {
|
|
246
|
+
const el = document.getElementById(PAGE_ID.announce);
|
|
247
|
+
const text = announcement(slides, pos);
|
|
248
|
+
if (el && text !== undefined) {
|
|
249
|
+
el.textContent = text;
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
setPrinting,
|
|
253
|
+
fit: fitDeck,
|
|
254
|
+
motion: {
|
|
255
|
+
duration: () => motion.duration(),
|
|
256
|
+
seek: (t: number) => motion.seek(t),
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Move } from "../core/position.ts";
|
|
2
|
+
|
|
3
|
+
export type StepElement = {
|
|
4
|
+
getAttribute(name: string): string | null;
|
|
5
|
+
classList: { toggle(name: string, force?: boolean): void };
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type MorphElement = {
|
|
9
|
+
getAttribute(name: string): string | null;
|
|
10
|
+
style: { setProperty(name: string, value: string): void; removeProperty(name: string): string };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function applyIsShown(elements: StepElement[], shown: Set<string>): void {
|
|
14
|
+
for (const el of elements) {
|
|
15
|
+
const step = el.getAttribute("data-step");
|
|
16
|
+
el.classList.toggle("is-shown", step !== null && shown.has(step));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type MoveKey = {
|
|
21
|
+
key: string;
|
|
22
|
+
shiftKey?: boolean;
|
|
23
|
+
altKey?: boolean;
|
|
24
|
+
ctrlKey?: boolean;
|
|
25
|
+
metaKey?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The move a key asks for. Clickers send the arrow and page keys; Space goes forward and
|
|
30
|
+
* Shift+Space back, as on a web page. Chords with Alt, Ctrl, or Cmd belong to the browser
|
|
31
|
+
* (Alt+← is Back), so they never move the deck.
|
|
32
|
+
*/
|
|
33
|
+
export function keyToMove(event: MoveKey): Move | null {
|
|
34
|
+
if (event.altKey || event.ctrlKey || event.metaKey) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
switch (event.key) {
|
|
38
|
+
case " ":
|
|
39
|
+
return event.shiftKey ? "retreat" : "advance";
|
|
40
|
+
case "ArrowRight":
|
|
41
|
+
case "PageDown":
|
|
42
|
+
return "advance";
|
|
43
|
+
case "ArrowLeft":
|
|
44
|
+
case "PageUp":
|
|
45
|
+
case "Backspace":
|
|
46
|
+
return "retreat";
|
|
47
|
+
case "Home":
|
|
48
|
+
return "first";
|
|
49
|
+
case "End":
|
|
50
|
+
return "last";
|
|
51
|
+
default:
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether `event` is the bare letter key `letter`, either case: no Alt, Ctrl, or Cmd, which
|
|
58
|
+
* belong to the browser, and no auto-repeat, which would toggle on and off while held.
|
|
59
|
+
*/
|
|
60
|
+
export function isLetterKey(
|
|
61
|
+
event: { key: string; altKey: boolean; ctrlKey: boolean; metaKey: boolean; repeat?: boolean },
|
|
62
|
+
letter: string,
|
|
63
|
+
): boolean {
|
|
64
|
+
if (event.repeat || event.altKey || event.ctrlKey || event.metaKey) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return event.key.toLowerCase() === letter;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A finished touch on the slide: where it lifted, and how far it travelled from where it began. */
|
|
71
|
+
export type PointerStroke = { x: number; y: number; dx: number; dy: number };
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The move a touch or a click asks for. A tap or click goes forward, except on the left third,
|
|
75
|
+
* which goes back. A swipe turns the page as a book does (leftward goes forward), but only by
|
|
76
|
+
* touch or pen: a mouse drag is selecting text. Anything between, such as a vertical scroll or
|
|
77
|
+
* a short drag, is not a move.
|
|
78
|
+
*/
|
|
79
|
+
export function pointerMove(
|
|
80
|
+
stroke: PointerStroke,
|
|
81
|
+
stage: { width: number },
|
|
82
|
+
pointerType = "touch",
|
|
83
|
+
): Move | null {
|
|
84
|
+
const ax = Math.abs(stroke.dx);
|
|
85
|
+
const ay = Math.abs(stroke.dy);
|
|
86
|
+
if (pointerType !== "mouse" && ax >= SWIPE_MIN_PX && ax > ay * 2) {
|
|
87
|
+
return stroke.dx < 0 ? "advance" : "retreat";
|
|
88
|
+
}
|
|
89
|
+
if (ax < TAP_MAX_PX && ay < TAP_MAX_PX) {
|
|
90
|
+
return stroke.x < stage.width / 3 ? "retreat" : "advance";
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const SWIPE_MIN_PX = 60;
|
|
96
|
+
const TAP_MAX_PX = 10;
|
|
97
|
+
|
|
98
|
+
/** Whether a touch on `target` belongs to it (a link, a control, media) rather than to the deck. */
|
|
99
|
+
export function isInteractive(target: unknown): boolean {
|
|
100
|
+
const el = target as { closest?: (selector: string) => unknown } | null;
|
|
101
|
+
return typeof el?.closest === "function" && el.closest(INTERACTIVE) !== null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const INTERACTIVE =
|
|
105
|
+
"a[href], button, input, textarea, select, label, summary, video, audio, [contenteditable]";
|
|
106
|
+
|
|
107
|
+
/** Whether keys pressed on `target` are typing: a field or editable text on a slide. */
|
|
108
|
+
export function isTextEntry(target: unknown): boolean {
|
|
109
|
+
if (typeof target !== "object" || target === null) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
const el = target as { tagName?: unknown; isContentEditable?: unknown };
|
|
113
|
+
return (
|
|
114
|
+
el.isContentEditable === true ||
|
|
115
|
+
el.tagName === "INPUT" ||
|
|
116
|
+
el.tagName === "TEXTAREA" ||
|
|
117
|
+
el.tagName === "SELECT"
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function applyMorphNames(elements: MorphElement[]): void {
|
|
122
|
+
for (const el of elements) {
|
|
123
|
+
const name = el.getAttribute("data-morph");
|
|
124
|
+
if (name) {
|
|
125
|
+
// The browser reads the CSS property; an attribute of the same name does nothing.
|
|
126
|
+
el.style.setProperty("view-transition-name", name);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function clearMorphNames(elements: MorphElement[]): void {
|
|
132
|
+
for (const el of elements) {
|
|
133
|
+
el.style.removeProperty("view-transition-name");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function shouldUseViewTransition(fromSlide: number, toSlide: number): boolean {
|
|
138
|
+
return fromSlide !== toSlide;
|
|
139
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Position } from "../core/step.ts";
|
|
2
|
+
|
|
3
|
+
/** How capture tools seek the current slide's script, the way they seek Web Animations. */
|
|
4
|
+
export type DekMotionHandle = {
|
|
5
|
+
/** The current beat's motion in ms; 0 when the slide has none. */
|
|
6
|
+
duration(): number;
|
|
7
|
+
seek(t: number): void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What the player shares through `window` with code outside its bundle: the
|
|
12
|
+
* video recorder and morph shots (run by Playwright), live reload, and the
|
|
13
|
+
* slide scripts that register themselves before the player starts.
|
|
14
|
+
*/
|
|
15
|
+
declare global {
|
|
16
|
+
interface Window {
|
|
17
|
+
dekGo?: (next: Position | null | undefined) => Promise<void>;
|
|
18
|
+
dekMotion?: DekMotionHandle;
|
|
19
|
+
dekLive?: (raw: unknown) => Promise<void>;
|
|
20
|
+
/** The go the video recorder started and has not awaited yet. */
|
|
21
|
+
__dekPendingGo?: Promise<void>;
|
|
22
|
+
/** The animations that go started, so capture tools seek those and nothing older. */
|
|
23
|
+
__dekStarted?: Animation[];
|
|
24
|
+
/** The animations the last finished beat ended; the next go did not start them. */
|
|
25
|
+
__dekSettled?: Set<Animation>;
|
|
26
|
+
__dekSlides?: Record<string, DekSlide>;
|
|
27
|
+
/** What each slide's draw threw on a still page, for the worker that measures it. */
|
|
28
|
+
__dekDrawErrors?: Array<{
|
|
29
|
+
slug: string;
|
|
30
|
+
step: string;
|
|
31
|
+
t: number;
|
|
32
|
+
kind: "throw" | "reach" | "seek";
|
|
33
|
+
message: string;
|
|
34
|
+
}>;
|
|
35
|
+
/** Takes back what the draws on a still page changed in attributes; absent when nothing. */
|
|
36
|
+
__dekUndoDraw?: () => void;
|
|
37
|
+
}
|
|
38
|
+
}
|