contour-deck 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -5
- package/dist/astro/Deck.astro +8 -1
- package/dist/astro/host.d.ts +7 -0
- package/dist/astro/host.js +58 -4
- package/dist/core/deck.js +16 -5
- package/dist/core/labels.js +1 -0
- package/dist/core/types.d.ts +12 -0
- package/dist/remote/clock.d.ts +35 -0
- package/dist/remote/clock.js +48 -0
- package/dist/remote/guest.d.ts +41 -0
- package/dist/remote/guest.js +229 -0
- package/dist/remote/index.d.ts +18 -0
- package/dist/remote/index.js +11 -0
- package/dist/remote/ink.d.ts +11 -0
- package/dist/remote/ink.js +74 -0
- package/dist/remote/labels.d.ts +76 -0
- package/dist/remote/labels.js +70 -0
- package/dist/remote/links.d.ts +2 -0
- package/dist/remote/links.js +12 -0
- package/dist/remote/overlay.d.ts +11 -0
- package/dist/remote/overlay.js +63 -0
- package/dist/remote/pairing.d.ts +40 -0
- package/dist/remote/pairing.js +97 -0
- package/dist/remote/panel.d.ts +40 -0
- package/dist/remote/panel.js +137 -0
- package/dist/remote/phone.d.ts +10 -0
- package/dist/remote/phone.js +408 -0
- package/dist/remote/presenter.d.ts +2 -0
- package/dist/remote/presenter.js +126 -0
- package/dist/remote/protocol.d.ts +106 -0
- package/dist/remote/protocol.js +123 -0
- package/dist/remote/qr.d.ts +8 -0
- package/dist/remote/qr.js +266 -0
- package/dist/remote/sensor.d.ts +9 -0
- package/dist/remote/sensor.js +50 -0
- package/dist/remote/session.d.ts +39 -0
- package/dist/remote/session.js +292 -0
- package/dist/remote/store.d.ts +9 -0
- package/dist/remote/store.js +27 -0
- package/dist/remote/style.d.ts +2 -0
- package/dist/remote/style.js +132 -0
- package/dist/remote/tilt.d.ts +78 -0
- package/dist/remote/tilt.js +148 -0
- package/dist/remote/transport.d.ts +36 -0
- package/dist/remote/transport.js +170 -0
- package/dist/remote/view.d.ts +48 -0
- package/dist/remote/view.js +119 -0
- package/docs/CONTRACT.md +14 -0
- package/docs/PROTOCOL.md +100 -0
- package/package.json +9 -2
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The phone remote, `?view=remote&room=…`: the same page as the deck, showing its own notes and a small
|
|
3
|
+
* copy of the current page, and sending commands, the pointer, ink, and tilt to the deck through the relay.
|
|
4
|
+
*/
|
|
5
|
+
import { h } from "../core/dom.js";
|
|
6
|
+
import { defaultLabels } from "../core/labels.js";
|
|
7
|
+
import { notesHtml } from "../core/notes.js";
|
|
8
|
+
import { createGuest } from "./guest.js";
|
|
9
|
+
import { createInk } from "./ink.js";
|
|
10
|
+
import { defaultRemoteLabels } from "./labels.js";
|
|
11
|
+
import { pageLink } from "./links.js";
|
|
12
|
+
import { isRoom } from "./protocol.js";
|
|
13
|
+
import { createSensor } from "./sensor.js";
|
|
14
|
+
import { load, save, TILT_KEY } from "./store.js";
|
|
15
|
+
import { injectStyle } from "./style.js";
|
|
16
|
+
import { calibrate, CALIBRATION_TARGETS, createPointer, defaultCalibration, isCalibration, meanAim } from "./tilt.js";
|
|
17
|
+
import { relay } from "./transport.js";
|
|
18
|
+
import { createPreview, createTimers, keepAwake, pageAt, positionLabel, readOutline, statePos, upNext } from "./view.js";
|
|
19
|
+
/** Where each calibration target sits on the screen. */
|
|
20
|
+
const TARGET_AT = {
|
|
21
|
+
centre: { x: 0.5, y: 0.5 },
|
|
22
|
+
topLeft: { x: 0, y: 0 },
|
|
23
|
+
topRight: { x: 1, y: 0 },
|
|
24
|
+
bottomRight: { x: 1, y: 1 },
|
|
25
|
+
bottomLeft: { x: 0, y: 1 },
|
|
26
|
+
};
|
|
27
|
+
/** Readings averaged for one calibration sample: about a tenth of a second. */
|
|
28
|
+
const STEADY = 6;
|
|
29
|
+
/** Ink points closer than this to the last one are not sent. */
|
|
30
|
+
const INK_STEP = 0.004;
|
|
31
|
+
export function remoteView(root, options = {}) {
|
|
32
|
+
injectStyle(options.nonce);
|
|
33
|
+
const labels = { ...defaultRemoteLabels, ...options.labels };
|
|
34
|
+
const outline = readOutline(root, options.skip);
|
|
35
|
+
const room = options.room ?? new URLSearchParams(location.search).get("room");
|
|
36
|
+
const abort = new AbortController();
|
|
37
|
+
const { signal } = abort;
|
|
38
|
+
/* ---- shell ---- */
|
|
39
|
+
const app = h("div", "ct-app ct-rmt", "", { "data-ct-mode": "stage", "data-ct-phase": "connecting", "data-ct-tab": "notes" });
|
|
40
|
+
const button = (action, text, primary = false) => {
|
|
41
|
+
const el = h("button", "ct-btn", text, { "data-ct-do": action });
|
|
42
|
+
if (primary)
|
|
43
|
+
el.setAttribute("data-ct-primary", "");
|
|
44
|
+
return el;
|
|
45
|
+
};
|
|
46
|
+
// Before control: one card with the state of the pairing, and the way to the notes that needs no connection.
|
|
47
|
+
const card = app.appendChild(h("div", "ct-rmt-card"));
|
|
48
|
+
const cardBadge = card.appendChild(h("span", "ct-badge"));
|
|
49
|
+
const cardCode = card.appendChild(h("b", "ct-rmt-code"));
|
|
50
|
+
const cardText = card.appendChild(h("p", "", "", { "aria-live": "polite" }));
|
|
51
|
+
const retry = card.appendChild(button("retry", labels.retry, true));
|
|
52
|
+
card.append(h("a", "ct-btn", labels.notesOnly, { href: pageLink("notes") }));
|
|
53
|
+
const top = app.appendChild(h("header", "ct-rmt-top"));
|
|
54
|
+
const status = h("span", "ct-badge ct-rmt-st");
|
|
55
|
+
const position = h("span", "ct-rmt-pos");
|
|
56
|
+
const timers = createTimers(labels, outline);
|
|
57
|
+
top.append(status, position, timers.time, timers.block);
|
|
58
|
+
const tabs = app.appendChild(h("nav", "ct-rmt-tabs"));
|
|
59
|
+
const tabNames = [
|
|
60
|
+
["notes", labels.tabNotes],
|
|
61
|
+
["pointer", labels.tabPointer],
|
|
62
|
+
["ink", labels.tabInk],
|
|
63
|
+
["tilt", labels.tabTilt],
|
|
64
|
+
];
|
|
65
|
+
const tabButtons = tabNames.map(([name, text]) => tabs.appendChild(button(`tab:${name}`, text)));
|
|
66
|
+
const main = app.appendChild(h("main", "ct-rmt-main"));
|
|
67
|
+
const notes = main.appendChild(h("div", "ct-rmt-notes"));
|
|
68
|
+
const padBox = main.appendChild(h("div", "ct-rmt-padbox"));
|
|
69
|
+
const pad = padBox.appendChild(h("div", "ct-rmt-pad"));
|
|
70
|
+
const preview = createPreview(root);
|
|
71
|
+
const canvas = h("canvas", "ct-ink");
|
|
72
|
+
const dot = h("i", "ct-rmt-dot");
|
|
73
|
+
pad.append(preview.el, canvas, dot);
|
|
74
|
+
const ink = createInk(canvas);
|
|
75
|
+
const tools = main.appendChild(h("div", "ct-rmt-tools"));
|
|
76
|
+
const hint = tools.appendChild(h("p", "", "", { "aria-live": "polite" }));
|
|
77
|
+
const hold = button("hold", labels.tiltHold);
|
|
78
|
+
hold.classList.add("ct-rmt-hold");
|
|
79
|
+
const toolButtons = [
|
|
80
|
+
["ink", button("clear", labels.clearInk)],
|
|
81
|
+
["tilt-off", button("tilt", labels.tiltEnable, true)],
|
|
82
|
+
["tilt-on", hold],
|
|
83
|
+
["tilt-on", button("recentre", labels.tiltRecentre)],
|
|
84
|
+
["tilt-on", button("calibrate", labels.tiltCalibrate)],
|
|
85
|
+
["tilt-cal", button("aimed", labels.tiltAimed, true)],
|
|
86
|
+
["tilt-cal", button("cancel", labels.tiltCancel)],
|
|
87
|
+
];
|
|
88
|
+
for (const [when, el] of toolButtons) {
|
|
89
|
+
el.dataset.for = when;
|
|
90
|
+
tools.append(el);
|
|
91
|
+
}
|
|
92
|
+
const next = app.appendChild(h("div", "ct-rmt-next"));
|
|
93
|
+
const nextKind = next.appendChild(h("span", "ct-meta", labels.upNext));
|
|
94
|
+
const nextText = next.appendChild(h("span"));
|
|
95
|
+
const nav = app.appendChild(h("nav", "ct-rmt-nav"));
|
|
96
|
+
nav.append(button("cmd:prev", defaultLabels.prev), button("cmd:slidePrev", labels.slidePrev), button("cmd:slideNext", labels.slideNext), button("cmd:overview", defaultLabels.overview));
|
|
97
|
+
const go = app.appendChild(button("cmd:next", defaultLabels.next, true));
|
|
98
|
+
go.classList.add("ct-rmt-go");
|
|
99
|
+
root.after(app);
|
|
100
|
+
document.documentElement.setAttribute("data-ct-open", "");
|
|
101
|
+
keepAwake(signal);
|
|
102
|
+
/* ---- connection ---- */
|
|
103
|
+
const relayBase = options.relay;
|
|
104
|
+
const guest = relayBase && isRoom(room) ? createGuest({ room, open: () => relay(relayBase, { room, role: "guest" }) }) : null;
|
|
105
|
+
let tab = "notes";
|
|
106
|
+
let drawnPage = "";
|
|
107
|
+
let tilt = "off";
|
|
108
|
+
let pointer = null;
|
|
109
|
+
let calibrated = false;
|
|
110
|
+
let calibrating = null;
|
|
111
|
+
let pointing = false;
|
|
112
|
+
let tiltNote = "";
|
|
113
|
+
let lastPoint = { x: 0.5, y: 0.5 };
|
|
114
|
+
const recent = [];
|
|
115
|
+
const kept = load(TILT_KEY);
|
|
116
|
+
if (isCalibration(kept)) {
|
|
117
|
+
pointer = createPointer(kept);
|
|
118
|
+
calibrated = true;
|
|
119
|
+
}
|
|
120
|
+
function showDot(point, on, target = false) {
|
|
121
|
+
dot.style.transform = `translate(${point.x * pad.clientWidth}px,${point.y * pad.clientHeight}px)`;
|
|
122
|
+
dot.toggleAttribute("data-on", on);
|
|
123
|
+
dot.toggleAttribute("data-target", target);
|
|
124
|
+
}
|
|
125
|
+
/** The laser on the deck and its twin on the pad. */
|
|
126
|
+
function point(at, on) {
|
|
127
|
+
lastPoint = at;
|
|
128
|
+
guest?.point(at.x, at.y, on);
|
|
129
|
+
showDot(at, on);
|
|
130
|
+
}
|
|
131
|
+
/* ---- drawing what the guest knows ---- */
|
|
132
|
+
function drawCard() {
|
|
133
|
+
if (!guest) {
|
|
134
|
+
cardBadge.textContent = labels.title;
|
|
135
|
+
cardText.textContent = labels.badLink;
|
|
136
|
+
cardCode.hidden = retry.hidden = true;
|
|
137
|
+
return "denied";
|
|
138
|
+
}
|
|
139
|
+
const { phase, closeCode } = guest;
|
|
140
|
+
cardCode.hidden = phase !== "pairing";
|
|
141
|
+
cardCode.textContent = guest.code;
|
|
142
|
+
// A full room is not retried by itself: each try counts against the relay's limit for this network.
|
|
143
|
+
retry.hidden = phase !== "denied" && phase !== "revoked" && !(phase === "offline" && closeCode === 4029);
|
|
144
|
+
cardBadge.textContent = phase === "pairing" ? labels.code : phase === "offline" ? labels.offline : labels.title;
|
|
145
|
+
cardText.textContent =
|
|
146
|
+
phase === "pairing"
|
|
147
|
+
? labels.pairHint
|
|
148
|
+
: phase === "denied"
|
|
149
|
+
? labels.denied
|
|
150
|
+
: phase === "revoked"
|
|
151
|
+
? labels.revoked
|
|
152
|
+
: phase === "offline" && (closeCode === 4004 || closeCode === 4000)
|
|
153
|
+
? labels.noHost
|
|
154
|
+
: phase === "offline" && closeCode === 4029
|
|
155
|
+
? labels.full
|
|
156
|
+
: labels.connecting;
|
|
157
|
+
return phase;
|
|
158
|
+
}
|
|
159
|
+
function drawTools() {
|
|
160
|
+
const state = guest?.state;
|
|
161
|
+
const shown = state?.mode === "overview" ? "notes" : tab;
|
|
162
|
+
app.dataset.ctTab = shown;
|
|
163
|
+
tabButtons.forEach((el, i) => el.setAttribute("aria-pressed", String(tabNames[i][0] === shown)));
|
|
164
|
+
const mode = shown !== "tilt" ? shown : calibrating ? "tilt-cal" : tilt === "on" ? "tilt-on" : "tilt-off";
|
|
165
|
+
for (const [when, el] of toolButtons)
|
|
166
|
+
el.toggleAttribute("data-show", when === mode);
|
|
167
|
+
hold.setAttribute("aria-pressed", String(pointing));
|
|
168
|
+
if (shown === "pointer")
|
|
169
|
+
hint.textContent = labels.pointerHint;
|
|
170
|
+
else if (shown === "ink")
|
|
171
|
+
hint.textContent = labels.inkHint;
|
|
172
|
+
else if (shown !== "tilt")
|
|
173
|
+
hint.textContent = "";
|
|
174
|
+
else if (calibrating) {
|
|
175
|
+
const target = CALIBRATION_TARGETS[calibrating.index];
|
|
176
|
+
hint.textContent = labels.tiltAim(labels.tiltTargets[target], calibrating.index + 1, CALIBRATION_TARGETS.length);
|
|
177
|
+
}
|
|
178
|
+
else if (tilt === "denied")
|
|
179
|
+
hint.textContent = labels.tiltDenied;
|
|
180
|
+
else if (tilt === "unsupported")
|
|
181
|
+
hint.textContent = labels.tiltUnsupported;
|
|
182
|
+
else
|
|
183
|
+
hint.textContent = tiltNote || (tilt === "on" ? (calibrated ? labels.tiltCalibrated : labels.tiltUncalibrated) : "");
|
|
184
|
+
}
|
|
185
|
+
function draw() {
|
|
186
|
+
app.dataset.ctPhase = drawCard();
|
|
187
|
+
const state = guest?.state;
|
|
188
|
+
if (!guest || guest.phase !== "granted" || !state) {
|
|
189
|
+
status.textContent = labels.connecting;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
status.textContent = labels.connected;
|
|
193
|
+
status.dataset.st = "granted";
|
|
194
|
+
timers.draw(state, guest.stateAt);
|
|
195
|
+
if (state.ratio)
|
|
196
|
+
app.style.setProperty("--ct-ratio", String(state.ratio));
|
|
197
|
+
const pos = statePos(outline, state);
|
|
198
|
+
position.textContent = !pos || state.mode === "overview" ? labels.inOverview : positionLabel(outline, pos, state);
|
|
199
|
+
const key = `${state.mode} ${state.at}`;
|
|
200
|
+
if (key !== drawnPage) {
|
|
201
|
+
drawnPage = key;
|
|
202
|
+
// A new page: its notes from the top, and the ink of the old one gone, as on the deck.
|
|
203
|
+
ink.clear();
|
|
204
|
+
notes.scrollTop = 0;
|
|
205
|
+
if (state.mode === "overview") {
|
|
206
|
+
const list = h("ul", "ct-rmt-list");
|
|
207
|
+
outline.blocks.forEach((block, b) => {
|
|
208
|
+
const open = list.appendChild(h("li")).appendChild(button(`open:${b + 1}`, ""));
|
|
209
|
+
if (pos?.b === b)
|
|
210
|
+
open.setAttribute("aria-current", "true");
|
|
211
|
+
open.append(h("span", "ct-meta", block.short), h("span", "", block.title));
|
|
212
|
+
if (block.minutes)
|
|
213
|
+
open.append(h("span", "ct-meta", defaultLabels.minutes(block.minutes)));
|
|
214
|
+
});
|
|
215
|
+
notes.replaceChildren(list);
|
|
216
|
+
}
|
|
217
|
+
else if (pos) {
|
|
218
|
+
notes.innerHTML = notesHtml(pageAt(outline, pos));
|
|
219
|
+
if (!notes.childNodes.length)
|
|
220
|
+
notes.append(h("p", "", defaultLabels.noNotes));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (pos) {
|
|
224
|
+
preview.show(pageAt(outline, pos), state.step);
|
|
225
|
+
const up = upNext(outline, pos, state.step);
|
|
226
|
+
const kinds = { step: labels.nextStep, page: labels.nextPage, slide: labels.nextSlide, end: labels.theEnd };
|
|
227
|
+
// On the outline the step button opens the slide the deck is on.
|
|
228
|
+
const outside = state.mode === "overview";
|
|
229
|
+
nextKind.textContent = outside ? labels.upNext : `${labels.upNext}|${kinds[up.kind]}`;
|
|
230
|
+
nextText.textContent = outside ? outline.blocks[pos.b].slides[pos.s].title : up.text;
|
|
231
|
+
}
|
|
232
|
+
drawTools();
|
|
233
|
+
}
|
|
234
|
+
/* ---- pad: pointer and ink ---- */
|
|
235
|
+
let finger = -1;
|
|
236
|
+
let stroke = null;
|
|
237
|
+
let strokeKey = "";
|
|
238
|
+
let strokes = 0;
|
|
239
|
+
let inked = { x: -1, y: -1 };
|
|
240
|
+
const onPad = (event) => {
|
|
241
|
+
const box = pad.getBoundingClientRect();
|
|
242
|
+
const unit = (value) => Math.min(1, Math.max(0, value));
|
|
243
|
+
return { x: unit((event.clientX - box.left) / box.width), y: unit((event.clientY - box.top) / box.height) };
|
|
244
|
+
};
|
|
245
|
+
function inkTo(at) {
|
|
246
|
+
if (Math.hypot(at.x - inked.x, at.y - inked.y) < INK_STEP)
|
|
247
|
+
return;
|
|
248
|
+
inked = at;
|
|
249
|
+
stroke?.add(at.x, at.y);
|
|
250
|
+
ink.add(strokeKey, [at.x, at.y]);
|
|
251
|
+
}
|
|
252
|
+
pad.addEventListener("pointerdown", event => {
|
|
253
|
+
if (finger !== -1 || (tab !== "pointer" && tab !== "ink") || guest?.phase !== "granted")
|
|
254
|
+
return;
|
|
255
|
+
finger = event.pointerId;
|
|
256
|
+
pad.setPointerCapture(finger);
|
|
257
|
+
const at = onPad(event);
|
|
258
|
+
if (tab === "pointer")
|
|
259
|
+
return point(at, true);
|
|
260
|
+
stroke = guest.stroke();
|
|
261
|
+
strokeKey = `own:${++strokes}`;
|
|
262
|
+
inked = { x: -1, y: -1 };
|
|
263
|
+
inkTo(at);
|
|
264
|
+
}, { signal });
|
|
265
|
+
pad.addEventListener("pointermove", event => {
|
|
266
|
+
if (event.pointerId !== finger)
|
|
267
|
+
return;
|
|
268
|
+
if (!stroke)
|
|
269
|
+
return point(onPad(event), true);
|
|
270
|
+
for (const sample of event.getCoalescedEvents?.() ?? [event])
|
|
271
|
+
inkTo(onPad(sample));
|
|
272
|
+
}, { signal });
|
|
273
|
+
const lift = (event) => {
|
|
274
|
+
if (event.pointerId !== finger)
|
|
275
|
+
return;
|
|
276
|
+
finger = -1;
|
|
277
|
+
if (stroke)
|
|
278
|
+
stroke.end();
|
|
279
|
+
else
|
|
280
|
+
point(lastPoint, false);
|
|
281
|
+
stroke = null;
|
|
282
|
+
};
|
|
283
|
+
pad.addEventListener("pointerup", lift, { signal });
|
|
284
|
+
pad.addEventListener("pointercancel", lift, { signal });
|
|
285
|
+
const padSize = new ResizeObserver(() => ink.resize());
|
|
286
|
+
padSize.observe(pad);
|
|
287
|
+
/* ---- tilt ---- */
|
|
288
|
+
const sensor = createSensor((aim, t) => {
|
|
289
|
+
recent.push(aim);
|
|
290
|
+
if (recent.length > STEADY)
|
|
291
|
+
recent.shift();
|
|
292
|
+
if (!pointing || calibrating)
|
|
293
|
+
return;
|
|
294
|
+
// Without a calibration, the direction of the first press is the middle of a screen of ordinary size.
|
|
295
|
+
pointer ??= createPointer(defaultCalibration(aim));
|
|
296
|
+
point(pointer.update(aim, t), true);
|
|
297
|
+
});
|
|
298
|
+
function stopPointing() {
|
|
299
|
+
if (!pointing)
|
|
300
|
+
return;
|
|
301
|
+
pointing = false;
|
|
302
|
+
point(lastPoint, false);
|
|
303
|
+
// Drift correction moved the calibration while pointing; a measured one is worth keeping.
|
|
304
|
+
if (calibrated && pointer)
|
|
305
|
+
save(TILT_KEY, pointer.calibration);
|
|
306
|
+
drawTools();
|
|
307
|
+
}
|
|
308
|
+
hold.addEventListener("pointerdown", event => {
|
|
309
|
+
if (tilt !== "on")
|
|
310
|
+
return;
|
|
311
|
+
hold.setPointerCapture(event.pointerId);
|
|
312
|
+
pointing = true;
|
|
313
|
+
tiltNote = "";
|
|
314
|
+
drawTools();
|
|
315
|
+
}, { signal });
|
|
316
|
+
for (const type of ["pointerup", "pointercancel", "lostpointercapture"])
|
|
317
|
+
hold.addEventListener(type, stopPointing, { signal });
|
|
318
|
+
function aimAtTarget() {
|
|
319
|
+
if (!calibrating)
|
|
320
|
+
return;
|
|
321
|
+
const at = TARGET_AT[CALIBRATION_TARGETS[calibrating.index]];
|
|
322
|
+
// The deck shows the dot on the spot to aim at; the pad marks the same spot.
|
|
323
|
+
guest?.point(at.x, at.y, true);
|
|
324
|
+
showDot(at, false, true);
|
|
325
|
+
drawTools();
|
|
326
|
+
}
|
|
327
|
+
function endCalibration(note) {
|
|
328
|
+
calibrating = null;
|
|
329
|
+
tiltNote = note;
|
|
330
|
+
point(TARGET_AT.centre, false);
|
|
331
|
+
drawTools();
|
|
332
|
+
}
|
|
333
|
+
const actions = {
|
|
334
|
+
cmd(c) {
|
|
335
|
+
guest?.command(c);
|
|
336
|
+
navigator.vibrate?.(8);
|
|
337
|
+
},
|
|
338
|
+
open: at => guest?.command("open", at),
|
|
339
|
+
tab(name) {
|
|
340
|
+
if (tab === "tilt" && name !== "tilt") {
|
|
341
|
+
stopPointing();
|
|
342
|
+
if (calibrating)
|
|
343
|
+
endCalibration("");
|
|
344
|
+
}
|
|
345
|
+
tab = name;
|
|
346
|
+
point(lastPoint, false);
|
|
347
|
+
drawTools();
|
|
348
|
+
ink.resize();
|
|
349
|
+
},
|
|
350
|
+
clear() {
|
|
351
|
+
ink.clear();
|
|
352
|
+
guest?.clearInk();
|
|
353
|
+
},
|
|
354
|
+
retry: () => guest?.retry(),
|
|
355
|
+
async tilt() {
|
|
356
|
+
const result = await sensor.start();
|
|
357
|
+
tilt = result === "ok" ? "on" : result;
|
|
358
|
+
drawTools();
|
|
359
|
+
},
|
|
360
|
+
recentre() {
|
|
361
|
+
if (!pointer || !recent.length)
|
|
362
|
+
return;
|
|
363
|
+
pointer.recentre(meanAim(recent));
|
|
364
|
+
if (calibrated)
|
|
365
|
+
save(TILT_KEY, pointer.calibration);
|
|
366
|
+
},
|
|
367
|
+
calibrate() {
|
|
368
|
+
stopPointing();
|
|
369
|
+
calibrating = { index: 0, aims: {} };
|
|
370
|
+
aimAtTarget();
|
|
371
|
+
},
|
|
372
|
+
aimed() {
|
|
373
|
+
if (!calibrating || !recent.length)
|
|
374
|
+
return;
|
|
375
|
+
calibrating.aims[CALIBRATION_TARGETS[calibrating.index]] = meanAim(recent);
|
|
376
|
+
if (++calibrating.index < CALIBRATION_TARGETS.length)
|
|
377
|
+
return aimAtTarget();
|
|
378
|
+
const result = calibrate(calibrating.aims);
|
|
379
|
+
if (result) {
|
|
380
|
+
pointer = createPointer(result);
|
|
381
|
+
calibrated = true;
|
|
382
|
+
save(TILT_KEY, result);
|
|
383
|
+
}
|
|
384
|
+
endCalibration(result ? labels.tiltCalibrated : labels.tiltBad);
|
|
385
|
+
},
|
|
386
|
+
cancel: () => endCalibration(""),
|
|
387
|
+
};
|
|
388
|
+
app.addEventListener("click", event => {
|
|
389
|
+
const [action, value = ""] = (event.target.closest("[data-ct-do]")?.dataset.ctDo ?? "").split(":");
|
|
390
|
+
void actions[action]?.(value);
|
|
391
|
+
}, { signal });
|
|
392
|
+
const stop = guest?.subscribe(draw);
|
|
393
|
+
const ticking = window.setInterval(() => guest && timers.draw(guest.state, guest.stateAt), 500);
|
|
394
|
+
draw();
|
|
395
|
+
return {
|
|
396
|
+
destroy() {
|
|
397
|
+
abort.abort();
|
|
398
|
+
stop?.();
|
|
399
|
+
clearInterval(ticking);
|
|
400
|
+
sensor.stop();
|
|
401
|
+
padSize.disconnect();
|
|
402
|
+
guest?.close();
|
|
403
|
+
preview.destroy();
|
|
404
|
+
app.remove();
|
|
405
|
+
document.documentElement.removeAttribute("data-ct-open");
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The presenter window, `?view=presenter&room=…`: a second window of the same browser that follows the deck
|
|
3
|
+
* over BroadcastChannel and shows the current page, what comes next, the notes, and the clocks.
|
|
4
|
+
*/
|
|
5
|
+
import { h } from "../core/dom.js";
|
|
6
|
+
import { defaultLabels } from "../core/labels.js";
|
|
7
|
+
import { notesHtml } from "../core/notes.js";
|
|
8
|
+
import { createGuest } from "./guest.js";
|
|
9
|
+
import { defaultRemoteLabels } from "./labels.js";
|
|
10
|
+
import { injectStyle } from "./style.js";
|
|
11
|
+
import { broadcast } from "./transport.js";
|
|
12
|
+
import { createPreview, createTimers, keepAwake, pageAt, positionLabel, readOutline, statePos, upNext } from "./view.js";
|
|
13
|
+
/** The keys of the deck, as commands. */
|
|
14
|
+
const KEYS = {
|
|
15
|
+
" ": "next",
|
|
16
|
+
Enter: "next",
|
|
17
|
+
PageDown: "next",
|
|
18
|
+
PageUp: "prev",
|
|
19
|
+
Backspace: "prev",
|
|
20
|
+
ArrowRight: "slideNext",
|
|
21
|
+
ArrowLeft: "slidePrev",
|
|
22
|
+
ArrowDown: "pageNext",
|
|
23
|
+
ArrowUp: "pagePrev",
|
|
24
|
+
Escape: "overview",
|
|
25
|
+
o: "overview",
|
|
26
|
+
};
|
|
27
|
+
export function presenterView(root, options = {}) {
|
|
28
|
+
injectStyle(options.nonce);
|
|
29
|
+
const labels = { ...defaultRemoteLabels, ...options.labels };
|
|
30
|
+
const outline = readOutline(root, options.skip);
|
|
31
|
+
const room = options.room ?? new URLSearchParams(location.search).get("room") ?? "";
|
|
32
|
+
const abort = new AbortController();
|
|
33
|
+
const { signal } = abort;
|
|
34
|
+
const app = h("div", "ct-app ct-pr", "", { "data-ct-mode": "stage", "data-ct-wait": "" });
|
|
35
|
+
const top = app.appendChild(h("header", "ct-pr-top"));
|
|
36
|
+
const position = h("span", "ct-rmt-pos", labels.noDeck);
|
|
37
|
+
const timers = createTimers(labels, outline);
|
|
38
|
+
const button = (command, text) => h("button", "ct-btn", text, { "data-ct-do": command });
|
|
39
|
+
top.append(button("prev", defaultLabels.prev), button("next", defaultLabels.next), button("overview", defaultLabels.overview), position, timers.block, timers.time);
|
|
40
|
+
function cell(title, next = false) {
|
|
41
|
+
const el = app.appendChild(h("section", "ct-pr-cell"));
|
|
42
|
+
if (next)
|
|
43
|
+
el.setAttribute("data-next", "");
|
|
44
|
+
const head = el.appendChild(h("h2", "", title));
|
|
45
|
+
const caption = head.appendChild(h("span"));
|
|
46
|
+
const preview = createPreview(root);
|
|
47
|
+
el.appendChild(h("div", "ct-pr-box")).append(preview.el);
|
|
48
|
+
return { preview, caption };
|
|
49
|
+
}
|
|
50
|
+
const now = cell(labels.now);
|
|
51
|
+
const coming = cell(labels.upNext, true);
|
|
52
|
+
const notes = app.appendChild(h("div", "ct-pr-notes"));
|
|
53
|
+
root.after(app);
|
|
54
|
+
document.documentElement.setAttribute("data-ct-open", "");
|
|
55
|
+
keepAwake(signal);
|
|
56
|
+
const guest = createGuest({ room, open: () => broadcast(room), trusted: true });
|
|
57
|
+
let drawnPage = "";
|
|
58
|
+
function draw() {
|
|
59
|
+
const { state } = guest;
|
|
60
|
+
app.toggleAttribute("data-ct-wait", !state);
|
|
61
|
+
if (!state)
|
|
62
|
+
return;
|
|
63
|
+
timers.draw(state, guest.stateAt);
|
|
64
|
+
app.style.setProperty("--ct-ratio", String(state.ratio ?? 16 / 9));
|
|
65
|
+
const pos = statePos(outline, state);
|
|
66
|
+
if (!pos)
|
|
67
|
+
return;
|
|
68
|
+
const overview = state.mode === "overview";
|
|
69
|
+
position.textContent = (overview ? `${labels.inOverview} · ` : "") + positionLabel(outline, pos, state);
|
|
70
|
+
const page = pageAt(outline, pos);
|
|
71
|
+
now.preview.show(page, state.step);
|
|
72
|
+
now.caption.textContent = outline.blocks[pos.b].slides[pos.s].title;
|
|
73
|
+
const up = upNext(outline, pos, state.step);
|
|
74
|
+
const kinds = { step: labels.nextStep, page: labels.nextPage, slide: labels.nextSlide, end: labels.theEnd };
|
|
75
|
+
coming.preview.show(up.kind === "end" ? null : pageAt(outline, up.pos), up.step);
|
|
76
|
+
coming.caption.textContent = up.kind === "end" ? kinds.end : `${kinds[up.kind]}|${up.text}`;
|
|
77
|
+
if (state.at === drawnPage)
|
|
78
|
+
return;
|
|
79
|
+
drawnPage = state.at;
|
|
80
|
+
notes.scrollTop = 0;
|
|
81
|
+
notes.innerHTML = notesHtml(page);
|
|
82
|
+
if (!notes.childNodes.length)
|
|
83
|
+
notes.append(h("p", "", defaultLabels.noNotes));
|
|
84
|
+
}
|
|
85
|
+
app.addEventListener("click", event => {
|
|
86
|
+
const command = event.target.closest("[data-ct-do]")?.dataset.ctDo;
|
|
87
|
+
if (command)
|
|
88
|
+
guest.command(command);
|
|
89
|
+
}, { signal });
|
|
90
|
+
document.addEventListener("keydown", event => {
|
|
91
|
+
if (event.metaKey || event.ctrlKey || event.altKey)
|
|
92
|
+
return;
|
|
93
|
+
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
|
94
|
+
const onButton = !!event.target.closest?.("button,a");
|
|
95
|
+
if (onButton && (key === " " || key === "Enter"))
|
|
96
|
+
return;
|
|
97
|
+
const command = key === " " && event.shiftKey ? "prev" : KEYS[key];
|
|
98
|
+
if (!command)
|
|
99
|
+
return;
|
|
100
|
+
event.preventDefault();
|
|
101
|
+
guest.command(command);
|
|
102
|
+
}, { signal });
|
|
103
|
+
// The mouse over the current page is the laser pointer on the deck.
|
|
104
|
+
const sheet = now.preview.el;
|
|
105
|
+
const aim = (event, on) => {
|
|
106
|
+
const box = sheet.getBoundingClientRect();
|
|
107
|
+
guest.point((event.clientX - box.left) / box.width, (event.clientY - box.top) / box.height, on);
|
|
108
|
+
};
|
|
109
|
+
sheet.addEventListener("pointermove", event => aim(event, true), { signal });
|
|
110
|
+
sheet.addEventListener("pointerleave", event => aim(event, false), { signal });
|
|
111
|
+
const stop = guest.subscribe(draw);
|
|
112
|
+
const ticking = window.setInterval(() => timers.draw(guest.state, guest.stateAt), 500);
|
|
113
|
+
draw();
|
|
114
|
+
return {
|
|
115
|
+
destroy() {
|
|
116
|
+
abort.abort();
|
|
117
|
+
stop();
|
|
118
|
+
clearInterval(ticking);
|
|
119
|
+
guest.close();
|
|
120
|
+
now.preview.destroy();
|
|
121
|
+
coming.preview.destroy();
|
|
122
|
+
app.remove();
|
|
123
|
+
document.documentElement.removeAttribute("data-ct-open");
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** The wire messages of docs/PROTOCOL.md, and the checks every incoming frame passes before it is used. */
|
|
2
|
+
/** Frames above this size are dropped by the relay (it closes the socket with 1009). */
|
|
3
|
+
export declare const MAX_FRAME = 2048;
|
|
4
|
+
/** An `ink` message carries at most this many points. */
|
|
5
|
+
export declare const MAX_POINTS = 64;
|
|
6
|
+
export type Command = "next" | "prev" | "slideNext" | "slidePrev" | "pageNext" | "pagePrev" | "overview" | "open";
|
|
7
|
+
/** Where the deck is and how the time stands; sent to the granted guest and to the presenter window. */
|
|
8
|
+
export interface StateMessage {
|
|
9
|
+
t: "state";
|
|
10
|
+
mode: "overview" | "stage";
|
|
11
|
+
/** `b.s.p`, 1-based. */
|
|
12
|
+
at: string;
|
|
13
|
+
step: number;
|
|
14
|
+
steps: number;
|
|
15
|
+
/** Seconds since the talk started; 0 while the clock has not started. */
|
|
16
|
+
elapsed: number;
|
|
17
|
+
/** Planned seconds of the whole talk, or `null` when no block has minutes. */
|
|
18
|
+
total: number | null;
|
|
19
|
+
blockElapsed: number;
|
|
20
|
+
blockMinutes: number | null;
|
|
21
|
+
/** Width over height of the stage, so a phone draws on a pad of the same shape. Not in protocol v1; optional. */
|
|
22
|
+
ratio?: number;
|
|
23
|
+
}
|
|
24
|
+
/** What the relay itself sends. */
|
|
25
|
+
export type RelayMessage = {
|
|
26
|
+
t: "hello";
|
|
27
|
+
id: string;
|
|
28
|
+
role: "host" | "guest";
|
|
29
|
+
} | {
|
|
30
|
+
t: "join";
|
|
31
|
+
id: string;
|
|
32
|
+
} | {
|
|
33
|
+
t: "leave";
|
|
34
|
+
id: string;
|
|
35
|
+
};
|
|
36
|
+
/** Host to guest. `to` addresses one guest; the relay strips nothing, the guest ignores it. */
|
|
37
|
+
export type HostMessage = {
|
|
38
|
+
t: "pair";
|
|
39
|
+
to: string;
|
|
40
|
+
code: string;
|
|
41
|
+
} | {
|
|
42
|
+
t: "grant";
|
|
43
|
+
to: string;
|
|
44
|
+
token: string;
|
|
45
|
+
} | {
|
|
46
|
+
t: "deny";
|
|
47
|
+
to: string;
|
|
48
|
+
} | {
|
|
49
|
+
t: "revoke";
|
|
50
|
+
to: string;
|
|
51
|
+
} | {
|
|
52
|
+
t: "pong";
|
|
53
|
+
to?: string;
|
|
54
|
+
} | (StateMessage & {
|
|
55
|
+
to?: string;
|
|
56
|
+
});
|
|
57
|
+
/** Guest to host. The relay sets `from`; over BroadcastChannel there is none. */
|
|
58
|
+
export type GuestMessage = {
|
|
59
|
+
t: "resume";
|
|
60
|
+
token: string;
|
|
61
|
+
} | {
|
|
62
|
+
t: "cmd";
|
|
63
|
+
c: Command;
|
|
64
|
+
at?: string;
|
|
65
|
+
} | {
|
|
66
|
+
t: "ptr";
|
|
67
|
+
x: number;
|
|
68
|
+
y: number;
|
|
69
|
+
on: boolean;
|
|
70
|
+
} | {
|
|
71
|
+
t: "ink";
|
|
72
|
+
s: number;
|
|
73
|
+
pts: number[];
|
|
74
|
+
end?: boolean;
|
|
75
|
+
} | {
|
|
76
|
+
t: "ink-clear";
|
|
77
|
+
} | {
|
|
78
|
+
t: "ping";
|
|
79
|
+
};
|
|
80
|
+
export type Message = RelayMessage | HostMessage | GuestMessage;
|
|
81
|
+
type Fields = Record<string, unknown>;
|
|
82
|
+
/** A frame as an object with a string `t`, or `null`. */
|
|
83
|
+
export declare function decode(frame: unknown): (Fields & {
|
|
84
|
+
t: string;
|
|
85
|
+
}) | null;
|
|
86
|
+
/**
|
|
87
|
+
* A message a guest may send, with every field checked and every coordinate clamped to [0, 1];
|
|
88
|
+
* `null` for anything else. This is the only door for input from outside the host's own page.
|
|
89
|
+
*/
|
|
90
|
+
export declare function guestMessage(value: Fields): GuestMessage | null;
|
|
91
|
+
/** A `state` message as the guest reads it; a field of the wrong type takes its neutral value. */
|
|
92
|
+
export declare function stateMessage(value: Fields): StateMessage | null;
|
|
93
|
+
/**
|
|
94
|
+
* A host key: 26 characters of Crockford base32 from `crypto.getRandomValues` (the first carries 3 bits, 128 in all).
|
|
95
|
+
* It stays in the host's tab; the relay gives the host seat of a room only to the key whose hash is the room.
|
|
96
|
+
*/
|
|
97
|
+
export declare const newKey: () => string;
|
|
98
|
+
/** A room name, and the shape of a host key too. */
|
|
99
|
+
export declare const isRoom: (value: unknown) => value is string;
|
|
100
|
+
/** The room of a host key: the first 26 Crockford base32 characters of SHA-256 of the key (docs/PROTOCOL.md). */
|
|
101
|
+
export declare function roomOf(key: string): Promise<string>;
|
|
102
|
+
/** A resume token: 22 characters of base64url, 132 random bits. */
|
|
103
|
+
export declare const newToken: () => string;
|
|
104
|
+
/** A fresh 4-digit pairing code, without modulo bias. */
|
|
105
|
+
export declare function newCode(): string;
|
|
106
|
+
export {};
|