footprint-storydeck 0.4.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 +46 -0
- package/README.md +177 -5
- 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/focus.css +2 -0
- package/focus.d.ts +31 -3
- package/focusHtml.d.ts +9 -2
- package/index.d.ts +64 -9
- package/narrationHtml.d.ts +4 -1
- package/package.json +86 -13
- package/plugin/skills/storydeck/SKILL.md +30 -6
- package/pptx.d.ts +75 -0
- package/storydeck.css +1 -0
- package/tour.d.ts +71 -0
- package/BlogView.jsx +0 -52
- package/PostView.jsx +0 -229
- package/ScrollyView.jsx +0 -112
- package/SlideDeck.jsx +0 -45
- 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 -309
- package/focusHtml.js +0 -177
- package/index.js +0 -21
- package/narration.js +0 -25
- package/narrationHtml.js +0 -153
- package/scopeDeckCss.js +0 -8
- package/sections.js +0 -72
- package/slug.js +0 -9
- package/useTheme.js +0 -37
- package/video.js +0 -228
- package/voice.js +0 -41
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
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
|
+
|
|
17
|
+
## 0.5.0 — PowerPoint, the exploded view, and a tour for decks nobody presents
|
|
18
|
+
|
|
19
|
+
- **`footprint-storydeck/pptx`: the deck as a PowerPoint file.** `exportPptx` makes a slide per click: its settled
|
|
20
|
+
picture full-bleed, its notes as speaker notes (a paragraph per line), its headline or label as an off-page title
|
|
21
|
+
(the outline and screen readers read it, the audience does not see it), and a fade. With clips, each slide plays
|
|
22
|
+
its clip and moves on after it (plus `gap`): a narrated deck that runs by itself. It drives the browser through
|
|
23
|
+
the video's port (`deckStageDriver`). The export is a footprintjs flowchart, so every run leaves a record
|
|
24
|
+
(`narrative`), and each check is a `decide()` with its evidence. A clip that is missing, empty, not the sound its
|
|
25
|
+
name says, over `maxClip` or never voiced, a click without a 16:9 PNG, or a package that does not read back is
|
|
26
|
+
refused before a browser opens or a file is written, naming the fix. Media are shared by their bytes, never by
|
|
27
|
+
name. The ZIP, the PresentationML parts and the read-back are written by hand and exported (`zip`, `unzip`,
|
|
28
|
+
`pptxParts`, `checkPptx`). Opened in PowerPoint 16 with no repair prompt; every part validates against the
|
|
29
|
+
ISO/IEC 29500 schemas. It is pictures, notes and sound, not editable native shapes. footprintjs is an optional
|
|
30
|
+
peer dependency: `npm i footprintjs` beside StoryDeck to export; nothing else loads it.
|
|
31
|
+
- **`apart`, the exploded view.** A focus mover: named groups fly apart along the vectors you give
|
|
32
|
+
(`data-focus-apart="lid: 0 -120 | core: 160 0 | base: 0 120 1.1"`), one spring each, `data-focus-stagger` ms apart
|
|
33
|
+
(35), out in the order written and back in reverse on a later click without it; closing groups settle for one
|
|
34
|
+
more click. A click reschedules only the groups not yet at its target, in order, never sooner than a start they
|
|
35
|
+
were already waiting for, so the earlier groups lead. `focusRuntime` plays the springs as Web Animations, each deck
|
|
36
|
+
its own, so at rest a piece is exactly as drawn and the video renderer steps them; `SlideDeck` now installs it.
|
|
37
|
+
Two movers on one click are refused, naming the fix; a group's numbers are strict and its scale must be above 0
|
|
38
|
+
(for `place` too). Every `focusStep` look now carries `apart` (`null` without it), and every piece `apart`.
|
|
39
|
+
- **`footprint-storydeck/tour`: a tour for decks nobody presents.** `idleTour` plays a deck's stops through its own
|
|
40
|
+
`goTo` after 1.4 s with nobody touching it. A key, tap, click or real move hands the deck over until 45 s of quiet;
|
|
41
|
+
Escape or the "Stop tour" control (its name is its visible words; hidden while no tour can move in view) stops
|
|
42
|
+
every tour for the page view (WCAG 2.2.2). It waits while out of sight or while listen mode plays the deck
|
|
43
|
+
(`data-listening`, now set by `listenRuntime`), and never runs under reduced motion. Declared as
|
|
44
|
+
`<deck-stage data-tour>`, `SlideDeck`'s `tour`, or post.json's `tour`; `tourPlan` and `tourText` give the plan as
|
|
45
|
+
data and in one line.
|
|
46
|
+
- **Video:** the driver finishes click n−1's motion before stepping on, so a strip renders the same frames as the
|
|
47
|
+
full video. `focusRuntime`'s install flag is its version, so an older inlined runtime no longer blocks it.
|
|
48
|
+
|
|
3
49
|
## 0.4.0 — a path: the subject piece after piece along a route
|
|
4
50
|
|
|
5
51
|
- **A fourth kind of focus strategy, the order — how the subject comes on — and its first strategy, `path`.**
|
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
|
|
@@ -73,6 +99,7 @@ decision every click makes again, so it is data on the click, and the library do
|
|
|
73
99
|
| mover | `zoom` | the view closes in on the subject (`share`, `most`) |
|
|
74
100
|
| mover | `left` · `right` · `up` · `down` | the context steps aside that way, smaller (`scale`), and the subject grows into the room it leaves (`grow`, `fill`, `gap`, `align`; `move: 'aside'` keeps the subject where it is) |
|
|
75
101
|
| mover | `place` | named groups moved by the amounts you give (`groups: 'page floor: -90 -200 0.5 \| tl: 0 -230 1.1 @ 960 968'`): a layout you art-direct, still animated from wherever the last click left it |
|
|
102
|
+
| mover | `apart` | the exploded view: named groups fly apart along the vectors you give (`apart: 'lid: 0 -120 \| core: 160 0 \| base: 0 120 1.1'`), one spring each, `stagger` ms apart (35), and come back together on a later click without it — the last one out is the first one back |
|
|
76
103
|
| overlay | `blur` | everything outside the subject's frame is blurred; a frame (and a `label`) on it, or on the `rect`s you give |
|
|
77
104
|
| order | `path` | the subject comes on piece after piece along a route, as a request travels it — node, wire, node: each lit piece on it lights at its turn (`step` seconds apart, 0.35) and glows as it is reached; `route` names the order (default — and for an empty `route` — the click's `on`, or its `in`); a greyed piece on it takes no turn, and a route with no order at all (`on: ['*']`) is refused; a `line` ("agent → MCP → gateway") names the route, shown as the last turn starts. A deck's own `filter` on a path piece is not carried through the glow: it steps where the glow starts and ends |
|
|
78
105
|
|
|
@@ -93,6 +120,35 @@ on one `filter` would flash), its ring (`hot`) comes .3 s after its turn, and a
|
|
|
93
120
|
twin's turn and only then steps aside, so the wire is never missing. Under a new blur the whole path, line included,
|
|
94
121
|
waits .4 s for it, as what a click adds does.
|
|
95
122
|
|
|
123
|
+
**An exploded view, on a figure of parts.** A figure built of parts — an engine, a stack, a page and the layers
|
|
124
|
+
under it — explains itself best when the parts come apart: the audience sees what it is made of, then sees it whole
|
|
125
|
+
again. Name the parts as groups and the way each one leaves:
|
|
126
|
+
|
|
127
|
+
```html
|
|
128
|
+
<step data-focus="apart" data-focus-apart="lid: 0 -120 | core: 160 0 | base: 0 120 1.1"></step>
|
|
129
|
+
<step data-on="core"></step> <!-- no apart: the parts come back together, the core lit -->
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Each group has one spring, its value 0 together and 1 apart, a little under critical damping, so a part settles with
|
|
133
|
+
a hint of weight instead of stopping dead. The groups set off `stagger` ms apart (`data-focus-stagger`, 35) — out in
|
|
134
|
+
the order written, back in reverse — so the last one out is the first one back. A click that turns the parts round
|
|
135
|
+
mid-way reschedules only the groups not yet at its target (those turning round, and those still waiting to set off),
|
|
136
|
+
in order, each a stagger after the one before and never sooner than a start it was already waiting for — so the
|
|
137
|
+
order is kept: among them the earlier groups always lead, and a click that keeps a group's target keeps its time.
|
|
138
|
+
The order is what the library keeps; where the parts go is yours — vectors that cross make paths that cross, so give
|
|
139
|
+
each part its own way out. The click after a close keeps the closing groups on their springs one click more, so a
|
|
140
|
+
quick click lets them settle from wherever they are. A line or connector that carries a group's name
|
|
141
|
+
(`data-k="wire core"`) moves with that group, keeping its place against it. `apart` is a mover, so it goes with a look (the rest
|
|
142
|
+
greyed), an overlay and an order, and a click that also asks for `place` is refused, naming the fix; across clicks
|
|
143
|
+
the two compose: the parts fly out from the layout `place` left, and back into it.
|
|
144
|
+
|
|
145
|
+
The springs run in `focusRuntime` (the Watch lens, `SlideDeck`, installs it; a built page inlines it), each deck on
|
|
146
|
+
the page its own, played as Web Animations of the same `translate`/`scale` every mover uses (so a piece's own
|
|
147
|
+
transform, an SVG group's `transform` attribute included, is never overwritten), which means the video renderer
|
|
148
|
+
steps them like any animation. Nothing of a spring is written on the piece: at rest the animation is over and the
|
|
149
|
+
piece is exactly as drawn — closed, the figure is its static art. A jump, a step back, print and reduced motion land
|
|
150
|
+
at once, as every focus does.
|
|
151
|
+
|
|
96
152
|
The plan is plain data (each piece's state, what changed since the last click, how far it moved), so a
|
|
97
153
|
renderer only draws it. `footprint-storydeck/focus-html` writes it into slide HTML at build time and
|
|
98
154
|
`footprint-storydeck/focus.css` animates **only what changed, only on a forward click** — a jump or a step back
|
|
@@ -128,6 +184,56 @@ a piece (a dashed wire under a solid one) step aside while its twin is lit. A pi
|
|
|
128
184
|
from how it looked and where it stood; a blur that moves to another subject stays, and only its frame is
|
|
129
185
|
new; overlapping holes stay clear, and nothing outside the `area` is blurred.
|
|
130
186
|
|
|
187
|
+
## Tour: a deck that shows its point while nobody is using it
|
|
188
|
+
|
|
189
|
+
A deck left on a screen with nobody presenting it — at a booth, on a post's Watch lens, as a page's hero figure — is
|
|
190
|
+
mostly looked at by people who have not touched it yet. If it sits on its first slide, they never see what it is
|
|
191
|
+
for. So, left alone for a moment (1.4 s), it plays a short loop of **stops**, the clicks that make its point, and the
|
|
192
|
+
moment someone reaches for it, it hands itself over:
|
|
193
|
+
|
|
194
|
+
- it drives the **same targets a person would** — the deck's own `goTo`, so a stop one click on animates as that click
|
|
195
|
+
does (focus, `apart`'s springs and all) and a jump lands, as a person's jump does; there is no second animation to
|
|
196
|
+
keep in step;
|
|
197
|
+
- **accessibility first**: the moment someone uses it — a key, a tap, a click, a focus, a real move of the pointer
|
|
198
|
+
over it (looked at every 250 ms, so a resting mouse or the deck moving under it is no move, and a finger lifting off
|
|
199
|
+
is not someone leaving), or a move it did not make (the presenter window) — it hands itself over, and it does not
|
|
200
|
+
take the deck back soon: only after **45 s of quiet** (`resumeMs`), or once the deck has left sight and come back.
|
|
201
|
+
It then goes on from the stop they left it on, when that is a stop;
|
|
202
|
+
- **Escape**, or the small **"Stop tour"** control it shows while a tour can move in view (or the page's own element
|
|
203
|
+
marked `data-tour-stop`, which it uses instead), stops every tour for the rest of the page view (WCAG 2.2.2). Escape
|
|
204
|
+
is one key with three meanings on a StoryDeck page, each by what is running: while listen mode plays, it stops listen
|
|
205
|
+
mode, and only that; in `PostView`'s Watch lens it returns to Read; and wherever a tour runs it stops every tour on
|
|
206
|
+
the page — so in a touring Watch lens one Escape does both;
|
|
207
|
+
- it **waits while out of sight** — scrolled away (IntersectionObserver, reading the newest entry) or the page
|
|
208
|
+
hidden — and while listen mode plays the deck (the deck carries `data-listening`); under **reduced motion** there
|
|
209
|
+
is no tour.
|
|
210
|
+
|
|
211
|
+
The plan is plain data — the stops, each one's hold, the waits — that a person or an LLM can read, and that the
|
|
212
|
+
deck's notes can say in one line (`tourText`):
|
|
213
|
+
|
|
214
|
+
```js
|
|
215
|
+
import { idleTour, tourPlan, tourText } from 'footprint-storydeck/tour';
|
|
216
|
+
|
|
217
|
+
idleTour(document.querySelector('deck-stage'), { stops: '3 4 5 9:6s', hold: '3s' }); // clicks as the deck counts them
|
|
218
|
+
tourText({ stops: '3 4 5 9:6s' });
|
|
219
|
+
// "Left alone for 1.4 s, it tours itself: slide 3 for 3 s, then slide 4 for 3 s, then slide 5 for 3 s, then slide 9
|
|
220
|
+
// for 6 s, and round again. A key, tap, click or move hands it over until 45 s of quiet; Escape or "Stop tour" stops it."
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Declared on the deck instead, for a page built as HTML: `<deck-stage data-tour="3 4 5 9:6s" data-tour-hold="3s">` (and
|
|
224
|
+
`data-tour-idle`, `data-tour-resume`), with `(${idleTour})()` once in the page; a deck whose `data-tour` it cannot
|
|
225
|
+
read is left out with a console warning naming the fix, and the others still tour. A stop is held 300 ms or more. In
|
|
226
|
+
React, the Watch lens takes the same spec — `<SlideDeck steps={…} tour="3 4 5 9:6s" />` — and `PostView` passes the
|
|
227
|
+
post's own `tour` (post.json), so a post opts in where its structure is written. A figure that is not a deck tours
|
|
228
|
+
through its own function: stops are its names, `go` is what a person's click calls
|
|
229
|
+
(`idleTour(figure, { stops: ['lid', 'core'], go: (part) => show(part) })`). The tour's state rides on the element as `data-tour-state` (`waiting`,
|
|
230
|
+
`touring`, `paused`, `hidden`, `still`, `done`), so a page can say "touring — touch to explore"; the control is
|
|
231
|
+
`.sd-tour-stop`, styled as weakly as can be, so a page restyles it.
|
|
232
|
+
|
|
233
|
+
It lives on the Watch deck, not in the Scroll or Read lenses: their figure follows the reader's scroll position, so a
|
|
234
|
+
tour there would move the page under the reader's eyes; a deck is a place a person steps through, and the tour steps
|
|
235
|
+
through it the same way.
|
|
236
|
+
|
|
131
237
|
## Narration: the deck tells itself, in your voice
|
|
132
238
|
|
|
133
239
|
A talk lives on after the room: someone opens the deck alone, or it becomes a video. The words were in the
|
|
@@ -199,6 +305,66 @@ await renderVideo({
|
|
|
199
305
|
});
|
|
200
306
|
```
|
|
201
307
|
|
|
308
|
+
## PowerPoint: the deck as a .pptx
|
|
309
|
+
|
|
310
|
+
Conferences often ask for the talk as a PowerPoint file. `footprint-storydeck/pptx` (Node, no ffmpeg) exports one
|
|
311
|
+
through the same browser port as the video, so a deck that can be filmed can be exported:
|
|
312
|
+
|
|
313
|
+
- **one slide per click** — the click's settled picture (made forward, its entrance played to the end, as the
|
|
314
|
+
video's held frame) full-bleed on a 16:9 slide; its written notes as the speaker notes, a paragraph per note;
|
|
315
|
+
its headline (or label) as the slide's title, in the title placeholder just above the page, so the outline, the
|
|
316
|
+
slide list and a screen reader read it and the audience does not see it; a fade between slides;
|
|
317
|
+
- **a narrated deck that runs by itself** — with clips (as the video takes them, or found in the voice cache), each
|
|
318
|
+
slide carries its clip, which plays as the slide starts, and moves on after the clip plus `gap` (a click without
|
|
319
|
+
a clip after `noclip` plus `gap`, the video's timing). Without clips no slide moves on by itself: the speaker clicks.
|
|
320
|
+
|
|
321
|
+
**What it is, honestly:** pictures plus notes plus sound. A slide's words and shapes are in its picture, not
|
|
322
|
+
native PowerPoint text or shapes, so they cannot be edited there. Turning a deck's HTML into native shapes is a
|
|
323
|
+
possible later step.
|
|
324
|
+
|
|
325
|
+
**Why a flowchart.** The export is a [footprintjs](https://github.com/footprintjs/footPrint) flowchart —
|
|
326
|
+
PlanClicks → GatherClips → CheckClips → TimeTheDeck → ShootPictures → CheckPictures → WriteParts → Zip →
|
|
327
|
+
CheckPackage → Verdict → Deliver — so every run leaves a record that explains it, to a person or an LLM
|
|
328
|
+
(`done.narrative`). Each check is a `decide()` that records its evidence: a clip whose file is gone, one PowerPoint
|
|
329
|
+
cannot play (not a .wav, .m4a or .mp3; an empty file; or bytes that do not begin as their kind of file — RIFF…WAVE,
|
|
330
|
+
ID3 or an MP3 frame, ftyp), one over `maxClip` (300 s), a click that says words with no clip;
|
|
331
|
+
a click without a 16:9 PNG; a package that does not read back as planned. A refusal names each click and its fix,
|
|
332
|
+
carries the record (`error.narrative`), and writes nothing to `out`. The clips are checked before a browser opens.
|
|
333
|
+
footprintjs is an optional peer dependency: StoryDeck does not install it (a browser-only install stays small), so
|
|
334
|
+
install it beside StoryDeck to export — `npm i footprintjs` — and an export without it is refused, saying so. It is
|
|
335
|
+
loaded only when an export runs; the deck's browser runtime never loads it.
|
|
336
|
+
|
|
337
|
+
```js
|
|
338
|
+
import { chromium } from 'playwright-core';
|
|
339
|
+
import { deckStageDriver } from 'footprint-storydeck/video';
|
|
340
|
+
import { exportPptx } from 'footprint-storydeck/pptx'; // and `npm i footprintjs`: an optional peer, needed to export
|
|
341
|
+
|
|
342
|
+
const done = await exportPptx({
|
|
343
|
+
url: 'file:///…/deck.html', steps, out: 'out/my-talk.pptx', // the steps the video takes; or `out` a folder → <name>.pptx
|
|
344
|
+
driver: deckStageDriver({ chromium }),
|
|
345
|
+
});
|
|
346
|
+
// done: { file, slides, voiced, runtime (s the deck runs by itself, or null), bytes, clicks, narrative }
|
|
347
|
+
// [Condition]: No rules matched, fell back to default "Every clip is there, playable and within maxClip": TimeTheDeck.
|
|
348
|
+
// …or, refused: exportPptx: a clip is not ready: click 12: its clip lasts 412 s, over maxClip (300 s) — split its notes across clicks, or raise maxClip
|
|
349
|
+
|
|
350
|
+
// From the deck's clicks and the voice cache instead of steps (give `voice` the `spell`/`say` the clips were voiced with):
|
|
351
|
+
await exportPptx({ url, deck: clicks.map(c => ({ label: c.label, notes: c.notes })), voice: { cache: 'voice/cache', profile }, out: 'out', driver });
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
The file is written by hand, with no dependency: a small ZIP writer (`zlib`'s raw deflate and a CRC-32) and a
|
|
355
|
+
minimal PresentationML part set (ECMA-376) — content types, relationships, the presentation, one master and one
|
|
356
|
+
title-only layout, a theme for each master, the slides, a notes master and a notes page per slide, the media (a
|
|
357
|
+
sound said on two clicks is stored once, matched by its bytes) and the document properties. The pieces are
|
|
358
|
+
exported for pictures made elsewhere: `pptxParts({ slides })` gives the parts, `zip(parts)` the file, and
|
|
359
|
+
`checkPptx(bytes)` reads one back — the ZIP against its CRC-32s, every part well-formed and typed, every
|
|
360
|
+
relationship's target there, every `r:id` known, slide, master and layout ids in PowerPoint's ranges, shape ids
|
|
361
|
+
unique on each slide — and lists each slide's title, notes, pictures, sound and time. Each export runs that check
|
|
362
|
+
before it writes to `out`. The tests also run Info-ZIP's `unzip -t`; LibreOffice, where it is installed, opens
|
|
363
|
+
the file and prints it to PDF; and with `STORYDECK_OOXML_SCHEMAS` pointing at the ISO/IEC 29500 schemas, every part
|
|
364
|
+
is validated against them. Limits: a clip is embedded as it is (a .wav as voiced: about 3 MB a minute at 24 kHz
|
|
365
|
+
mono — convert to .m4a to shrink a long talk); no ZIP64, so a file stays under 4 GB; the slides advance by
|
|
366
|
+
themselves when the show's "use timings" is on (PowerPoint's default).
|
|
367
|
+
|
|
202
368
|
## Faster edits — what making one talk taught us
|
|
203
369
|
|
|
204
370
|
- **Cache by content, not by position.** A clip is keyed by its words and settings: inserting or removing a
|
|
@@ -277,7 +443,7 @@ const { theme, toggle, setTheme } = useTheme(); // flips an html class + persi
|
|
|
277
443
|
| Export | What |
|
|
278
444
|
|---|---|
|
|
279
445
|
| `PostView` | the lens controller (Read · Scroll · Watch toggle) |
|
|
280
|
-
| `BlogView` · `ScrollyView` · `SlideDeck` | the three lenses (take `sections` / `sections` / `steps`) |
|
|
446
|
+
| `BlogView` · `ScrollyView` · `SlideDeck` | the three lenses (take `sections` / `sections` / `steps`; `SlideDeck` also a `tour`) |
|
|
281
447
|
| `SlideFigure` | one slide rendered as a scaled static figure |
|
|
282
448
|
| `StoryDeckProvider` · `useBasePath` | inject deploy config (asset base path) |
|
|
283
449
|
| `useTheme` · `ThemeToggle` | headless theming + a default toggle |
|
|
@@ -287,13 +453,19 @@ const { theme, toggle, setTheme } = useTheme(); // flips an html class + persi
|
|
|
287
453
|
| `slugify` · `scopeDeckCss` | utilities |
|
|
288
454
|
| `planFocus` · `focusStep` · `readFocus` · `FOCUS` | focus: per click, what it is about and what happens to the rest (also `footprint-storydeck/focus`) |
|
|
289
455
|
| `readPieces` · `readStep` · `drawFocus` · `wrapStage` · `slideClass` · `focusOverlay` · `focusRuntime` | focus, written into slide HTML (also `footprint-storydeck/focus-html`; styles in `footprint-storydeck/focus.css`) |
|
|
456
|
+
| `idleTour` · `tourPlan` · `tourText` | tour: a deck (or a figure) that shows its point while nobody is using it, and its plan in data and in one line (also `footprint-storydeck/tour`) |
|
|
290
457
|
| `narrationText` · `writtenText` | narration: what a click says (also `footprint-storydeck/narration`); the rules beneath, sentences, captions and chapters are `footprint-narration`'s |
|
|
291
458
|
| `notesData` · `notesRuntime` · `listenRuntime` | narration in the page: notes, presenter window, listen mode (also `footprint-storydeck/narration-html`; styles in `footprint-storydeck/narration.css`) |
|
|
292
459
|
| `embedClips` | `footprint-storydeck/voice` (Node only): a deck's clips inside one page; the clip cache, the voice port and its local-kit adapter are `footprint-narration/voice`'s |
|
|
293
460
|
| `renderVideo` · `planVideo` · `deckStageDriver` · `ffmpegMajor` | `footprint-storydeck/video` (Node only, ffmpeg 5.0+): the deck as a narrated video, with captions and chapters |
|
|
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 |
|
|
294
462
|
|
|
295
|
-
|
|
296
|
-
|
|
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.
|
|
297
469
|
|
|
298
470
|
## Tests
|
|
299
471
|
|
|
@@ -302,7 +474,7 @@ npm test # vitest
|
|
|
302
474
|
npm run test:cov
|
|
303
475
|
```
|
|
304
476
|
|
|
305
|
-
|
|
477
|
+
317 tests (and 2 that run where LibreOffice or the ECMA-376 schemas are found) · ~99% lines · coverage thresholds enforced (90% statements, 85% branches, 90% functions, 95% lines).
|
|
306
478
|
|
|
307
479
|
## License
|
|
308
480
|
|
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
|
+
};
|