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,20 @@
|
|
|
1
|
+
export const defaultLabels = {
|
|
2
|
+
overview: "輪廓",
|
|
3
|
+
notes: "講稿",
|
|
4
|
+
script: "提詞",
|
|
5
|
+
threeD: "3D",
|
|
6
|
+
prev: "上一步",
|
|
7
|
+
next: "下一步",
|
|
8
|
+
exit: "離開簡報",
|
|
9
|
+
remote: "遙控",
|
|
10
|
+
here: "目前位置",
|
|
11
|
+
appendix: "書面",
|
|
12
|
+
rail: "整份簡報的位置",
|
|
13
|
+
noNotes: "(這一頁沒有講稿)",
|
|
14
|
+
slides: count => `${count} 張`,
|
|
15
|
+
pages: count => `${count} 頁`,
|
|
16
|
+
minutes: count => `約 ${count} 分`,
|
|
17
|
+
page: (index, count) => `頁 ${index}/${count}`,
|
|
18
|
+
steps: (shown, count) => `要點 ${shown}/${count}`,
|
|
19
|
+
thumb: (block, slide, title, pages) => `${block},第 ${slide} 張:${title}${pages > 1 ? `,${pages} 頁` : ""}`,
|
|
20
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** The outline tree, read once from the contract HTML (docs/CONTRACT.md). */
|
|
2
|
+
export interface PageNode {
|
|
3
|
+
el: HTMLElement;
|
|
4
|
+
steps: HTMLElement[];
|
|
5
|
+
notes: HTMLElement[];
|
|
6
|
+
}
|
|
7
|
+
export interface SlideNode {
|
|
8
|
+
el: HTMLElement;
|
|
9
|
+
title: string;
|
|
10
|
+
pages: PageNode[];
|
|
11
|
+
}
|
|
12
|
+
export interface BlockNode {
|
|
13
|
+
el: HTMLElement;
|
|
14
|
+
title: string;
|
|
15
|
+
short: string;
|
|
16
|
+
kind: string;
|
|
17
|
+
minutes: number;
|
|
18
|
+
slides: SlideNode[];
|
|
19
|
+
}
|
|
20
|
+
/** A position with 0-based indexes. The public API and the URL use 1-based ordinals. */
|
|
21
|
+
export interface Pos {
|
|
22
|
+
b: number;
|
|
23
|
+
s: number;
|
|
24
|
+
p: number;
|
|
25
|
+
}
|
|
26
|
+
export declare function readModel(root: HTMLElement, deckTitle: string): BlockNode[];
|
|
27
|
+
/** `"4"`, `"4.4"`, or `"4.4.2"` (1-based) to a position; `null` when the text is not numeric. */
|
|
28
|
+
export declare function parseAt(at: string): Pos | null;
|
|
29
|
+
export declare const formatAt: ({ b, s, p }: Pos) => string;
|
|
30
|
+
/** A position that exists in `blocks`, or `null`: the block and slide must exist, the page is clamped. */
|
|
31
|
+
export declare function fitPos(blocks: {
|
|
32
|
+
slides: {
|
|
33
|
+
pages: unknown[];
|
|
34
|
+
}[];
|
|
35
|
+
}[], pos: Pos): Pos | null;
|
|
36
|
+
/** The position of the block, slide, or page that is, or contains, `target`. */
|
|
37
|
+
export declare function posOf(blocks: BlockNode[], target: Element): Pos | null;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** The outline tree, read once from the contract HTML (docs/CONTRACT.md). */
|
|
2
|
+
const childrenWith = (el, attribute) => Array.from(el.children).filter((child) => child.hasAttribute(attribute));
|
|
3
|
+
function need(list, what) {
|
|
4
|
+
if (!list.length)
|
|
5
|
+
throw new Error(`contour-deck: ${what} (see docs/CONTRACT.md)`);
|
|
6
|
+
return list;
|
|
7
|
+
}
|
|
8
|
+
export function readModel(root, deckTitle) {
|
|
9
|
+
return need(childrenWith(root, "data-ct-block"), "the deck has no [data-ct-block] child").map((el, b) => ({
|
|
10
|
+
el,
|
|
11
|
+
title: el.dataset.title || deckTitle,
|
|
12
|
+
short: el.dataset.short || String(b + 1),
|
|
13
|
+
kind: el.dataset.kind || "",
|
|
14
|
+
minutes: Number(el.dataset.minutes) || 0,
|
|
15
|
+
slides: need(childrenWith(el, "data-ct-slide"), `block ${b + 1} has no [data-ct-slide] child`).map((slide, s) => ({
|
|
16
|
+
el: slide,
|
|
17
|
+
title: slide.dataset.title || "",
|
|
18
|
+
pages: need(childrenWith(slide, "data-ct-page"), `slide ${b + 1}.${s + 1} has no [data-ct-page] child`).map(page => ({
|
|
19
|
+
el: page,
|
|
20
|
+
steps: Array.from(page.querySelectorAll("[data-ct-step]")),
|
|
21
|
+
notes: childrenWith(page, "data-ct-notes"),
|
|
22
|
+
})),
|
|
23
|
+
})),
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
/** `"4"`, `"4.4"`, or `"4.4.2"` (1-based) to a position; `null` when the text is not numeric. */
|
|
27
|
+
export function parseAt(at) {
|
|
28
|
+
const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(at);
|
|
29
|
+
if (!match)
|
|
30
|
+
return null;
|
|
31
|
+
return { b: Number(match[1]) - 1, s: Number(match[2] ?? 1) - 1, p: Number(match[3] ?? 1) - 1 };
|
|
32
|
+
}
|
|
33
|
+
export const formatAt = ({ b, s, p }) => `${b + 1}.${s + 1}.${p + 1}`;
|
|
34
|
+
/** A position that exists in `blocks`, or `null`: the block and slide must exist, the page is clamped. */
|
|
35
|
+
export function fitPos(blocks, pos) {
|
|
36
|
+
const slide = blocks[pos.b]?.slides[pos.s];
|
|
37
|
+
if (!slide || pos.p < 0)
|
|
38
|
+
return null;
|
|
39
|
+
return { b: pos.b, s: pos.s, p: Math.min(pos.p, slide.pages.length - 1) };
|
|
40
|
+
}
|
|
41
|
+
/** The position of the block, slide, or page that is, or contains, `target`. */
|
|
42
|
+
export function posOf(blocks, target) {
|
|
43
|
+
for (const [b, block] of blocks.entries()) {
|
|
44
|
+
if (!block.el.contains(target))
|
|
45
|
+
continue;
|
|
46
|
+
const s = Math.max(0, block.slides.findIndex(slide => slide.el.contains(target)));
|
|
47
|
+
const p = Math.max(0, block.slides[s].pages.findIndex(page => page.el.contains(target)));
|
|
48
|
+
return { b, s, p };
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Which block a move across the block boundary lands in. */
|
|
2
|
+
/**
|
|
3
|
+
* The nearest block in `direction` whose kind is not skipped, or -1.
|
|
4
|
+
* With `skip = ["appendix"]` the main part ends at its last block, and an appendix is left backwards only.
|
|
5
|
+
*/
|
|
6
|
+
export declare function neighbourBlock(kinds: string[], from: number, direction: 1 | -1, skip: string[]): number;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Which block a move across the block boundary lands in. */
|
|
2
|
+
/**
|
|
3
|
+
* The nearest block in `direction` whose kind is not skipped, or -1.
|
|
4
|
+
* With `skip = ["appendix"]` the main part ends at its last block, and an appendix is left backwards only.
|
|
5
|
+
*/
|
|
6
|
+
export function neighbourBlock(kinds, from, direction, skip) {
|
|
7
|
+
for (let b = from + direction; b >= 0 && b < kinds.length; b += direction) {
|
|
8
|
+
if (!skip.includes(kinds[b]))
|
|
9
|
+
return b;
|
|
10
|
+
}
|
|
11
|
+
return -1;
|
|
12
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { BlockNode, PageNode, Pos } from "./model.js";
|
|
2
|
+
import type { Labels } from "./types.js";
|
|
3
|
+
export declare const notesHtml: (page: PageNode) => string;
|
|
4
|
+
export declare function pageTitle(page: PageNode): string;
|
|
5
|
+
export interface Script {
|
|
6
|
+
el: HTMLElement;
|
|
7
|
+
/** Scrolls the entry of a position to the top. */
|
|
8
|
+
show(pos: Pos): void;
|
|
9
|
+
}
|
|
10
|
+
/** Every page in order with its notes; the heading of an entry opens that page on the stage. */
|
|
11
|
+
export declare function buildScript(blocks: BlockNode[], labels: Labels): Script;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Speaker notes: the panel beside the stage, and the notes view (a scrolling script for a phone). */
|
|
2
|
+
import { h } from "./dom.js";
|
|
3
|
+
export const notesHtml = (page) => page.notes.map(note => note.innerHTML).join("");
|
|
4
|
+
export function pageTitle(page) {
|
|
5
|
+
return page.el.querySelector("h1,h2,h3")?.textContent?.trim() ?? "";
|
|
6
|
+
}
|
|
7
|
+
/** Every page in order with its notes; the heading of an entry opens that page on the stage. */
|
|
8
|
+
export function buildScript(blocks, labels) {
|
|
9
|
+
const el = h("div", "ct-script", "", { tabindex: -1 });
|
|
10
|
+
const entries = new Map();
|
|
11
|
+
blocks.forEach((block, b) => {
|
|
12
|
+
const section = el.appendChild(h("section", "ct-sb"));
|
|
13
|
+
const head = section.appendChild(h("h2", "ct-sbh", block.title));
|
|
14
|
+
if (block.kind === "appendix")
|
|
15
|
+
head.append(h("span", "ct-badge", labels.appendix));
|
|
16
|
+
if (block.minutes)
|
|
17
|
+
head.append(h("span", "ct-meta", labels.minutes(block.minutes)));
|
|
18
|
+
block.slides.forEach((slide, s) => {
|
|
19
|
+
slide.pages.forEach((page, p) => {
|
|
20
|
+
const entry = section.appendChild(h("article", "ct-se"));
|
|
21
|
+
const count = slide.pages.length;
|
|
22
|
+
const open = entry.appendChild(h("button", "ct-sh", "", { "data-ct-go": `${b},${s},${p}` }));
|
|
23
|
+
open.append(h("span", "ct-meta", `${block.short}.${s + 1}${count > 1 ? ` · ${labels.page(p + 1, count)}` : ""}`), h("b", "", pageTitle(page) || slide.title));
|
|
24
|
+
const body = entry.appendChild(h("div", "ct-sn"));
|
|
25
|
+
body.innerHTML = notesHtml(page);
|
|
26
|
+
entries.set(`${b},${s},${p}`, entry);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
return {
|
|
31
|
+
el,
|
|
32
|
+
show: ({ b, s, p }) => entries.get(`${b},${s},${p}`)?.scrollIntoView(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { BlockNode } from "./model.js";
|
|
2
|
+
import type { Labels } from "./types.js";
|
|
3
|
+
export interface Overview {
|
|
4
|
+
el: HTMLElement;
|
|
5
|
+
/** `faces[b][s]` is the front sheet of a thumbnail, the rectangle the stage zooms from. */
|
|
6
|
+
faces: HTMLElement[][];
|
|
7
|
+
thumbs: HTMLButtonElement[][];
|
|
8
|
+
/** Moves the "you are here" ring onto a thumbnail. */
|
|
9
|
+
ring(b: number, s: number, instant?: boolean): void;
|
|
10
|
+
}
|
|
11
|
+
export declare function buildOverview(blocks: BlockNode[], labels: Labels): Overview;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** The outline: one panel per block, one schematic thumbnail per slide. */
|
|
2
|
+
import { h, offsetIn } from "./dom.js";
|
|
3
|
+
const FIGURE = "figure,img,picture,svg,canvas,video,iframe";
|
|
4
|
+
function sketchType(el) {
|
|
5
|
+
if (el.dataset.ctSk)
|
|
6
|
+
return el.dataset.ctSk;
|
|
7
|
+
if (el.matches("ul,ol"))
|
|
8
|
+
return "list";
|
|
9
|
+
if (el.matches("table") || (el.children.length === 1 && el.children[0].matches("table")))
|
|
10
|
+
return "table";
|
|
11
|
+
if (el.matches("blockquote"))
|
|
12
|
+
return "quote";
|
|
13
|
+
if (el.matches(FIGURE))
|
|
14
|
+
return "figure";
|
|
15
|
+
return el.tagName === "DIV" && el.children.length > 1 ? "grid" : "text";
|
|
16
|
+
}
|
|
17
|
+
/** Up to two marks that hint at what the page holds. */
|
|
18
|
+
function sketch(page) {
|
|
19
|
+
const box = h("span", "ct-sk");
|
|
20
|
+
for (const el of page.children) {
|
|
21
|
+
if (!(el instanceof HTMLElement) || el.matches("[data-ct-notes],h1,h2,h3,h4,h5,h6"))
|
|
22
|
+
continue;
|
|
23
|
+
const type = sketchType(el);
|
|
24
|
+
const mark = h("i", `ct-g ct-g-${type}`);
|
|
25
|
+
const cells = type === "nums" ? 2 : Math.min(el.children.length, 4);
|
|
26
|
+
if (type === "list")
|
|
27
|
+
mark.style.setProperty("--c", String(cells));
|
|
28
|
+
if (type === "grid" || type === "flow" || type === "nums")
|
|
29
|
+
for (let i = 0; i < cells; i++)
|
|
30
|
+
mark.append(h("b"));
|
|
31
|
+
box.append(mark);
|
|
32
|
+
if (box.children.length === 2)
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
return box;
|
|
36
|
+
}
|
|
37
|
+
export function buildOverview(blocks, labels) {
|
|
38
|
+
const el = h("div", "ct-ov");
|
|
39
|
+
const plane = el.appendChild(h("div", "ct-plane"));
|
|
40
|
+
const faces = [];
|
|
41
|
+
const thumbs = [];
|
|
42
|
+
blocks.forEach((block, b) => {
|
|
43
|
+
const tile = plane.appendChild(h("section", "ct-tile"));
|
|
44
|
+
if (block.kind)
|
|
45
|
+
tile.dataset.kind = block.kind;
|
|
46
|
+
if (block.slides.length > 3)
|
|
47
|
+
tile.dataset.wide = "";
|
|
48
|
+
const pages = block.slides.reduce((sum, slide) => sum + slide.pages.length, 0);
|
|
49
|
+
const meta = [labels.slides(block.slides.length)];
|
|
50
|
+
if (pages > block.slides.length)
|
|
51
|
+
meta.push(labels.pages(pages));
|
|
52
|
+
if (block.minutes)
|
|
53
|
+
meta.push(labels.minutes(block.minutes));
|
|
54
|
+
const open = tile.appendChild(h("button", "ct-open", "", { "data-ct-go": `${b},0,0` }));
|
|
55
|
+
open.append(h("span", "ct-tt", block.title));
|
|
56
|
+
if (block.kind === "appendix")
|
|
57
|
+
open.append(h("span", "ct-badge", labels.appendix));
|
|
58
|
+
open.append(h("span", "ct-meta", meta.join(" · ")));
|
|
59
|
+
const strip = tile.appendChild(h("ol", "ct-strip"));
|
|
60
|
+
faces[b] = [];
|
|
61
|
+
thumbs[b] = block.slides.map((slide, s) => {
|
|
62
|
+
const count = slide.pages.length;
|
|
63
|
+
const thumb = h("button", "ct-thumb", "", {
|
|
64
|
+
"data-ct-go": `${b},${s},0`,
|
|
65
|
+
"aria-label": labels.thumb(block.title, s + 1, slide.title, count),
|
|
66
|
+
});
|
|
67
|
+
thumb.style.setProperty("--n", String(count));
|
|
68
|
+
// A slide with several pages is a stack of sheets, one per page.
|
|
69
|
+
for (let k = count - 1; k > 0; k--)
|
|
70
|
+
thumb.appendChild(h("span", "ct-layer")).style.setProperty("--k", String(k));
|
|
71
|
+
const face = thumb.appendChild(h("span", "ct-face"));
|
|
72
|
+
const number = face.appendChild(h("span", "ct-num"));
|
|
73
|
+
number.append(h("span", "", `${block.short}.${s + 1}`));
|
|
74
|
+
if (count > 1)
|
|
75
|
+
number.append(h("span", "", labels.pages(count)));
|
|
76
|
+
face.append(h("span", "ct-t", slide.title), sketch(slide.pages[0].el));
|
|
77
|
+
strip.appendChild(h("li")).append(thumb);
|
|
78
|
+
faces[b][s] = face;
|
|
79
|
+
return thumb;
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
const ringEl = plane.appendChild(h("div", "ct-ring", "", { "data-label": labels.here }));
|
|
83
|
+
return {
|
|
84
|
+
el,
|
|
85
|
+
faces,
|
|
86
|
+
thumbs,
|
|
87
|
+
ring(b, s, instant) {
|
|
88
|
+
const face = faces[b][s];
|
|
89
|
+
const { x, y } = offsetIn(face, plane);
|
|
90
|
+
const style = ringEl.style;
|
|
91
|
+
if (instant)
|
|
92
|
+
style.transition = "none";
|
|
93
|
+
style.left = `${x}px`;
|
|
94
|
+
style.top = `${y}px`;
|
|
95
|
+
style.width = `${face.offsetWidth}px`;
|
|
96
|
+
style.height = `${face.offsetHeight}px`;
|
|
97
|
+
style.setProperty("--n", String(blocks[b].slides[s].pages.length));
|
|
98
|
+
if (instant) {
|
|
99
|
+
void ringEl.offsetWidth;
|
|
100
|
+
style.transition = "";
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { BlockNode, Pos } from "./model.js";
|
|
2
|
+
import type { Labels } from "./types.js";
|
|
3
|
+
export interface Rail {
|
|
4
|
+
el: HTMLElement;
|
|
5
|
+
/** Slides the marker onto the pip of a position. */
|
|
6
|
+
mark(pos: Pos): void;
|
|
7
|
+
}
|
|
8
|
+
export declare function buildRail(blocks: BlockNode[], labels: Labels): Rail;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** The locator rail: the whole deck as a mini-map, one column per slide, one pip per page. */
|
|
2
|
+
import { h, offsetIn } from "./dom.js";
|
|
3
|
+
export function buildRail(blocks, labels) {
|
|
4
|
+
const el = h("nav", "ct-rail", "", { "aria-label": labels.rail });
|
|
5
|
+
const groups = [];
|
|
6
|
+
const pips = blocks.map((block, b) => {
|
|
7
|
+
const group = el.appendChild(h("div", "ct-rg"));
|
|
8
|
+
if (block.kind)
|
|
9
|
+
group.dataset.kind = block.kind;
|
|
10
|
+
groups.push(group);
|
|
11
|
+
group.append(h("button", "ct-rl", block.short, { "data-ct-go": `${b},0,0`, title: block.title }));
|
|
12
|
+
return block.slides.map((slide, s) => {
|
|
13
|
+
const column = group.appendChild(h("button", "ct-rc", "", { "data-ct-go": `${b},${s},0`, title: slide.title, "aria-label": `${block.title}:${slide.title}` }));
|
|
14
|
+
return slide.pages.map((_, p) => column.appendChild(h("i", "", "", { "data-ct-go": `${b},${s},${p}` })));
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
const marker = el.appendChild(h("span", "ct-rm"));
|
|
18
|
+
return {
|
|
19
|
+
el,
|
|
20
|
+
mark({ b, s, p }) {
|
|
21
|
+
groups.forEach((group, i) => group.toggleAttribute("data-cur", i === b));
|
|
22
|
+
const { x, y } = offsetIn(pips[b][s][p], el);
|
|
23
|
+
marker.style.transform = `translate(${x}px,${y}px)`;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Where a vertical step may stop inside one slide. Pure geometry, no DOM. */
|
|
2
|
+
export interface Box {
|
|
3
|
+
top: number;
|
|
4
|
+
height: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Every page top is a stop. A page taller than the viewport adds stops inside it and one flush with its bottom,
|
|
8
|
+
* so a step first walks down the page and only then reaches the next one.
|
|
9
|
+
*/
|
|
10
|
+
export declare function pageStops(pages: Box[], viewport: number): number[];
|
|
11
|
+
/** The first stop past `y` in the direction of travel, or `undefined` at the end. */
|
|
12
|
+
export declare function nextStop(stops: number[], y: number, direction: 1 | -1): number | undefined;
|
|
13
|
+
/** The page under the reading line, 35% down the viewport. */
|
|
14
|
+
export declare function pageIndexAt(pages: Box[], y: number, viewport: number): number;
|
|
15
|
+
/** How much of a tall page has been read, 0 to 1; 1 for a page that fits. */
|
|
16
|
+
export declare function pageProgress(page: Box, y: number, viewport: number): number;
|
|
17
|
+
export declare const isTall: (page: Box, viewport: number) => boolean;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Where a vertical step may stop inside one slide. Pure geometry, no DOM. */
|
|
2
|
+
/** A page counts as taller than the viewport only past this slack, in pixels. */
|
|
3
|
+
const SLACK = 8;
|
|
4
|
+
/** A tall page is walked in steps of this share of the viewport, so the last lines of one screen open the next. */
|
|
5
|
+
const STEP = 0.85;
|
|
6
|
+
/** An inner stop closer than this share of the viewport to the page's end is dropped. */
|
|
7
|
+
const MIN_LAST = 0.2;
|
|
8
|
+
/**
|
|
9
|
+
* Every page top is a stop. A page taller than the viewport adds stops inside it and one flush with its bottom,
|
|
10
|
+
* so a step first walks down the page and only then reaches the next one.
|
|
11
|
+
*/
|
|
12
|
+
export function pageStops(pages, viewport) {
|
|
13
|
+
const stops = [];
|
|
14
|
+
for (const page of pages) {
|
|
15
|
+
stops.push(page.top);
|
|
16
|
+
if (!isTall(page, viewport))
|
|
17
|
+
continue;
|
|
18
|
+
const end = page.top + page.height - viewport;
|
|
19
|
+
for (let y = page.top + viewport * STEP; y < end - viewport * MIN_LAST; y += viewport * STEP)
|
|
20
|
+
stops.push(Math.round(y));
|
|
21
|
+
stops.push(end);
|
|
22
|
+
}
|
|
23
|
+
return stops;
|
|
24
|
+
}
|
|
25
|
+
/** The first stop past `y` in the direction of travel, or `undefined` at the end. */
|
|
26
|
+
export function nextStop(stops, y, direction) {
|
|
27
|
+
return direction > 0 ? stops.find(stop => stop > y + SLACK) : stops.findLast(stop => stop < y - SLACK);
|
|
28
|
+
}
|
|
29
|
+
/** The page under the reading line, 35% down the viewport. */
|
|
30
|
+
export function pageIndexAt(pages, y, viewport) {
|
|
31
|
+
const line = y + viewport * 0.35;
|
|
32
|
+
return Math.max(0, pages.findLastIndex(page => page.top <= line));
|
|
33
|
+
}
|
|
34
|
+
/** How much of a tall page has been read, 0 to 1; 1 for a page that fits. */
|
|
35
|
+
export function pageProgress(page, y, viewport) {
|
|
36
|
+
if (page.height - viewport <= SLACK)
|
|
37
|
+
return 1;
|
|
38
|
+
return Math.min(1, Math.max(0, (y - page.top + viewport) / page.height));
|
|
39
|
+
}
|
|
40
|
+
export const isTall = (page, viewport) => page.height > viewport + SLACK;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Zoom between a thumbnail and the stage (FLIP), with the outline pulling back around the same thumbnail. */
|
|
2
|
+
type Direction = "in" | "out";
|
|
3
|
+
/** The stage grows out of the rectangle `thumb` (`in`) or shrinks into it (`out`); `content` fades with it. */
|
|
4
|
+
export declare function zoomStage(stage: HTMLElement, content: HTMLElement, thumb: DOMRect, direction: Direction, ms: number, reduced: boolean): Animation[];
|
|
5
|
+
/** The outline scales up and fades around `thumb` as the stage opens (`out`), and settles back as it closes (`in`). */
|
|
6
|
+
export declare function zoomOverview(overview: HTMLElement, thumb: DOMRect, direction: Direction, ms: number, reduced: boolean): Animation;
|
|
7
|
+
/** Resolves when every animation has finished; a cancelled one counts as finished. */
|
|
8
|
+
export declare const finished: (animations: Animation[]) => Promise<(void | Animation)[]>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Zoom between a thumbnail and the stage (FLIP), with the outline pulling back around the same thumbnail. */
|
|
2
|
+
const EASE_OUT = "cubic-bezier(.22,1,.36,1)";
|
|
3
|
+
const EASE_IN_OUT = "cubic-bezier(.6,0,.35,1)";
|
|
4
|
+
const FADE_MS = 140;
|
|
5
|
+
const fade = (el, direction) => el.animate(direction === "in" ? [{ opacity: 0 }, { opacity: 1 }] : [{ opacity: 1 }, { opacity: 0 }], { duration: FADE_MS, fill: "both" });
|
|
6
|
+
/** The stage grows out of the rectangle `thumb` (`in`) or shrinks into it (`out`); `content` fades with it. */
|
|
7
|
+
export function zoomStage(stage, content, thumb, direction, ms, reduced) {
|
|
8
|
+
if (reduced)
|
|
9
|
+
return [fade(stage, direction)];
|
|
10
|
+
const full = stage.getBoundingClientRect();
|
|
11
|
+
const small = {
|
|
12
|
+
transform: `translate(${thumb.left - full.left}px,${thumb.top - full.top}px) scale(${thumb.width / full.width},${thumb.height / full.height})`,
|
|
13
|
+
opacity: 0,
|
|
14
|
+
};
|
|
15
|
+
const big = { transform: "none", opacity: 1 };
|
|
16
|
+
const isIn = direction === "in";
|
|
17
|
+
return [
|
|
18
|
+
stage.animate(isIn ? [small, { opacity: 1, offset: 0.3 }, big] : [big, { opacity: 1, offset: 0.7 }, small], {
|
|
19
|
+
duration: ms,
|
|
20
|
+
easing: isIn ? EASE_OUT : EASE_IN_OUT,
|
|
21
|
+
fill: "both",
|
|
22
|
+
}),
|
|
23
|
+
content.animate(isIn ? [{ opacity: 0 }, { opacity: 0, offset: 0.35 }, { opacity: 1 }] : [{ opacity: 1 }, { opacity: 0, offset: 0.6 }, { opacity: 0 }], { duration: ms, fill: "both" }),
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
/** The outline scales up and fades around `thumb` as the stage opens (`out`), and settles back as it closes (`in`). */
|
|
27
|
+
export function zoomOverview(overview, thumb, direction, ms, reduced) {
|
|
28
|
+
if (reduced)
|
|
29
|
+
return fade(overview, direction);
|
|
30
|
+
const box = overview.getBoundingClientRect();
|
|
31
|
+
overview.style.transformOrigin = `${thumb.left + thumb.width / 2 - box.left}px ${thumb.top + thumb.height / 2 - box.top}px`;
|
|
32
|
+
const far = { transform: "scale(1.45)", opacity: 0 };
|
|
33
|
+
const near = { transform: "none", opacity: 1 };
|
|
34
|
+
return overview.animate(direction === "in" ? [far, near] : [near, far], { duration: ms, easing: EASE_OUT, fill: "both" });
|
|
35
|
+
}
|
|
36
|
+
/** Resolves when every animation has finished; a cancelled one counts as finished. */
|
|
37
|
+
export const finished = (animations) => Promise.all(animations.map(animation => animation.finished.catch(() => { })));
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Which layer is in front: the outline, one block on the stage, or the notes script. */
|
|
2
|
+
export type Mode = "overview" | "stage" | "notes";
|
|
3
|
+
/** Where the deck is. `block`, `slide`, and `page` are 1-based ordinals, as in `?at=4.4.2`. */
|
|
4
|
+
export interface DeckState {
|
|
5
|
+
mode: Mode;
|
|
6
|
+
block: number;
|
|
7
|
+
slide: number;
|
|
8
|
+
page: number;
|
|
9
|
+
/** Steps revealed on the current page, from 0. */
|
|
10
|
+
step: number;
|
|
11
|
+
}
|
|
12
|
+
export interface DeckChange extends DeckState {
|
|
13
|
+
/** The position as written in the URL, for example `4.4.2`. */
|
|
14
|
+
at: string;
|
|
15
|
+
}
|
|
16
|
+
/** Interface text. The defaults are Traditional Chinese. */
|
|
17
|
+
export interface Labels {
|
|
18
|
+
overview: string;
|
|
19
|
+
notes: string;
|
|
20
|
+
script: string;
|
|
21
|
+
threeD: string;
|
|
22
|
+
prev: string;
|
|
23
|
+
next: string;
|
|
24
|
+
exit: string;
|
|
25
|
+
remote: string;
|
|
26
|
+
here: string;
|
|
27
|
+
appendix: string;
|
|
28
|
+
rail: string;
|
|
29
|
+
noNotes: string;
|
|
30
|
+
slides(count: number): string;
|
|
31
|
+
pages(count: number): string;
|
|
32
|
+
minutes(count: number): string;
|
|
33
|
+
page(index: number, count: number): string;
|
|
34
|
+
steps(shown: number, count: number): string;
|
|
35
|
+
thumb(block: string, slide: number, title: string, pages: number): string;
|
|
36
|
+
}
|
|
37
|
+
export interface DeckOptions {
|
|
38
|
+
/** `snap` moves one stop per wheel gesture; `free` leaves scrolling native with proximity snap. Default `snap`. */
|
|
39
|
+
paging?: "snap" | "free";
|
|
40
|
+
/** How the outline looks on entry. Default `flat`. */
|
|
41
|
+
overview?: "flat" | "3d";
|
|
42
|
+
/** Block kinds the linear advance and the arrow keys never enter. Default `["appendix"]`. */
|
|
43
|
+
linear?: {
|
|
44
|
+
skip?: string[];
|
|
45
|
+
};
|
|
46
|
+
/** Entry view. Default: `?view=notes` selects `notes`, anything else `deck`. */
|
|
47
|
+
view?: "deck" | "notes";
|
|
48
|
+
/** Entry position, in the `?at=` format. Default: the `at` query parameter. */
|
|
49
|
+
at?: string;
|
|
50
|
+
/** Read and write `?view=` and `?at=`. Default `true`. */
|
|
51
|
+
url?: boolean;
|
|
52
|
+
labels?: Partial<Labels>;
|
|
53
|
+
/**
|
|
54
|
+
* The remote: with this option the bar has a remote button, and the button and the `R` key call it.
|
|
55
|
+
* It usually loads `contour-deck/remote` on first use. Without it the deck has neither.
|
|
56
|
+
*/
|
|
57
|
+
remote?: (deck: Deck) => void;
|
|
58
|
+
}
|
|
59
|
+
export interface Deck {
|
|
60
|
+
/** Back to the outline. */
|
|
61
|
+
overview(): Promise<void>;
|
|
62
|
+
/** Put a block on the stage: `"4"` or `"4.2"`. */
|
|
63
|
+
open(at: string): Promise<void>;
|
|
64
|
+
/** Linear advance: next step, down within the page, next page, next slide, next block. */
|
|
65
|
+
next(): void;
|
|
66
|
+
/** The linear advance in reverse. */
|
|
67
|
+
prev(): void;
|
|
68
|
+
/** Go to a position (`"4.4.2"` or an element id), with the transition that fits the distance. */
|
|
69
|
+
go(at: string): Promise<void>;
|
|
70
|
+
/** The slide before or after, as the left and right arrow keys: it leaves the block at its ends. */
|
|
71
|
+
slideBy(direction: 1 | -1): void;
|
|
72
|
+
/** One stop up or down inside the slide, as the up and down arrow keys. */
|
|
73
|
+
pageBy(direction: 1 | -1): void;
|
|
74
|
+
on(type: "change", listener: (change: DeckChange) => void): () => void;
|
|
75
|
+
/** `exit` fires when the reader leaves: Esc on the outline, or the close button of the bar. The host usually calls `destroy()`. */
|
|
76
|
+
on(type: "exit", listener: () => void): () => void;
|
|
77
|
+
/** `destroy` fires once, at the start of `destroy()`, while the shell is still in the document. */
|
|
78
|
+
on(type: "destroy", listener: () => void): () => void;
|
|
79
|
+
readonly state: DeckState;
|
|
80
|
+
/** Puts the deck element back where it was and removes everything the core added. */
|
|
81
|
+
destroy(): void;
|
|
82
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface HastNode {
|
|
2
|
+
type: string;
|
|
3
|
+
value?: string;
|
|
4
|
+
tagName?: string;
|
|
5
|
+
properties?: Record<string, unknown>;
|
|
6
|
+
children?: HastNode[];
|
|
7
|
+
}
|
|
8
|
+
export interface Element extends HastNode {
|
|
9
|
+
type: "element";
|
|
10
|
+
tagName: string;
|
|
11
|
+
properties: Record<string, unknown>;
|
|
12
|
+
children: HastNode[];
|
|
13
|
+
}
|
|
14
|
+
export declare function isElement(node: HastNode): node is Element;
|
|
15
|
+
export declare function element(tagName: string, properties: Record<string, unknown>, children: HastNode[]): Element;
|
|
16
|
+
export declare function classesOf(node: Element): string[];
|
|
17
|
+
export declare function hasClass(node: HastNode, name: string): boolean;
|
|
18
|
+
export declare function isBlank(node: HastNode): boolean;
|
|
19
|
+
/** The marker the remark plugin put on `node`, if any. */
|
|
20
|
+
export declare function markOf(node: HastNode): string | undefined;
|
|
21
|
+
/** Plain text of `node`; subtrees for which `skip` is true count as empty. */
|
|
22
|
+
export declare function textOf(node: HastNode, skip?: (node: HastNode) => boolean): string;
|
|
23
|
+
/** Text as a title: whitespace collapsed and trimmed. */
|
|
24
|
+
export declare function plainText(node: HastNode): string;
|
|
25
|
+
/** Calls `visit` for every element under `nodes`, in document order. */
|
|
26
|
+
export declare function walkElements(nodes: HastNode[], visit: (node: Element) => void): void;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** The few hast shapes the plugin touches; hast itself is not a dependency. */
|
|
2
|
+
import { MARK } from "../shared/markers.js";
|
|
3
|
+
export function isElement(node) {
|
|
4
|
+
return node.type === "element";
|
|
5
|
+
}
|
|
6
|
+
export function element(tagName, properties, children) {
|
|
7
|
+
return { type: "element", tagName, properties, children };
|
|
8
|
+
}
|
|
9
|
+
export function classesOf(node) {
|
|
10
|
+
const value = node.properties.className;
|
|
11
|
+
if (Array.isArray(value))
|
|
12
|
+
return value.map(String);
|
|
13
|
+
return typeof value === "string" ? value.split(/\s+/) : [];
|
|
14
|
+
}
|
|
15
|
+
export function hasClass(node, name) {
|
|
16
|
+
return isElement(node) && classesOf(node).includes(name);
|
|
17
|
+
}
|
|
18
|
+
export function isBlank(node) {
|
|
19
|
+
return node.type === "text" && !(node.value ?? "").trim();
|
|
20
|
+
}
|
|
21
|
+
/** The marker the remark plugin put on `node`, if any. */
|
|
22
|
+
export function markOf(node) {
|
|
23
|
+
if (!isElement(node))
|
|
24
|
+
return undefined;
|
|
25
|
+
const mark = node.properties[MARK];
|
|
26
|
+
return typeof mark === "string" ? mark : undefined;
|
|
27
|
+
}
|
|
28
|
+
/** Plain text of `node`; subtrees for which `skip` is true count as empty. */
|
|
29
|
+
export function textOf(node, skip) {
|
|
30
|
+
if (node.type === "text")
|
|
31
|
+
return node.value ?? "";
|
|
32
|
+
if (skip?.(node))
|
|
33
|
+
return "";
|
|
34
|
+
return (node.children ?? []).map(child => textOf(child, skip)).join("");
|
|
35
|
+
}
|
|
36
|
+
/** Text as a title: whitespace collapsed and trimmed. */
|
|
37
|
+
export function plainText(node) {
|
|
38
|
+
return textOf(node).replace(/\s+/g, " ").trim();
|
|
39
|
+
}
|
|
40
|
+
/** Calls `visit` for every element under `nodes`, in document order. */
|
|
41
|
+
export function walkElements(nodes, visit) {
|
|
42
|
+
for (const node of nodes) {
|
|
43
|
+
if (!isElement(node))
|
|
44
|
+
continue;
|
|
45
|
+
visit(node);
|
|
46
|
+
walkElements(node.children, visit);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type HastNode } from "./hast.js";
|
|
2
|
+
export interface DeckOptions {
|
|
3
|
+
/** Deck title for `data-title`. Default: the `title` frontmatter field under Astro, else none. */
|
|
4
|
+
title?: string;
|
|
5
|
+
/** Whether to build a deck for this file. Default: every file. */
|
|
6
|
+
match?: (file: DeckFile) => boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface DeckFile {
|
|
9
|
+
data: object;
|
|
10
|
+
fail(reason: string, place?: unknown, origin?: string): never;
|
|
11
|
+
}
|
|
12
|
+
export declare function rehypeDeck(options?: DeckOptions): (tree: HastNode, file: DeckFile) => void;
|