vexy-stax-js 3.0.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 +24 -0
- package/LICENSE +201 -0
- package/README.md +77 -0
- package/package.json +57 -0
- package/schema/vexy-stax-scene.schema.json +122 -0
- package/src/element.js +131 -0
- package/src/export.js +143 -0
- package/src/geometry.js +248 -0
- package/src/global.js +16 -0
- package/src/index.js +246 -0
- package/src/scene.js +268 -0
- package/src/scrollspy.js +140 -0
- package/src/stage.js +349 -0
- package/src/transition.js +169 -0
package/src/scene.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// this_file: src/scene.js
|
|
3
|
+
//
|
|
4
|
+
// Shared scene-format v1 parser, mirroring vexy-stax-py/src/vexy_stax/scene.py
|
|
5
|
+
// and schema/vexy-stax-scene.schema.json. Parse, don't validate: unknown keys
|
|
6
|
+
// throw at the boundary (fail loud), defaults are filled in, and slide `src`
|
|
7
|
+
// paths are resolved against the scene URL base.
|
|
8
|
+
|
|
9
|
+
const VIEWS = ["expanded", "compact"];
|
|
10
|
+
const TRANSITION_KINDS = ["expand", "collapse", "expand_collapse", "collapse_expand"];
|
|
11
|
+
const SHOW_IN = ["expanded", "compact", "both", "none"];
|
|
12
|
+
const EASINGS = ["linear", "easeInOutCubic", "easeOutCubic", "easeInCubic"];
|
|
13
|
+
const DISTANCE_RE = /^[0-9]+(\.[0-9]+)?%?$/;
|
|
14
|
+
|
|
15
|
+
/** Reject any key on `obj` not present in `allowed`. */
|
|
16
|
+
function rejectExtraKeys(obj, allowed, where) {
|
|
17
|
+
for (const key of Object.keys(obj)) {
|
|
18
|
+
if (!allowed.has(key)) {
|
|
19
|
+
throw new Error(`Unknown key ${JSON.stringify(key)} in ${where}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function asObject(value, where) {
|
|
25
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
26
|
+
throw new Error(`${where} must be an object`);
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function num(value, where, { min, max, gt, lt } = {}) {
|
|
32
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
33
|
+
throw new Error(`${where} must be a finite number`);
|
|
34
|
+
}
|
|
35
|
+
if (min !== undefined && value < min) throw new Error(`${where} must be >= ${min}`);
|
|
36
|
+
if (max !== undefined && value > max) throw new Error(`${where} must be <= ${max}`);
|
|
37
|
+
if (gt !== undefined && value <= gt) throw new Error(`${where} must be > ${gt}`);
|
|
38
|
+
if (lt !== undefined && value >= lt) throw new Error(`${where} must be < ${lt}`);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function int(value, where, opts) {
|
|
43
|
+
num(value, where, opts);
|
|
44
|
+
if (!Number.isInteger(value)) throw new Error(`${where} must be an integer`);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function str(value, where) {
|
|
49
|
+
if (typeof value !== "string") throw new Error(`${where} must be a string`);
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function bool(value, where) {
|
|
54
|
+
if (typeof value !== "boolean") throw new Error(`${where} must be a boolean`);
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function oneOf(value, choices, where) {
|
|
59
|
+
if (!choices.includes(value)) {
|
|
60
|
+
throw new Error(`${where} must be one of ${choices.join(", ")} (got ${JSON.stringify(value)})`);
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseSize(raw) {
|
|
66
|
+
if (raw === undefined) return { width: 1920, height: 1080 };
|
|
67
|
+
const o = asObject(raw, "size");
|
|
68
|
+
rejectExtraKeys(o, new Set(["width", "height"]), "size");
|
|
69
|
+
return {
|
|
70
|
+
width: o.width === undefined ? 1920 : int(o.width, "size.width", { min: 1 }),
|
|
71
|
+
height: o.height === undefined ? 1080 : int(o.height, "size.height", { min: 1 }),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseDistance(raw) {
|
|
76
|
+
if (raw === undefined) return "90%";
|
|
77
|
+
if (typeof raw === "number") return num(raw, "camera.distance");
|
|
78
|
+
if (typeof raw === "string") {
|
|
79
|
+
if (!DISTANCE_RE.test(raw.trim())) {
|
|
80
|
+
throw new Error(`camera.distance string must match ${DISTANCE_RE} (got ${JSON.stringify(raw)})`);
|
|
81
|
+
}
|
|
82
|
+
return raw;
|
|
83
|
+
}
|
|
84
|
+
throw new Error("camera.distance must be a number or string");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseCamera(raw) {
|
|
88
|
+
if (raw === undefined) {
|
|
89
|
+
return { gap: 1920, distance: "90%", angle: 60, elevation: 0, fov: 39.6 };
|
|
90
|
+
}
|
|
91
|
+
const o = asObject(raw, "camera");
|
|
92
|
+
rejectExtraKeys(o, new Set(["gap", "distance", "angle", "elevation", "fov"]), "camera");
|
|
93
|
+
return {
|
|
94
|
+
gap: o.gap === undefined ? 1920 : num(o.gap, "camera.gap", { min: 0 }),
|
|
95
|
+
distance: parseDistance(o.distance),
|
|
96
|
+
angle: o.angle === undefined ? 60 : num(o.angle, "camera.angle"),
|
|
97
|
+
elevation: o.elevation === undefined ? 0 : num(o.elevation, "camera.elevation"),
|
|
98
|
+
fov: o.fov === undefined ? 39.6 : num(o.fov, "camera.fov", { gt: 0, lt: 180 }),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseTransition(raw) {
|
|
103
|
+
if (raw === undefined) return null;
|
|
104
|
+
const o = asObject(raw, "transition");
|
|
105
|
+
rejectExtraKeys(o, new Set(["kind", "duration", "wait", "fps", "easing"]), "transition");
|
|
106
|
+
if (o.kind === undefined) throw new Error("transition.kind is required");
|
|
107
|
+
return {
|
|
108
|
+
kind: oneOf(o.kind, TRANSITION_KINDS, "transition.kind"),
|
|
109
|
+
duration: o.duration === undefined ? 3.0 : num(o.duration, "transition.duration", { gt: 0 }),
|
|
110
|
+
wait: o.wait === undefined ? 1.0 : num(o.wait, "transition.wait", { min: 0 }),
|
|
111
|
+
fps: o.fps === undefined ? 30 : int(o.fps, "transition.fps", { min: 1 }),
|
|
112
|
+
easing: o.easing === undefined ? "easeInOutCubic" : oneOf(o.easing, EASINGS, "transition.easing"),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseFloor(raw) {
|
|
117
|
+
if (raw === undefined) return { color: "#f2f2f2", opacity: 1.0, reflectivity: 0.5 };
|
|
118
|
+
const o = asObject(raw, "floor");
|
|
119
|
+
rejectExtraKeys(o, new Set(["color", "opacity", "reflectivity"]), "floor");
|
|
120
|
+
return {
|
|
121
|
+
color: o.color === undefined ? "#f2f2f2" : str(o.color, "floor.color"),
|
|
122
|
+
opacity: o.opacity === undefined ? 1.0 : num(o.opacity, "floor.opacity", { min: 0, max: 1 }),
|
|
123
|
+
reflectivity:
|
|
124
|
+
o.reflectivity === undefined ? 0.5 : num(o.reflectivity, "floor.reflectivity", { min: 0, max: 1 }),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseCaptionStyle(raw, where) {
|
|
129
|
+
if (raw === undefined) return null;
|
|
130
|
+
const o = asObject(raw, where);
|
|
131
|
+
rejectExtraKeys(o, new Set(["size", "color", "font"]), where);
|
|
132
|
+
const out = {};
|
|
133
|
+
if (o.size !== undefined) out.size = num(o.size, `${where}.size`, { gt: 0 });
|
|
134
|
+
if (o.color !== undefined) out.color = str(o.color, `${where}.color`);
|
|
135
|
+
if (o.font !== undefined) out.font = str(o.font, `${where}.font`);
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseCaption(raw, where) {
|
|
140
|
+
if (raw === undefined) return null;
|
|
141
|
+
const o = asObject(raw, where);
|
|
142
|
+
rejectExtraKeys(o, new Set(["text", "show_in", "style"]), where);
|
|
143
|
+
if (o.text === undefined) throw new Error(`${where}.text is required`);
|
|
144
|
+
return {
|
|
145
|
+
text: str(o.text, `${where}.text`),
|
|
146
|
+
show_in: o.show_in === undefined ? "expanded" : oneOf(o.show_in, SHOW_IN, `${where}.show_in`),
|
|
147
|
+
style: parseCaptionStyle(o.style, `${where}.style`),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseOpacity(raw, where) {
|
|
152
|
+
if (raw === undefined) return 1.0;
|
|
153
|
+
if (typeof raw === "number") return num(raw, where, { min: 0, max: 1 });
|
|
154
|
+
const o = asObject(raw, where);
|
|
155
|
+
rejectExtraKeys(o, new Set(["expanded", "compact"]), where);
|
|
156
|
+
return {
|
|
157
|
+
expanded: o.expanded === undefined ? 1.0 : num(o.expanded, `${where}.expanded`, { min: 0, max: 1 }),
|
|
158
|
+
compact: o.compact === undefined ? 1.0 : num(o.compact, `${where}.compact`, { min: 0, max: 1 }),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseSlide(raw, index) {
|
|
163
|
+
const where = `slides[${index}]`;
|
|
164
|
+
const o = asObject(raw, where);
|
|
165
|
+
rejectExtraKeys(o, new Set(["src", "gap", "opacity", "caption"]), where);
|
|
166
|
+
if (o.src === undefined) throw new Error(`${where}.src is required`);
|
|
167
|
+
let gap = null;
|
|
168
|
+
if (o.gap !== undefined && o.gap !== null) gap = num(o.gap, `${where}.gap`, { min: 0 });
|
|
169
|
+
return {
|
|
170
|
+
src: str(o.src, `${where}.src`),
|
|
171
|
+
gap,
|
|
172
|
+
opacity: parseOpacity(o.opacity, `${where}.opacity`),
|
|
173
|
+
caption: parseCaption(o.caption, `${where}.caption`),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Opacity for `view`; a scalar opacity returns itself. Mirrors Slide.resolved_opacity. */
|
|
178
|
+
export function resolvedOpacity(slide, view) {
|
|
179
|
+
const op = slide.opacity;
|
|
180
|
+
if (op !== null && typeof op === "object") {
|
|
181
|
+
return view === "expanded" ? op.expanded : op.compact;
|
|
182
|
+
}
|
|
183
|
+
return Number(op);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Parse a raw scene object into a normalized scene. Strict: unknown top-level or
|
|
188
|
+
* slide keys throw. Defaults are filled to match scene.py. The `$schema` pointer
|
|
189
|
+
* key is accepted and ignored.
|
|
190
|
+
*/
|
|
191
|
+
export function parseScene(raw) {
|
|
192
|
+
const o = asObject(raw, "scene");
|
|
193
|
+
const allowed = new Set([
|
|
194
|
+
"$schema",
|
|
195
|
+
"version",
|
|
196
|
+
"view",
|
|
197
|
+
"size",
|
|
198
|
+
"camera",
|
|
199
|
+
"transition",
|
|
200
|
+
"floor",
|
|
201
|
+
"background",
|
|
202
|
+
"juicy",
|
|
203
|
+
"caption_defaults",
|
|
204
|
+
"slides",
|
|
205
|
+
]);
|
|
206
|
+
rejectExtraKeys(o, allowed, "scene");
|
|
207
|
+
|
|
208
|
+
if (o.version !== undefined && o.version !== 1) {
|
|
209
|
+
throw new Error(`version must be 1 (got ${JSON.stringify(o.version)})`);
|
|
210
|
+
}
|
|
211
|
+
if (o.slides === undefined) throw new Error("slides is required");
|
|
212
|
+
if (!Array.isArray(o.slides) || o.slides.length < 1) {
|
|
213
|
+
throw new Error("slides must be a non-empty array");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
version: 1,
|
|
218
|
+
view: o.view === undefined ? "expanded" : oneOf(o.view, VIEWS, "view"),
|
|
219
|
+
size: parseSize(o.size),
|
|
220
|
+
camera: parseCamera(o.camera),
|
|
221
|
+
transition: parseTransition(o.transition),
|
|
222
|
+
floor: parseFloor(o.floor),
|
|
223
|
+
background: o.background === undefined ? "#ffffff" : str(o.background, "background"),
|
|
224
|
+
juicy: o.juicy === undefined ? false : bool(o.juicy, "juicy"),
|
|
225
|
+
caption_defaults: parseCaptionStyle(o.caption_defaults, "caption_defaults"),
|
|
226
|
+
slides: o.slides.map((s, i) => parseSlide(s, i)),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Resolve a slide `src` against `base` (a URL string). `data:` URIs pass through. */
|
|
231
|
+
function resolveSrc(src, base) {
|
|
232
|
+
if (src.startsWith("data:")) return src;
|
|
233
|
+
if (!base) return src;
|
|
234
|
+
try {
|
|
235
|
+
return new URL(src, base).href;
|
|
236
|
+
} catch {
|
|
237
|
+
return src;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Load and normalize a scene from a URL string or an inline object.
|
|
243
|
+
*
|
|
244
|
+
* - String: fetched, JSON-parsed, and slide `src` paths are resolved relative to
|
|
245
|
+
* that URL.
|
|
246
|
+
* - Object: parsed in place; slide `src` paths are resolved relative to
|
|
247
|
+
* `options.baseUrl` if given, otherwise left as-is.
|
|
248
|
+
*/
|
|
249
|
+
export async function loadScene(urlOrObject, options = {}) {
|
|
250
|
+
let raw;
|
|
251
|
+
let base = options.baseUrl;
|
|
252
|
+
|
|
253
|
+
if (typeof urlOrObject === "string") {
|
|
254
|
+
const url = new URL(urlOrObject, options.baseUrl ?? (typeof location !== "undefined" ? location.href : undefined));
|
|
255
|
+
base = url.href;
|
|
256
|
+
const res = await fetch(url.href);
|
|
257
|
+
if (!res.ok) throw new Error(`Failed to fetch scene ${url.href}: ${res.status}`);
|
|
258
|
+
raw = await res.json();
|
|
259
|
+
} else {
|
|
260
|
+
raw = urlOrObject;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const scene = parseScene(raw);
|
|
264
|
+
for (const slide of scene.slides) {
|
|
265
|
+
slide.src = resolveSrc(slide.src, base);
|
|
266
|
+
}
|
|
267
|
+
return scene;
|
|
268
|
+
}
|
package/src/scrollspy.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// this_file: src/scrollspy.js
|
|
3
|
+
//
|
|
4
|
+
// Scroll-driven transition (SPEC.md §6.4). Maps scroll progress over a trigger
|
|
5
|
+
// region to transition progress [0,1]: IntersectionObserver activates the
|
|
6
|
+
// rAF/scroll loop only while the region is on screen, and computeScrollProgress
|
|
7
|
+
// turns the region's viewport rect into a [0,1] factor. Respects
|
|
8
|
+
// prefers-reduced-motion by snapping to the endpoints (no per-frame morph).
|
|
9
|
+
//
|
|
10
|
+
// computeScrollProgress is pure and exported for unit tests.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Scroll progress [0,1] for a trigger region given its bounding-client rect and
|
|
14
|
+
* the viewport height.
|
|
15
|
+
*
|
|
16
|
+
* Mapping (a common "scroll-through" model): progress is 0 while the top of the
|
|
17
|
+
* region is at or below the bottom of the viewport, reaches 1 once the bottom of
|
|
18
|
+
* the region has scrolled up to the top of the viewport, and lerps linearly in
|
|
19
|
+
* between. This makes a tall trigger region drive the full morph as the user
|
|
20
|
+
* scrolls past it.
|
|
21
|
+
*
|
|
22
|
+
* @param {{top:number, bottom:number, height:number}} rect getBoundingClientRect of the trigger
|
|
23
|
+
* @param {number} viewportH window.innerHeight
|
|
24
|
+
* @returns {number} progress in [0,1]
|
|
25
|
+
*/
|
|
26
|
+
export function computeScrollProgress(rect, viewportH) {
|
|
27
|
+
const travel = rect.height + viewportH;
|
|
28
|
+
if (travel <= 0) return 0;
|
|
29
|
+
// distance scrolled = how far the region's top has moved above the viewport
|
|
30
|
+
// bottom. At start top === viewportH (just below) → 0; at end bottom === 0
|
|
31
|
+
// (just above) → travel.
|
|
32
|
+
const scrolled = viewportH - rect.top;
|
|
33
|
+
return Math.max(0, Math.min(1, scrolled / travel));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True when the OS/browser requests reduced motion. */
|
|
37
|
+
export function prefersReducedMotion() {
|
|
38
|
+
if (typeof matchMedia !== "function") return false;
|
|
39
|
+
try {
|
|
40
|
+
return matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Drive a transition from scroll position over a trigger region.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} opts
|
|
50
|
+
* @param {Element} opts.trigger the region whose scroll-through drives progress
|
|
51
|
+
* @param {(p:number)=>void} opts.onProgress called with progress [0,1] (already
|
|
52
|
+
* reduced-motion-aware: only endpoints when reduced motion is on)
|
|
53
|
+
* @param {boolean} [opts.reducedMotion] override prefers-reduced-motion detection
|
|
54
|
+
* @param {Window} [opts.win] injectable window (defaults to global window)
|
|
55
|
+
* @returns {{disconnect:()=>void}} handle to stop observing
|
|
56
|
+
*/
|
|
57
|
+
export function attachScrollspy({ trigger, onProgress, reducedMotion, win } = {}) {
|
|
58
|
+
if (!trigger) throw new Error("scrollspy: trigger element is required");
|
|
59
|
+
if (typeof onProgress !== "function") throw new Error("scrollspy: onProgress callback is required");
|
|
60
|
+
const w = win ?? (typeof window !== "undefined" ? window : undefined);
|
|
61
|
+
if (!w) throw new Error("scrollspy: no window available");
|
|
62
|
+
|
|
63
|
+
const reduced = reducedMotion ?? prefersReducedMotion();
|
|
64
|
+
|
|
65
|
+
// Reduced motion: snap to the endpoints. We pick the endpoint by whether the
|
|
66
|
+
// region's center has passed the middle of the viewport.
|
|
67
|
+
const snap = () => {
|
|
68
|
+
const rect = trigger.getBoundingClientRect();
|
|
69
|
+
const p = computeScrollProgress(rect, w.innerHeight);
|
|
70
|
+
onProgress(p < 0.5 ? 0 : 1);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
if (reduced) {
|
|
74
|
+
snap();
|
|
75
|
+
const onScroll = () => snap();
|
|
76
|
+
w.addEventListener("scroll", onScroll, { passive: true });
|
|
77
|
+
w.addEventListener("resize", onScroll, { passive: true });
|
|
78
|
+
return {
|
|
79
|
+
disconnect() {
|
|
80
|
+
w.removeEventListener("scroll", onScroll);
|
|
81
|
+
w.removeEventListener("resize", onScroll);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let active = false;
|
|
87
|
+
let rafHandle = null;
|
|
88
|
+
let lastP = -1;
|
|
89
|
+
|
|
90
|
+
const update = () => {
|
|
91
|
+
rafHandle = null;
|
|
92
|
+
const rect = trigger.getBoundingClientRect();
|
|
93
|
+
const p = computeScrollProgress(rect, w.innerHeight);
|
|
94
|
+
if (p !== lastP) {
|
|
95
|
+
lastP = p;
|
|
96
|
+
onProgress(p);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const schedule = () => {
|
|
101
|
+
if (rafHandle != null) return;
|
|
102
|
+
rafHandle = (w.requestAnimationFrame ?? ((cb) => setTimeout(cb, 16)))(update);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const onScroll = () => {
|
|
106
|
+
if (active) schedule();
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// IntersectionObserver activates the scroll loop only while visible. When IO is
|
|
110
|
+
// unavailable (older/headless contexts), fall back to always-active.
|
|
111
|
+
let observer = null;
|
|
112
|
+
if (typeof IntersectionObserver === "function") {
|
|
113
|
+
observer = new IntersectionObserver(
|
|
114
|
+
(entries) => {
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
active = entry.isIntersecting;
|
|
117
|
+
if (active) schedule();
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
{ threshold: [0, 0.01, 0.99, 1] }
|
|
121
|
+
);
|
|
122
|
+
observer.observe(trigger);
|
|
123
|
+
} else {
|
|
124
|
+
active = true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
w.addEventListener("scroll", onScroll, { passive: true });
|
|
128
|
+
w.addEventListener("resize", onScroll, { passive: true });
|
|
129
|
+
// Prime the initial value.
|
|
130
|
+
schedule();
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
disconnect() {
|
|
134
|
+
observer?.disconnect();
|
|
135
|
+
w.removeEventListener("scroll", onScroll);
|
|
136
|
+
w.removeEventListener("resize", onScroll);
|
|
137
|
+
if (rafHandle != null) (w.cancelAnimationFrame ?? clearTimeout)(rafHandle);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|