contour-deck 0.0.0-stage → 0.2.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.
Files changed (94) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +261 -2
  3. package/dist/astro/Deck.astro +68 -0
  4. package/dist/astro/host.d.ts +20 -0
  5. package/dist/astro/host.js +170 -0
  6. package/dist/astro/legacy.d.ts +22 -0
  7. package/dist/astro/legacy.js +42 -0
  8. package/dist/core/deck.d.ts +2 -0
  9. package/dist/core/deck.js +598 -0
  10. package/dist/core/dom.d.ts +8 -0
  11. package/dist/core/dom.js +22 -0
  12. package/dist/core/gestures.d.ts +6 -0
  13. package/dist/core/gestures.js +24 -0
  14. package/dist/core/index.d.ts +3 -0
  15. package/dist/core/index.js +2 -0
  16. package/dist/core/labels.d.ts +2 -0
  17. package/dist/core/labels.js +20 -0
  18. package/dist/core/model.d.ts +37 -0
  19. package/dist/core/model.js +51 -0
  20. package/dist/core/navigator.d.ts +6 -0
  21. package/dist/core/navigator.js +12 -0
  22. package/dist/core/notes.d.ts +11 -0
  23. package/dist/core/notes.js +34 -0
  24. package/dist/core/overview.d.ts +11 -0
  25. package/dist/core/overview.js +104 -0
  26. package/dist/core/rail.d.ts +8 -0
  27. package/dist/core/rail.js +26 -0
  28. package/dist/core/stops.d.ts +17 -0
  29. package/dist/core/stops.js +40 -0
  30. package/dist/core/transitions.d.ts +9 -0
  31. package/dist/core/transitions.js +37 -0
  32. package/dist/core/types.d.ts +82 -0
  33. package/dist/core/types.js +1 -0
  34. package/dist/rehype/hast.d.ts +26 -0
  35. package/dist/rehype/hast.js +48 -0
  36. package/dist/rehype/index.d.ts +12 -0
  37. package/dist/rehype/index.js +221 -0
  38. package/dist/rehype/page.d.ts +2 -0
  39. package/dist/rehype/page.js +84 -0
  40. package/dist/rehype/reading.d.ts +11 -0
  41. package/dist/rehype/reading.js +9 -0
  42. package/dist/rehype/titles.d.ts +19 -0
  43. package/dist/rehype/titles.js +63 -0
  44. package/dist/remark/index.d.ts +20 -0
  45. package/dist/remark/index.js +117 -0
  46. package/dist/remote/clock.d.ts +35 -0
  47. package/dist/remote/clock.js +48 -0
  48. package/dist/remote/guest.d.ts +41 -0
  49. package/dist/remote/guest.js +223 -0
  50. package/dist/remote/index.d.ts +18 -0
  51. package/dist/remote/index.js +11 -0
  52. package/dist/remote/ink.d.ts +11 -0
  53. package/dist/remote/ink.js +74 -0
  54. package/dist/remote/labels.d.ts +72 -0
  55. package/dist/remote/labels.js +68 -0
  56. package/dist/remote/links.d.ts +2 -0
  57. package/dist/remote/links.js +12 -0
  58. package/dist/remote/overlay.d.ts +11 -0
  59. package/dist/remote/overlay.js +63 -0
  60. package/dist/remote/pairing.d.ts +40 -0
  61. package/dist/remote/pairing.js +97 -0
  62. package/dist/remote/panel.d.ts +38 -0
  63. package/dist/remote/panel.js +131 -0
  64. package/dist/remote/phone.d.ts +10 -0
  65. package/dist/remote/phone.js +407 -0
  66. package/dist/remote/presenter.d.ts +2 -0
  67. package/dist/remote/presenter.js +126 -0
  68. package/dist/remote/protocol.d.ts +106 -0
  69. package/dist/remote/protocol.js +123 -0
  70. package/dist/remote/qr.d.ts +8 -0
  71. package/dist/remote/qr.js +266 -0
  72. package/dist/remote/sensor.d.ts +9 -0
  73. package/dist/remote/sensor.js +50 -0
  74. package/dist/remote/session.d.ts +39 -0
  75. package/dist/remote/session.js +287 -0
  76. package/dist/remote/store.d.ts +9 -0
  77. package/dist/remote/store.js +27 -0
  78. package/dist/remote/style.d.ts +2 -0
  79. package/dist/remote/style.js +131 -0
  80. package/dist/remote/tilt.d.ts +78 -0
  81. package/dist/remote/tilt.js +148 -0
  82. package/dist/remote/transport.d.ts +35 -0
  83. package/dist/remote/transport.js +162 -0
  84. package/dist/remote/view.d.ts +48 -0
  85. package/dist/remote/view.js +119 -0
  86. package/dist/shared/markers.d.ts +15 -0
  87. package/dist/shared/markers.js +15 -0
  88. package/dist/style.css +629 -0
  89. package/docs/CONTRACT.md +178 -0
  90. package/docs/PROTOCOL.md +99 -0
  91. package/package.json +89 -4
  92. package/styles/base.css +3 -0
  93. package/styles/deck.css +151 -0
  94. package/styles/index.css +3 -0
