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,598 @@
|
|
|
1
|
+
/** createDeck: builds the shell around a contract deck and wires the stage, the outline, and the input. */
|
|
2
|
+
import { clamp, h } from "./dom.js";
|
|
3
|
+
import { createWheelGate } from "./gestures.js";
|
|
4
|
+
import { defaultLabels } from "./labels.js";
|
|
5
|
+
import { fitPos, formatAt, parseAt, posOf, readModel } from "./model.js";
|
|
6
|
+
import { neighbourBlock } from "./navigator.js";
|
|
7
|
+
import { buildScript, notesHtml } from "./notes.js";
|
|
8
|
+
import { buildOverview } from "./overview.js";
|
|
9
|
+
import { buildRail } from "./rail.js";
|
|
10
|
+
import { isTall, nextStop, pageIndexAt, pageProgress, pageStops } from "./stops.js";
|
|
11
|
+
import { finished, zoomOverview, zoomStage } from "./transitions.js";
|
|
12
|
+
const ON = "data-ct-on";
|
|
13
|
+
const OPEN_MS = 520;
|
|
14
|
+
const CLOSE_MS = 480;
|
|
15
|
+
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
16
|
+
export function createDeck(root, options = {}) {
|
|
17
|
+
const labels = { ...defaultLabels, ...options.labels };
|
|
18
|
+
const deckTitle = root.dataset.title || document.title;
|
|
19
|
+
const blocks = readModel(root, deckTitle);
|
|
20
|
+
const kinds = blocks.map(block => block.kind);
|
|
21
|
+
const skip = options.linear?.skip ?? ["appendix"];
|
|
22
|
+
const useUrl = options.url !== false;
|
|
23
|
+
const motion = matchMedia("(prefers-reduced-motion: reduce)");
|
|
24
|
+
const reduced = () => motion.matches;
|
|
25
|
+
const smooth = () => (reduced() ? "auto" : "smooth");
|
|
26
|
+
const abort = new AbortController();
|
|
27
|
+
const { signal } = abort;
|
|
28
|
+
const listeners = { change: new Set(), exit: new Set(), destroy: new Set() };
|
|
29
|
+
let mode = "overview";
|
|
30
|
+
let pos = { b: 0, s: 0, p: 0 };
|
|
31
|
+
let busy = false;
|
|
32
|
+
let alive = true;
|
|
33
|
+
/** The page that has received `ct:enter` and not yet `ct:leave`. */
|
|
34
|
+
let current = null;
|
|
35
|
+
let lastChange = "";
|
|
36
|
+
let lastUrl = "";
|
|
37
|
+
let frame = 0;
|
|
38
|
+
let idle = 0;
|
|
39
|
+
/** A slide change the deck itself started and whose scroll has not arrived yet. */
|
|
40
|
+
let pending = null;
|
|
41
|
+
let wakeLock = null;
|
|
42
|
+
/* ---- shell: bar, outline, stage, notes panel, notes view ---- */
|
|
43
|
+
const app = h("div", "ct-app");
|
|
44
|
+
const bar = app.appendChild(h("header", "ct-bar"));
|
|
45
|
+
const button = (action, text, label = "") => {
|
|
46
|
+
const el = h("button", "ct-btn", text, { "data-ct-act": action });
|
|
47
|
+
if (label)
|
|
48
|
+
el.setAttribute("aria-label", label);
|
|
49
|
+
return el;
|
|
50
|
+
};
|
|
51
|
+
const titleEl = h("span", "ct-title");
|
|
52
|
+
const rail = buildRail(blocks, labels);
|
|
53
|
+
const threeDButton = button("3d", labels.threeD);
|
|
54
|
+
const notesButton = button("notes", labels.notes);
|
|
55
|
+
bar.append(button("overview", labels.overview), titleEl, rail.el, threeDButton, button("script", labels.script), notesButton);
|
|
56
|
+
if (options.remote)
|
|
57
|
+
bar.append(button("remote", labels.remote));
|
|
58
|
+
bar.append(button("exit", "✕", labels.exit));
|
|
59
|
+
const viewport = app.appendChild(h("main", "ct-vp"));
|
|
60
|
+
const overview = buildOverview(blocks, labels);
|
|
61
|
+
const stage = h("div", "ct-stage", "", { tabindex: -1 });
|
|
62
|
+
const pager = h("div", "ct-pager", "", { "aria-hidden": "true" });
|
|
63
|
+
const controls = h("div", "ct-ctl");
|
|
64
|
+
controls.append(button("prev", "◂", labels.prev), button("next", "▸", labels.next));
|
|
65
|
+
const panel = h("aside", "ct-notes", "", { "aria-label": labels.notes });
|
|
66
|
+
const panelPos = panel.appendChild(h("h2"));
|
|
67
|
+
const panelBody = panel.appendChild(h("div", "ct-nb"));
|
|
68
|
+
const script = buildScript(blocks, labels);
|
|
69
|
+
viewport.append(overview.el, stage, panel, script.el);
|
|
70
|
+
const live = app.appendChild(h("div", "ct-live", "", { "aria-live": "polite" }));
|
|
71
|
+
// The shell takes the deck's place in the document and the deck itself moves onto the stage: same nodes, no copy.
|
|
72
|
+
const anchor = document.createComment("contour-deck");
|
|
73
|
+
root.replaceWith(anchor);
|
|
74
|
+
stage.append(root, pager, controls);
|
|
75
|
+
anchor.after(app);
|
|
76
|
+
document.documentElement.setAttribute("data-ct-open", "");
|
|
77
|
+
if (options.paging === "free")
|
|
78
|
+
app.dataset.ctPaging = "free";
|
|
79
|
+
/* ---- reading the current position ---- */
|
|
80
|
+
const slideAt = (at = pos) => blocks[at.b].slides[at.s];
|
|
81
|
+
const pageAt = (at = pos) => slideAt(at).pages[at.p];
|
|
82
|
+
const boxes = (slide) => slide.pages.map(page => ({ top: page.el.offsetTop, height: page.el.offsetHeight }));
|
|
83
|
+
const stopsOf = (slide) => pageStops(boxes(slide), slide.el.clientHeight);
|
|
84
|
+
const shown = (page) => page.steps.filter(step => step.hasAttribute(ON)).length;
|
|
85
|
+
const reveal = (pages, on) => pages.forEach(page => page.steps.forEach(step => step.toggleAttribute(ON, on)));
|
|
86
|
+
function label() {
|
|
87
|
+
const block = blocks[pos.b];
|
|
88
|
+
const count = slideAt().pages.length;
|
|
89
|
+
return `${block.short} · ${pos.s + 1}/${block.slides.length}${count > 1 ? ` · ${labels.page(pos.p + 1, count)}` : ""}`;
|
|
90
|
+
}
|
|
91
|
+
const snapshot = () => ({ mode, block: pos.b + 1, slide: pos.s + 1, page: pos.p + 1, step: shown(pageAt()) });
|
|
92
|
+
/* ---- rendering ---- */
|
|
93
|
+
function setCurrent(page) {
|
|
94
|
+
if (page === (current?.page ?? null))
|
|
95
|
+
return;
|
|
96
|
+
const fire = (type, target) => {
|
|
97
|
+
target.page.el.toggleAttribute("data-ct-current", type === "ct:enter");
|
|
98
|
+
const detail = { block: target.at.b + 1, slide: target.at.s + 1, page: target.at.p + 1 };
|
|
99
|
+
target.page.el.dispatchEvent(new CustomEvent(type, { bubbles: true, detail }));
|
|
100
|
+
};
|
|
101
|
+
if (current)
|
|
102
|
+
fire("ct:leave", current);
|
|
103
|
+
current = page && { page, at: pos };
|
|
104
|
+
if (current)
|
|
105
|
+
fire("ct:enter", current);
|
|
106
|
+
}
|
|
107
|
+
function writeUrl(clear = false) {
|
|
108
|
+
if (!useUrl)
|
|
109
|
+
return;
|
|
110
|
+
const url = new URL(location.href);
|
|
111
|
+
url.searchParams.delete("view");
|
|
112
|
+
url.searchParams.delete("at");
|
|
113
|
+
if (!clear) {
|
|
114
|
+
url.searchParams.set("view", mode === "notes" ? "notes" : "deck");
|
|
115
|
+
if (mode === "stage")
|
|
116
|
+
url.searchParams.set("at", formatAt(pos));
|
|
117
|
+
}
|
|
118
|
+
if (url.href === lastUrl)
|
|
119
|
+
return;
|
|
120
|
+
lastUrl = url.href;
|
|
121
|
+
try {
|
|
122
|
+
history.replaceState(history.state, "", url);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Some browsers refuse replaceState on file:// or rate-limit it; the deck works without the URL.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** The part of the picture that follows vertical scrolling: the page marks. */
|
|
129
|
+
function renderScroll() {
|
|
130
|
+
const slide = slideAt();
|
|
131
|
+
const y = slide.el.scrollTop;
|
|
132
|
+
const height = slide.el.clientHeight;
|
|
133
|
+
const pages = boxes(slide);
|
|
134
|
+
Array.from(pager.children).forEach((mark, p) => {
|
|
135
|
+
mark.toggleAttribute("data-on", p === pos.p);
|
|
136
|
+
if (p === pos.p)
|
|
137
|
+
mark.style.setProperty("--f", String(pageProgress(pages[p], y, height)));
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function render() {
|
|
141
|
+
const slide = slideAt();
|
|
142
|
+
const page = pageAt();
|
|
143
|
+
app.dataset.ctMode = mode;
|
|
144
|
+
overview.el.inert = mode !== "overview";
|
|
145
|
+
stage.inert = mode !== "stage";
|
|
146
|
+
// The title first: its width moves the rail, and the marker is placed by layout offsets.
|
|
147
|
+
titleEl.textContent = mode === "stage" ? blocks[pos.b].title : deckTitle;
|
|
148
|
+
rail.mark(pos);
|
|
149
|
+
overview.ring(pos.b, pos.s);
|
|
150
|
+
const height = slide.el.clientHeight || 1;
|
|
151
|
+
const pages = boxes(slide);
|
|
152
|
+
pager.replaceChildren(...(pages.length > 1 || isTall(pages[0], height)
|
|
153
|
+
? pages.map(box => {
|
|
154
|
+
const mark = h("i");
|
|
155
|
+
mark.style.height = `${Math.round(clamp((14 * box.height) / height, 14, 46))}px`;
|
|
156
|
+
return mark;
|
|
157
|
+
})
|
|
158
|
+
: []));
|
|
159
|
+
renderScroll();
|
|
160
|
+
panelPos.textContent = label();
|
|
161
|
+
panelBody.innerHTML = notesHtml(page);
|
|
162
|
+
if (!panelBody.childNodes.length)
|
|
163
|
+
panelBody.append(h("p", "", labels.noNotes));
|
|
164
|
+
setCurrent(mode === "stage" ? page : null);
|
|
165
|
+
if (mode === "notes")
|
|
166
|
+
wakeLock ??= navigator.wakeLock?.request("screen").catch(() => { }) ?? null;
|
|
167
|
+
else if (wakeLock) {
|
|
168
|
+
void wakeLock.then(lock => lock?.release());
|
|
169
|
+
wakeLock = null;
|
|
170
|
+
}
|
|
171
|
+
writeUrl();
|
|
172
|
+
const state = snapshot();
|
|
173
|
+
const key = JSON.stringify(state);
|
|
174
|
+
if (key === lastChange)
|
|
175
|
+
return;
|
|
176
|
+
lastChange = key;
|
|
177
|
+
live.textContent = mode === "stage" ? `${blocks[pos.b].title} ${label()}` : "";
|
|
178
|
+
const change = { ...state, at: formatAt(pos) };
|
|
179
|
+
listeners.change.forEach(listener => listener(change));
|
|
180
|
+
}
|
|
181
|
+
/* ---- the stage: scroll position is the source of truth for slide and page ---- */
|
|
182
|
+
/** Puts a block on the stage at a slide and page (or at the slide's end), without animation. */
|
|
183
|
+
function place(at, end = false) {
|
|
184
|
+
pending = null;
|
|
185
|
+
blocks.forEach((block, b) => block.el.toggleAttribute("data-ct-active", b === at.b));
|
|
186
|
+
const block = blocks[at.b];
|
|
187
|
+
const slide = block.slides[at.s];
|
|
188
|
+
block.el.scrollLeft = at.s * block.el.clientWidth;
|
|
189
|
+
block.slides.forEach((other, s) => {
|
|
190
|
+
reveal(other.pages, s < at.s || (s === at.s && end));
|
|
191
|
+
if (s !== at.s)
|
|
192
|
+
other.el.scrollTop = 0;
|
|
193
|
+
});
|
|
194
|
+
if (!end)
|
|
195
|
+
reveal(slide.pages.slice(0, at.p), true);
|
|
196
|
+
slide.el.scrollTop = end ? slide.el.scrollHeight : slide.pages[at.p].el.offsetTop;
|
|
197
|
+
pos = { b: at.b, s: at.s, p: end ? slide.pages.length - 1 : at.p };
|
|
198
|
+
}
|
|
199
|
+
/** Scroll events are read once a frame; a move that starts from the position reads it first, so it never acts on a stale one. */
|
|
200
|
+
function readScroll() {
|
|
201
|
+
cancelAnimationFrame(frame);
|
|
202
|
+
frame = 0;
|
|
203
|
+
if (busy || mode !== "stage")
|
|
204
|
+
return;
|
|
205
|
+
const block = blocks[pos.b];
|
|
206
|
+
const s = clamp(Math.round(block.el.scrollLeft / (block.el.clientWidth || 1)), 0, block.slides.length - 1);
|
|
207
|
+
const slide = block.slides[s];
|
|
208
|
+
if (pending) {
|
|
209
|
+
// On the way to a slide the deck chose, the slides passed over are not positions.
|
|
210
|
+
if (s !== pending.s)
|
|
211
|
+
return;
|
|
212
|
+
// Arrived: the slide shows the page it was sent to, even if the browser dropped the early scroll.
|
|
213
|
+
const top = pending.top();
|
|
214
|
+
pending = null;
|
|
215
|
+
if (Math.abs(slide.el.scrollTop - top) > 1)
|
|
216
|
+
slide.el.scrollTop = top;
|
|
217
|
+
}
|
|
218
|
+
const p = pageIndexAt(boxes(slide), slide.el.scrollTop, slide.el.clientHeight);
|
|
219
|
+
if (s === pos.s && p === pos.p)
|
|
220
|
+
return renderScroll();
|
|
221
|
+
pos = { b: pos.b, s, p };
|
|
222
|
+
render();
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Scrolling has paused. Slides off screen are put in order: later ones wait unrevealed at their top,
|
|
226
|
+
* earlier ones stay revealed. A pause is not always the end: under load a smooth scroll starts late or
|
|
227
|
+
* goes quiet mid-way, so while the deck's own slide change is under way nothing is reset.
|
|
228
|
+
*/
|
|
229
|
+
function settle() {
|
|
230
|
+
if (busy || mode !== "stage")
|
|
231
|
+
return;
|
|
232
|
+
const block = blocks[pos.b];
|
|
233
|
+
if (pending) {
|
|
234
|
+
const left = block.el.scrollLeft;
|
|
235
|
+
pending.still = left === pending.left ? pending.still + 1 : 0;
|
|
236
|
+
pending.left = left;
|
|
237
|
+
if (pending.still < 3)
|
|
238
|
+
return void (idle = window.setTimeout(settle, 160));
|
|
239
|
+
// It stopped short of the slide it was sent to: the reader took over.
|
|
240
|
+
pending = null;
|
|
241
|
+
readScroll();
|
|
242
|
+
}
|
|
243
|
+
block.slides.forEach((slide, s) => {
|
|
244
|
+
if (s > pos.s)
|
|
245
|
+
slide.el.scrollTop = 0;
|
|
246
|
+
if (s !== pos.s)
|
|
247
|
+
reveal(slide.pages, s < pos.s);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
root.addEventListener("scroll", () => {
|
|
251
|
+
frame ||= requestAnimationFrame(readScroll);
|
|
252
|
+
clearTimeout(idle);
|
|
253
|
+
idle = window.setTimeout(settle, 160);
|
|
254
|
+
}, { capture: true, signal });
|
|
255
|
+
/* ---- moves ---- */
|
|
256
|
+
/** One stop down or up inside the current slide; false at its end. */
|
|
257
|
+
function stepV(direction) {
|
|
258
|
+
const slide = slideAt();
|
|
259
|
+
const target = nextStop(stopsOf(slide), slide.el.scrollTop, direction);
|
|
260
|
+
if (target === undefined)
|
|
261
|
+
return false;
|
|
262
|
+
slide.el.scrollTo({ top: target, behavior: smooth() });
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
function slideTo(s, end = false, p = 0) {
|
|
266
|
+
const block = blocks[pos.b];
|
|
267
|
+
const target = block.slides[s];
|
|
268
|
+
if (s === pos.s)
|
|
269
|
+
return target.el.scrollTo({ top: target.pages[p].el.offsetTop, behavior: smooth() });
|
|
270
|
+
reveal(target.pages, s < pos.s || end);
|
|
271
|
+
if (!end && s > pos.s)
|
|
272
|
+
reveal(target.pages.slice(0, p), true);
|
|
273
|
+
const top = () => (end ? target.el.scrollHeight - target.el.clientHeight : target.pages[p].el.offsetTop);
|
|
274
|
+
target.el.scrollTop = top();
|
|
275
|
+
pending = { s, top, left: block.el.scrollLeft, still: 0 };
|
|
276
|
+
block.el.scrollTo({ left: s * block.el.clientWidth, behavior: smooth() });
|
|
277
|
+
clearTimeout(idle);
|
|
278
|
+
idle = window.setTimeout(settle, 160);
|
|
279
|
+
}
|
|
280
|
+
function slideBy(direction, end = false) {
|
|
281
|
+
readScroll();
|
|
282
|
+
const s = pos.s + direction;
|
|
283
|
+
if (blocks[pos.b].slides[s])
|
|
284
|
+
return slideTo(s, end);
|
|
285
|
+
const b = neighbourBlock(kinds, pos.b, direction, skip);
|
|
286
|
+
if (b >= 0)
|
|
287
|
+
void fly({ b, s: direction > 0 ? 0 : blocks[b].slides.length - 1, p: 0 }, direction < 0 && end);
|
|
288
|
+
}
|
|
289
|
+
function next() {
|
|
290
|
+
if (busy || mode === "notes")
|
|
291
|
+
return;
|
|
292
|
+
if (mode === "overview")
|
|
293
|
+
return void open(pos);
|
|
294
|
+
readScroll();
|
|
295
|
+
const slide = slideAt();
|
|
296
|
+
const step = pageAt().steps.find(item => !item.hasAttribute(ON));
|
|
297
|
+
if (!step)
|
|
298
|
+
return void (stepV(1) || slideBy(1));
|
|
299
|
+
step.setAttribute(ON, "");
|
|
300
|
+
const below = step.getBoundingClientRect().bottom - slide.el.getBoundingClientRect().bottom;
|
|
301
|
+
if (below > -8)
|
|
302
|
+
slide.el.scrollBy({ top: below + 32, behavior: smooth() });
|
|
303
|
+
render();
|
|
304
|
+
}
|
|
305
|
+
function prev() {
|
|
306
|
+
if (busy || mode !== "stage")
|
|
307
|
+
return;
|
|
308
|
+
readScroll();
|
|
309
|
+
const step = pageAt().steps.findLast(item => item.hasAttribute(ON));
|
|
310
|
+
if (!step)
|
|
311
|
+
return void (stepV(-1) || slideBy(-1, true));
|
|
312
|
+
step.removeAttribute(ON);
|
|
313
|
+
render();
|
|
314
|
+
}
|
|
315
|
+
/* ---- transitions between the outline and the stage ---- */
|
|
316
|
+
const setBusy = (on) => {
|
|
317
|
+
busy = on;
|
|
318
|
+
app.toggleAttribute("data-ct-busy", on);
|
|
319
|
+
};
|
|
320
|
+
const thumbRect = (at) => {
|
|
321
|
+
const face = overview.faces[at.b][at.s];
|
|
322
|
+
face.scrollIntoView({ block: "nearest" });
|
|
323
|
+
return face.getBoundingClientRect();
|
|
324
|
+
};
|
|
325
|
+
const cancel = (animations) => animations.forEach(animation => animation.cancel());
|
|
326
|
+
/** Outline to stage: the stage grows out of the slide's thumbnail. */
|
|
327
|
+
async function open(at) {
|
|
328
|
+
setBusy(true);
|
|
329
|
+
place(at);
|
|
330
|
+
overview.ring(at.b, at.s, true);
|
|
331
|
+
const thumb = thumbRect(at);
|
|
332
|
+
const animations = [...zoomStage(stage, root, thumb, "in", OPEN_MS, reduced()), zoomOverview(overview.el, thumb, "out", OPEN_MS, reduced())];
|
|
333
|
+
await finished(animations);
|
|
334
|
+
if (!alive)
|
|
335
|
+
return;
|
|
336
|
+
mode = "stage";
|
|
337
|
+
render();
|
|
338
|
+
setBusy(false);
|
|
339
|
+
cancel(animations);
|
|
340
|
+
stage.focus({ preventScroll: true });
|
|
341
|
+
}
|
|
342
|
+
/** Stage to outline: the stage shrinks back into its thumbnail. */
|
|
343
|
+
async function close() {
|
|
344
|
+
setBusy(true);
|
|
345
|
+
overview.ring(pos.b, pos.s, true);
|
|
346
|
+
const thumb = thumbRect(pos);
|
|
347
|
+
const animations = [...zoomStage(stage, root, thumb, "out", CLOSE_MS, reduced()), zoomOverview(overview.el, thumb, "in", CLOSE_MS, reduced())];
|
|
348
|
+
await finished(animations);
|
|
349
|
+
if (!alive)
|
|
350
|
+
return;
|
|
351
|
+
mode = "overview";
|
|
352
|
+
render();
|
|
353
|
+
setBusy(false);
|
|
354
|
+
cancel(animations);
|
|
355
|
+
overview.thumbs[pos.b][pos.s].focus({ preventScroll: true });
|
|
356
|
+
}
|
|
357
|
+
/** Block change: pull back to the outline, let the ring and the rail marker travel, dive into the new block. */
|
|
358
|
+
async function fly(at, end = false) {
|
|
359
|
+
setBusy(true);
|
|
360
|
+
overview.ring(pos.b, pos.s, true);
|
|
361
|
+
const out = zoomStage(stage, root, thumbRect(pos), "out", 360, reduced());
|
|
362
|
+
await finished(out);
|
|
363
|
+
if (!alive)
|
|
364
|
+
return;
|
|
365
|
+
stage.style.opacity = "0";
|
|
366
|
+
cancel(out);
|
|
367
|
+
place(at, end);
|
|
368
|
+
const thumb = thumbRect(pos);
|
|
369
|
+
render();
|
|
370
|
+
await wait(reduced() ? 80 : 400);
|
|
371
|
+
if (!alive)
|
|
372
|
+
return;
|
|
373
|
+
const into = zoomStage(stage, root, thumb, "in", 460, reduced());
|
|
374
|
+
stage.style.opacity = "";
|
|
375
|
+
await finished(into);
|
|
376
|
+
if (!alive)
|
|
377
|
+
return;
|
|
378
|
+
setBusy(false);
|
|
379
|
+
cancel(into);
|
|
380
|
+
stage.focus({ preventScroll: true });
|
|
381
|
+
render();
|
|
382
|
+
}
|
|
383
|
+
async function goTo(target) {
|
|
384
|
+
const at = target && fitPos(blocks, target);
|
|
385
|
+
if (!at || busy)
|
|
386
|
+
return;
|
|
387
|
+
if (mode === "overview")
|
|
388
|
+
return open(at);
|
|
389
|
+
if (mode === "notes") {
|
|
390
|
+
place(at);
|
|
391
|
+
mode = "stage";
|
|
392
|
+
render();
|
|
393
|
+
return stage.focus({ preventScroll: true });
|
|
394
|
+
}
|
|
395
|
+
if (at.b !== pos.b)
|
|
396
|
+
return fly(at);
|
|
397
|
+
slideTo(at.s, false, at.p);
|
|
398
|
+
}
|
|
399
|
+
async function toOverview() {
|
|
400
|
+
if (busy || mode === "overview")
|
|
401
|
+
return;
|
|
402
|
+
readScroll();
|
|
403
|
+
if (mode === "stage")
|
|
404
|
+
return close();
|
|
405
|
+
mode = "overview";
|
|
406
|
+
render();
|
|
407
|
+
}
|
|
408
|
+
function toScript() {
|
|
409
|
+
if (busy)
|
|
410
|
+
return;
|
|
411
|
+
readScroll();
|
|
412
|
+
mode = "notes";
|
|
413
|
+
render();
|
|
414
|
+
script.show(pos);
|
|
415
|
+
focusLayer();
|
|
416
|
+
}
|
|
417
|
+
/** Puts the focus on the layer in front: the stage, the current thumbnail, or the notes script. */
|
|
418
|
+
function focusLayer() {
|
|
419
|
+
const target = mode === "stage" ? stage : mode === "notes" ? script.el : overview.thumbs[pos.b][pos.s];
|
|
420
|
+
target.focus({ preventScroll: true });
|
|
421
|
+
}
|
|
422
|
+
function resolve(at) {
|
|
423
|
+
const numeric = parseAt(at);
|
|
424
|
+
if (numeric)
|
|
425
|
+
return fitPos(blocks, numeric);
|
|
426
|
+
const target = document.getElementById(at);
|
|
427
|
+
return target && root.contains(target) ? posOf(blocks, target) : null;
|
|
428
|
+
}
|
|
429
|
+
/* ---- input ---- */
|
|
430
|
+
const press = (el, on) => el.setAttribute("aria-pressed", String(on));
|
|
431
|
+
const toggle3d = () => press(threeDButton, app.toggleAttribute("data-ct-3d"));
|
|
432
|
+
/** The stage changed size: put the current slide and page back in view. */
|
|
433
|
+
function align(p = pos.p) {
|
|
434
|
+
if (busy)
|
|
435
|
+
return;
|
|
436
|
+
const block = blocks[pos.b];
|
|
437
|
+
const slide = slideAt();
|
|
438
|
+
pos = { ...pos, p: clamp(p, 0, slide.pages.length - 1) };
|
|
439
|
+
pending = null;
|
|
440
|
+
block.el.scrollLeft = pos.s * block.el.clientWidth;
|
|
441
|
+
slide.el.scrollTop = slide.pages[pos.p].el.offsetTop;
|
|
442
|
+
render();
|
|
443
|
+
}
|
|
444
|
+
function toggleNotes() {
|
|
445
|
+
readScroll();
|
|
446
|
+
const p = pos.p;
|
|
447
|
+
press(notesButton, app.toggleAttribute("data-ct-panel"));
|
|
448
|
+
align(p);
|
|
449
|
+
}
|
|
450
|
+
/** Leaving is the host's move: it usually answers `exit` with `destroy()`. */
|
|
451
|
+
const exit = () => busy || listeners.exit.forEach(listener => listener());
|
|
452
|
+
const remote = () => options.remote?.(api);
|
|
453
|
+
const actions = { overview: toOverview, "3d": toggle3d, script: toScript, notes: toggleNotes, remote, prev, next, exit };
|
|
454
|
+
app.addEventListener("click", event => {
|
|
455
|
+
const target = event.target;
|
|
456
|
+
const action = target.closest("[data-ct-act]")?.dataset.ctAct;
|
|
457
|
+
if (action)
|
|
458
|
+
return void actions[action]?.();
|
|
459
|
+
const go = target.closest("[data-ct-go]")?.dataset.ctGo;
|
|
460
|
+
if (!go)
|
|
461
|
+
return;
|
|
462
|
+
const [b, s, p] = go.split(",").map(Number);
|
|
463
|
+
void goTo({ b, s, p });
|
|
464
|
+
}, { signal });
|
|
465
|
+
addEventListener("resize", () => {
|
|
466
|
+
const p = pos.p;
|
|
467
|
+
requestAnimationFrame(() => alive && align(p));
|
|
468
|
+
}, { signal });
|
|
469
|
+
// The browser drops the screen wake lock when the page is hidden; the notes view asks again on return.
|
|
470
|
+
document.addEventListener("visibilitychange", () => {
|
|
471
|
+
if (document.hidden || !wakeLock)
|
|
472
|
+
return;
|
|
473
|
+
wakeLock = null;
|
|
474
|
+
render();
|
|
475
|
+
}, { signal });
|
|
476
|
+
function focusThumb(step) {
|
|
477
|
+
const all = overview.thumbs.flat();
|
|
478
|
+
const focused = all.indexOf(document.activeElement);
|
|
479
|
+
all[focused < 0 ? all.indexOf(overview.thumbs[pos.b][pos.s]) : clamp(focused + step, 0, all.length - 1)].focus();
|
|
480
|
+
}
|
|
481
|
+
document.addEventListener("keydown", event => {
|
|
482
|
+
const target = event.target;
|
|
483
|
+
if (event.metaKey || event.ctrlKey || event.altKey || busy || target.closest?.("input,textarea,select,[contenteditable]"))
|
|
484
|
+
return;
|
|
485
|
+
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
|
486
|
+
const onControl = !!target.closest?.("button,a");
|
|
487
|
+
const activates = key === " " || key === "Enter";
|
|
488
|
+
let run;
|
|
489
|
+
if (key === "r" && options.remote && mode !== "notes")
|
|
490
|
+
run = remote;
|
|
491
|
+
else if (mode === "overview") {
|
|
492
|
+
if (key === "3")
|
|
493
|
+
run = toggle3d;
|
|
494
|
+
else if (key === "Escape")
|
|
495
|
+
run = exit;
|
|
496
|
+
else if (key.startsWith("Arrow"))
|
|
497
|
+
run = () => focusThumb(key === "ArrowRight" || key === "ArrowDown" ? 1 : -1);
|
|
498
|
+
else if (key === "PageDown" || (activates && !onControl))
|
|
499
|
+
run = next;
|
|
500
|
+
}
|
|
501
|
+
else if (mode === "notes") {
|
|
502
|
+
if (key === "Escape" || key === "o")
|
|
503
|
+
run = toOverview;
|
|
504
|
+
}
|
|
505
|
+
else if (!(activates && onControl)) {
|
|
506
|
+
const keys = {
|
|
507
|
+
" ": event.shiftKey ? prev : next,
|
|
508
|
+
Enter: next,
|
|
509
|
+
PageDown: next,
|
|
510
|
+
PageUp: prev,
|
|
511
|
+
Backspace: prev,
|
|
512
|
+
ArrowRight: () => slideBy(1),
|
|
513
|
+
ArrowLeft: () => slideBy(-1),
|
|
514
|
+
ArrowDown: () => stepV(1),
|
|
515
|
+
ArrowUp: () => stepV(-1),
|
|
516
|
+
Escape: toOverview,
|
|
517
|
+
o: toOverview,
|
|
518
|
+
n: toggleNotes,
|
|
519
|
+
Home: () => slideTo(0),
|
|
520
|
+
End: () => slideTo(blocks[pos.b].slides.length - 1),
|
|
521
|
+
};
|
|
522
|
+
run = keys[key];
|
|
523
|
+
}
|
|
524
|
+
if (!run)
|
|
525
|
+
return;
|
|
526
|
+
event.preventDefault();
|
|
527
|
+
void run();
|
|
528
|
+
}, { signal });
|
|
529
|
+
// Wheel and trackpad: one gesture moves one stop. Touch scrolling stays native (CSS scroll snap).
|
|
530
|
+
const wheelStep = createWheelGate();
|
|
531
|
+
if (options.paging !== "free") {
|
|
532
|
+
stage.addEventListener("wheel", event => {
|
|
533
|
+
if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY))
|
|
534
|
+
return; // pinch, or a sideways swipe
|
|
535
|
+
event.preventDefault();
|
|
536
|
+
const step = wheelStep(event.deltaY * (event.deltaMode === 1 ? 16 : 1), performance.now(), busy);
|
|
537
|
+
if (step)
|
|
538
|
+
stepV(step);
|
|
539
|
+
}, { passive: false, signal });
|
|
540
|
+
}
|
|
541
|
+
/* ---- start ---- */
|
|
542
|
+
const params = new URLSearchParams(useUrl ? location.search : "");
|
|
543
|
+
const startAt = resolve(options.at ?? params.get("at") ?? "");
|
|
544
|
+
const startView = options.view ?? (params.get("view") === "notes" ? "notes" : "deck");
|
|
545
|
+
place(startAt ?? pos);
|
|
546
|
+
if (startView === "notes")
|
|
547
|
+
mode = "notes";
|
|
548
|
+
else if (startAt)
|
|
549
|
+
mode = "stage";
|
|
550
|
+
if (options.overview === "3d")
|
|
551
|
+
toggle3d();
|
|
552
|
+
render();
|
|
553
|
+
if (mode === "notes")
|
|
554
|
+
script.show(pos);
|
|
555
|
+
// Focus moves into the shell, so the keys reach the deck and not the control that opened it.
|
|
556
|
+
focusLayer();
|
|
557
|
+
function destroy() {
|
|
558
|
+
if (!alive)
|
|
559
|
+
return;
|
|
560
|
+
alive = false;
|
|
561
|
+
listeners.destroy.forEach(listener => listener());
|
|
562
|
+
abort.abort();
|
|
563
|
+
cancelAnimationFrame(frame);
|
|
564
|
+
clearTimeout(idle);
|
|
565
|
+
setCurrent(null);
|
|
566
|
+
void wakeLock?.then(lock => lock?.release());
|
|
567
|
+
for (const block of blocks) {
|
|
568
|
+
block.el.removeAttribute("data-ct-active");
|
|
569
|
+
for (const slide of block.slides)
|
|
570
|
+
reveal(slide.pages, false);
|
|
571
|
+
}
|
|
572
|
+
cancel(root.getAnimations());
|
|
573
|
+
anchor.replaceWith(root);
|
|
574
|
+
app.remove();
|
|
575
|
+
document.documentElement.removeAttribute("data-ct-open");
|
|
576
|
+
writeUrl(true);
|
|
577
|
+
}
|
|
578
|
+
const onStage = (move) => void (busy || mode !== "stage" || move());
|
|
579
|
+
const api = {
|
|
580
|
+
overview: toOverview,
|
|
581
|
+
open: at => goTo(resolve(at)),
|
|
582
|
+
go: at => goTo(resolve(at)),
|
|
583
|
+
next,
|
|
584
|
+
prev,
|
|
585
|
+
slideBy: direction => onStage(() => slideBy(direction)),
|
|
586
|
+
pageBy: direction => onStage(() => stepV(direction)),
|
|
587
|
+
on(type, listener) {
|
|
588
|
+
const set = listeners[type];
|
|
589
|
+
set.add(listener);
|
|
590
|
+
return () => set.delete(listener);
|
|
591
|
+
},
|
|
592
|
+
get state() {
|
|
593
|
+
return snapshot();
|
|
594
|
+
},
|
|
595
|
+
destroy,
|
|
596
|
+
};
|
|
597
|
+
return api;
|
|
598
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const clamp: (value: number, min: number, max: number) => number;
|
|
2
|
+
/** Creates an element; text goes in as text, never as markup. */
|
|
3
|
+
export declare function h<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string, attributes?: Record<string, string | number>): HTMLElementTagNameMap[K];
|
|
4
|
+
/** Layout offset of `el` inside its positioned ancestor `box`, unaffected by transforms. */
|
|
5
|
+
export declare function offsetIn(el: HTMLElement, box: HTMLElement): {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
};
|
package/dist/core/dom.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
2
|
+
/** Creates an element; text goes in as text, never as markup. */
|
|
3
|
+
export function h(tag, className = "", text = "", attributes = {}) {
|
|
4
|
+
const el = document.createElement(tag);
|
|
5
|
+
if (className)
|
|
6
|
+
el.className = className;
|
|
7
|
+
if (text)
|
|
8
|
+
el.textContent = text;
|
|
9
|
+
for (const name in attributes)
|
|
10
|
+
el.setAttribute(name, String(attributes[name]));
|
|
11
|
+
return el;
|
|
12
|
+
}
|
|
13
|
+
/** Layout offset of `el` inside its positioned ancestor `box`, unaffected by transforms. */
|
|
14
|
+
export function offsetIn(el, box) {
|
|
15
|
+
let x = 0;
|
|
16
|
+
let y = 0;
|
|
17
|
+
for (let node = el; node && node !== box; node = node.offsetParent) {
|
|
18
|
+
x += node.offsetLeft;
|
|
19
|
+
y += node.offsetTop;
|
|
20
|
+
}
|
|
21
|
+
return { x, y };
|
|
22
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns a stream of wheel events into single steps: one gesture moves one stop.
|
|
3
|
+
* A trackpad keeps sending shrinking deltas after the fingers lift (the inertia tail);
|
|
4
|
+
* those are ignored until a pause, or a clearly larger delta, marks a new gesture.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createWheelGate(lockMs?: number, gapMs?: number): (deltaY: number, now: number, blocked?: boolean) => 1 | -1 | 0;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns a stream of wheel events into single steps: one gesture moves one stop.
|
|
3
|
+
* A trackpad keeps sending shrinking deltas after the fingers lift (the inertia tail);
|
|
4
|
+
* those are ignored until a pause, or a clearly larger delta, marks a new gesture.
|
|
5
|
+
*/
|
|
6
|
+
export function createWheelGate(lockMs = 420, gapMs = 140) {
|
|
7
|
+
let lockedUntil = 0;
|
|
8
|
+
let inTail = false;
|
|
9
|
+
let lastTime = -Infinity;
|
|
10
|
+
let lastSize = 0;
|
|
11
|
+
/** Returns the step to take for this event: 1 down, -1 up, 0 none. */
|
|
12
|
+
return (deltaY, now, blocked = false) => {
|
|
13
|
+
const size = Math.abs(deltaY);
|
|
14
|
+
if (now - lastTime > gapMs || size > lastSize * 1.7 + 6)
|
|
15
|
+
inTail = false;
|
|
16
|
+
lastTime = now;
|
|
17
|
+
lastSize = size;
|
|
18
|
+
if (blocked || now < lockedUntil || inTail || size < 3)
|
|
19
|
+
return 0;
|
|
20
|
+
inTail = true;
|
|
21
|
+
lockedUntil = now + lockMs;
|
|
22
|
+
return deltaY > 0 ? 1 : -1;
|
|
23
|
+
};
|
|
24
|
+
}
|