@sphynxoid/stepwise 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +126 -2
- package/dist/adapters/driver-js.d.ts +23 -0
- package/dist/adapters/driver-js.js +45 -0
- package/dist/adapters/driver-js.js.map +1 -0
- package/dist/adapters/gsap.d.ts +23 -0
- package/dist/adapters/gsap.js +51 -0
- package/dist/adapters/gsap.js.map +1 -0
- package/dist/adapters/scroll.d.ts +23 -0
- package/dist/adapters/scroll.js +71 -0
- package/dist/adapters/scroll.js.map +1 -0
- package/dist/index.d.ts +441 -0
- package/dist/index.js +1441 -0
- package/dist/index.js.map +1 -0
- package/dist/types-CPf9ZD_Z.d.ts +88 -0
- package/package.json +84 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Sphynx
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,127 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sphynxoid/stepwise
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A kit for interactive, narrated, step-driven visualizations — one primitive (`useSteps`) at every scale.
|
|
4
|
+
|
|
5
|
+
Frames in a trace, chapters in a lesson, lessons in a notebook, and tour steps are the same thing at the core: a clamped cursor over an ordered sequence. Timers, tour effects, scroll tracking, and third-party presenters are policies and adapters layered on top.
|
|
6
|
+
|
|
7
|
+
- **Headless core.** The hooks never render; the styled components are optional and theme-injectable.
|
|
8
|
+
- **One dependency by default.** The root barrel needs only React. GSAP and driver.js integrations are opt-in via adapter subpaths.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @sphynxoid/stepwise
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Optional adapters (install only what you use):
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install gsap # @sphynxoid/stepwise/adapters/gsap
|
|
20
|
+
npm install driver.js # @sphynxoid/stepwise/adapters/driver-js
|
|
21
|
+
# @sphynxoid/stepwise/adapters/scroll is dependency-free
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Required: Tailwind CSS setup
|
|
25
|
+
|
|
26
|
+
The styled components ship **className strings**. Tailwind v4's automatic
|
|
27
|
+
content detection skips `node_modules`, so **no library class gets generated**
|
|
28
|
+
unless you register the source. Without this, every library element renders
|
|
29
|
+
completely unstyled.
|
|
30
|
+
|
|
31
|
+
```css
|
|
32
|
+
@import "tailwindcss";
|
|
33
|
+
@source "../node_modules/@sphynxoid/stepwise/src";
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Components are also fully usable without Tailwind if you bring your own
|
|
37
|
+
presentation — the hooks are headless.
|
|
38
|
+
|
|
39
|
+
## Quick start — a narrated player
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
"use client";
|
|
43
|
+
import { useStepPlayer, PlayerBar, CodeBlock, type StepTone } from "@sphynxoid/stepwise";
|
|
44
|
+
|
|
45
|
+
const frames: { tone: StepTone; label: string; code: string[] }[] = [
|
|
46
|
+
{ tone: "write", label: "Initialize", code: ["let i = 0;"] },
|
|
47
|
+
{ tone: "neutral", label: "Loop", code: ["while (i < n) { … }"] },
|
|
48
|
+
{ tone: "good", label: "Done", code: ["return arr;"] },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
export function Demo() {
|
|
52
|
+
const player = useStepPlayer({ total: frames.length, autoPlay: true, syncHash: "frame" });
|
|
53
|
+
const frame = frames[Math.max(0, player.index)] ?? frames[0];
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
<CodeBlock lines={frame.code} />
|
|
58
|
+
<PlayerBar player={player} toneAt={(i) => frames[i]?.tone ?? "neutral"} titleAt={(i) => frames[i]?.label ?? ""} />
|
|
59
|
+
</>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`useStepPlayer` gives you a timer-driven playhead with play/pause/seek, speed control, and optional `#frame=2` deep links — or hand it an `engine` to drive the playhead from GSAP or scroll instead. Two end-state flavors: `atEnd` (cursor parked on the last frame, seeks included) and `ended` (playback ran to the finish, via the `onEnd` callback option) — clearing on any cursor movement, so a completion summary knows the demo actually finished.
|
|
65
|
+
|
|
66
|
+
## The three scales
|
|
67
|
+
|
|
68
|
+
| Scale | Hook | Presentation |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| **Player** — auto-played frames in a trace | `useStepPlayer` (+ `PlayerEngine` adapters) | `PlayerBar`, `StepScrubber`, `CodeBlock` |
|
|
71
|
+
| **Tour** — steps that mutate app state via `onEnter` | `useGuidedTour` (+ `TourDriver` adapters) | `TourSpotlight`, `TourCard` |
|
|
72
|
+
| **Chapters** — scroll-tracked sections | `useChapters`, `flattenChapters` | `ChapterNav`, `LessonPager` |
|
|
73
|
+
|
|
74
|
+
All three compose the same `useSteps` cursor, so movement, clamping, and boundary flags behave identically everywhere.
|
|
75
|
+
|
|
76
|
+
Tours are small state machines: a step may declare `when` (skip on arrival when false), `gate` (block `next()` — and expose `blocked` — until the user actually does the thing), and `next` (edge override to another step's `id`, enabling branches and loops). `gotoId(id)` navigates by identity; programmatic navigation never trips gates.
|
|
77
|
+
|
|
78
|
+
## Show-once tutorials (led discovery)
|
|
79
|
+
|
|
80
|
+
Pair `useDiscoveries` with `useGuidedTour` to auto-play a tour the first time a user encounters a feature:
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
const { discovered, ready, markDiscovered } = useDiscoveries();
|
|
84
|
+
const tour = useGuidedTour(steps);
|
|
85
|
+
|
|
86
|
+
// when this build carries an undiscovered feature, play its tour once
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (ready && !tour.active && !discovered.has("search:v1")) tour.start();
|
|
89
|
+
}, [ready, tour.active, discovered]);
|
|
90
|
+
|
|
91
|
+
// mark on completion (or explicit dismiss) — never on mere trigger
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (launched && !tour.active) markDiscovered("search:v1");
|
|
94
|
+
}, [tour.active]);
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The ledger is a set of opaque ids behind a `DiscoveryStore` seam: `localStorageStore` (default), `cookieStore` (when SSR markup must know), `memoryStore` (tests), or your own store backed by an API for cross-device discovery.
|
|
98
|
+
|
|
99
|
+
## Supporting pieces
|
|
100
|
+
|
|
101
|
+
- `useGlobalKeys` — global keyboard bindings with a form-control guard
|
|
102
|
+
- `useFocusTrap` — dialog/tour focus containment
|
|
103
|
+
- `usePrefersReducedMotion` — motion preference gate
|
|
104
|
+
- `useDiscoveries` (+ `localStorageStore` / `cookieStore` / `memoryStore`) — persisted discovery ledger with a swappable storage seam
|
|
105
|
+
- `GlossaryProvider` / `Term` / `GlossaryAppendix` — recursive, depth-capped term tooltips with an auto-collecting appendix
|
|
106
|
+
- `ShortcutsDialog` — keyboard shortcut reference
|
|
107
|
+
- `defaultTheme` / `StepwiseTheme` — the default styling, overridable per component via a `theme` prop (swap in any design system)
|
|
108
|
+
|
|
109
|
+
## Adapters
|
|
110
|
+
|
|
111
|
+
Import adapters from their subpaths — never from the root barrel:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { createGsapPlayerEngine } from "@sphynxoid/stepwise/adapters/gsap";
|
|
115
|
+
import { createDriverJsTourDriver } from "@sphynxoid/stepwise/adapters/driver-js";
|
|
116
|
+
import { createScrollPlayerEngine } from "@sphynxoid/stepwise/adapters/scroll";
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
An adapter plugs into a tiny port (`PlayerEngine` / `TourDriver`), so you can write your own for Shepherd, Framer Motion, or anything else without touching the kit.
|
|
120
|
+
|
|
121
|
+
## React
|
|
122
|
+
|
|
123
|
+
Supports React 18 and 19. All hooks are SSR-safe (`"use client"` modules, no window access at module evaluation) and every public hook returns referentially stable callbacks.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { T as TourDriver } from '../types-CPf9ZD_Z.js';
|
|
2
|
+
import 'react';
|
|
3
|
+
|
|
4
|
+
interface DriverJsTourOptions {
|
|
5
|
+
/** Extra class for the driver.js popover, e.g. to blend with the app theme. */
|
|
6
|
+
popoverClass?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* TourDriver backed by driver.js. The hook still owns state coupling (onEnter
|
|
10
|
+
* effects fire per step); driver.js only presents the spotlight + popover.
|
|
11
|
+
*
|
|
12
|
+
* Caveats vs the built-in presenter:
|
|
13
|
+
* - every step needs a `target` (driver.js anchors a popover to an element);
|
|
14
|
+
* targetless steps are presented as empty passes — prefer the built-in
|
|
15
|
+
* presenter for tours with intro/outro steps.
|
|
16
|
+
* - step bodies must be plain strings.
|
|
17
|
+
*
|
|
18
|
+
* const driver = useMemo(() => createDriverJsTourDriver(), []);
|
|
19
|
+
* const tour = useGuidedTour(steps, { driver });
|
|
20
|
+
*/
|
|
21
|
+
declare function createDriverJsTourDriver(opts?: DriverJsTourOptions): TourDriver;
|
|
22
|
+
|
|
23
|
+
export { type DriverJsTourOptions, createDriverJsTourDriver };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
// src/adapters/driver-js.ts
|
|
5
|
+
import { driver } from "driver.js";
|
|
6
|
+
import "driver.js/dist/driver.css";
|
|
7
|
+
function createDriverJsTourDriver(opts = {}) {
|
|
8
|
+
let instance = null;
|
|
9
|
+
const destroy = () => {
|
|
10
|
+
if (instance?.isActive()) instance.destroy();
|
|
11
|
+
instance = null;
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
show(step, ctx) {
|
|
15
|
+
destroy();
|
|
16
|
+
if (!step.target || !document.querySelector(`[data-tour="${step.target}"]`)) return;
|
|
17
|
+
instance = driver({
|
|
18
|
+
popoverClass: opts.popoverClass,
|
|
19
|
+
showProgress: false,
|
|
20
|
+
allowClose: true,
|
|
21
|
+
showButtons: ["next", "previous", "close"],
|
|
22
|
+
nextBtnText: "Next",
|
|
23
|
+
prevBtnText: "Back",
|
|
24
|
+
doneBtnText: "Finish",
|
|
25
|
+
onNextClick: () => ctx.next(),
|
|
26
|
+
onPrevClick: () => ctx.back(),
|
|
27
|
+
onCloseClick: () => ctx.end()
|
|
28
|
+
});
|
|
29
|
+
const stepConfig = {
|
|
30
|
+
element: `[data-tour="${step.target}"]`,
|
|
31
|
+
popover: {
|
|
32
|
+
title: step.title,
|
|
33
|
+
description: typeof step.body === "string" ? step.body : ""
|
|
34
|
+
},
|
|
35
|
+
waitForElement: 500
|
|
36
|
+
};
|
|
37
|
+
instance.highlight(stepConfig);
|
|
38
|
+
},
|
|
39
|
+
hide: destroy
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export {
|
|
43
|
+
createDriverJsTourDriver
|
|
44
|
+
};
|
|
45
|
+
//# sourceMappingURL=driver-js.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/adapters/driver-js.ts"],"sourcesContent":["\"use client\";\n\nimport { driver, type DriveStep } from \"driver.js\";\nimport \"driver.js/dist/driver.css\";\nimport type { TourDriver, TourStepInput } from \"../types\";\n\nexport interface DriverJsTourOptions {\n /** Extra class for the driver.js popover, e.g. to blend with the app theme. */\n popoverClass?: string;\n}\n\n/**\n * TourDriver backed by driver.js. The hook still owns state coupling (onEnter\n * effects fire per step); driver.js only presents the spotlight + popover.\n *\n * Caveats vs the built-in presenter:\n * - every step needs a `target` (driver.js anchors a popover to an element);\n * targetless steps are presented as empty passes — prefer the built-in\n * presenter for tours with intro/outro steps.\n * - step bodies must be plain strings.\n *\n * const driver = useMemo(() => createDriverJsTourDriver(), []);\n * const tour = useGuidedTour(steps, { driver });\n */\nexport function createDriverJsTourDriver(opts: DriverJsTourOptions = {}): TourDriver {\n let instance: ReturnType<typeof driver> | null = null;\n\n const destroy = () => {\n if (instance?.isActive()) instance.destroy();\n instance = null;\n };\n\n return {\n show(step: TourStepInput, ctx) {\n destroy();\n if (!step.target || !document.querySelector(`[data-tour=\"${step.target}\"]`)) return;\n instance = driver({\n popoverClass: opts.popoverClass,\n showProgress: false,\n allowClose: true,\n showButtons: [\"next\", \"previous\", \"close\"],\n nextBtnText: \"Next\",\n prevBtnText: \"Back\",\n doneBtnText: \"Finish\",\n onNextClick: () => ctx.next(),\n onPrevClick: () => ctx.back(),\n onCloseClick: () => ctx.end(),\n });\n const stepConfig = {\n element: `[data-tour=\"${step.target}\"]`,\n popover: {\n title: step.title,\n description: typeof step.body === \"string\" ? step.body : \"\",\n },\n waitForElement: 500,\n } as DriveStep;\n instance.highlight(stepConfig);\n },\n hide: destroy,\n };\n}\n"],"mappings":";;;;AAEA,SAAS,cAA8B;AACvC,OAAO;AAqBA,SAAS,yBAAyB,OAA4B,CAAC,GAAe;AACnF,MAAI,WAA6C;AAEjD,QAAM,UAAU,MAAM;AACpB,QAAI,UAAU,SAAS,EAAG,UAAS,QAAQ;AAC3C,eAAW;AAAA,EACb;AAEA,SAAO;AAAA,IACL,KAAK,MAAqB,KAAK;AAC7B,cAAQ;AACR,UAAI,CAAC,KAAK,UAAU,CAAC,SAAS,cAAc,eAAe,KAAK,MAAM,IAAI,EAAG;AAC7E,iBAAW,OAAO;AAAA,QAChB,cAAc,KAAK;AAAA,QACnB,cAAc;AAAA,QACd,YAAY;AAAA,QACZ,aAAa,CAAC,QAAQ,YAAY,OAAO;AAAA,QACzC,aAAa;AAAA,QACb,aAAa;AAAA,QACb,aAAa;AAAA,QACb,aAAa,MAAM,IAAI,KAAK;AAAA,QAC5B,aAAa,MAAM,IAAI,KAAK;AAAA,QAC5B,cAAc,MAAM,IAAI,IAAI;AAAA,MAC9B,CAAC;AACD,YAAM,aAAa;AAAA,QACjB,SAAS,eAAe,KAAK,MAAM;AAAA,QACnC,SAAS;AAAA,UACP,OAAO,KAAK;AAAA,UACZ,aAAa,OAAO,KAAK,SAAS,WAAW,KAAK,OAAO;AAAA,QAC3D;AAAA,QACA,gBAAgB;AAAA,MAClB;AACA,eAAS,UAAU,UAAU;AAAA,IAC/B;AAAA,IACA,MAAM;AAAA,EACR;AACF;","names":[]}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { P as PlayerEngine } from '../types-CPf9ZD_Z.js';
|
|
2
|
+
import 'react';
|
|
3
|
+
|
|
4
|
+
interface GsapEngineOptions {
|
|
5
|
+
/** Frames in the current timeline. Recreate the engine when it changes. */
|
|
6
|
+
total: number;
|
|
7
|
+
/** Wall-clock time per frame at 1× speed. */
|
|
8
|
+
baseDelayMs?: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* PlayerEngine backed by a GSAP tween. The playhead maps 1:1 onto frame
|
|
12
|
+
* indices, so play/pause/seek/rate come from GSAP and frames stream back
|
|
13
|
+
* through onUpdate. Swap-in for useStepPlayer's built-in timer:
|
|
14
|
+
*
|
|
15
|
+
* const engine = useMemo(
|
|
16
|
+
* () => createGsapPlayerEngine({ total: frames.length, baseDelayMs: 900 }),
|
|
17
|
+
* [frames.length]
|
|
18
|
+
* );
|
|
19
|
+
* const player = useStepPlayer({ total: frames.length, engine });
|
|
20
|
+
*/
|
|
21
|
+
declare function createGsapPlayerEngine(opts: GsapEngineOptions): PlayerEngine;
|
|
22
|
+
|
|
23
|
+
export { type GsapEngineOptions, createGsapPlayerEngine };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
// src/adapters/gsap.ts
|
|
5
|
+
import gsap from "gsap";
|
|
6
|
+
function createGsapPlayerEngine(opts) {
|
|
7
|
+
return {
|
|
8
|
+
connect(api) {
|
|
9
|
+
const last = Math.max(0, opts.total - 1);
|
|
10
|
+
const proxy = { frame: 0 };
|
|
11
|
+
const duration = Math.max(1e-3, opts.total * (opts.baseDelayMs ?? 900) / 1e3);
|
|
12
|
+
let endFired = false;
|
|
13
|
+
const tween = gsap.to(proxy, {
|
|
14
|
+
frame: last,
|
|
15
|
+
duration,
|
|
16
|
+
ease: "none",
|
|
17
|
+
paused: true,
|
|
18
|
+
onUpdate: () => {
|
|
19
|
+
const frame = Math.min(Math.max(Math.floor(proxy.frame), 0), api.total - 1);
|
|
20
|
+
api.onFrame(frame);
|
|
21
|
+
if (!endFired && frame >= api.total - 1) {
|
|
22
|
+
endFired = true;
|
|
23
|
+
api.onEnd();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
play: () => {
|
|
29
|
+
tween.play();
|
|
30
|
+
},
|
|
31
|
+
pause: () => {
|
|
32
|
+
tween.pause();
|
|
33
|
+
},
|
|
34
|
+
seek: (frame) => {
|
|
35
|
+
tween.pause(Math.min(frame, last));
|
|
36
|
+
if (frame < last) endFired = false;
|
|
37
|
+
},
|
|
38
|
+
setRate: (rate) => {
|
|
39
|
+
tween.timeScale(rate);
|
|
40
|
+
},
|
|
41
|
+
disconnect: () => {
|
|
42
|
+
tween.kill();
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export {
|
|
49
|
+
createGsapPlayerEngine
|
|
50
|
+
};
|
|
51
|
+
//# sourceMappingURL=gsap.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/adapters/gsap.ts"],"sourcesContent":["\"use client\";\n\nimport gsap from \"gsap\";\nimport type { PlayerEngine, PlayerEngineApi, PlayerEngineHandle } from \"../types\";\n\nexport interface GsapEngineOptions {\n /** Frames in the current timeline. Recreate the engine when it changes. */\n total: number;\n /** Wall-clock time per frame at 1× speed. */\n baseDelayMs?: number;\n}\n\n/**\n * PlayerEngine backed by a GSAP tween. The playhead maps 1:1 onto frame\n * indices, so play/pause/seek/rate come from GSAP and frames stream back\n * through onUpdate. Swap-in for useStepPlayer's built-in timer:\n *\n * const engine = useMemo(\n * () => createGsapPlayerEngine({ total: frames.length, baseDelayMs: 900 }),\n * [frames.length]\n * );\n * const player = useStepPlayer({ total: frames.length, engine });\n */\nexport function createGsapPlayerEngine(opts: GsapEngineOptions): PlayerEngine {\n return {\n connect(api: PlayerEngineApi): PlayerEngineHandle {\n const last = Math.max(0, opts.total - 1);\n const proxy = { frame: 0 };\n const duration = Math.max(0.001, (opts.total * (opts.baseDelayMs ?? 900)) / 1000);\n // Port contract: onEnd fires once per run, on the transition into the\n // final frame — not on every parked tick. Rewinding (seek below the\n // last frame) re-arms it.\n let endFired = false;\n const tween = gsap.to(proxy, {\n frame: last,\n duration,\n ease: \"none\",\n paused: true,\n onUpdate: () => {\n const frame = Math.min(Math.max(Math.floor(proxy.frame), 0), api.total - 1);\n api.onFrame(frame);\n if (!endFired && frame >= api.total - 1) {\n endFired = true;\n api.onEnd();\n }\n },\n });\n return {\n play: () => {\n tween.play();\n },\n pause: () => {\n tween.pause();\n },\n seek: (frame) => {\n tween.pause(Math.min(frame, last));\n if (frame < last) endFired = false;\n },\n setRate: (rate) => {\n tween.timeScale(rate);\n },\n disconnect: () => {\n tween.kill();\n },\n };\n },\n };\n}\n"],"mappings":";;;;AAEA,OAAO,UAAU;AAqBV,SAAS,uBAAuB,MAAuC;AAC5E,SAAO;AAAA,IACL,QAAQ,KAA0C;AAChD,YAAM,OAAO,KAAK,IAAI,GAAG,KAAK,QAAQ,CAAC;AACvC,YAAM,QAAQ,EAAE,OAAO,EAAE;AACzB,YAAM,WAAW,KAAK,IAAI,MAAQ,KAAK,SAAS,KAAK,eAAe,OAAQ,GAAI;AAIhF,UAAI,WAAW;AACf,YAAM,QAAQ,KAAK,GAAG,OAAO;AAAA,QAC3B,OAAO;AAAA,QACP;AAAA,QACA,MAAM;AAAA,QACN,QAAQ;AAAA,QACR,UAAU,MAAM;AACd,gBAAM,QAAQ,KAAK,IAAI,KAAK,IAAI,KAAK,MAAM,MAAM,KAAK,GAAG,CAAC,GAAG,IAAI,QAAQ,CAAC;AAC1E,cAAI,QAAQ,KAAK;AACjB,cAAI,CAAC,YAAY,SAAS,IAAI,QAAQ,GAAG;AACvC,uBAAW;AACX,gBAAI,MAAM;AAAA,UACZ;AAAA,QACF;AAAA,MACF,CAAC;AACD,aAAO;AAAA,QACL,MAAM,MAAM;AACV,gBAAM,KAAK;AAAA,QACb;AAAA,QACA,OAAO,MAAM;AACX,gBAAM,MAAM;AAAA,QACd;AAAA,QACA,MAAM,CAAC,UAAU;AACf,gBAAM,MAAM,KAAK,IAAI,OAAO,IAAI,CAAC;AACjC,cAAI,QAAQ,KAAM,YAAW;AAAA,QAC/B;AAAA,QACA,SAAS,CAAC,SAAS;AACjB,gBAAM,UAAU,IAAI;AAAA,QACtB;AAAA,QACA,YAAY,MAAM;AAChB,gBAAM,KAAK;AAAA,QACb;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { P as PlayerEngine } from '../types-CPf9ZD_Z.js';
|
|
2
|
+
import 'react';
|
|
3
|
+
|
|
4
|
+
interface ScrollEngineOptions {
|
|
5
|
+
/** The scrollytelling section whose scroll progress drives the frames. */
|
|
6
|
+
getContainer: () => HTMLElement | null;
|
|
7
|
+
/** Frames in the current timeline. Recreate the engine when it changes. */
|
|
8
|
+
total: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* PlayerEngine driven by scroll progress through a container section — the
|
|
12
|
+
* scrollytelling mode. Scrolling maps to frame indices; manual stepping seeks
|
|
13
|
+
* the scroll position; "play" smooth-scrolls to the end of the section.
|
|
14
|
+
* Dependency-free (a scrollama replacement for this specific job).
|
|
15
|
+
*
|
|
16
|
+
* const engine = useMemo(
|
|
17
|
+
* () => createScrollPlayerEngine({ getContainer: () => sectionRef.current, total: frames.length }),
|
|
18
|
+
* [frames.length]
|
|
19
|
+
* );
|
|
20
|
+
*/
|
|
21
|
+
declare function createScrollPlayerEngine(opts: ScrollEngineOptions): PlayerEngine;
|
|
22
|
+
|
|
23
|
+
export { type ScrollEngineOptions, createScrollPlayerEngine };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
// src/adapters/scroll.ts
|
|
5
|
+
function createScrollPlayerEngine(opts) {
|
|
6
|
+
return {
|
|
7
|
+
connect(api) {
|
|
8
|
+
let playing = false;
|
|
9
|
+
let silent = false;
|
|
10
|
+
const progressToFrame = (progress) => Math.min(api.total - 1, Math.max(0, Math.floor(progress * api.total)));
|
|
11
|
+
const emitFromScroll = () => {
|
|
12
|
+
if (silent || !api) return;
|
|
13
|
+
const el = opts.getContainer();
|
|
14
|
+
if (!el) return;
|
|
15
|
+
const rect = el.getBoundingClientRect();
|
|
16
|
+
const scrollable = rect.height - window.innerHeight;
|
|
17
|
+
if (scrollable <= 0) return;
|
|
18
|
+
const progress = Math.min(1, Math.max(0, -rect.top / scrollable));
|
|
19
|
+
const frame = progressToFrame(progress);
|
|
20
|
+
api.onFrame(frame);
|
|
21
|
+
if (playing && frame >= api.total - 1) {
|
|
22
|
+
playing = false;
|
|
23
|
+
api.onEnd();
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const scrollToProgress = (progress, smooth) => {
|
|
27
|
+
const el = opts.getContainer();
|
|
28
|
+
if (!el) return;
|
|
29
|
+
const rect = el.getBoundingClientRect();
|
|
30
|
+
const scrollable = rect.height - window.innerHeight;
|
|
31
|
+
if (scrollable <= 0) return;
|
|
32
|
+
const top = window.scrollY + rect.top + scrollable * Math.min(1, Math.max(0, progress));
|
|
33
|
+
silent = true;
|
|
34
|
+
window.scrollTo({ top, behavior: smooth ? "smooth" : "auto" });
|
|
35
|
+
window.setTimeout(() => {
|
|
36
|
+
silent = false;
|
|
37
|
+
}, smooth ? 700 : 50);
|
|
38
|
+
};
|
|
39
|
+
const onScroll = () => emitFromScroll();
|
|
40
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
41
|
+
window.addEventListener("resize", onScroll);
|
|
42
|
+
return {
|
|
43
|
+
play: () => {
|
|
44
|
+
playing = true;
|
|
45
|
+
const el = opts.getContainer();
|
|
46
|
+
if (!el) return;
|
|
47
|
+
scrollToProgress(1, true);
|
|
48
|
+
},
|
|
49
|
+
pause: () => {
|
|
50
|
+
playing = false;
|
|
51
|
+
},
|
|
52
|
+
seek: (frame) => {
|
|
53
|
+
const progress = api.total > 1 ? frame / api.total : 0;
|
|
54
|
+
scrollToProgress(progress, false);
|
|
55
|
+
api.onFrame(Math.min(frame, api.total - 1));
|
|
56
|
+
},
|
|
57
|
+
setRate: () => {
|
|
58
|
+
},
|
|
59
|
+
disconnect: () => {
|
|
60
|
+
playing = false;
|
|
61
|
+
window.removeEventListener("scroll", onScroll);
|
|
62
|
+
window.removeEventListener("resize", onScroll);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export {
|
|
69
|
+
createScrollPlayerEngine
|
|
70
|
+
};
|
|
71
|
+
//# sourceMappingURL=scroll.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/adapters/scroll.ts"],"sourcesContent":["\"use client\";\n\nimport type { PlayerEngine, PlayerEngineApi, PlayerEngineHandle } from \"../types\";\n\nexport interface ScrollEngineOptions {\n /** The scrollytelling section whose scroll progress drives the frames. */\n getContainer: () => HTMLElement | null;\n /** Frames in the current timeline. Recreate the engine when it changes. */\n total: number;\n}\n\n/**\n * PlayerEngine driven by scroll progress through a container section — the\n * scrollytelling mode. Scrolling maps to frame indices; manual stepping seeks\n * the scroll position; \"play\" smooth-scrolls to the end of the section.\n * Dependency-free (a scrollama replacement for this specific job).\n *\n * const engine = useMemo(\n * () => createScrollPlayerEngine({ getContainer: () => sectionRef.current, total: frames.length }),\n * [frames.length]\n * );\n */\nexport function createScrollPlayerEngine(opts: ScrollEngineOptions): PlayerEngine {\n return {\n connect(api: PlayerEngineApi): PlayerEngineHandle {\n let playing = false;\n let silent = false;\n\n const progressToFrame = (progress: number) =>\n Math.min(api.total - 1, Math.max(0, Math.floor(progress * api.total)));\n\n const emitFromScroll = () => {\n if (silent || !api) return;\n const el = opts.getContainer();\n if (!el) return;\n const rect = el.getBoundingClientRect();\n const scrollable = rect.height - window.innerHeight;\n if (scrollable <= 0) return;\n const progress = Math.min(1, Math.max(0, -rect.top / scrollable));\n const frame = progressToFrame(progress);\n api.onFrame(frame);\n if (playing && frame >= api.total - 1) {\n playing = false;\n api.onEnd();\n }\n };\n\n /** Scroll the window so the section sits at `progress` (0..1). */\n const scrollToProgress = (progress: number, smooth: boolean) => {\n const el = opts.getContainer();\n if (!el) return;\n const rect = el.getBoundingClientRect();\n const scrollable = rect.height - window.innerHeight;\n if (scrollable <= 0) return;\n const top = window.scrollY + rect.top + scrollable * Math.min(1, Math.max(0, progress));\n silent = true;\n window.scrollTo({ top, behavior: smooth ? \"smooth\" : \"auto\" });\n // Silence the synthetic scroll events until the scroll settles.\n window.setTimeout(() => {\n silent = false;\n }, smooth ? 700 : 50);\n };\n\n const onScroll = () => emitFromScroll();\n\n window.addEventListener(\"scroll\", onScroll, { passive: true });\n window.addEventListener(\"resize\", onScroll);\n\n return {\n play: () => {\n playing = true;\n const el = opts.getContainer();\n if (!el) return;\n scrollToProgress(1, true);\n },\n pause: () => {\n playing = false;\n },\n seek: (frame) => {\n const progress = api.total > 1 ? frame / api.total : 0;\n scrollToProgress(progress, false);\n api.onFrame(Math.min(frame, api.total - 1));\n },\n setRate: () => {\n // Scroll position is user-driven; rate has no meaning here.\n },\n disconnect: () => {\n playing = false;\n window.removeEventListener(\"scroll\", onScroll);\n window.removeEventListener(\"resize\", onScroll);\n },\n };\n },\n };\n}\n"],"mappings":";;;;AAsBO,SAAS,yBAAyB,MAAyC;AAChF,SAAO;AAAA,IACL,QAAQ,KAA0C;AAChD,UAAI,UAAU;AACd,UAAI,SAAS;AAEb,YAAM,kBAAkB,CAAC,aACvB,KAAK,IAAI,IAAI,QAAQ,GAAG,KAAK,IAAI,GAAG,KAAK,MAAM,WAAW,IAAI,KAAK,CAAC,CAAC;AAEvE,YAAM,iBAAiB,MAAM;AAC3B,YAAI,UAAU,CAAC,IAAK;AACpB,cAAM,KAAK,KAAK,aAAa;AAC7B,YAAI,CAAC,GAAI;AACT,cAAM,OAAO,GAAG,sBAAsB;AACtC,cAAM,aAAa,KAAK,SAAS,OAAO;AACxC,YAAI,cAAc,EAAG;AACrB,cAAM,WAAW,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,CAAC,KAAK,MAAM,UAAU,CAAC;AAChE,cAAM,QAAQ,gBAAgB,QAAQ;AACtC,YAAI,QAAQ,KAAK;AACjB,YAAI,WAAW,SAAS,IAAI,QAAQ,GAAG;AACrC,oBAAU;AACV,cAAI,MAAM;AAAA,QACZ;AAAA,MACF;AAGA,YAAM,mBAAmB,CAAC,UAAkB,WAAoB;AAC9D,cAAM,KAAK,KAAK,aAAa;AAC7B,YAAI,CAAC,GAAI;AACT,cAAM,OAAO,GAAG,sBAAsB;AACtC,cAAM,aAAa,KAAK,SAAS,OAAO;AACxC,YAAI,cAAc,EAAG;AACrB,cAAM,MAAM,OAAO,UAAU,KAAK,MAAM,aAAa,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,QAAQ,CAAC;AACtF,iBAAS;AACT,eAAO,SAAS,EAAE,KAAK,UAAU,SAAS,WAAW,OAAO,CAAC;AAE7D,eAAO,WAAW,MAAM;AACtB,mBAAS;AAAA,QACX,GAAG,SAAS,MAAM,EAAE;AAAA,MACtB;AAEA,YAAM,WAAW,MAAM,eAAe;AAEtC,aAAO,iBAAiB,UAAU,UAAU,EAAE,SAAS,KAAK,CAAC;AAC7D,aAAO,iBAAiB,UAAU,QAAQ;AAE1C,aAAO;AAAA,QACL,MAAM,MAAM;AACV,oBAAU;AACV,gBAAM,KAAK,KAAK,aAAa;AAC7B,cAAI,CAAC,GAAI;AACT,2BAAiB,GAAG,IAAI;AAAA,QAC1B;AAAA,QACA,OAAO,MAAM;AACX,oBAAU;AAAA,QACZ;AAAA,QACA,MAAM,CAAC,UAAU;AACf,gBAAM,WAAW,IAAI,QAAQ,IAAI,QAAQ,IAAI,QAAQ;AACrD,2BAAiB,UAAU,KAAK;AAChC,cAAI,QAAQ,KAAK,IAAI,OAAO,IAAI,QAAQ,CAAC,CAAC;AAAA,QAC5C;AAAA,QACA,SAAS,MAAM;AAAA,QAEf;AAAA,QACA,YAAY,MAAM;AAChB,oBAAU;AACV,iBAAO,oBAAoB,UAAU,QAAQ;AAC7C,iBAAO,oBAAoB,UAAU,QAAQ;AAAA,QAC/C;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
|