@@ -0,0 +1,41 @@
1
+ import type { Command, StateMessage } from "./protocol.js";
2
+ import type { Transport } from "./transport.js";
3
+ /**
4
+ * `connecting`: no answer from the room yet. `pairing`: showing a code, waiting for the presenter.
5
+ * `granted`: in control. `denied`, `revoked`: the presenter said no; the connection is closed until `retry()`.
6
+ * `offline`: the connection ended and is being retried.
7
+ */
8
+ export type GuestPhase = "connecting" | "pairing" | "granted" | "denied" | "revoked" | "offline";
9
+ export interface Stroke {
10
+ add(x: number, y: number): void;
11
+ end(): void;
12
+ }
13
+ export interface Guest {
14
+ readonly phase: GuestPhase;
15
+ /** The pairing code to show, while `phase` is `pairing`. */
16
+ readonly code: string;
17
+ /** Close code of the last connection (4004: no host, 4029: room full), 0 when none ended. */
18
+ readonly closeCode: number;
19
+ /** The last `state` from the host, and when it arrived on `performance.now()`. */
20
+ readonly state: StateMessage | null;
21
+ readonly stateAt: number;
22
+ command(c: Command, at?: string): void;
23
+ /** The laser pointer; at most one message every 33 ms, the last position always goes out. */
24
+ point(x: number, y: number, on: boolean): void;
25
+ /** Starts an ink stroke; its points go out in batches. */
26
+ stroke(): Stroke;
27
+ clearInk(): void;
28
+ /** After `denied` or `revoked`: connects again and asks anew. */
29
+ retry(): void;
30
+ /** Calls `listener` after every change of phase, code, or state. */
31
+ subscribe(listener: () => void): () => void;
32
+ close(): void;
33
+ }
34
+ export interface GuestOptions {
35
+ room: string;
36
+ /** Opens the transport; called again by `retry()`. */
37
+ open(): Transport;
38
+ /** Same browser as the host (BroadcastChannel): no pairing, control from the start. */
39
+ trusted?: boolean;
40
+ }
41
+ export declare function createGuest({ room, open, trusted }: GuestOptions): Guest;
@@ -0,0 +1,223 @@
1
+ /**
2
+ * The guest side: a phone through the relay, or the presenter window on the room's BroadcastChannel.
3
+ * It follows the pairing of docs/PROTOCOL.md, keeps the resume token, and paces what it sends.
4
+ */
5
+ import { MAX_POINTS, stateMessage } from "./protocol.js";
6
+ import { guestKey, load, save } from "./store.js";
7
+ const POINT_EVERY = 33;
8
+ const INK_EVERY = 50;
9
+ const PING_EVERY = 10_000;
10
+ /** A granted guest that hears nothing for this long reconnects: a phone's socket can die without closing. */
11
+ const SILENCE = 25_000;
12
+ /** With a token in hand, the code stays hidden this long: the grant usually arrives first. */
13
+ const RESUME_WAIT = 1200;
14
+ const round = (value) => Math.round(Math.min(1, Math.max(0, value)) * 1e4) / 1e4;
15
+ export function createGuest({ room, open, trusted = false }) {
16
+ const listeners = new Set();
17
+ const emit = () => listeners.forEach(listener => listener());
18
+ let transport;
19
+ let stops = [];
20
+ let phase = "connecting";
21
+ let code = "";
22
+ let state = null;
23
+ let stateAt = 0;
24
+ let heard = performance.now();
25
+ let reveal = 0;
26
+ let strokes = 0;
27
+ let pointTimer = 0;
28
+ let pointAt = 0;
29
+ let pending = null;
30
+ let closed = false;
31
+ const token = () => {
32
+ const value = load(guestKey(room));
33
+ return typeof value === "string" ? value : null;
34
+ };
35
+ const send = (message) => {
36
+ if (phase === "granted")
37
+ transport.send(message);
38
+ };
39
+ function set(next) {
40
+ clearTimeout(reveal);
41
+ phase = next;
42
+ emit();
43
+ }
44
+ function receive(message) {
45
+ heard = performance.now();
46
+ switch (message.t) {
47
+ case "hello": {
48
+ // A fresh connection: present the token, if this tab was granted before.
49
+ const kept = token();
50
+ if (kept)
51
+ transport.send({ t: "resume", token: kept });
52
+ if (phase !== "granted")
53
+ set("connecting");
54
+ return;
55
+ }
56
+ case "pair":
57
+ if (typeof message.code !== "string" || !/^\d{4}$/.test(message.code))
58
+ return;
59
+ code = message.code;
60
+ clearTimeout(reveal);
61
+ if (token() && phase !== "pairing")
62
+ reveal = window.setTimeout(() => set("pairing"), RESUME_WAIT);
63
+ else
64
+ set("pairing");
65
+ return;
66
+ case "grant":
67
+ if (typeof message.token !== "string" || message.token.length > 64)
68
+ return;
69
+ save(guestKey(room), message.token);
70
+ return set("granted");
71
+ case "deny":
72
+ case "revoke":
73
+ if (message.t === "revoke")
74
+ save(guestKey(room), null);
75
+ state = null;
76
+ // Leave the room: a guest without control has nothing to wait for, and frees one of its places.
77
+ hangUp();
78
+ return set(message.t === "deny" ? "denied" : "revoked");
79
+ case "state": {
80
+ const next = stateMessage(message);
81
+ if (!next)
82
+ return;
83
+ state = next;
84
+ stateAt = performance.now();
85
+ return emit();
86
+ }
87
+ }
88
+ }
89
+ function hangUp() {
90
+ stops.forEach(stop => stop());
91
+ stops = [];
92
+ transport.close();
93
+ }
94
+ function connect() {
95
+ transport = open();
96
+ if (trusted)
97
+ phase = "granted";
98
+ stops = [
99
+ transport.subscribe(receive),
100
+ transport.watch(status => {
101
+ if (status === "closed")
102
+ set("offline");
103
+ else if (status === "connecting" && phase === "offline")
104
+ emit();
105
+ }),
106
+ ];
107
+ // The presenter window asks for the state; the host answers a ping on the channel with pong and state.
108
+ if (trusted)
109
+ transport.send({ t: "ping" });
110
+ }
111
+ connect();
112
+ const beat = window.setInterval(() => {
113
+ if (phase !== "granted")
114
+ return;
115
+ if (trusted) {
116
+ // Until the deck answers, ask every beat: the presenter window may open before the remote is on.
117
+ if (!state)
118
+ transport.send({ t: "ping" });
119
+ return;
120
+ }
121
+ if (performance.now() - heard > SILENCE) {
122
+ heard = performance.now();
123
+ transport.reconnect();
124
+ }
125
+ else
126
+ transport.send({ t: "ping" });
127
+ }, trusted ? 1500 : PING_EVERY);
128
+ function flushPoint() {
129
+ pointTimer = 0;
130
+ if (!pending)
131
+ return;
132
+ send(pending);
133
+ pending = null;
134
+ pointAt = performance.now();
135
+ }
136
+ return {
137
+ get phase() {
138
+ return phase;
139
+ },
140
+ get code() {
141
+ return code;
142
+ },
143
+ get closeCode() {
144
+ return transport.code;
145
+ },
146
+ get state() {
147
+ return state;
148
+ },
149
+ get stateAt() {
150
+ return stateAt;
151
+ },
152
+ command(c, at) {
153
+ send(at === undefined ? { t: "cmd", c } : { t: "cmd", c, at });
154
+ },
155
+ point(x, y, on) {
156
+ pending = { t: "ptr", x: round(x), y: round(y), on };
157
+ const wait = POINT_EVERY - (performance.now() - pointAt);
158
+ // Switching off is never held back; a position waits for its slot, and the newest one wins it.
159
+ if (!on || wait <= 0) {
160
+ clearTimeout(pointTimer);
161
+ flushPoint();
162
+ }
163
+ else
164
+ pointTimer ||= window.setTimeout(flushPoint, wait);
165
+ },
166
+ stroke() {
167
+ const s = ++strokes;
168
+ let points = [];
169
+ let last = [];
170
+ let timer = 0;
171
+ let ended = false;
172
+ const flush = (end = false) => {
173
+ clearTimeout(timer);
174
+ timer = 0;
175
+ while (points.length > MAX_POINTS * 2)
176
+ send({ t: "ink", s, pts: points.splice(0, MAX_POINTS * 2) });
177
+ // The closing message carries `end`; when every point has gone out already, it repeats the last one.
178
+ if (end && !points.length)
179
+ points = last;
180
+ if (points.length)
181
+ send({ t: "ink", s, pts: points, ...(end ? { end: true } : {}) });
182
+ points = [];
183
+ };
184
+ return {
185
+ add(x, y) {
186
+ if (ended)
187
+ return;
188
+ last = [round(x), round(y)];
189
+ points.push(...last);
190
+ if (points.length >= MAX_POINTS * 2)
191
+ flush();
192
+ else
193
+ timer ||= window.setTimeout(flush, INK_EVERY);
194
+ },
195
+ end() {
196
+ if (ended)
197
+ return;
198
+ ended = true;
199
+ flush(true);
200
+ },
201
+ };
202
+ },
203
+ clearInk: () => send({ t: "ink-clear" }),
204
+ retry() {
205
+ if (closed || (phase !== "denied" && phase !== "revoked"))
206
+ return;
207
+ phase = "connecting";
208
+ connect();
209
+ emit();
210
+ },
211
+ subscribe(listener) {
212
+ listeners.add(listener);
213
+ return () => void listeners.delete(listener);
214
+ },
215
+ close() {
216
+ closed = true;
217
+ clearInterval(beat);
218
+ clearTimeout(reveal);
219
+ clearTimeout(pointTimer);
220
+ hangUp();
221
+ },
222
+ };
223
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * contour-deck/remote: the phone remote, the laser pointer, ink, tilt pointing, and the presenter window.
3
+ * Load it on demand: the deck calls its `remote` option on the R key, and a page opened with
4
+ * `?view=remote` or `?view=presenter` calls `remoteView` or `presenterView`.
5
+ */
6
+ export { remoteHost } from "./session.js";
7
+ export type { RemoteHost, RemoteOptions } from "./session.js";
8
+ export { remoteView } from "./phone.js";
9
+ export type { RemoteView, RemoteViewOptions } from "./phone.js";
10
+ export { presenterView } from "./presenter.js";
11
+ export { broadcast, relay, relayUrl } from "./transport.js";
12
+ export type { Incoming, RelayOptions, Transport, TransportStatus } from "./transport.js";
13
+ export type { Command, GuestMessage, HostMessage, Message, RelayMessage, StateMessage } from "./protocol.js";
14
+ export { defaultRemoteLabels } from "./labels.js";
15
+ export type { RemoteLabels } from "./labels.js";
16
+ export type { QrEncoder } from "./panel.js";
17
+ export { aimOf, calibrate, createPointer, defaultCalibration, project, recentre } from "./tilt.js";
18
+ export type { Aim, Calibration, CalibrationTarget, Pointer, TiltSample } from "./tilt.js";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * contour-deck/remote: the phone remote, the laser pointer, ink, tilt pointing, and the presenter window.
3
+ * Load it on demand: the deck calls its `remote` option on the R key, and a page opened with
4
+ * `?view=remote` or `?view=presenter` calls `remoteView` or `presenterView`.
5
+ */
6
+ export { remoteHost } from "./session.js";
7
+ export { remoteView } from "./phone.js";
8
+ export { presenterView } from "./presenter.js";
9
+ export { broadcast, relay, relayUrl } from "./transport.js";
10
+ export { defaultRemoteLabels } from "./labels.js";
11
+ export { aimOf, calibrate, createPointer, defaultCalibration, project, recentre } from "./tilt.js";
@@ -0,0 +1,11 @@
1
+ /** Freehand strokes on a canvas, kept in normalised coordinates so a resize redraws them in place. */
2
+ export interface Ink {
3
+ /** Adds points (`[x, y, x, y, …]` in [0, 1]) to the stroke named `key`, starting it when new. */
4
+ add(key: string, points: number[]): void;
5
+ clear(): void;
6
+ /** Call when the canvas element changed size. */
7
+ resize(): void;
8
+ /** Number of strokes on the canvas. */
9
+ readonly count: number;
10
+ }
11
+ export declare function createInk(canvas: HTMLCanvasElement): Ink;
@@ -0,0 +1,74 @@
1
+ /** Freehand strokes on a canvas, kept in normalised coordinates so a resize redraws them in place. */
2
+ /** Above this many points the oldest strokes go, so a guest cannot grow the page without bound. */
3
+ const MAX_POINTS = 20_000;
4
+ export function createInk(canvas) {
5
+ const strokes = new Map();
6
+ const context = canvas.getContext("2d");
7
+ let total = 0;
8
+ let frame = 0;
9
+ function draw() {
10
+ frame = 0;
11
+ if (!context)
12
+ return;
13
+ const { width, height } = canvas;
14
+ context.clearRect(0, 0, width, height);
15
+ context.strokeStyle = context.fillStyle = getComputedStyle(canvas).color;
16
+ context.lineWidth = Math.max(2, Math.min(width, height) * 0.006);
17
+ context.lineCap = context.lineJoin = "round";
18
+ for (const points of strokes.values()) {
19
+ context.beginPath();
20
+ if (points.length === 2) {
21
+ context.arc(points[0] * width, points[1] * height, context.lineWidth / 2, 0, Math.PI * 2);
22
+ context.fill();
23
+ continue;
24
+ }
25
+ context.moveTo(points[0] * width, points[1] * height);
26
+ // Through the midpoints, with each sample as a control point: corners of the sampling turn into curves.
27
+ for (let i = 2; i < points.length - 2; i += 2) {
28
+ const [x, y, nextX, nextY] = [points[i] * width, points[i + 1] * height, points[i + 2] * width, points[i + 3] * height];
29
+ context.quadraticCurveTo(x, y, (x + nextX) / 2, (y + nextY) / 2);
30
+ }
31
+ context.lineTo(points.at(-2) * width, points.at(-1) * height);
32
+ context.stroke();
33
+ }
34
+ }
35
+ const schedule = () => void (frame ||= requestAnimationFrame(draw));
36
+ function resize() {
37
+ const ratio = window.devicePixelRatio || 1;
38
+ const width = Math.round(canvas.clientWidth * ratio);
39
+ const height = Math.round(canvas.clientHeight * ratio);
40
+ if (canvas.width === width && canvas.height === height)
41
+ return;
42
+ canvas.width = width;
43
+ canvas.height = height;
44
+ cancelAnimationFrame(frame);
45
+ draw();
46
+ }
47
+ resize();
48
+ return {
49
+ add(key, points) {
50
+ const stroke = strokes.get(key);
51
+ if (stroke)
52
+ stroke.push(...points);
53
+ else
54
+ strokes.set(key, [...points]);
55
+ total += points.length / 2;
56
+ for (const [oldest, old] of strokes) {
57
+ if (total <= MAX_POINTS || oldest === key)
58
+ break;
59
+ strokes.delete(oldest);
60
+ total -= old.length / 2;
61
+ }
62
+ schedule();
63
+ },
64
+ clear() {
65
+ strokes.clear();
66
+ total = 0;
67
+ schedule();
68
+ },
69
+ resize,
70
+ get count() {
71
+ return strokes.size;
72
+ },
73
+ };
74
+ }
@@ -0,0 +1,72 @@
1
+ /** Interface text of the remote: the panel on the deck, the phone, and the presenter window. Traditional Chinese by default. */
2
+ import type { CalibrationTarget } from "./tilt.js";
3
+ export interface RemoteLabels {
4
+ title: string;
5
+ close: string;
6
+ remoteQr: string;
7
+ remoteHint: string;
8
+ notesQr: string;
9
+ notesHint: string;
10
+ noRelay: string;
11
+ relayConnecting: string;
12
+ relayOpen: string;
13
+ relayClosed: string;
14
+ roomTaken: string;
15
+ request: string;
16
+ /** Name of the bar button while phones wait for an answer. */
17
+ waiting(count: number): string;
18
+ code: string;
19
+ allow: string;
20
+ deny: string;
21
+ paired: string;
22
+ pairedOffline: string;
23
+ revoke: string;
24
+ presenter: string;
25
+ timer: string;
26
+ resetTimer: string;
27
+ end: string;
28
+ connecting: string;
29
+ connected: string;
30
+ offline: string;
31
+ pairHint: string;
32
+ denied: string;
33
+ revoked: string;
34
+ retry: string;
35
+ noHost: string;
36
+ full: string;
37
+ badLink: string;
38
+ notesOnly: string;
39
+ leave: string;
40
+ slidePrev: string;
41
+ slideNext: string;
42
+ tabNotes: string;
43
+ tabPointer: string;
44
+ tabInk: string;
45
+ tabTilt: string;
46
+ upNext: string;
47
+ nextStep: string;
48
+ nextPage: string;
49
+ nextSlide: string;
50
+ theEnd: string;
51
+ inOverview: string;
52
+ block: string;
53
+ pointerHint: string;
54
+ inkHint: string;
55
+ clearInk: string;
56
+ tiltEnable: string;
57
+ tiltDenied: string;
58
+ tiltUnsupported: string;
59
+ tiltHold: string;
60
+ tiltRecentre: string;
61
+ tiltCalibrate: string;
62
+ tiltUncalibrated: string;
63
+ tiltCalibrated: string;
64
+ tiltAim(target: string, index: number, count: number): string;
65
+ tiltAimed: string;
66
+ tiltCancel: string;
67
+ tiltBad: string;
68
+ tiltTargets: Record<CalibrationTarget, string>;
69
+ now: string;
70
+ noDeck: string;
71
+ }
72
+ export declare const defaultRemoteLabels: RemoteLabels;
@@ -0,0 +1,68 @@
1
+ export const defaultRemoteLabels = {
2
+ title: "遙控",
3
+ close: "關閉",
4
+ remoteQr: "手機遙控",
5
+ remoteHint: "掃描後手機會顯示確認碼,在這裡按允許才能控制。",
6
+ notesQr: "講稿",
7
+ notesHint: "手機自己翻頁的講稿,不需要連線。",
8
+ noRelay: "這個頁面沒有設定中繼服務,手機無法遙控;講稿與講者視窗仍可使用。",
9
+ relayConnecting: "中繼連線中",
10
+ relayOpen: "中繼已連線",
11
+ relayClosed: "中繼離線,重試中",
12
+ roomTaken: "房間已被另一個視窗使用",
13
+ request: "有手機要求控制",
14
+ waiting: count => `遙控:${count} 支手機等待允許`,
15
+ code: "確認碼",
16
+ allow: "允許",
17
+ deny: "拒絕",
18
+ paired: "手機已配對",
19
+ pairedOffline: "手機已配對,目前離線",
20
+ revoke: "收回控制",
21
+ presenter: "開啟講者視窗",
22
+ timer: "計時",
23
+ resetTimer: "重設",
24
+ end: "結束遙控",
25
+ connecting: "連線中…",
26
+ connected: "已連線",
27
+ offline: "離線,重試中",
28
+ pairHint: "請在電腦上核對這組數字,再按「允許」。",
29
+ denied: "電腦沒有允許這支手機。",
30
+ revoked: "電腦收回了控制權。",
31
+ retry: "重新要求控制",
32
+ noHost: "找不到簡報:電腦還沒開啟遙控,或已經關閉。重試中…",
33
+ full: "已有太多裝置連上這場簡報。重試中…",
34
+ badLink: "這個連結無法遙控,請重新掃描電腦上的 QR。",
35
+ notesOnly: "只看講稿",
36
+ leave: "離開",
37
+ slidePrev: "上一張",
38
+ slideNext: "下一張",
39
+ tabNotes: "講稿",
40
+ tabPointer: "紅點",
41
+ tabInk: "畫筆",
42
+ tabTilt: "傾斜",
43
+ upNext: "接下來",
44
+ nextStep: "要點",
45
+ nextPage: "往下",
46
+ nextSlide: "下一張",
47
+ theEnd: "結尾",
48
+ inOverview: "輪廓",
49
+ block: "區塊",
50
+ pointerHint: "在縮圖上拖曳,螢幕上會出現紅點。",
51
+ inkHint: "在縮圖上畫線;換頁時會清除。",
52
+ clearInk: "清除筆跡",
53
+ tiltEnable: "啟用傾斜感測",
54
+ tiltDenied: "沒有取得動作感測的權限,請到瀏覽器設定允許後再試。",
55
+ tiltUnsupported: "這個裝置或瀏覽器沒有提供方向感測。",
56
+ tiltHold: "按住指向",
57
+ tiltRecentre: "重新置中",
58
+ tiltCalibrate: "校正",
59
+ tiltUncalibrated: "尚未校正:按住時手機的方向當作螢幕中央。",
60
+ tiltCalibrated: "已校正。指標偏掉時,對準螢幕中央按「重新置中」。",
61
+ tiltAim: (target, index, count) => `${index}/${count} 把手機頂端對準螢幕的${target}`,
62
+ tiltAimed: "對準了",
63
+ tiltCancel: "取消",
64
+ tiltBad: "範圍太小或方向不一致,請重新校正。",
65
+ tiltTargets: { centre: "中央", topLeft: "左上角", topRight: "右上角", bottomRight: "右下角", bottomLeft: "左下角" },
66
+ now: "目前",
67
+ noDeck: "等待簡報視窗…",
68
+ };
@@ -0,0 +1,2 @@
1
+ /** Links to the other views of this page: the same URL with its own `view` (and `room`), without position or fragment. */
2
+ export declare function pageLink(view: "remote" | "presenter" | "notes" | null, room?: string): string;
@@ -0,0 +1,12 @@
1
+ /** Links to the other views of this page: the same URL with its own `view` (and `room`), without position or fragment. */
2
+ export function pageLink(view, room) {
3
+ const url = new URL(location.href);
4
+ for (const name of ["view", "at", "room"])
5
+ url.searchParams.delete(name);
6
+ url.hash = "";
7
+ if (view)
8
+ url.searchParams.set("view", view);
9
+ if (room)
10
+ url.searchParams.set("room", room);
11
+ return url.href;
12
+ }
@@ -0,0 +1,11 @@
1
+ export interface Overlay {
2
+ /** Shows the dot at a point of the stage ([0, 1], origin top left), or lets it fade. */
3
+ point(x: number, y: number, on: boolean): void;
4
+ /** Lets the dot fade where it is. */
5
+ hide(): void;
6
+ /** Adds points to a stroke; `key` names the stroke across messages. */
7
+ ink(key: string, points: number[]): void;
8
+ clear(): void;
9
+ destroy(): void;
10
+ }
11
+ export declare function createOverlay(stage: HTMLElement): Overlay;
@@ -0,0 +1,63 @@
1
+ /** What a remote draws over the stage: the laser dot and the ink canvas. */
2
+ import { h } from "../core/dom.js";
3
+ import { createInk } from "./ink.js";
4
+ /** The dot covers this share of the remaining distance in 16 ms: quick, without the jitter of a hand. */
5
+ const EASE = 0.35;
6
+ export function createOverlay(stage) {
7
+ const canvas = h("canvas", "ct-ink", "", { "aria-hidden": "true" });
8
+ const dot = h("div", "ct-laser", "", { "aria-hidden": "true" });
9
+ stage.append(canvas, dot);
10
+ const ink = createInk(canvas);
11
+ const motion = matchMedia("(prefers-reduced-motion: reduce)");
12
+ const observer = new ResizeObserver(() => {
13
+ ink.resize();
14
+ place();
15
+ });
16
+ observer.observe(stage);
17
+ let target = { x: 0.5, y: 0.5 };
18
+ let shown = { ...target };
19
+ let on = false;
20
+ let frame = 0;
21
+ let last = 0;
22
+ function place() {
23
+ dot.style.transform = `translate(${shown.x * stage.clientWidth}px,${shown.y * stage.clientHeight}px)`;
24
+ }
25
+ // One step per animation frame, however many `ptr` messages came in since the last one.
26
+ function step(now) {
27
+ frame = 0;
28
+ const share = motion.matches ? 1 : 1 - (1 - EASE) ** Math.max(1, (now - last) / 16);
29
+ last = now;
30
+ shown = { x: shown.x + (target.x - shown.x) * share, y: shown.y + (target.y - shown.y) * share };
31
+ if (Math.abs(target.x - shown.x) + Math.abs(target.y - shown.y) < 0.0005)
32
+ shown = { ...target };
33
+ else
34
+ frame = requestAnimationFrame(step);
35
+ place();
36
+ }
37
+ return {
38
+ point(x, y, show) {
39
+ target = { x, y };
40
+ // Coming on, the dot appears where it is aimed; it does not sweep in from where it was last seen.
41
+ if (show && !on)
42
+ shown = { x, y };
43
+ on = show;
44
+ dot.toggleAttribute("data-on", show);
45
+ if (!frame) {
46
+ last = performance.now();
47
+ frame = requestAnimationFrame(step);
48
+ }
49
+ },
50
+ hide() {
51
+ on = false;
52
+ dot.removeAttribute("data-on");
53
+ },
54
+ ink: (key, points) => ink.add(key, points),
55
+ clear: () => ink.clear(),
56
+ destroy() {
57
+ cancelAnimationFrame(frame);
58
+ observer.disconnect();
59
+ canvas.remove();
60
+ dot.remove();
61
+ },
62
+ };
63
+ }
@@ -0,0 +1,40 @@
1
+ import type { HostMessage } from "./protocol.js";
2
+ export interface PairRequest {
3
+ id: string;
4
+ code: string;
5
+ /** When the guest joined, on the host's clock. */
6
+ at: number;
7
+ }
8
+ /** A request the presenter has not answered after this long is denied. */
9
+ export declare const REQUEST_TTL = 120000;
10
+ export interface PairingOptions {
11
+ send(message: HostMessage): void;
12
+ /** Called after every change of `requests`, `controller`, or `token`. */
13
+ changed(): void;
14
+ /** The token granted before a reload of the host page, if any. */
15
+ token?: string | null;
16
+ now?: () => number;
17
+ }
18
+ export interface Pairing {
19
+ /** Guests waiting for the presenter's answer, oldest first. */
20
+ readonly requests: readonly PairRequest[];
21
+ /** Relay id of the connected guest that holds control, or `null`. */
22
+ readonly controller: string | null;
23
+ /** The one token that resumes control, or `null` when no guest is paired. */
24
+ readonly token: string | null;
25
+ join(id: string): void;
26
+ leave(id: string): void;
27
+ /** A guest presents a token; true when it now holds control. */
28
+ resume(id: string, token: string): boolean;
29
+ allow(id: string): void;
30
+ deny(id: string): void;
31
+ /** Takes control back and forgets the token, whether the guest is connected or not. */
32
+ revoke(): void;
33
+ /** Denies the requests older than `REQUEST_TTL`. */
34
+ expire(): void;
35
+ /** True when a message from this relay id may act on the deck. */
36
+ controls(id: unknown): boolean;
37
+ /** The relay connection ended: nobody is connected any more; the token stays. */
38
+ offline(): void;
39
+ }
40
+ export declare function createPairing({ send, changed, token: restored, now }: PairingOptions): Pairing;