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.
- package/LICENSE +373 -0
- package/README.md +261 -2
- package/dist/astro/Deck.astro +68 -0
- package/dist/astro/host.d.ts +20 -0
- package/dist/astro/host.js +170 -0
- package/dist/astro/legacy.d.ts +22 -0
- package/dist/astro/legacy.js +42 -0
- package/dist/core/deck.d.ts +2 -0
- package/dist/core/deck.js +598 -0
- package/dist/core/dom.d.ts +8 -0
- package/dist/core/dom.js +22 -0
- package/dist/core/gestures.d.ts +6 -0
- package/dist/core/gestures.js +24 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.js +2 -0
- package/dist/core/labels.d.ts +2 -0
- package/dist/core/labels.js +20 -0
- package/dist/core/model.d.ts +37 -0
- package/dist/core/model.js +51 -0
- package/dist/core/navigator.d.ts +6 -0
- package/dist/core/navigator.js +12 -0
- package/dist/core/notes.d.ts +11 -0
- package/dist/core/notes.js +34 -0
- package/dist/core/overview.d.ts +11 -0
- package/dist/core/overview.js +104 -0
- package/dist/core/rail.d.ts +8 -0
- package/dist/core/rail.js +26 -0
- package/dist/core/stops.d.ts +17 -0
- package/dist/core/stops.js +40 -0
- package/dist/core/transitions.d.ts +9 -0
- package/dist/core/transitions.js +37 -0
- package/dist/core/types.d.ts +82 -0
- package/dist/core/types.js +1 -0
- package/dist/rehype/hast.d.ts +26 -0
- package/dist/rehype/hast.js +48 -0
- package/dist/rehype/index.d.ts +12 -0
- package/dist/rehype/index.js +221 -0
- package/dist/rehype/page.d.ts +2 -0
- package/dist/rehype/page.js +84 -0
- package/dist/rehype/reading.d.ts +11 -0
- package/dist/rehype/reading.js +9 -0
- package/dist/rehype/titles.d.ts +19 -0
- package/dist/rehype/titles.js +63 -0
- package/dist/remark/index.d.ts +20 -0
- package/dist/remark/index.js +117 -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 +223 -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 +72 -0
- package/dist/remote/labels.js +68 -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 +38 -0
- package/dist/remote/panel.js +131 -0
- package/dist/remote/phone.d.ts +10 -0
- package/dist/remote/phone.js +407 -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 +287 -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 +131 -0
- package/dist/remote/tilt.d.ts +78 -0
- package/dist/remote/tilt.js +148 -0
- package/dist/remote/transport.d.ts +35 -0
- package/dist/remote/transport.js +162 -0
- package/dist/remote/view.d.ts +48 -0
- package/dist/remote/view.js +119 -0
- package/dist/shared/markers.d.ts +15 -0
- package/dist/shared/markers.js +15 -0
- package/dist/style.css +629 -0
- package/docs/CONTRACT.md +178 -0
- package/docs/PROTOCOL.md +99 -0
- package/package.json +89 -4
- package/styles/base.css +3 -0
- package/styles/deck.css +151 -0
- package/styles/index.css +3 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may control the deck: the host's side of the pairing in docs/PROTOCOL.md, as a state machine without DOM.
|
|
3
|
+
* A guest gets a 4-digit code and waits; the presenter allows or denies it; one guest at a time holds control,
|
|
4
|
+
* and its token lets it come back after a reconnect without asking again.
|
|
5
|
+
*/
|
|
6
|
+
import { newCode, newToken } from "./protocol.js";
|
|
7
|
+
/** A request the presenter has not answered after this long is denied. */
|
|
8
|
+
export const REQUEST_TTL = 120_000;
|
|
9
|
+
export function createPairing({ send, changed, token: restored = null, now = Date.now }) {
|
|
10
|
+
let requests = [];
|
|
11
|
+
let controller = null;
|
|
12
|
+
let token = restored;
|
|
13
|
+
const drop = (id) => {
|
|
14
|
+
const before = requests.length;
|
|
15
|
+
requests = requests.filter(request => request.id !== id);
|
|
16
|
+
return requests.length !== before;
|
|
17
|
+
};
|
|
18
|
+
/** Hands control to `id`; whoever held it before is told it lost it. */
|
|
19
|
+
function seat(id, granted) {
|
|
20
|
+
if (controller && controller !== id)
|
|
21
|
+
send({ t: "revoke", to: controller });
|
|
22
|
+
drop(id);
|
|
23
|
+
controller = id;
|
|
24
|
+
token = granted;
|
|
25
|
+
send({ t: "grant", to: id, token: granted });
|
|
26
|
+
changed();
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
get requests() {
|
|
30
|
+
return requests;
|
|
31
|
+
},
|
|
32
|
+
get controller() {
|
|
33
|
+
return controller;
|
|
34
|
+
},
|
|
35
|
+
get token() {
|
|
36
|
+
return token;
|
|
37
|
+
},
|
|
38
|
+
join(id) {
|
|
39
|
+
drop(id);
|
|
40
|
+
const request = { id, code: newCode(), at: now() };
|
|
41
|
+
requests.push(request);
|
|
42
|
+
send({ t: "pair", to: id, code: request.code });
|
|
43
|
+
changed();
|
|
44
|
+
},
|
|
45
|
+
leave(id) {
|
|
46
|
+
const waiting = drop(id);
|
|
47
|
+
if (controller === id)
|
|
48
|
+
controller = null;
|
|
49
|
+
else if (!waiting)
|
|
50
|
+
return;
|
|
51
|
+
changed();
|
|
52
|
+
},
|
|
53
|
+
resume(id, presented) {
|
|
54
|
+
// Only a guest the relay announced, and only with the one token in force.
|
|
55
|
+
if (!token || presented !== token || (controller !== id && !requests.some(request => request.id === id)))
|
|
56
|
+
return false;
|
|
57
|
+
seat(id, token);
|
|
58
|
+
return true;
|
|
59
|
+
},
|
|
60
|
+
allow(id) {
|
|
61
|
+
// A fresh token on every decision: the one of the phone that held control before stops working.
|
|
62
|
+
if (requests.some(request => request.id === id))
|
|
63
|
+
seat(id, newToken());
|
|
64
|
+
},
|
|
65
|
+
deny(id) {
|
|
66
|
+
if (!drop(id))
|
|
67
|
+
return;
|
|
68
|
+
send({ t: "deny", to: id });
|
|
69
|
+
changed();
|
|
70
|
+
},
|
|
71
|
+
revoke() {
|
|
72
|
+
if (!controller && !token)
|
|
73
|
+
return;
|
|
74
|
+
if (controller)
|
|
75
|
+
send({ t: "revoke", to: controller });
|
|
76
|
+
controller = null;
|
|
77
|
+
token = null;
|
|
78
|
+
changed();
|
|
79
|
+
},
|
|
80
|
+
expire() {
|
|
81
|
+
const limit = now() - REQUEST_TTL;
|
|
82
|
+
for (const request of requests.filter(item => item.at <= limit)) {
|
|
83
|
+
drop(request.id);
|
|
84
|
+
send({ t: "deny", to: request.id });
|
|
85
|
+
changed();
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
controls: id => controller !== null && id === controller,
|
|
89
|
+
offline() {
|
|
90
|
+
if (!controller && !requests.length)
|
|
91
|
+
return;
|
|
92
|
+
controller = null;
|
|
93
|
+
requests = [];
|
|
94
|
+
changed();
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { RemoteLabels } from "./labels.js";
|
|
2
|
+
import type { PairRequest } from "./pairing.js";
|
|
3
|
+
import type { TransportStatus } from "./transport.js";
|
|
4
|
+
/** Draws a QR code as an SVG string; `null` (or a rejection) shows the link as text only. */
|
|
5
|
+
export type QrEncoder = (text: string) => string | null | Promise<string | null>;
|
|
6
|
+
/** What the panel shows; the host session hands in a new one on every change. */
|
|
7
|
+
export interface PanelView {
|
|
8
|
+
/** `null` without a relay. */
|
|
9
|
+
relay: TransportStatus | null;
|
|
10
|
+
/** Close code of the last relay connection. */
|
|
11
|
+
code: number;
|
|
12
|
+
requests: readonly PairRequest[];
|
|
13
|
+
paired: "no" | "online" | "offline";
|
|
14
|
+
elapsed: number;
|
|
15
|
+
}
|
|
16
|
+
export interface PanelOptions {
|
|
17
|
+
/** The viewport of the deck's shell (`.ct-vp`). */
|
|
18
|
+
parent: HTMLElement;
|
|
19
|
+
labels: RemoteLabels;
|
|
20
|
+
qr: QrEncoder;
|
|
21
|
+
/** The phone-remote link, or `null` without a relay. */
|
|
22
|
+
remoteLink: string | null;
|
|
23
|
+
notesLink: string;
|
|
24
|
+
allow(id: string): void;
|
|
25
|
+
deny(id: string): void;
|
|
26
|
+
revoke(): void;
|
|
27
|
+
presenter(): void;
|
|
28
|
+
resetTimer(): void;
|
|
29
|
+
end(): void;
|
|
30
|
+
close(): void;
|
|
31
|
+
}
|
|
32
|
+
export interface Panel {
|
|
33
|
+
readonly open: boolean;
|
|
34
|
+
show(open: boolean): void;
|
|
35
|
+
update(view: PanelView): void;
|
|
36
|
+
destroy(): void;
|
|
37
|
+
}
|
|
38
|
+
export declare function createPanel(options: PanelOptions): Panel;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/** The remote panel on the deck: the QR codes, the pairing requests, the paired phone, and the presenter window. */
|
|
2
|
+
import { h } from "../core/dom.js";
|
|
3
|
+
import { formatClock } from "./clock.js";
|
|
4
|
+
export function createPanel(options) {
|
|
5
|
+
const { labels } = options;
|
|
6
|
+
const el = h("aside", "ct-rp", "", { role: "dialog", "aria-label": labels.title, tabindex: -1 });
|
|
7
|
+
el.hidden = true;
|
|
8
|
+
const button = (action, text, primary = false) => {
|
|
9
|
+
const control = h("button", "ct-btn", text, { "data-ct-rp": action });
|
|
10
|
+
if (primary)
|
|
11
|
+
control.setAttribute("data-ct-primary", "");
|
|
12
|
+
return control;
|
|
13
|
+
};
|
|
14
|
+
const head = el.appendChild(h("div", "ct-rp-head"));
|
|
15
|
+
const status = h("span", "ct-badge");
|
|
16
|
+
const closeButton = button("close", "✕");
|
|
17
|
+
closeButton.setAttribute("aria-label", labels.close);
|
|
18
|
+
head.append(h("h2", "", labels.title), status, closeButton);
|
|
19
|
+
/** Requests and the paired phone: the part that changes. */
|
|
20
|
+
const live = el.appendChild(h("div", "ct-rp-live", "", { "aria-live": "polite" }));
|
|
21
|
+
live.style.display = "contents";
|
|
22
|
+
const codes = el.appendChild(h("div", "ct-rp-qrs"));
|
|
23
|
+
function tile(title, hint, link) {
|
|
24
|
+
const figure = codes.appendChild(h("figure", "ct-rp-qr"));
|
|
25
|
+
const image = h("div", "ct-rp-img");
|
|
26
|
+
image.hidden = true;
|
|
27
|
+
figure.append(image, h("h3", "", title), h("p", "", hint), h("a", "ct-rp-url", link, { href: link, target: "_blank", rel: "noopener" }));
|
|
28
|
+
// The encoder belongs to the host page (or is the built-in one): its SVG goes in as markup.
|
|
29
|
+
void Promise.resolve()
|
|
30
|
+
.then(() => options.qr(link))
|
|
31
|
+
.then(svg => {
|
|
32
|
+
if (!svg)
|
|
33
|
+
return;
|
|
34
|
+
image.innerHTML = svg;
|
|
35
|
+
image.hidden = false;
|
|
36
|
+
})
|
|
37
|
+
.catch(() => { });
|
|
38
|
+
}
|
|
39
|
+
if (options.remoteLink)
|
|
40
|
+
tile(labels.remoteQr, labels.remoteHint, options.remoteLink);
|
|
41
|
+
else
|
|
42
|
+
codes.before(h("p", "", labels.noRelay));
|
|
43
|
+
tile(labels.notesQr, labels.notesHint, options.notesLink);
|
|
44
|
+
const tools = el.appendChild(h("div", "ct-rp-row"));
|
|
45
|
+
const time = h("b", "ct-rp-time", "0:00");
|
|
46
|
+
const timer = h("span", "ct-rp-row");
|
|
47
|
+
timer.append(h("span", "ct-meta", labels.timer), time, button("reset", labels.resetTimer));
|
|
48
|
+
tools.append(button("presenter", labels.presenter), timer);
|
|
49
|
+
el.appendChild(h("div", "ct-rp-row")).append(h("span"), button("end", labels.end));
|
|
50
|
+
const actions = {
|
|
51
|
+
close: options.close,
|
|
52
|
+
allow: options.allow,
|
|
53
|
+
deny: options.deny,
|
|
54
|
+
revoke: options.revoke,
|
|
55
|
+
presenter: options.presenter,
|
|
56
|
+
reset: options.resetTimer,
|
|
57
|
+
end: options.end,
|
|
58
|
+
};
|
|
59
|
+
el.addEventListener("click", event => {
|
|
60
|
+
const control = event.target.closest("[data-ct-rp]");
|
|
61
|
+
if (control)
|
|
62
|
+
actions[control.dataset.ctRp ?? ""]?.(control.dataset.ctId ?? "");
|
|
63
|
+
});
|
|
64
|
+
// Escape closes the panel, and only the panel: the deck's own Escape (outline, exit) does not run.
|
|
65
|
+
const onKey = (event) => {
|
|
66
|
+
if (event.key !== "Escape" || el.hidden)
|
|
67
|
+
return;
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
event.stopPropagation();
|
|
70
|
+
options.close();
|
|
71
|
+
};
|
|
72
|
+
document.addEventListener("keydown", onKey, true);
|
|
73
|
+
options.parent.append(el);
|
|
74
|
+
let returnTo = null;
|
|
75
|
+
let drawn = "";
|
|
76
|
+
return {
|
|
77
|
+
get open() {
|
|
78
|
+
return !el.hidden;
|
|
79
|
+
},
|
|
80
|
+
show(open) {
|
|
81
|
+
if (open === !el.hidden)
|
|
82
|
+
return;
|
|
83
|
+
el.hidden = !open;
|
|
84
|
+
if (open) {
|
|
85
|
+
returnTo = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
86
|
+
el.focus({ preventScroll: true });
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
returnTo?.focus({ preventScroll: true });
|
|
90
|
+
returnTo = null;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
update(view) {
|
|
94
|
+
time.textContent = formatClock(view.elapsed);
|
|
95
|
+
const relayText = view.relay === null
|
|
96
|
+
? ""
|
|
97
|
+
: view.relay === "open"
|
|
98
|
+
? labels.relayOpen
|
|
99
|
+
: view.code === 4009
|
|
100
|
+
? labels.roomTaken
|
|
101
|
+
: view.relay === "closed"
|
|
102
|
+
? labels.relayClosed
|
|
103
|
+
: labels.relayConnecting;
|
|
104
|
+
status.textContent = relayText;
|
|
105
|
+
status.hidden = !relayText;
|
|
106
|
+
status.dataset.st = view.relay ?? "";
|
|
107
|
+
// The requests are rebuilt only when they change, so a button under the finger stays the same element.
|
|
108
|
+
const key = JSON.stringify([view.requests, view.paired]);
|
|
109
|
+
if (key === drawn)
|
|
110
|
+
return;
|
|
111
|
+
drawn = key;
|
|
112
|
+
live.replaceChildren(...view.requests.map(request => {
|
|
113
|
+
const ask = h("section", "ct-rp-ask", "", { "data-ct-id": request.id });
|
|
114
|
+
const allow = button("allow", labels.allow, true);
|
|
115
|
+
const deny = button("deny", labels.deny);
|
|
116
|
+
allow.dataset.ctId = deny.dataset.ctId = request.id;
|
|
117
|
+
const code = h("b", "ct-rp-code", request.code, { "aria-label": `${labels.code} ${request.code.split("").join(" ")}` });
|
|
118
|
+
ask.append(h("h3", "", labels.request), h("span", "ct-meta", labels.code), code, allow, deny);
|
|
119
|
+
return ask;
|
|
120
|
+
}));
|
|
121
|
+
if (view.paired !== "no") {
|
|
122
|
+
const paired = live.appendChild(h("div", "ct-rp-row"));
|
|
123
|
+
paired.append(h("span", "ct-badge", view.paired === "online" ? labels.paired : labels.pairedOffline), button("revoke", labels.revoke));
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
destroy() {
|
|
127
|
+
document.removeEventListener("keydown", onKey, true);
|
|
128
|
+
el.remove();
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RemoteOptions } from "./session.js";
|
|
2
|
+
export interface RemoteViewOptions extends RemoteOptions {
|
|
3
|
+
/** The room to join. Default: the `room` query parameter. */
|
|
4
|
+
room?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface RemoteView {
|
|
7
|
+
/** Closes the connection and removes the view; the page is a document again. */
|
|
8
|
+
destroy(): void;
|
|
9
|
+
}
|
|
10
|
+
export declare function remoteView(root: HTMLElement, options?: RemoteViewOptions): RemoteView;
|
|
@@ -0,0 +1,407 @@
|
|
|
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
|
+
retry.hidden = phase !== "denied" && phase !== "revoked";
|
|
143
|
+
cardBadge.textContent = phase === "pairing" ? labels.code : phase === "offline" ? labels.offline : labels.title;
|
|
144
|
+
cardText.textContent =
|
|
145
|
+
phase === "pairing"
|
|
146
|
+
? labels.pairHint
|
|
147
|
+
: phase === "denied"
|
|
148
|
+
? labels.denied
|
|
149
|
+
: phase === "revoked"
|
|
150
|
+
? labels.revoked
|
|
151
|
+
: phase === "offline" && (closeCode === 4004 || closeCode === 4000)
|
|
152
|
+
? labels.noHost
|
|
153
|
+
: phase === "offline" && closeCode === 4029
|
|
154
|
+
? labels.full
|
|
155
|
+
: labels.connecting;
|
|
156
|
+
return phase;
|
|
157
|
+
}
|
|
158
|
+
function drawTools() {
|
|
159
|
+
const state = guest?.state;
|
|
160
|
+
const shown = state?.mode === "overview" ? "notes" : tab;
|
|
161
|
+
app.dataset.ctTab = shown;
|
|
162
|
+
tabButtons.forEach((el, i) => el.setAttribute("aria-pressed", String(tabNames[i][0] === shown)));
|
|
163
|
+
const mode = shown !== "tilt" ? shown : calibrating ? "tilt-cal" : tilt === "on" ? "tilt-on" : "tilt-off";
|
|
164
|
+
for (const [when, el] of toolButtons)
|
|
165
|
+
el.toggleAttribute("data-show", when === mode);
|
|
166
|
+
hold.setAttribute("aria-pressed", String(pointing));
|
|
167
|
+
if (shown === "pointer")
|
|
168
|
+
hint.textContent = labels.pointerHint;
|
|
169
|
+
else if (shown === "ink")
|
|
170
|
+
hint.textContent = labels.inkHint;
|
|
171
|
+
else if (shown !== "tilt")
|
|
172
|
+
hint.textContent = "";
|
|
173
|
+
else if (calibrating) {
|
|
174
|
+
const target = CALIBRATION_TARGETS[calibrating.index];
|
|
175
|
+
hint.textContent = labels.tiltAim(labels.tiltTargets[target], calibrating.index + 1, CALIBRATION_TARGETS.length);
|
|
176
|
+
}
|
|
177
|
+
else if (tilt === "denied")
|
|
178
|
+
hint.textContent = labels.tiltDenied;
|
|
179
|
+
else if (tilt === "unsupported")
|
|
180
|
+
hint.textContent = labels.tiltUnsupported;
|
|
181
|
+
else
|
|
182
|
+
hint.textContent = tiltNote || (tilt === "on" ? (calibrated ? labels.tiltCalibrated : labels.tiltUncalibrated) : "");
|
|
183
|
+
}
|
|
184
|
+
function draw() {
|
|
185
|
+
app.dataset.ctPhase = drawCard();
|
|
186
|
+
const state = guest?.state;
|
|
187
|
+
if (!guest || guest.phase !== "granted" || !state) {
|
|
188
|
+
status.textContent = labels.connecting;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
status.textContent = labels.connected;
|
|
192
|
+
status.dataset.st = "granted";
|
|
193
|
+
timers.draw(state, guest.stateAt);
|
|
194
|
+
if (state.ratio)
|
|
195
|
+
app.style.setProperty("--ct-ratio", String(state.ratio));
|
|
196
|
+
const pos = statePos(outline, state);
|
|
197
|
+
position.textContent = !pos || state.mode === "overview" ? labels.inOverview : positionLabel(outline, pos, state);
|
|
198
|
+
const key = `${state.mode} ${state.at}`;
|
|
199
|
+
if (key !== drawnPage) {
|
|
200
|
+
drawnPage = key;
|
|
201
|
+
// A new page: its notes from the top, and the ink of the old one gone, as on the deck.
|
|
202
|
+
ink.clear();
|
|
203
|
+
notes.scrollTop = 0;
|
|
204
|
+
if (state.mode === "overview") {
|
|
205
|
+
const list = h("ul", "ct-rmt-list");
|
|
206
|
+
outline.blocks.forEach((block, b) => {
|
|
207
|
+
const open = list.appendChild(h("li")).appendChild(button(`open:${b + 1}`, ""));
|
|
208
|
+
if (pos?.b === b)
|
|
209
|
+
open.setAttribute("aria-current", "true");
|
|
210
|
+
open.append(h("span", "ct-meta", block.short), h("span", "", block.title));
|
|
211
|
+
if (block.minutes)
|
|
212
|
+
open.append(h("span", "ct-meta", defaultLabels.minutes(block.minutes)));
|
|
213
|
+
});
|
|
214
|
+
notes.replaceChildren(list);
|
|
215
|
+
}
|
|
216
|
+
else if (pos) {
|
|
217
|
+
notes.innerHTML = notesHtml(pageAt(outline, pos));
|
|
218
|
+
if (!notes.childNodes.length)
|
|
219
|
+
notes.append(h("p", "", defaultLabels.noNotes));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (pos) {
|
|
223
|
+
preview.show(pageAt(outline, pos), state.step);
|
|
224
|
+
const up = upNext(outline, pos, state.step);
|
|
225
|
+
const kinds = { step: labels.nextStep, page: labels.nextPage, slide: labels.nextSlide, end: labels.theEnd };
|
|
226
|
+
// On the outline the step button opens the slide the deck is on.
|
|
227
|
+
const outside = state.mode === "overview";
|
|
228
|
+
nextKind.textContent = outside ? labels.upNext : `${labels.upNext}|${kinds[up.kind]}`;
|
|
229
|
+
nextText.textContent = outside ? outline.blocks[pos.b].slides[pos.s].title : up.text;
|
|
230
|
+
}
|
|
231
|
+
drawTools();
|
|
232
|
+
}
|
|
233
|
+
/* ---- pad: pointer and ink ---- */
|
|
234
|
+
let finger = -1;
|
|
235
|
+
let stroke = null;
|
|
236
|
+
let strokeKey = "";
|
|
237
|
+
let strokes = 0;
|
|
238
|
+
let inked = { x: -1, y: -1 };
|
|
239
|
+
const onPad = (event) => {
|
|
240
|
+
const box = pad.getBoundingClientRect();
|
|
241
|
+
const unit = (value) => Math.min(1, Math.max(0, value));
|
|
242
|
+
return { x: unit((event.clientX - box.left) / box.width), y: unit((event.clientY - box.top) / box.height) };
|
|
243
|
+
};
|
|
244
|
+
function inkTo(at) {
|
|
245
|
+
if (Math.hypot(at.x - inked.x, at.y - inked.y) < INK_STEP)
|
|
246
|
+
return;
|
|
247
|
+
inked = at;
|
|
248
|
+
stroke?.add(at.x, at.y);
|
|
249
|
+
ink.add(strokeKey, [at.x, at.y]);
|
|
250
|
+
}
|
|
251
|
+
pad.addEventListener("pointerdown", event => {
|
|
252
|
+
if (finger !== -1 || (tab !== "pointer" && tab !== "ink") || guest?.phase !== "granted")
|
|
253
|
+
return;
|
|
254
|
+
finger = event.pointerId;
|
|
255
|
+
pad.setPointerCapture(finger);
|
|
256
|
+
const at = onPad(event);
|
|
257
|
+
if (tab === "pointer")
|
|
258
|
+
return point(at, true);
|
|
259
|
+
stroke = guest.stroke();
|
|
260
|
+
strokeKey = `own:${++strokes}`;
|
|
261
|
+
inked = { x: -1, y: -1 };
|
|
262
|
+
inkTo(at);
|
|
263
|
+
}, { signal });
|
|
264
|
+
pad.addEventListener("pointermove", event => {
|
|
265
|
+
if (event.pointerId !== finger)
|
|
266
|
+
return;
|
|
267
|
+
if (!stroke)
|
|
268
|
+
return point(onPad(event), true);
|
|
269
|
+
for (const sample of event.getCoalescedEvents?.() ?? [event])
|
|
270
|
+
inkTo(onPad(sample));
|
|
271
|
+
}, { signal });
|
|
272
|
+
const lift = (event) => {
|
|
273
|
+
if (event.pointerId !== finger)
|
|
274
|
+
return;
|
|
275
|
+
finger = -1;
|
|
276
|
+
if (stroke)
|
|
277
|
+
stroke.end();
|
|
278
|
+
else
|
|
279
|
+
point(lastPoint, false);
|
|
280
|
+
stroke = null;
|
|
281
|
+
};
|
|
282
|
+
pad.addEventListener("pointerup", lift, { signal });
|
|
283
|
+
pad.addEventListener("pointercancel", lift, { signal });
|
|
284
|
+
const padSize = new ResizeObserver(() => ink.resize());
|
|
285
|
+
padSize.observe(pad);
|
|
286
|
+
/* ---- tilt ---- */
|
|
287
|
+
const sensor = createSensor((aim, t) => {
|
|
288
|
+
recent.push(aim);
|
|
289
|
+
if (recent.length > STEADY)
|
|
290
|
+
recent.shift();
|
|
291
|
+
if (!pointing || calibrating)
|
|
292
|
+
return;
|
|
293
|
+
// Without a calibration, the direction of the first press is the middle of a screen of ordinary size.
|
|
294
|
+
pointer ??= createPointer(defaultCalibration(aim));
|
|
295
|
+
point(pointer.update(aim, t), true);
|
|
296
|
+
});
|
|
297
|
+
function stopPointing() {
|
|
298
|
+
if (!pointing)
|
|
299
|
+
return;
|
|
300
|
+
pointing = false;
|
|
301
|
+
point(lastPoint, false);
|
|
302
|
+
// Drift correction moved the calibration while pointing; a measured one is worth keeping.
|
|
303
|
+
if (calibrated && pointer)
|
|
304
|
+
save(TILT_KEY, pointer.calibration);
|
|
305
|
+
drawTools();
|
|
306
|
+
}
|
|
307
|
+
hold.addEventListener("pointerdown", event => {
|
|
308
|
+
if (tilt !== "on")
|
|
309
|
+
return;
|
|
310
|
+
hold.setPointerCapture(event.pointerId);
|
|
311
|
+
pointing = true;
|
|
312
|
+
tiltNote = "";
|
|
313
|
+
drawTools();
|
|
314
|
+
}, { signal });
|
|
315
|
+
for (const type of ["pointerup", "pointercancel", "lostpointercapture"])
|
|
316
|
+
hold.addEventListener(type, stopPointing, { signal });
|
|
317
|
+
function aimAtTarget() {
|
|
318
|
+
if (!calibrating)
|
|
319
|
+
return;
|
|
320
|
+
const at = TARGET_AT[CALIBRATION_TARGETS[calibrating.index]];
|
|
321
|
+
// The deck shows the dot on the spot to aim at; the pad marks the same spot.
|
|
322
|
+
guest?.point(at.x, at.y, true);
|
|
323
|
+
showDot(at, false, true);
|
|
324
|
+
drawTools();
|
|
325
|
+
}
|
|
326
|
+
function endCalibration(note) {
|
|
327
|
+
calibrating = null;
|
|
328
|
+
tiltNote = note;
|
|
329
|
+
point(TARGET_AT.centre, false);
|
|
330
|
+
drawTools();
|
|
331
|
+
}
|
|
332
|
+
const actions = {
|
|
333
|
+
cmd(c) {
|
|
334
|
+
guest?.command(c);
|
|
335
|
+
navigator.vibrate?.(8);
|
|
336
|
+
},
|
|
337
|
+
open: at => guest?.command("open", at),
|
|
338
|
+
tab(name) {
|
|
339
|
+
if (tab === "tilt" && name !== "tilt") {
|
|
340
|
+
stopPointing();
|
|
341
|
+
if (calibrating)
|
|
342
|
+
endCalibration("");
|
|
343
|
+
}
|
|
344
|
+
tab = name;
|
|
345
|
+
point(lastPoint, false);
|
|
346
|
+
drawTools();
|
|
347
|
+
ink.resize();
|
|
348
|
+
},
|
|
349
|
+
clear() {
|
|
350
|
+
ink.clear();
|
|
351
|
+
guest?.clearInk();
|
|
352
|
+
},
|
|
353
|
+
retry: () => guest?.retry(),
|
|
354
|
+
async tilt() {
|
|
355
|
+
const result = await sensor.start();
|
|
356
|
+
tilt = result === "ok" ? "on" : result;
|
|
357
|
+
drawTools();
|
|
358
|
+
},
|
|
359
|
+
recentre() {
|
|
360
|
+
if (!pointer || !recent.length)
|
|
361
|
+
return;
|
|
362
|
+
pointer.recentre(meanAim(recent));
|
|
363
|
+
if (calibrated)
|
|
364
|
+
save(TILT_KEY, pointer.calibration);
|
|
365
|
+
},
|
|
366
|
+
calibrate() {
|
|
367
|
+
stopPointing();
|
|
368
|
+
calibrating = { index: 0, aims: {} };
|
|
369
|
+
aimAtTarget();
|
|
370
|
+
},
|
|
371
|
+
aimed() {
|
|
372
|
+
if (!calibrating || !recent.length)
|
|
373
|
+
return;
|
|
374
|
+
calibrating.aims[CALIBRATION_TARGETS[calibrating.index]] = meanAim(recent);
|
|
375
|
+
if (++calibrating.index < CALIBRATION_TARGETS.length)
|
|
376
|
+
return aimAtTarget();
|
|
377
|
+
const result = calibrate(calibrating.aims);
|
|
378
|
+
if (result) {
|
|
379
|
+
pointer = createPointer(result);
|
|
380
|
+
calibrated = true;
|
|
381
|
+
save(TILT_KEY, result);
|
|
382
|
+
}
|
|
383
|
+
endCalibration(result ? labels.tiltCalibrated : labels.tiltBad);
|
|
384
|
+
},
|
|
385
|
+
cancel: () => endCalibration(""),
|
|
386
|
+
};
|
|
387
|
+
app.addEventListener("click", event => {
|
|
388
|
+
const [action, value = ""] = (event.target.closest("[data-ct-do]")?.dataset.ctDo ?? "").split(":");
|
|
389
|
+
void actions[action]?.(value);
|
|
390
|
+
}, { signal });
|
|
391
|
+
const stop = guest?.subscribe(draw);
|
|
392
|
+
const ticking = window.setInterval(() => guest && timers.draw(guest.state, guest.stateAt), 500);
|
|
393
|
+
draw();
|
|
394
|
+
return {
|
|
395
|
+
destroy() {
|
|
396
|
+
abort.abort();
|
|
397
|
+
stop?.();
|
|
398
|
+
clearInterval(ticking);
|
|
399
|
+
sensor.stop();
|
|
400
|
+
padSize.disconnect();
|
|
401
|
+
guest?.close();
|
|
402
|
+
preview.destroy();
|
|
403
|
+
app.remove();
|
|
404
|
+
document.documentElement.removeAttribute("data-ct-open");
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
}
|