footprint-storydeck 0.5.0 → 0.6.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/CHANGELOG.md +14 -0
- package/README.md +33 -3
- package/dist/BlogView.js +48 -0
- package/dist/PostView.js +170 -0
- package/dist/ScrollyView.js +93 -0
- package/dist/SlideDeck.js +49 -0
- package/dist/SlideFigure.js +24 -0
- package/dist/ThemeToggle.js +13 -0
- package/dist/content/loadPost.js +25 -0
- package/dist/content/markdown.js +23 -0
- package/dist/context.js +14 -0
- package/dist/figure.js +6 -0
- package/dist/focus.js +339 -0
- package/dist/focusHtml.js +235 -0
- package/dist/index.js +59 -0
- package/dist/narration.js +11 -0
- package/dist/narrationHtml.js +243 -0
- package/dist/pptx.js +280 -0
- package/dist/pptxCheck.js +156 -0
- package/dist/pptxParts.js +198 -0
- package/dist/scopeDeckCss.js +6 -0
- package/dist/sections.js +52 -0
- package/dist/slug.js +6 -0
- package/dist/tour.js +225 -0
- package/dist/useTheme.js +35 -0
- package/dist/video.js +244 -0
- package/dist/voice.js +35 -0
- package/dist/xml.js +106 -0
- package/dist/zip.js +133 -0
- package/index.d.ts +51 -6
- package/package.json +16 -18
- package/storydeck.css +1 -0
- package/BlogView.jsx +0 -52
- package/PostView.jsx +0 -229
- package/ScrollyView.jsx +0 -112
- package/SlideDeck.jsx +0 -64
- package/SlideFigure.jsx +0 -30
- package/ThemeToggle.jsx +0 -24
- package/content/loadPost.js +0 -36
- package/content/markdown.js +0 -27
- package/context.jsx +0 -15
- package/focus.js +0 -387
- package/focusHtml.js +0 -281
- package/index.js +0 -22
- package/narration.js +0 -25
- package/narrationHtml.js +0 -156
- package/pptx.js +0 -313
- package/pptxCheck.js +0 -167
- package/pptxParts.js +0 -262
- package/scopeDeckCss.js +0 -8
- package/sections.js +0 -72
- package/slug.js +0 -9
- package/tour.js +0 -206
- package/useTheme.js +0 -37
- package/video.js +0 -236
- package/voice.js +0 -41
- package/xml.js +0 -123
- package/zip.js +0 -155
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 — a figure in any framework, and compiled JavaScript
|
|
4
|
+
|
|
5
|
+
- **`StoryFigure`: the Scroll lens drives a figure it does not know how to draw.** A section's `figure` may
|
|
6
|
+
now be `{ mount(el), seek(beat), dispose() }` beside the React render function it already took. The lens
|
|
7
|
+
mounts it once into a plain element in the pinned stage, asks it to `seek` with the beat's plain coordinates
|
|
8
|
+
(`{ key, index, sectionKey, step }`) on every beat change, and prints the sentence a seek resolves to under
|
|
9
|
+
the figure (a refusal, verbatim) until a later seek answers; only the latest seek's answer is shown.
|
|
10
|
+
`isStoryFigure` is exported. Additive: render-function figures and HTML steps are unchanged.
|
|
11
|
+
- **The package ships compiled JavaScript.** `dist/` holds each source file compiled on its own (JSX to
|
|
12
|
+
`react/jsx-runtime`, relative imports with `.js`, `'use client'` kept), so plain Node and any bundler load
|
|
13
|
+
it with no transform: the `.jsx` workarounds consumers needed (`transpilePackages`, a test runner's
|
|
14
|
+
inline list) are no longer required. Types stay the hand-kept `*.d.ts`. `npm run check:dist` loads every
|
|
15
|
+
door in plain Node and refuses a package that ships source JSX; CI runs it.
|
|
16
|
+
|
|
3
17
|
## 0.5.0 — PowerPoint, the exploded view, and a tour for decks nobody presents
|
|
4
18
|
|
|
5
19
|
- **`footprint-storydeck/pptx`: the deck as a PowerPoint file.** `exportPptx` makes a slide per click: its settled
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
- **Watch** — an animated, click-through slide deck.
|
|
8
8
|
|
|
9
9
|
Same content, your lens. It's the footprintjs idea — *one canonical record, you choose the view* —
|
|
10
|
-
applied to content. A React library; theme-agnostic; ships as
|
|
10
|
+
applied to content. A React library; theme-agnostic; ships compiled ESM that Node and any bundler load as-is.
|
|
11
11
|
|
|
12
12
|
> Demo (dogfooded — storydeck explaining storydeck): **[footprintjs.github.io/storydeck](https://footprintjs.github.io/storydeck/)**
|
|
13
13
|
> First consumer: the **[footprintjs blog](https://footprintjs.github.io/blog/)**.
|
|
@@ -58,6 +58,32 @@ A function cannot come from JSON, so `figure` is not part of the authoring model
|
|
|
58
58
|
builds sections from post.json + Markdown, and the consumer attaches `figure` to them. The beat a
|
|
59
59
|
figure is handed carries `sectionKey`, `step` and `index` — which beat it is being asked to draw.
|
|
60
60
|
|
|
61
|
+
### A figure in any framework: `StoryFigure`
|
|
62
|
+
|
|
63
|
+
A section's `figure` may instead be a **`StoryFigure`** — three plain methods, no React:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
interface StoryFigure {
|
|
67
|
+
mount(el: HTMLElement): void; // draw into this element
|
|
68
|
+
seek(beat: StoryFigureBeat): Promise<string | void>; // the reader is on this beat: move there
|
|
69
|
+
dispose(): void; // take down what mount drew
|
|
70
|
+
}
|
|
71
|
+
// StoryFigureBeat = { key, index, sectionKey, step } — plain data
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
const figure = { mount(el) { chart.attach(el); }, seek: (beat) => chart.goTo(beat.sectionKey), dispose() { chart.detach(); } };
|
|
76
|
+
<ScrollyView sections={sections.map((s) => ({ ...s, figure }))} />
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
It is the seam for a figure the story should not have to know: a chart library, a canvas, another
|
|
80
|
+
framework's component. `ScrollyView` adapts it in a thin host — mounted once while it is on stage (a
|
|
81
|
+
different section's figure is disposed and the new one mounted), and asked to `seek` on every beat change.
|
|
82
|
+
**A seek that cannot move says why:** it resolves to a sentence, and the lens prints that sentence under the
|
|
83
|
+
figure, verbatim, until a later seek answers; it resolves nothing when it moved. Only the latest seek's
|
|
84
|
+
answer is shown, and a seek that rejects is shown as the lens's own sentence. The lens judges nothing — the
|
|
85
|
+
figure knows whether it can stand on a beat. `isStoryFigure(value)` is the check the lens uses.
|
|
86
|
+
|
|
61
87
|
## Focus: what a click is about, and what happens to the rest
|
|
62
88
|
|
|
63
89
|
A talk often tells its story on **one picture** — a diagram, a page, a timeline — click by click. Each
|
|
@@ -434,8 +460,12 @@ const { theme, toggle, setTheme } = useTheme(); // flips an html class + persi
|
|
|
434
460
|
| `renderVideo` · `planVideo` · `deckStageDriver` · `ffmpegMajor` | `footprint-storydeck/video` (Node only, ffmpeg 5.0+): the deck as a narrated video, with captions and chapters |
|
|
435
461
|
| `exportPptx` · `pptxParts` · `zip` · `unzip` · `checkPptx` | `footprint-storydeck/pptx` (Node only): the deck as a PowerPoint file — a slide per click, its picture, notes and clip — through a footprintjs flowchart that records why |
|
|
436
462
|
|
|
437
|
-
|
|
438
|
-
|
|
463
|
+
The package ships compiled JavaScript (`dist/`, built by `npm run build`: each source file compiled on its
|
|
464
|
+
own, JSX turned into `react/jsx-runtime` calls, every relative import given its `.js`, `'use client'` kept), so
|
|
465
|
+
plain Node and every bundler load it with no transform of their own; `npm run check:dist` loads every door in
|
|
466
|
+
plain Node and refuses a package that ships source JSX. Types ship in `index.d.ts` — hand-kept (nothing
|
|
467
|
+
type-checks the JSX to generate them from), and here rather than in a consumer's shim, because a package's
|
|
468
|
+
shape belongs in the package.
|
|
439
469
|
|
|
440
470
|
## Tests
|
|
441
471
|
|
package/dist/BlogView.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import SlideFigure from "./SlideFigure.js";
|
|
5
|
+
import { finalStep } from "./sections.js";
|
|
6
|
+
import { slugify } from "./slug.js";
|
|
7
|
+
function BlogView({ sections }) {
|
|
8
|
+
const [copied, setCopied] = useState("");
|
|
9
|
+
function copyLink(e, id) {
|
|
10
|
+
e.preventDefault();
|
|
11
|
+
const url = `${window.location.origin}${window.location.pathname}#${id}`;
|
|
12
|
+
if (window.location.hash !== `#${id}`) window.history.replaceState(null, "", `#${id}`);
|
|
13
|
+
try {
|
|
14
|
+
navigator.clipboard.writeText(url);
|
|
15
|
+
} catch (_) {
|
|
16
|
+
}
|
|
17
|
+
setCopied(id);
|
|
18
|
+
setTimeout(() => setCopied((c) => c === id ? "" : c), 1600);
|
|
19
|
+
}
|
|
20
|
+
return /* @__PURE__ */ jsx("article", { children: sections.map((s) => {
|
|
21
|
+
const id = slugify(s.key);
|
|
22
|
+
return /* @__PURE__ */ jsxs("section", { id, className: "post-section", children: [
|
|
23
|
+
/* @__PURE__ */ jsxs("div", { className: "section-head", children: [
|
|
24
|
+
/* @__PURE__ */ jsx(
|
|
25
|
+
"a",
|
|
26
|
+
{
|
|
27
|
+
className: "section-anchor",
|
|
28
|
+
href: `#${id}`,
|
|
29
|
+
onClick: (e) => copyLink(e, id),
|
|
30
|
+
title: "Copy link to this section",
|
|
31
|
+
"aria-label": `Copy link to \u201C${s.heading}\u201D`,
|
|
32
|
+
children: "#"
|
|
33
|
+
}
|
|
34
|
+
),
|
|
35
|
+
/* @__PURE__ */ jsxs("div", { className: "section-head-text", children: [
|
|
36
|
+
/* @__PURE__ */ jsx("p", { className: "eyebrow", children: s.label }),
|
|
37
|
+
/* @__PURE__ */ jsx("h2", { children: s.heading })
|
|
38
|
+
] }),
|
|
39
|
+
copied === id ? /* @__PURE__ */ jsx("span", { className: "section-copied", "aria-live": "polite", children: "link copied \u2713" }) : null
|
|
40
|
+
] }),
|
|
41
|
+
/* @__PURE__ */ jsx(SlideFigure, { html: finalStep(s) }),
|
|
42
|
+
s.body ? /* @__PURE__ */ jsx("div", { className: "prose", dangerouslySetInnerHTML: { __html: s.body } }) : null
|
|
43
|
+
] }, id);
|
|
44
|
+
}) });
|
|
45
|
+
}
|
|
46
|
+
export {
|
|
47
|
+
BlogView as default
|
|
48
|
+
};
|
package/dist/PostView.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import BlogView from "./BlogView.js";
|
|
5
|
+
import ScrollyView from "./ScrollyView.js";
|
|
6
|
+
import SlideDeck from "./SlideDeck.js";
|
|
7
|
+
const LENSES = [
|
|
8
|
+
{ id: "read", label: "Read it" },
|
|
9
|
+
{ id: "scrolly", label: "Scroll it" },
|
|
10
|
+
{ id: "slides", label: "Watch it" }
|
|
11
|
+
];
|
|
12
|
+
const ESCAPE_LENSES = LENSES.filter((l) => l.id !== "slides");
|
|
13
|
+
const NARROW_WATCH_MQ = "(max-width: 700px)";
|
|
14
|
+
function isNarrowViewport() {
|
|
15
|
+
return typeof window.matchMedia === "function" && window.matchMedia(NARROW_WATCH_MQ).matches;
|
|
16
|
+
}
|
|
17
|
+
function fullscreenElement() {
|
|
18
|
+
return document.fullscreenElement || document.webkitFullscreenElement || null;
|
|
19
|
+
}
|
|
20
|
+
function PostView({ post }) {
|
|
21
|
+
const [view, setView] = useState("read");
|
|
22
|
+
const [takeover, setTakeover] = useState(false);
|
|
23
|
+
const [fsApi, setFsApi] = useState(false);
|
|
24
|
+
const stageRef = useRef(null);
|
|
25
|
+
const viewRef = useRef("read");
|
|
26
|
+
const autoFsRef = useRef(false);
|
|
27
|
+
const content = post.sections.filter((s) => s.key !== "Title" && s.key !== "Close");
|
|
28
|
+
const isWatch = view === "slides";
|
|
29
|
+
const idle = useIdleFade(isWatch);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
viewRef.current = view;
|
|
32
|
+
}, [view]);
|
|
33
|
+
const enterFullscreen = useCallback((auto) => {
|
|
34
|
+
const el = stageRef.current;
|
|
35
|
+
if (!el) return;
|
|
36
|
+
autoFsRef.current = !!auto;
|
|
37
|
+
const request = el.requestFullscreen || el.webkitRequestFullscreen;
|
|
38
|
+
if (!request) {
|
|
39
|
+
setTakeover(true);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
let p;
|
|
43
|
+
try {
|
|
44
|
+
p = request.call(el);
|
|
45
|
+
} catch (_) {
|
|
46
|
+
setTakeover(true);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (p && typeof p.catch === "function") {
|
|
50
|
+
p.catch(() => setTakeover(true));
|
|
51
|
+
} else if (auto) {
|
|
52
|
+
setTimeout(() => {
|
|
53
|
+
if (!fullscreenElement() && viewRef.current === "slides" && autoFsRef.current) setTakeover(true);
|
|
54
|
+
}, 500);
|
|
55
|
+
}
|
|
56
|
+
}, []);
|
|
57
|
+
const leaveFullscreen = useCallback(() => {
|
|
58
|
+
autoFsRef.current = false;
|
|
59
|
+
setTakeover(false);
|
|
60
|
+
if (!fullscreenElement()) return;
|
|
61
|
+
const exit = document.exitFullscreen || document.webkitExitFullscreen;
|
|
62
|
+
if (!exit) return;
|
|
63
|
+
try {
|
|
64
|
+
const p = exit.call(document);
|
|
65
|
+
if (p && typeof p.catch === "function") p.catch(() => {
|
|
66
|
+
});
|
|
67
|
+
} catch (_) {
|
|
68
|
+
}
|
|
69
|
+
}, []);
|
|
70
|
+
const choose = useCallback((next) => {
|
|
71
|
+
setView(next);
|
|
72
|
+
const u = new URL(window.location.href);
|
|
73
|
+
if (next === "read") u.searchParams.delete("view");
|
|
74
|
+
else u.searchParams.set("view", next);
|
|
75
|
+
window.history.replaceState(null, "", u);
|
|
76
|
+
if (next === "slides") {
|
|
77
|
+
if (isNarrowViewport()) enterFullscreen(true);
|
|
78
|
+
} else {
|
|
79
|
+
leaveFullscreen();
|
|
80
|
+
}
|
|
81
|
+
}, [enterFullscreen, leaveFullscreen]);
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const v = new URLSearchParams(window.location.search).get("view");
|
|
84
|
+
if (v === "slides" || v === "scrolly") setView(v);
|
|
85
|
+
if (v === "slides" && isNarrowViewport()) enterFullscreen(true);
|
|
86
|
+
const el = stageRef.current;
|
|
87
|
+
setFsApi(!!(el && (el.requestFullscreen || el.webkitRequestFullscreen)));
|
|
88
|
+
}, [enterFullscreen]);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
function onFullscreenChange() {
|
|
91
|
+
if (!fullscreenElement() && viewRef.current === "slides" && autoFsRef.current) choose("read");
|
|
92
|
+
}
|
|
93
|
+
document.addEventListener("fullscreenchange", onFullscreenChange);
|
|
94
|
+
document.addEventListener("webkitfullscreenchange", onFullscreenChange);
|
|
95
|
+
return () => {
|
|
96
|
+
document.removeEventListener("fullscreenchange", onFullscreenChange);
|
|
97
|
+
document.removeEventListener("webkitfullscreenchange", onFullscreenChange);
|
|
98
|
+
};
|
|
99
|
+
}, [choose]);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!isWatch) return;
|
|
102
|
+
function onKeyDown(e) {
|
|
103
|
+
if (e.key === "Escape") choose("read");
|
|
104
|
+
}
|
|
105
|
+
window.addEventListener("keydown", onKeyDown);
|
|
106
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
107
|
+
}, [isWatch, choose]);
|
|
108
|
+
return /* @__PURE__ */ jsxs("main", { id: "main", className: "wrap wide", children: [
|
|
109
|
+
/* @__PURE__ */ jsxs("header", { className: "post-head", children: [
|
|
110
|
+
/* @__PURE__ */ jsx("p", { className: "eyebrow", children: "Primer" }),
|
|
111
|
+
/* @__PURE__ */ jsx("h1", { children: post.title }),
|
|
112
|
+
/* @__PURE__ */ jsx("p", { className: "lead", children: post.description }),
|
|
113
|
+
/* @__PURE__ */ jsxs("p", { className: "byline", children: [
|
|
114
|
+
"By ",
|
|
115
|
+
post.author,
|
|
116
|
+
" \xB7 ",
|
|
117
|
+
(/* @__PURE__ */ new Date(post.date + "T00:00:00Z")).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", timeZone: "UTC" })
|
|
118
|
+
] })
|
|
119
|
+
] }),
|
|
120
|
+
/* @__PURE__ */ jsx("div", { className: "view-toggle", role: "group", "aria-label": "Choose a view", children: LENSES.map((l) => /* @__PURE__ */ jsx("button", { "aria-pressed": view === l.id, onClick: () => choose(l.id), children: l.label }, l.id)) }),
|
|
121
|
+
view === "read" ? /* @__PURE__ */ jsx(BlogView, { sections: content }) : null,
|
|
122
|
+
view === "scrolly" ? /* @__PURE__ */ jsx(ScrollyView, { sections: content }) : null,
|
|
123
|
+
/* @__PURE__ */ jsx("div", { ref: stageRef, className: `watch-stage${isWatch && takeover ? " is-takeover" : ""}`, children: isWatch ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
124
|
+
/* @__PURE__ */ jsxs("div", { className: `watch-lens-dock${idle ? " is-idle" : ""}`, role: "group", "aria-label": "Switch view", children: [
|
|
125
|
+
ESCAPE_LENSES.map((l) => /* @__PURE__ */ jsx("button", { type: "button", onClick: () => choose(l.id), children: l.label.replace(/ it$/, "") }, l.id)),
|
|
126
|
+
fsApi ? /* @__PURE__ */ jsx(
|
|
127
|
+
"button",
|
|
128
|
+
{
|
|
129
|
+
type: "button",
|
|
130
|
+
className: "watch-lens-fs",
|
|
131
|
+
"aria-label": "Toggle fullscreen",
|
|
132
|
+
title: "Full screen",
|
|
133
|
+
onClick: () => fullscreenElement() || takeover ? leaveFullscreen() : enterFullscreen(false),
|
|
134
|
+
children: "\u26F6"
|
|
135
|
+
}
|
|
136
|
+
) : null,
|
|
137
|
+
/* @__PURE__ */ jsx("span", { className: "watch-lens-hint", title: "Press Esc to return to Read", "aria-hidden": "true", children: "Esc" })
|
|
138
|
+
] }),
|
|
139
|
+
/* @__PURE__ */ jsx(SlideDeck, { steps: post.deckSteps, tour: post.tour })
|
|
140
|
+
] }) : null })
|
|
141
|
+
] });
|
|
142
|
+
}
|
|
143
|
+
function useIdleFade(active, ms = 2600) {
|
|
144
|
+
const [idle, setIdle] = useState(false);
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (!active) {
|
|
147
|
+
setIdle(false);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
let timer = setTimeout(() => setIdle(true), ms);
|
|
151
|
+
function wake() {
|
|
152
|
+
setIdle(false);
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
timer = setTimeout(() => setIdle(true), ms);
|
|
155
|
+
}
|
|
156
|
+
window.addEventListener("pointermove", wake);
|
|
157
|
+
window.addEventListener("touchstart", wake, { passive: true });
|
|
158
|
+
window.addEventListener("keydown", wake);
|
|
159
|
+
return () => {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
window.removeEventListener("pointermove", wake);
|
|
162
|
+
window.removeEventListener("touchstart", wake);
|
|
163
|
+
window.removeEventListener("keydown", wake);
|
|
164
|
+
};
|
|
165
|
+
}, [active, ms]);
|
|
166
|
+
return idle;
|
|
167
|
+
}
|
|
168
|
+
export {
|
|
169
|
+
PostView as default
|
|
170
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import SlideFigure from "./SlideFigure.js";
|
|
5
|
+
import { slugify } from "./slug.js";
|
|
6
|
+
import { isStoryFigure } from "./figure.js";
|
|
7
|
+
function ScrollyView({ sections }) {
|
|
8
|
+
const beats = [];
|
|
9
|
+
sections.forEach((s) => {
|
|
10
|
+
s.steps.forEach((html, stepIdx) => {
|
|
11
|
+
beats.push({
|
|
12
|
+
key: `${slugify(s.key)}-${stepIdx}`,
|
|
13
|
+
index: beats.length,
|
|
14
|
+
html,
|
|
15
|
+
label: s.label,
|
|
16
|
+
heading: s.heading,
|
|
17
|
+
body: s.body,
|
|
18
|
+
// the section this beat belongs to, and which of its steps it is — what a live figure needs
|
|
19
|
+
// to know WHICH beat it is being asked to draw
|
|
20
|
+
sectionKey: s.key,
|
|
21
|
+
step: stepIdx,
|
|
22
|
+
firstOfSection: stepIdx === 0,
|
|
23
|
+
figure: isStoryFigure(s.figure) || typeof s.figure === "function" ? s.figure : null
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
const [active, setActive] = useState(0);
|
|
28
|
+
const refs = useRef([]);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const io = new IntersectionObserver(
|
|
31
|
+
(entries) => {
|
|
32
|
+
entries.forEach((e) => {
|
|
33
|
+
if (e.isIntersecting) {
|
|
34
|
+
const i = Number(e.target.dataset.beat);
|
|
35
|
+
if (!Number.isNaN(i)) setActive(i);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
{ rootMargin: "-45% 0px -45% 0px", threshold: 0 }
|
|
40
|
+
// fire as a beat crosses the viewport centre
|
|
41
|
+
);
|
|
42
|
+
refs.current.forEach((el) => el && io.observe(el));
|
|
43
|
+
return () => io.disconnect();
|
|
44
|
+
}, [beats.length]);
|
|
45
|
+
const activeBeat = beats[active] || beats[0];
|
|
46
|
+
const live = beats.some((b) => b.figure !== null);
|
|
47
|
+
return /* @__PURE__ */ jsxs("div", { className: `scrolly${live ? " scrolly-live" : ""}`, children: [
|
|
48
|
+
/* @__PURE__ */ jsxs("div", { className: "scrolly-stage", children: [
|
|
49
|
+
activeBeat ? activeBeat.figure !== null ? isStoryFigure(activeBeat.figure) ? /* @__PURE__ */ jsx(FigureHost, { figure: activeBeat.figure, beat: activeBeat }) : /* @__PURE__ */ jsx("div", { className: "scrolly-stage-live", children: activeBeat.figure(activeBeat) }) : /* @__PURE__ */ jsx(SlideFigure, { html: activeBeat.html }, active) : null,
|
|
50
|
+
/* @__PURE__ */ jsx("div", { className: "scrolly-rail", "aria-hidden": "true", children: beats.map((b, i) => /* @__PURE__ */ jsx("span", { className: i === active ? "on" : "" }, b.key)) })
|
|
51
|
+
] }),
|
|
52
|
+
/* @__PURE__ */ jsx("div", { className: "scrolly-flow", children: beats.map((b, i) => /* @__PURE__ */ jsxs("div", { className: "scrolly-beat", "data-beat": i, ref: (el) => refs.current[i] = el, children: [
|
|
53
|
+
b.firstOfSection ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
54
|
+
/* @__PURE__ */ jsx("p", { className: "eyebrow", children: b.label }),
|
|
55
|
+
/* @__PURE__ */ jsx("h2", { children: b.heading }),
|
|
56
|
+
b.body ? /* @__PURE__ */ jsx("div", { className: "prose", dangerouslySetInnerHTML: { __html: b.body } }) : null
|
|
57
|
+
] }) : null,
|
|
58
|
+
/* @__PURE__ */ jsx("div", { className: "scrolly-beat-figure", children: /* @__PURE__ */ jsx(SlideFigure, { html: b.html }) })
|
|
59
|
+
] }, b.key)) })
|
|
60
|
+
] });
|
|
61
|
+
}
|
|
62
|
+
const UNREACHED = "this figure could not move to this beat";
|
|
63
|
+
const useMountEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
64
|
+
function FigureHost({ figure, beat }) {
|
|
65
|
+
const host = useRef(null);
|
|
66
|
+
const [said, setSaid] = useState(null);
|
|
67
|
+
useMountEffect(() => {
|
|
68
|
+
figure.mount(host.current);
|
|
69
|
+
return () => figure.dispose();
|
|
70
|
+
}, [figure]);
|
|
71
|
+
const { key, index, sectionKey, step } = beat;
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
let current = true;
|
|
74
|
+
new Promise((resolve) => resolve(figure.seek({ key, index, sectionKey, step }))).then(
|
|
75
|
+
(answer) => {
|
|
76
|
+
if (current) setSaid(typeof answer === "string" && answer !== "" ? answer : null);
|
|
77
|
+
},
|
|
78
|
+
() => {
|
|
79
|
+
if (current) setSaid(UNREACHED);
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
return () => {
|
|
83
|
+
current = false;
|
|
84
|
+
};
|
|
85
|
+
}, [figure, key, index, sectionKey, step]);
|
|
86
|
+
return /* @__PURE__ */ jsxs("div", { className: "scrolly-stage-live", children: [
|
|
87
|
+
/* @__PURE__ */ jsx("div", { className: "scrolly-figure", ref: host }),
|
|
88
|
+
said === null ? null : /* @__PURE__ */ jsx("p", { className: "scrolly-figure-said", role: "status", children: said })
|
|
89
|
+
] });
|
|
90
|
+
}
|
|
91
|
+
export {
|
|
92
|
+
ScrollyView as default
|
|
93
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { useBasePath } from "./context.js";
|
|
5
|
+
import { idleTour } from "./tour.js";
|
|
6
|
+
import { focusRuntime } from "./focusHtml.js";
|
|
7
|
+
function SlideDeck({ steps, tour }) {
|
|
8
|
+
const host = useRef(null);
|
|
9
|
+
const [copied, setCopied] = useState(false);
|
|
10
|
+
const basePath = useBasePath();
|
|
11
|
+
const tourKey = tour ? JSON.stringify(tour) : "";
|
|
12
|
+
function copySlideLink() {
|
|
13
|
+
try {
|
|
14
|
+
navigator.clipboard.writeText(window.location.href);
|
|
15
|
+
} catch (_) {
|
|
16
|
+
}
|
|
17
|
+
setCopied(true);
|
|
18
|
+
setTimeout(() => setCopied(false), 1600);
|
|
19
|
+
}
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const el = host.current;
|
|
22
|
+
if (!el) return;
|
|
23
|
+
focusRuntime();
|
|
24
|
+
const inner = (steps || []).join("\n");
|
|
25
|
+
el.innerHTML = `<deck-stage no-rail width="1920" height="1080">${inner}</deck-stage>`;
|
|
26
|
+
if (!document.querySelector("script[data-deck-stage]")) {
|
|
27
|
+
const sc = document.createElement("script");
|
|
28
|
+
sc.src = `${basePath}/deck-stage.js`;
|
|
29
|
+
sc.setAttribute("data-deck-stage", "");
|
|
30
|
+
document.body.appendChild(sc);
|
|
31
|
+
}
|
|
32
|
+
}, [steps]);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const stage = host.current?.querySelector("deck-stage");
|
|
35
|
+
if (!tourKey || !stage) return void 0;
|
|
36
|
+
const t = idleTour(stage, typeof tour === "string" ? { stops: tour } : tour);
|
|
37
|
+
return () => t.stop();
|
|
38
|
+
}, [steps, tourKey]);
|
|
39
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
40
|
+
/* @__PURE__ */ jsx("div", { className: "deck-shell deck-scope", ref: host }),
|
|
41
|
+
/* @__PURE__ */ jsxs("div", { className: "deck-actions", children: [
|
|
42
|
+
/* @__PURE__ */ jsx("p", { className: "deck-hint", children: "\u2190 / \u2192 to navigate \xB7 Home / End to jump" }),
|
|
43
|
+
/* @__PURE__ */ jsx("button", { type: "button", className: "deck-copy", onClick: copySlideLink, children: copied ? "link copied \u2713" : "Copy link to this slide" })
|
|
44
|
+
] })
|
|
45
|
+
] });
|
|
46
|
+
}
|
|
47
|
+
export {
|
|
48
|
+
SlideDeck as default
|
|
49
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
function SlideFigure({ html }) {
|
|
5
|
+
const box = useRef(null);
|
|
6
|
+
const canvas = useRef(null);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const el = box.current, c = canvas.current;
|
|
9
|
+
if (!el || !c) return;
|
|
10
|
+
const fit = () => {
|
|
11
|
+
const s = el.clientWidth / 1920;
|
|
12
|
+
c.style.transform = `scale(${s})`;
|
|
13
|
+
el.style.height = 1080 * s + "px";
|
|
14
|
+
};
|
|
15
|
+
fit();
|
|
16
|
+
const ro = new ResizeObserver(fit);
|
|
17
|
+
ro.observe(el);
|
|
18
|
+
return () => ro.disconnect();
|
|
19
|
+
}, []);
|
|
20
|
+
return /* @__PURE__ */ jsx("div", { className: "slide-figure deck-scope", ref: box, "aria-hidden": "true", children: /* @__PURE__ */ jsx("div", { className: "slide-figure-canvas", ref: canvas, dangerouslySetInnerHTML: { __html: html } }) });
|
|
21
|
+
}
|
|
22
|
+
export {
|
|
23
|
+
SlideFigure as default
|
|
24
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useTheme } from "./useTheme.js";
|
|
4
|
+
function ThemeToggle() {
|
|
5
|
+
const { theme, toggle } = useTheme();
|
|
6
|
+
return /* @__PURE__ */ jsx("button", { className: "theme-toggle", onClick: toggle, "aria-label": `Switch to ${theme === "dark" ? "light" : "dark"} theme`, children: theme === "dark" ? /* @__PURE__ */ jsxs("svg", { viewBox: "0 0 24 24", width: "18", height: "18", fill: "none", stroke: "currentColor", strokeWidth: "2", "aria-hidden": "true", children: [
|
|
7
|
+
/* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "4" }),
|
|
8
|
+
/* @__PURE__ */ jsx("path", { strokeLinecap: "round", d: "M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5L19 19M19 5l-1.5 1.5M6.5 17.5L5 19" })
|
|
9
|
+
] }) : /* @__PURE__ */ jsx("svg", { viewBox: "0 0 24 24", width: "18", height: "18", fill: "currentColor", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" }) }) });
|
|
10
|
+
}
|
|
11
|
+
export {
|
|
12
|
+
ThemeToggle as default
|
|
13
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { renderMarkdown, splitBodyByMarkers } from "./markdown.js";
|
|
2
|
+
import { compositeSlides } from "../sections.js";
|
|
3
|
+
function assemblePost({ meta, sections, bodyMd, deckSlides }) {
|
|
4
|
+
const byLabel = new Map(compositeSlides(deckSlides).map((s) => [s.label, s.html]));
|
|
5
|
+
const bodies = splitBodyByMarkers(bodyMd || "");
|
|
6
|
+
const builtSections = sections.map((s) => {
|
|
7
|
+
const steps = (s.slides || []).map((label) => byLabel.get(label)).filter(Boolean);
|
|
8
|
+
return {
|
|
9
|
+
key: s.key,
|
|
10
|
+
label: s.label || s.key,
|
|
11
|
+
heading: s.heading || s.label || s.key,
|
|
12
|
+
steps,
|
|
13
|
+
body: bodies[s.key] ? renderMarkdown(bodies[s.key]) : ""
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
return {
|
|
17
|
+
...meta,
|
|
18
|
+
sections: builtSections,
|
|
19
|
+
deckSteps: deckSlides.map((s) => s.html)
|
|
20
|
+
// full deck, in order, for Watch
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export {
|
|
24
|
+
assemblePost
|
|
25
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import MarkdownIt from "markdown-it";
|
|
2
|
+
const md = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: false });
|
|
3
|
+
function renderMarkdown(src) {
|
|
4
|
+
return md.render(String(src || "").trim());
|
|
5
|
+
}
|
|
6
|
+
function splitBodyByMarkers(bodyMd) {
|
|
7
|
+
const out = {};
|
|
8
|
+
const re = /<!--\s*section:\s*([\w-]+)\s*-->/g;
|
|
9
|
+
const parts = [];
|
|
10
|
+
let m, last = null, lastIdx = 0;
|
|
11
|
+
while ((m = re.exec(bodyMd)) !== null) {
|
|
12
|
+
if (last !== null) parts.push([last, bodyMd.slice(lastIdx, m.index)]);
|
|
13
|
+
last = m[1];
|
|
14
|
+
lastIdx = re.lastIndex;
|
|
15
|
+
}
|
|
16
|
+
if (last !== null) parts.push([last, bodyMd.slice(lastIdx)]);
|
|
17
|
+
for (const [key, text] of parts) out[key] = text.trim();
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
export {
|
|
21
|
+
renderMarkdown,
|
|
22
|
+
splitBodyByMarkers
|
|
23
|
+
};
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
const StoryDeckContext = createContext({ basePath: "" });
|
|
5
|
+
function StoryDeckProvider({ basePath = "", children }) {
|
|
6
|
+
return /* @__PURE__ */ jsx(StoryDeckContext.Provider, { value: { basePath }, children });
|
|
7
|
+
}
|
|
8
|
+
function useBasePath() {
|
|
9
|
+
return useContext(StoryDeckContext).basePath;
|
|
10
|
+
}
|
|
11
|
+
export {
|
|
12
|
+
StoryDeckProvider,
|
|
13
|
+
useBasePath
|
|
14
|
+
};
|
package/dist/figure.js
ADDED