@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,32 @@
|
|
|
1
|
+
import type { Size } from "../core/size.ts";
|
|
2
|
+
|
|
3
|
+
export function deckScale(options: {
|
|
4
|
+
viewport: Size;
|
|
5
|
+
logical: Size;
|
|
6
|
+
reservedRight?: number;
|
|
7
|
+
}): number {
|
|
8
|
+
const availableWidth = options.viewport.width - (options.reservedRight ?? 0);
|
|
9
|
+
const { width, height } = options.logical;
|
|
10
|
+
if (width <= 0 || height <= 0 || availableWidth <= 0 || options.viewport.height <= 0) {
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
return Math.min(availableWidth / width, options.viewport.height / height);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function deckFitTransform(viewport: Size, logical: Size): string {
|
|
17
|
+
const scale = deckScale({ viewport, logical });
|
|
18
|
+
const x = (viewport.width - logical.width * scale) / 2;
|
|
19
|
+
const y = (viewport.height - logical.height * scale) / 2;
|
|
20
|
+
return `translate(${x}px, ${y}px) scale(${scale})`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Scale `el`, laid out at the deck's size, to fit inside `stage` and center it there. */
|
|
24
|
+
export function fitStage(
|
|
25
|
+
el: { offsetWidth: number; offsetHeight: number; style: { transform: string } },
|
|
26
|
+
stage: { clientWidth: number; clientHeight: number },
|
|
27
|
+
): void {
|
|
28
|
+
el.style.transform = deckFitTransform(
|
|
29
|
+
{ width: stage.clientWidth, height: stage.clientHeight },
|
|
30
|
+
{ width: el.offsetWidth || 1280, height: el.offsetHeight || 720 },
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run one move at a time. A move asked for while another runs waits, and only the latest waiting
|
|
3
|
+
* move runs next. `onQueue` fires as one starts waiting, so the move in flight can cut its
|
|
4
|
+
* animation short instead of making the presenter wait it out.
|
|
5
|
+
*/
|
|
6
|
+
export function createGuardedGo<T>(
|
|
7
|
+
run: (next: T) => Promise<void>,
|
|
8
|
+
options: { onQueue?: () => void } = {},
|
|
9
|
+
): (next: T | null | undefined) => Promise<void> {
|
|
10
|
+
let busy = false;
|
|
11
|
+
let queued: T | undefined;
|
|
12
|
+
return async (next) => {
|
|
13
|
+
if (next == null) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (busy) {
|
|
17
|
+
queued = next;
|
|
18
|
+
options.onQueue?.();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
busy = true;
|
|
22
|
+
let firstError: unknown;
|
|
23
|
+
try {
|
|
24
|
+
let current: T | undefined = next;
|
|
25
|
+
while (current !== undefined) {
|
|
26
|
+
queued = undefined;
|
|
27
|
+
try {
|
|
28
|
+
await run(current);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
firstError ??= error;
|
|
31
|
+
}
|
|
32
|
+
current = queued;
|
|
33
|
+
}
|
|
34
|
+
} finally {
|
|
35
|
+
busy = false;
|
|
36
|
+
}
|
|
37
|
+
if (firstError !== undefined) {
|
|
38
|
+
throw firstError;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Follow a peer's move to `next` through `go`, unless the deck already heads there. */
|
|
44
|
+
export function applyIncomingPosition<T>(
|
|
45
|
+
go: (next: T) => unknown,
|
|
46
|
+
next: T,
|
|
47
|
+
options: {
|
|
48
|
+
equal: (a: T, b: T) => boolean;
|
|
49
|
+
current: () => T;
|
|
50
|
+
},
|
|
51
|
+
): void {
|
|
52
|
+
if (options.equal(options.current(), next)) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
void go(next);
|
|
56
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
import { PAGE_ID } from "../core/page.ts";
|
|
3
|
+
|
|
4
|
+
import type { Move } from "../core/position.ts";
|
|
5
|
+
import { isPresenterToggleKey } from "./presenter.ts";
|
|
6
|
+
import { isRailToggleKey } from "./rail.ts";
|
|
7
|
+
import { isInteractive, isLetterKey, isTextEntry, keyToMove, pointerMove } from "./step.ts";
|
|
8
|
+
|
|
9
|
+
/** What a key or a touch on the page may ask for. Each returns whether it took the request. */
|
|
10
|
+
export type InputActions = {
|
|
11
|
+
/** Any key the page hears. */
|
|
12
|
+
onKey: () => void;
|
|
13
|
+
togglePresenter: () => boolean;
|
|
14
|
+
toggleFullscreen: () => void;
|
|
15
|
+
toggleRail: () => boolean;
|
|
16
|
+
/** Space; left to `move` unless this takes it (a rehearsal plays and pauses). */
|
|
17
|
+
togglePlay: () => boolean;
|
|
18
|
+
toggleLaser: () => boolean;
|
|
19
|
+
/** Mark the beat on screen to rewrite, or unmark it; false where marks cannot be kept. */
|
|
20
|
+
toggleMark: () => boolean;
|
|
21
|
+
/** While the laser is on, the slide is for pointing: a tap or a click on it moves nothing. */
|
|
22
|
+
pointing: () => boolean;
|
|
23
|
+
move: (move: Move) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Listen for the keys on the page and for taps and swipes on the slide stage. */
|
|
27
|
+
export function bindInput(actions: InputActions): void {
|
|
28
|
+
document.addEventListener("keydown", (event) => {
|
|
29
|
+
// Keys typed into a field on a slide are the field's.
|
|
30
|
+
if (isTextEntry(event.target)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
actions.onKey();
|
|
34
|
+
if (isPresenterToggleKey(event)) {
|
|
35
|
+
if (actions.togglePresenter()) {
|
|
36
|
+
event.preventDefault();
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (isLetterKey(event, "f")) {
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
actions.toggleFullscreen();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (isLetterKey(event, "m")) {
|
|
46
|
+
if (actions.toggleMark()) {
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (isLetterKey(event, "l")) {
|
|
52
|
+
if (actions.toggleLaser()) {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (isRailToggleKey(event)) {
|
|
58
|
+
if (actions.toggleRail()) {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (event.key === " " && actions.togglePlay()) {
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const move = keyToMove(event);
|
|
68
|
+
if (!move) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
actions.move(move);
|
|
73
|
+
});
|
|
74
|
+
const stageEl = document.getElementById(PAGE_ID.currentStage);
|
|
75
|
+
if (stageEl) {
|
|
76
|
+
bindStagePointer(stageEl, actions);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function bindStagePointer(
|
|
81
|
+
stageEl: HTMLElement,
|
|
82
|
+
actions: Pick<InputActions, "move" | "pointing">,
|
|
83
|
+
): void {
|
|
84
|
+
const onMove = actions.move;
|
|
85
|
+
let start: { x: number; y: number; id: number } | undefined;
|
|
86
|
+
stageEl.addEventListener("pointerdown", (event) => {
|
|
87
|
+
if (actions.pointing()) {
|
|
88
|
+
start = undefined;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// A right click, or one held with a modifier (Shift extends a selection), is the browser's.
|
|
92
|
+
const modified = event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
|
|
93
|
+
const theirs = event.pointerType === "mouse" && (event.button !== 0 || modified);
|
|
94
|
+
if (theirs || isInteractive(event.target)) {
|
|
95
|
+
start = undefined;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
start = { x: event.clientX, y: event.clientY, id: event.pointerId };
|
|
99
|
+
});
|
|
100
|
+
stageEl.addEventListener("pointerup", (event) => {
|
|
101
|
+
if (!start || start.id !== event.pointerId) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// A click that ends a text selection, such as a double click on a word, keeps the slide.
|
|
105
|
+
if (event.pointerType === "mouse" && getSelection()?.isCollapsed === false) {
|
|
106
|
+
start = undefined;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const rect = stageEl.getBoundingClientRect();
|
|
110
|
+
const move = pointerMove(
|
|
111
|
+
{
|
|
112
|
+
x: event.clientX - rect.left,
|
|
113
|
+
y: event.clientY - rect.top,
|
|
114
|
+
dx: event.clientX - start.x,
|
|
115
|
+
dy: event.clientY - start.y,
|
|
116
|
+
},
|
|
117
|
+
rect,
|
|
118
|
+
event.pointerType,
|
|
119
|
+
);
|
|
120
|
+
start = undefined;
|
|
121
|
+
if (move) {
|
|
122
|
+
onMove(move);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
stageEl.addEventListener("pointercancel", () => {
|
|
126
|
+
start = undefined;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
import type { Pointer } from "../core/live-protocol.ts";
|
|
3
|
+
import { PAGE_ID } from "../core/page.ts";
|
|
4
|
+
import { createLaserDot, createLaserFeed, pointOnSlide } from "./laser.ts";
|
|
5
|
+
|
|
6
|
+
export type LaserView = {
|
|
7
|
+
/** Turn this window's laser on or off; true, since the key is always the laser's. */
|
|
8
|
+
toggle(): boolean;
|
|
9
|
+
/** Whether this window's laser is on, so the slide is for pointing rather than for tapping. */
|
|
10
|
+
isOn(): boolean;
|
|
11
|
+
/** Where another window's laser points, or null once it went away. */
|
|
12
|
+
receive(pointer: Pointer | null): void;
|
|
13
|
+
/** The deck moved. */
|
|
14
|
+
sync(): void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The laser: while it is on, where the pointer or a finger rests on the slide is shown here and
|
|
19
|
+
* sent to the deck's other windows, which show it at the same place on the slide. A window with
|
|
20
|
+
* its laser off still shows another's. The dot is the deck's first child, so it scales with the
|
|
21
|
+
* slide and stays out of what counts slides, such as the print CSS's last page.
|
|
22
|
+
*/
|
|
23
|
+
export function createLaserView(options: {
|
|
24
|
+
/** The slide on screen. */
|
|
25
|
+
current: () => number;
|
|
26
|
+
/** Send a point to the deck's other windows. */
|
|
27
|
+
send: (pointer: Pointer | null) => void;
|
|
28
|
+
}): LaserView {
|
|
29
|
+
const deckEl = document.getElementById(PAGE_ID.deck);
|
|
30
|
+
const stageEl = document.getElementById(PAGE_ID.currentStage);
|
|
31
|
+
const toggleEl = document.getElementById(PAGE_ID.laserToggle);
|
|
32
|
+
const dotEl = document.createElement("div");
|
|
33
|
+
dotEl.id = PAGE_ID.laser;
|
|
34
|
+
dotEl.hidden = true;
|
|
35
|
+
dotEl.setAttribute("aria-hidden", "true");
|
|
36
|
+
deckEl?.prepend(dotEl);
|
|
37
|
+
|
|
38
|
+
const timers = {
|
|
39
|
+
setTimer: (fn: () => void, ms: number) => window.setTimeout(fn, ms),
|
|
40
|
+
clearTimer: (id: number) => window.clearTimeout(id),
|
|
41
|
+
};
|
|
42
|
+
const dot = createLaserDot({
|
|
43
|
+
...timers,
|
|
44
|
+
current: options.current,
|
|
45
|
+
draw(pointer) {
|
|
46
|
+
dotEl.hidden = pointer === null;
|
|
47
|
+
if (pointer) {
|
|
48
|
+
dotEl.style.left = `${pointer.x * 100}%`;
|
|
49
|
+
dotEl.style.top = `${pointer.y * 100}%`;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
// This window's own dot is one more reader of what it sends.
|
|
54
|
+
const feed = createLaserFeed({
|
|
55
|
+
...timers,
|
|
56
|
+
frame: (fn) => window.requestAnimationFrame(fn),
|
|
57
|
+
send(pointer) {
|
|
58
|
+
options.send(pointer);
|
|
59
|
+
dot.receive(pointer);
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
let on = false;
|
|
63
|
+
|
|
64
|
+
function setOn(next: boolean): void {
|
|
65
|
+
on = next;
|
|
66
|
+
document.body.classList.toggle("is-laser", on);
|
|
67
|
+
toggleEl?.setAttribute("aria-pressed", String(on));
|
|
68
|
+
if (!on) {
|
|
69
|
+
feed.lift();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function track(event: PointerEvent): void {
|
|
74
|
+
if (!on || !deckEl) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const at = pointOnSlide({ x: event.clientX, y: event.clientY }, deckEl.getBoundingClientRect());
|
|
78
|
+
if (at) {
|
|
79
|
+
feed.point({ slideIndex: options.current(), ...at });
|
|
80
|
+
} else {
|
|
81
|
+
feed.lift();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
stageEl?.addEventListener("pointermove", track);
|
|
86
|
+
stageEl?.addEventListener("pointerdown", track);
|
|
87
|
+
stageEl?.addEventListener("pointerleave", () => feed.lift());
|
|
88
|
+
stageEl?.addEventListener("pointercancel", () => feed.lift());
|
|
89
|
+
// A finger that lifts takes the laser with it; a mouse stays where it is.
|
|
90
|
+
stageEl?.addEventListener("pointerup", (event) => {
|
|
91
|
+
if (event.pointerType !== "mouse") {
|
|
92
|
+
feed.lift();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
toggleEl?.addEventListener("click", () => setOn(!on));
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
toggle() {
|
|
99
|
+
setOn(!on);
|
|
100
|
+
return true;
|
|
101
|
+
},
|
|
102
|
+
isOn: () => on,
|
|
103
|
+
receive: (pointer) => dot.receive(pointer),
|
|
104
|
+
sync() {
|
|
105
|
+
// A point made on the slide the deck left is no longer where the laser is.
|
|
106
|
+
feed.lift();
|
|
107
|
+
dot.sync();
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { Pointer } from "../core/live-protocol.ts";
|
|
2
|
+
|
|
3
|
+
/** How often a laser held still says again where it points. */
|
|
4
|
+
export const LASER_HEARTBEAT_MS = 1_000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How long a dot stays with no word from its laser. Past a few heartbeats missed, the window
|
|
8
|
+
* that pointed has closed or lost its line, and a dot left on the projector would mislead.
|
|
9
|
+
*/
|
|
10
|
+
export const LASER_STALE_MS = 3_000;
|
|
11
|
+
|
|
12
|
+
type Rect = { left: number; top: number; width: number; height: number };
|
|
13
|
+
|
|
14
|
+
/** Where on the slide a point in the window falls, as a share of each side; nothing off it. */
|
|
15
|
+
export function pointOnSlide(
|
|
16
|
+
client: { x: number; y: number },
|
|
17
|
+
rect: Rect,
|
|
18
|
+
): { x: number; y: number } | undefined {
|
|
19
|
+
if (rect.width <= 0 || rect.height <= 0) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const x = (client.x - rect.left) / rect.width;
|
|
23
|
+
const y = (client.y - rect.top) / rect.height;
|
|
24
|
+
return x >= 0 && x <= 1 && y >= 0 && y <= 1 ? { x, y } : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type Timers = {
|
|
28
|
+
setTimer: (fn: () => void, ms: number) => number;
|
|
29
|
+
clearTimer: (id: number) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type LaserFeed = {
|
|
33
|
+
/** The laser points here now. */
|
|
34
|
+
point(pointer: Pointer): void;
|
|
35
|
+
/** The laser went away: off the slide, the finger lifted, or the laser turned off. */
|
|
36
|
+
lift(): void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What one window's laser sends. A hand moves the pointer many times a frame, so only the latest
|
|
41
|
+
* point of each frame goes out; a hand held still says again where it points each heartbeat, so
|
|
42
|
+
* the other windows can tell a laser at rest from one that is gone.
|
|
43
|
+
*/
|
|
44
|
+
export function createLaserFeed(
|
|
45
|
+
options: Timers & {
|
|
46
|
+
send: (pointer: Pointer | null) => void;
|
|
47
|
+
/** Run once before the next paint: requestAnimationFrame in a browser. */
|
|
48
|
+
frame: (fn: () => void) => void;
|
|
49
|
+
},
|
|
50
|
+
): LaserFeed {
|
|
51
|
+
let latest: Pointer | undefined;
|
|
52
|
+
let framed = false;
|
|
53
|
+
let heartbeat: number | undefined;
|
|
54
|
+
let pointing = false;
|
|
55
|
+
|
|
56
|
+
const stopHeartbeat = (): void => {
|
|
57
|
+
if (heartbeat !== undefined) {
|
|
58
|
+
options.clearTimer(heartbeat);
|
|
59
|
+
heartbeat = undefined;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const send = (pointer: Pointer): void => {
|
|
64
|
+
pointing = true;
|
|
65
|
+
options.send(pointer);
|
|
66
|
+
stopHeartbeat();
|
|
67
|
+
heartbeat = options.setTimer(() => {
|
|
68
|
+
heartbeat = undefined;
|
|
69
|
+
send(pointer);
|
|
70
|
+
}, LASER_HEARTBEAT_MS);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
point(pointer) {
|
|
75
|
+
latest = pointer;
|
|
76
|
+
if (framed) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
framed = true;
|
|
80
|
+
options.frame(() => {
|
|
81
|
+
framed = false;
|
|
82
|
+
if (latest) {
|
|
83
|
+
send(latest);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
lift() {
|
|
88
|
+
latest = undefined;
|
|
89
|
+
stopHeartbeat();
|
|
90
|
+
if (pointing) {
|
|
91
|
+
pointing = false;
|
|
92
|
+
options.send(null);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type LaserDot = {
|
|
99
|
+
/** A point, or null for none, from this window's laser or another's. */
|
|
100
|
+
receive(pointer: Pointer | null): void;
|
|
101
|
+
/** The deck moved: a point made on another slide is no longer on screen. */
|
|
102
|
+
sync(): void;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The dot every window of the deck draws where a laser points: on the slide it was made on, and
|
|
107
|
+
* only for as long as its laser keeps saying so.
|
|
108
|
+
*/
|
|
109
|
+
export function createLaserDot(
|
|
110
|
+
options: Timers & {
|
|
111
|
+
/** Put the dot at a point, or take it away for null. */
|
|
112
|
+
draw: (pointer: Pointer | null) => void;
|
|
113
|
+
/** The slide on screen. */
|
|
114
|
+
current: () => number;
|
|
115
|
+
},
|
|
116
|
+
): LaserDot {
|
|
117
|
+
let shown: Pointer | null = null;
|
|
118
|
+
let stale: number | undefined;
|
|
119
|
+
|
|
120
|
+
const show = (pointer: Pointer | null): void => {
|
|
121
|
+
shown = pointer && pointer.slideIndex === options.current() ? pointer : null;
|
|
122
|
+
options.draw(shown);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
receive(pointer) {
|
|
127
|
+
if (stale !== undefined) {
|
|
128
|
+
options.clearTimer(stale);
|
|
129
|
+
stale = undefined;
|
|
130
|
+
}
|
|
131
|
+
show(pointer);
|
|
132
|
+
if (pointer) {
|
|
133
|
+
stale = options.setTimer(() => {
|
|
134
|
+
stale = undefined;
|
|
135
|
+
show(null);
|
|
136
|
+
}, LASER_STALE_MS);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
sync() {
|
|
140
|
+
if (shown && shown.slideIndex !== options.current()) {
|
|
141
|
+
show(null);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
|
|
3
|
+
import { type LiveHost, slideSelector } from "./live.ts";
|
|
4
|
+
|
|
5
|
+
export function documentLiveHost(): LiveHost {
|
|
6
|
+
return {
|
|
7
|
+
replaceSlide(slug, html) {
|
|
8
|
+
const current = document.querySelector(slideSelector(slug));
|
|
9
|
+
if (current) {
|
|
10
|
+
current.outerHTML = html;
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
setTheme(css) {
|
|
14
|
+
const el = document.querySelector("style[data-dek-theme]");
|
|
15
|
+
if (el) {
|
|
16
|
+
el.textContent = css;
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
setDiagnostics(text) {
|
|
20
|
+
let el = document.querySelector(".dek-diagnostics");
|
|
21
|
+
if (!text) {
|
|
22
|
+
el?.remove();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (!el) {
|
|
26
|
+
el = document.createElement("div");
|
|
27
|
+
el.className = "dek-diagnostics";
|
|
28
|
+
document.body.prepend(el);
|
|
29
|
+
}
|
|
30
|
+
el.textContent = text;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { formatDeckRoute, liveTokenQuery, withDeckPrefix } from "./routes.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The page's line to its deck's event stream on the dev server. A presenter page sends its
|
|
5
|
+
* token, as the socket does, so the stream is cleared for the presenter's events.
|
|
6
|
+
*/
|
|
7
|
+
export function liveReloadScript(): string {
|
|
8
|
+
return `(() => {
|
|
9
|
+
const withDeckPrefix = ${withDeckPrefix};
|
|
10
|
+
const liveTokenQuery = ${liveTokenQuery};
|
|
11
|
+
const url = withDeckPrefix(location.pathname, ${JSON.stringify(formatDeckRoute({ kind: "events" }))});
|
|
12
|
+
const es = new EventSource(url + liveTokenQuery(document.body.dataset.liveToken));
|
|
13
|
+
es.onmessage = async (message) => {
|
|
14
|
+
let event;
|
|
15
|
+
try {
|
|
16
|
+
event = JSON.parse(message.data);
|
|
17
|
+
} catch {
|
|
18
|
+
location.reload();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const live = window["dekLive"];
|
|
22
|
+
if (typeof live === "function") {
|
|
23
|
+
await live(event);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
location.reload();
|
|
27
|
+
};
|
|
28
|
+
})();`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Diagnostic } from "../core/diagnostic.ts";
|
|
2
|
+
import type { LiveEvent } from "../core/live-protocol.ts";
|
|
3
|
+
import { deckUrl } from "./routes.ts";
|
|
4
|
+
|
|
5
|
+
/** What live reload changes on the page; the stage draws the current beat again after. */
|
|
6
|
+
export type LiveHost = {
|
|
7
|
+
replaceSlide(slug: string, html: string): void;
|
|
8
|
+
setTheme(css: string): void;
|
|
9
|
+
setDiagnostics(text: string | null): void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type Hydrated<Type extends LiveEvent["type"], Extra> = Extract<LiveEvent, { type: Type }> & Extra;
|
|
13
|
+
|
|
14
|
+
/** A live event with what it names fetched: a slide's new fragment, the theme's new CSS. */
|
|
15
|
+
export type HydratedLiveEvent =
|
|
16
|
+
| Exclude<LiveEvent, { type: "reload-slide" | "reload-theme" }>
|
|
17
|
+
| Hydrated<"reload-slide", { html: string }>
|
|
18
|
+
| Hydrated<"reload-theme", { css: string }>;
|
|
19
|
+
|
|
20
|
+
export function slideSelector(slug: string): string {
|
|
21
|
+
const escaped = slug.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
22
|
+
return `#deck > .slide[data-slug="${escaped}"]`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The slice of `fetch` the live client uses; keeps test stubs free of the fetch namespace. */
|
|
26
|
+
export type LiveFetch = (input: string) => Promise<Response>;
|
|
27
|
+
|
|
28
|
+
export async function hydrateLiveEvent(
|
|
29
|
+
event: LiveEvent,
|
|
30
|
+
pathname: string,
|
|
31
|
+
fetchImpl: LiveFetch = fetch,
|
|
32
|
+
): Promise<HydratedLiveEvent | undefined> {
|
|
33
|
+
if (event.type === "reload-slide") {
|
|
34
|
+
const response = await fetchImpl(liveSlidePath(pathname, event.slug));
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
return { ...event, html: await response.text() };
|
|
39
|
+
}
|
|
40
|
+
if (event.type === "reload-theme") {
|
|
41
|
+
const response = await fetchImpl(liveThemePath(pathname));
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return { ...event, css: await response.text() };
|
|
46
|
+
}
|
|
47
|
+
return event;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function applyLiveEvent(event: HydratedLiveEvent, host: LiveHost): { reload: boolean } {
|
|
51
|
+
switch (event.type) {
|
|
52
|
+
case "sync":
|
|
53
|
+
case "reload-script":
|
|
54
|
+
return { reload: true };
|
|
55
|
+
case "reload-slide":
|
|
56
|
+
host.replaceSlide(event.slug, event.html);
|
|
57
|
+
return { reload: false };
|
|
58
|
+
case "reload-theme":
|
|
59
|
+
host.setTheme(event.css);
|
|
60
|
+
return { reload: false };
|
|
61
|
+
case "diagnostics":
|
|
62
|
+
host.setDiagnostics(formatLiveDiagnostics(event.diagnostics));
|
|
63
|
+
return { reload: false };
|
|
64
|
+
case "timeline":
|
|
65
|
+
return { reload: false };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatLiveDiagnostics(diagnostics: Diagnostic[]): string | null {
|
|
70
|
+
if (diagnostics.length === 0) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return diagnostics
|
|
74
|
+
.map((diagnostic) =>
|
|
75
|
+
diagnostic.severity === "warning"
|
|
76
|
+
? `${diagnostic.id} warning: ${diagnostic.message}`
|
|
77
|
+
: `${diagnostic.id}: ${diagnostic.message}`,
|
|
78
|
+
)
|
|
79
|
+
.join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function liveSlidePath(pathname: string, slug: string): string {
|
|
83
|
+
return deckUrl(pathname, { kind: "slide", slug });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function liveThemePath(pathname: string): string {
|
|
87
|
+
return deckUrl(pathname, { kind: "theme" });
|
|
88
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
import { decodePosition, encodePosition } from "../core/live-protocol.ts";
|
|
3
|
+
import { PAGE_ID } from "../core/page.ts";
|
|
4
|
+
import { positionsEqual } from "../core/position.ts";
|
|
5
|
+
import type { Position } from "../core/step.ts";
|
|
6
|
+
|
|
7
|
+
export type MarksView = {
|
|
8
|
+
/** Mark the beat on screen, or unmark it; true, since the key is the marks' on this page. */
|
|
9
|
+
toggle(): boolean;
|
|
10
|
+
/** Show whether the beat at `pos` is marked, and which beats of its slide are. */
|
|
11
|
+
render(pos: Position): void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The marks the speaker leaves while rehearsing on beats that want rewriting, kept by the dev
|
|
16
|
+
* server in a file an agent reads with `dekc marks`. Only a page whose presenter bar has the mark
|
|
17
|
+
* button has marks, so a built file never tries to reach a server it does not have.
|
|
18
|
+
*/
|
|
19
|
+
export function createMarksView(options: {
|
|
20
|
+
/** The dev server's marks endpoint for this deck, with the presenter's token. */
|
|
21
|
+
url: string;
|
|
22
|
+
/** The beat on screen. */
|
|
23
|
+
current: () => Position;
|
|
24
|
+
fetch: (url: string, init?: RequestInit) => Promise<Response>;
|
|
25
|
+
}): MarksView | undefined {
|
|
26
|
+
const button = document.getElementById(PAGE_ID.markToggle);
|
|
27
|
+
if (!button) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
let marked: Position[] = [];
|
|
31
|
+
|
|
32
|
+
function render(pos: Position): void {
|
|
33
|
+
const isMarked = (beatIndex: number): boolean =>
|
|
34
|
+
marked.some((mark) => positionsEqual(mark, { slideIndex: pos.slideIndex, beatIndex }));
|
|
35
|
+
button?.setAttribute("aria-pressed", String(isMarked(pos.beatIndex)));
|
|
36
|
+
for (const item of document.querySelectorAll(`#${PAGE_ID.beats} [data-beat-index]`)) {
|
|
37
|
+
item.classList.toggle("is-marked", isMarked(Number(item.getAttribute("data-beat-index"))));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Ask the server, and draw what it says the marks are now; a refusal is said on the button. */
|
|
42
|
+
async function ask(init?: RequestInit): Promise<void> {
|
|
43
|
+
try {
|
|
44
|
+
const response = await options.fetch(options.url, init);
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new Error(`the dev server answered ${response.status}`);
|
|
47
|
+
}
|
|
48
|
+
const body = (await response.json()) as { positions?: unknown[] };
|
|
49
|
+
marked = (body.positions ?? []).flatMap((raw) => decodePosition(raw) ?? []);
|
|
50
|
+
button?.removeAttribute("data-error");
|
|
51
|
+
button?.setAttribute("title", button.getAttribute("aria-label") ?? "");
|
|
52
|
+
} catch (error) {
|
|
53
|
+
button?.setAttribute("data-error", "");
|
|
54
|
+
button?.setAttribute(
|
|
55
|
+
"title",
|
|
56
|
+
`not marked: ${error instanceof Error ? error.message : error}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
render(options.current());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function toggle(): boolean {
|
|
63
|
+
void ask({
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { "content-type": "application/json" },
|
|
66
|
+
body: encodePosition(options.current()),
|
|
67
|
+
});
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
button.addEventListener("click", () => toggle());
|
|
72
|
+
void ask();
|
|
73
|
+
return { toggle, render };
|
|
74
|
+
}
|