@multitrack/core 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/dist/index.js ADDED
@@ -0,0 +1,608 @@
1
+ // src/errors.ts
2
+ var MultitrackError = class extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(`[@multitrack] ${code}: ${message}`);
6
+ this.code = code;
7
+ this.name = "MultitrackError";
8
+ }
9
+ };
10
+ function stepNotFound(name) {
11
+ return new MultitrackError(
12
+ "STEP_NOT_FOUND",
13
+ `Step "${name}" not found in resolved steps. Check that the step name matches your config.`
14
+ );
15
+ }
16
+ function emptyConfig() {
17
+ return new MultitrackError(
18
+ "EMPTY_CONFIG",
19
+ "No step configurations provided. Pass at least one StepConfig to create a timeline."
20
+ );
21
+ }
22
+ function duplicateStepName(name) {
23
+ return new MultitrackError(
24
+ "DUPLICATE_STEP_NAME",
25
+ `Step name "${name}" is used more than once. Non-buffer step names must be unique.`
26
+ );
27
+ }
28
+
29
+ // src/warnings.ts
30
+ var SNAP_LONG_THRESHOLD = 5;
31
+ var emitted = /* @__PURE__ */ new Set();
32
+ function warn(code, message) {
33
+ if (typeof process !== "undefined" && process.env?.NODE_ENV === "production") return;
34
+ const key = `${code}:${message}`;
35
+ if (emitted.has(key)) return;
36
+ emitted.add(key);
37
+ console.warn(`[@multitrack] ${code}: ${message}`);
38
+ }
39
+ function resetWarnings() {
40
+ emitted.clear();
41
+ }
42
+ function validateStepConfigs(config) {
43
+ if (typeof process !== "undefined" && process.env?.NODE_ENV === "production") return;
44
+ const trackCounts = /* @__PURE__ */ new Map();
45
+ for (const step of config) {
46
+ if (step.duration <= 0 && step.name !== "buffer") {
47
+ warn(
48
+ "ZERO_DURATION",
49
+ `Step "${step.name}" has duration ${step.duration}. This is probably unintentional.`
50
+ );
51
+ }
52
+ const easing = step.easing ?? "snap";
53
+ if (easing === "snap" && step.duration > SNAP_LONG_THRESHOLD && step.name !== "buffer") {
54
+ warn(
55
+ "SNAP_LONG_STEP",
56
+ `Step "${step.name}" uses snap easing with duration ${step.duration}. Snap on long steps looks jarring \u2014 consider "linear" or "easeInOut".`
57
+ );
58
+ }
59
+ if (step.name !== "buffer") {
60
+ trackCounts.set(step.track, (trackCounts.get(step.track) ?? 0) + 1);
61
+ }
62
+ }
63
+ if (trackCounts.size > 1) {
64
+ for (const [track, count] of trackCounts) {
65
+ if (count === 1) {
66
+ warn(
67
+ "LONE_TRACK",
68
+ `Track "${track}" has only one step. Is this a typo?`
69
+ );
70
+ }
71
+ }
72
+ }
73
+ }
74
+ function validateBreakpointRefs(config, breakpointNames) {
75
+ if (typeof process !== "undefined" && process.env?.NODE_ENV === "production") return;
76
+ const known = new Set(breakpointNames);
77
+ for (const step of config) {
78
+ if (step.when && !known.has(step.when)) {
79
+ warn(
80
+ "UNKNOWN_BREAKPOINT",
81
+ `Step "${step.name}" references breakpoint "${step.when}" which is not defined. Known breakpoints: ${breakpointNames.join(", ") || "(none)"}`
82
+ );
83
+ }
84
+ }
85
+ }
86
+
87
+ // src/resolve-steps.ts
88
+ function resolveSteps(config) {
89
+ if (config.length === 0) {
90
+ throw emptyConfig();
91
+ }
92
+ validateStepConfigs(config);
93
+ const filtered = config.filter((step) => {
94
+ if (step.condition) return step.condition();
95
+ return true;
96
+ });
97
+ const trackCursors = {};
98
+ let bufferIndex = 0;
99
+ const seen = /* @__PURE__ */ new Set();
100
+ const steps = filtered.map((stepConfig) => {
101
+ const name = stepConfig.name === "buffer" ? `buffer_${++bufferIndex}` : stepConfig.name;
102
+ if (stepConfig.name !== "buffer") {
103
+ if (seen.has(name)) {
104
+ throw duplicateStepName(name);
105
+ }
106
+ seen.add(name);
107
+ }
108
+ const track = stepConfig.track;
109
+ const start = trackCursors[track] ?? 0;
110
+ const end = start + stepConfig.duration;
111
+ trackCursors[track] = end;
112
+ return {
113
+ name,
114
+ start,
115
+ end,
116
+ track,
117
+ easing: stepConfig.easing ?? "snap"
118
+ };
119
+ });
120
+ return steps;
121
+ }
122
+ function getTotalSteps(steps) {
123
+ return Math.max(...steps.map((s) => s.end));
124
+ }
125
+
126
+ // src/easings.ts
127
+ var snap = () => 1;
128
+ var linear = (t) => t;
129
+ var easeIn = (t) => t * t;
130
+ var easeOut = (t) => t * (2 - t);
131
+ var easeInOut = (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
132
+ var easingPresets = {
133
+ snap,
134
+ linear,
135
+ easeIn,
136
+ easeOut,
137
+ easeInOut
138
+ };
139
+ function resolveEasing(easing) {
140
+ if (easing === void 0) return snap;
141
+ if (typeof easing === "function") return easing;
142
+ return easingPresets[easing];
143
+ }
144
+
145
+ // src/opacity.ts
146
+ function calculateStepOpacity(step, totalSteps, scrollPercentage) {
147
+ const normalizedScroll = scrollPercentage * totalSteps;
148
+ if (normalizedScroll < step.start || normalizedScroll > step.end) {
149
+ return 0;
150
+ }
151
+ const isLastStep = step.end === totalSteps;
152
+ if (isLastStep && normalizedScroll >= step.start) {
153
+ const easing2 = resolveEasing(step.easing);
154
+ if (step.easing === "snap" || step.easing === void 0) return 1;
155
+ const duration2 = step.end - step.start;
156
+ if (duration2 === 0) return 1;
157
+ const progress2 = Math.min((normalizedScroll - step.start) / duration2, 1);
158
+ return easing2(progress2);
159
+ }
160
+ const easing = resolveEasing(step.easing);
161
+ if (step.easing === "snap" || step.easing === void 0) {
162
+ return 1;
163
+ }
164
+ const duration = step.end - step.start;
165
+ if (duration === 0) return 1;
166
+ const progress = (normalizedScroll - step.start) / duration;
167
+ return easing(Math.max(0, Math.min(1, progress)));
168
+ }
169
+ function calculateAllOpacities(scrollPercentage, steps) {
170
+ const totalSteps = Math.max(...steps.map((s) => s.end));
171
+ return steps.reduce(
172
+ (acc, step) => {
173
+ acc[step.name] = calculateStepOpacity(
174
+ step,
175
+ totalSteps,
176
+ scrollPercentage
177
+ );
178
+ return acc;
179
+ },
180
+ {}
181
+ );
182
+ }
183
+ function getStepRange(stepName, steps) {
184
+ const step = steps.find((s) => s.name === stepName);
185
+ if (!step) {
186
+ throw stepNotFound(stepName);
187
+ }
188
+ return { start: step.start, end: step.end };
189
+ }
190
+ function getCurrentSteps(currentStep, steps) {
191
+ const active = steps.filter(
192
+ (s) => currentStep >= s.start && currentStep < s.end
193
+ );
194
+ if (active.length > 0) {
195
+ return active.map((s) => ({
196
+ name: s.name,
197
+ track: s.track,
198
+ start: s.start,
199
+ end: s.end
200
+ }));
201
+ }
202
+ return [];
203
+ }
204
+
205
+ // src/scroll-driver.ts
206
+ var ScrollDriver = class {
207
+ target;
208
+ callbacks = /* @__PURE__ */ new Set();
209
+ bound = null;
210
+ _scrollPercentage = 0;
211
+ constructor(options = {}) {
212
+ this.target = options.target ?? window;
213
+ }
214
+ /** Current scroll progress (0 to 1). */
215
+ get scrollPercentage() {
216
+ return this._scrollPercentage;
217
+ }
218
+ /**
219
+ * Subscribe to scroll updates. Returns an unsubscribe function.
220
+ */
221
+ onScroll(callback) {
222
+ this.callbacks.add(callback);
223
+ return () => {
224
+ this.callbacks.delete(callback);
225
+ };
226
+ }
227
+ /**
228
+ * Start listening for scroll events.
229
+ */
230
+ start() {
231
+ if (this.bound) return;
232
+ this.bound = () => {
233
+ this._scrollPercentage = this.calculateProgress();
234
+ this.callbacks.forEach((cb) => cb(this._scrollPercentage));
235
+ };
236
+ this.target.addEventListener("scroll", this.bound, { passive: true });
237
+ this.bound();
238
+ }
239
+ /**
240
+ * Stop listening and clean up.
241
+ */
242
+ destroy() {
243
+ if (this.bound) {
244
+ this.target.removeEventListener("scroll", this.bound);
245
+ this.bound = null;
246
+ }
247
+ this.callbacks.clear();
248
+ }
249
+ calculateProgress() {
250
+ if (this.target === window || this.target instanceof Window) {
251
+ const maxScroll2 = document.documentElement.scrollHeight - window.innerHeight;
252
+ if (maxScroll2 <= 0) return 0;
253
+ return Math.max(0, Math.min(1, window.scrollY / maxScroll2));
254
+ }
255
+ const el = this.target;
256
+ const maxScroll = el.scrollHeight - el.clientHeight;
257
+ if (maxScroll <= 0) return 0;
258
+ return Math.max(0, Math.min(1, el.scrollTop / maxScroll));
259
+ }
260
+ };
261
+
262
+ // src/emitter.ts
263
+ var Emitter = class {
264
+ listeners = /* @__PURE__ */ new Map();
265
+ on(event, handler) {
266
+ if (!this.listeners.has(event)) {
267
+ this.listeners.set(event, /* @__PURE__ */ new Set());
268
+ }
269
+ this.listeners.get(event).add(handler);
270
+ return () => {
271
+ this.listeners.get(event)?.delete(handler);
272
+ };
273
+ }
274
+ emit(event, payload) {
275
+ this.listeners.get(event)?.forEach((handler) => handler(payload));
276
+ }
277
+ removeAllListeners() {
278
+ this.listeners.clear();
279
+ }
280
+ };
281
+
282
+ // src/scope.ts
283
+ var Scope = class {
284
+ disposers = [];
285
+ /** Track a disposer function for later cleanup. */
286
+ add(disposer) {
287
+ this.disposers.push(disposer);
288
+ }
289
+ /** Call all tracked disposers and clear the list. Idempotent. */
290
+ dispose() {
291
+ for (let i = this.disposers.length - 1; i >= 0; i--) {
292
+ this.disposers[i]();
293
+ }
294
+ this.disposers.length = 0;
295
+ }
296
+ /** Number of tracked disposers. */
297
+ get size() {
298
+ return this.disposers.length;
299
+ }
300
+ };
301
+
302
+ // src/middleware.ts
303
+ var MiddlewareChain = class {
304
+ fns = [];
305
+ /** Add middleware. Returns a function to remove it. */
306
+ add(fn) {
307
+ this.fns.push(fn);
308
+ return () => {
309
+ const idx = this.fns.indexOf(fn);
310
+ if (idx !== -1) this.fns.splice(idx, 1);
311
+ };
312
+ }
313
+ /**
314
+ * Run the middleware chain. If all middleware call `next()`,
315
+ * `finalAction` executes (emitting the event to listeners).
316
+ * If any middleware skips `next()`, the event is swallowed.
317
+ */
318
+ run(event, finalAction) {
319
+ let index = 0;
320
+ const fns = this.fns;
321
+ const next = () => {
322
+ if (index < fns.length) {
323
+ fns[index++](event, next);
324
+ } else {
325
+ finalAction();
326
+ }
327
+ };
328
+ next();
329
+ }
330
+ /** Remove all middleware. */
331
+ clear() {
332
+ this.fns.length = 0;
333
+ }
334
+ };
335
+
336
+ // src/breakpoints.ts
337
+ var BreakpointManager = class {
338
+ queries = /* @__PURE__ */ new Map();
339
+ listeners = [];
340
+ constructor(breakpoints) {
341
+ if (typeof globalThis.matchMedia !== "function") return;
342
+ for (const [name, query] of Object.entries(breakpoints)) {
343
+ this.queries.set(name, globalThis.matchMedia(query));
344
+ }
345
+ }
346
+ /** Whether a named breakpoint currently matches. */
347
+ isActive(name) {
348
+ return this.queries.get(name)?.matches ?? false;
349
+ }
350
+ /** All registered breakpoint names. */
351
+ get names() {
352
+ return [...this.queries.keys()];
353
+ }
354
+ /**
355
+ * Subscribe to breakpoint changes. Returns unsubscribe function.
356
+ * The callback fires whenever any breakpoint's match state changes.
357
+ */
358
+ onChange(callback) {
359
+ const handler = () => callback();
360
+ this.listeners.push(handler);
361
+ for (const mql of this.queries.values()) {
362
+ mql.addEventListener("change", handler);
363
+ }
364
+ return () => {
365
+ for (const mql of this.queries.values()) {
366
+ mql.removeEventListener("change", handler);
367
+ }
368
+ const idx = this.listeners.indexOf(handler);
369
+ if (idx !== -1) this.listeners.splice(idx, 1);
370
+ };
371
+ }
372
+ /** Remove all change listeners. */
373
+ destroy() {
374
+ for (const handler of this.listeners) {
375
+ for (const mql of this.queries.values()) {
376
+ mql.removeEventListener("change", handler);
377
+ }
378
+ }
379
+ this.listeners.length = 0;
380
+ }
381
+ };
382
+
383
+ // src/timeline.ts
384
+ var Timeline = class {
385
+ _steps;
386
+ _totalSteps;
387
+ config;
388
+ scrollDriver;
389
+ emitter = new Emitter();
390
+ activeSteps = /* @__PURE__ */ new Set();
391
+ devtoolsEnabled;
392
+ scrollUnsubscribe = null;
393
+ _activeScope = null;
394
+ middleware = new MiddlewareChain();
395
+ breakpointManager = null;
396
+ breakpointUnsub = null;
397
+ constructor(options) {
398
+ this.config = options.config;
399
+ this.devtoolsEnabled = options.devtools ?? false;
400
+ if (options.breakpoints && Object.keys(options.breakpoints).length > 0) {
401
+ this.breakpointManager = new BreakpointManager(options.breakpoints);
402
+ validateBreakpointRefs(options.config, this.breakpointManager.names);
403
+ }
404
+ this._steps = this.resolveWithBreakpoints();
405
+ this._totalSteps = getTotalSteps(this._steps);
406
+ this.scrollDriver = new ScrollDriver();
407
+ }
408
+ /** Resolved steps (re-computed on breakpoint changes). */
409
+ get steps() {
410
+ return this._steps;
411
+ }
412
+ /** Total steps across all tracks (re-computed on breakpoint changes). */
413
+ get totalSteps() {
414
+ return this._totalSteps;
415
+ }
416
+ /** Start listening to scroll events. */
417
+ start() {
418
+ this.scrollUnsubscribe?.();
419
+ if (this.breakpointManager) {
420
+ this.breakpointUnsub?.();
421
+ this.breakpointUnsub = this.breakpointManager.onChange(() => {
422
+ this.reconfigure();
423
+ });
424
+ }
425
+ this.scrollUnsubscribe = this.scrollDriver.onScroll((scrollPercentage) => {
426
+ const currentStep = scrollPercentage * this._totalSteps;
427
+ this.emitter.emit("scroll", { scrollPercentage, currentStep });
428
+ const nowActive = /* @__PURE__ */ new Set();
429
+ for (const step of this._steps) {
430
+ if (currentStep >= step.start && currentStep < step.end) {
431
+ nowActive.add(step.name);
432
+ if (!this.activeSteps.has(step.name)) {
433
+ const payload = {
434
+ name: step.name,
435
+ track: step.track,
436
+ start: step.start,
437
+ end: step.end
438
+ };
439
+ this.middleware.run(
440
+ { type: "step:enter", payload },
441
+ () => this.emitter.emit("step:enter", payload)
442
+ );
443
+ }
444
+ }
445
+ }
446
+ for (const name of this.activeSteps) {
447
+ if (!nowActive.has(name)) {
448
+ const step = this._steps.find((s) => s.name === name);
449
+ if (step) {
450
+ const payload = {
451
+ name: step.name,
452
+ track: step.track,
453
+ start: step.start,
454
+ end: step.end
455
+ };
456
+ this.middleware.run(
457
+ { type: "step:exit", payload },
458
+ () => this.emitter.emit("step:exit", payload)
459
+ );
460
+ }
461
+ }
462
+ }
463
+ this.activeSteps = nowActive;
464
+ if (this.devtoolsEnabled && typeof window !== "undefined") {
465
+ this.updateDevtools(scrollPercentage, currentStep);
466
+ }
467
+ });
468
+ if (this.devtoolsEnabled && typeof window !== "undefined") {
469
+ this.updateDevtools(0, 0);
470
+ }
471
+ this.scrollDriver.start();
472
+ }
473
+ /** Stop listening and clean up all resources. */
474
+ destroy() {
475
+ this.scrollUnsubscribe?.();
476
+ this.scrollUnsubscribe = null;
477
+ this.breakpointUnsub?.();
478
+ this.breakpointUnsub = null;
479
+ this.breakpointManager?.destroy();
480
+ this.scrollDriver.destroy();
481
+ this.emitter.removeAllListeners();
482
+ this.middleware.clear();
483
+ this.activeSteps.clear();
484
+ if (this.devtoolsEnabled && typeof window !== "undefined" && window.__MULTITRACK_DEVTOOLS__) {
485
+ delete window.__MULTITRACK_DEVTOOLS__;
486
+ }
487
+ }
488
+ /**
489
+ * Register middleware that intercepts step:enter/step:exit events.
490
+ * Call `next()` to pass through, or skip it to swallow the event.
491
+ *
492
+ * ```ts
493
+ * timeline.use((event, next) => {
494
+ * analytics.track(event.type, event.payload.name);
495
+ * next();
496
+ * });
497
+ * ```
498
+ */
499
+ use(fn) {
500
+ const unsub = this.middleware.add(fn);
501
+ this._activeScope?.add(unsub);
502
+ return unsub;
503
+ }
504
+ /** Subscribe to timeline events. Returns an unsubscribe function. */
505
+ on(event, handler) {
506
+ const unsub = this.emitter.on(event, handler);
507
+ this._activeScope?.add(unsub);
508
+ return unsub;
509
+ }
510
+ /**
511
+ * Collect all subscriptions created inside `fn` into a Scope.
512
+ * Call `scope.dispose()` to clean them all up at once.
513
+ *
514
+ * ```ts
515
+ * const ctx = timeline.scope(() => {
516
+ * timeline.on('step:enter', handleEnter);
517
+ * timeline.on('scroll', handleScroll);
518
+ * });
519
+ * // later: ctx.dispose() cleans up both listeners
520
+ * ```
521
+ */
522
+ scope(fn) {
523
+ const scope = new Scope();
524
+ const previousScope = this._activeScope;
525
+ this._activeScope = scope;
526
+ try {
527
+ fn();
528
+ } finally {
529
+ this._activeScope = previousScope;
530
+ }
531
+ return scope;
532
+ }
533
+ /** Current scroll progress (0 to 1). */
534
+ get scrollPercentage() {
535
+ return this.scrollDriver.scrollPercentage;
536
+ }
537
+ /** Current step position (0 to totalSteps). */
538
+ get currentStep() {
539
+ return this.scrollDriver.scrollPercentage * this._totalSteps;
540
+ }
541
+ /** Calculate opacities for all steps at a given scroll position. */
542
+ getOpacities(scrollPercentage) {
543
+ return calculateAllOpacities(
544
+ scrollPercentage ?? this.scrollDriver.scrollPercentage,
545
+ this._steps
546
+ );
547
+ }
548
+ /** Get the start/end range for a named step. */
549
+ getStepRange(name) {
550
+ return getStepRange(name, this._steps);
551
+ }
552
+ /** Get all steps that are active at a given position. */
553
+ getCurrentSteps(position) {
554
+ return getCurrentSteps(position ?? this.currentStep, this._steps);
555
+ }
556
+ /** Enable devtools integration (exposes state on window.__MULTITRACK_DEVTOOLS__). */
557
+ enableDevtools() {
558
+ this.devtoolsEnabled = true;
559
+ }
560
+ /**
561
+ * Filter config by active breakpoints and resolve steps.
562
+ * Steps without `when` are always included.
563
+ * Steps with `when` are included only if that breakpoint currently matches.
564
+ */
565
+ resolveWithBreakpoints() {
566
+ const filtered = this.breakpointManager ? this.config.filter(
567
+ (step) => !step.when || this.breakpointManager.isActive(step.when)
568
+ ) : this.config;
569
+ return resolveSteps(filtered);
570
+ }
571
+ /**
572
+ * Re-resolve steps after a breakpoint change.
573
+ * Clears active steps and emits timeline:reconfigure.
574
+ */
575
+ reconfigure() {
576
+ this._steps = this.resolveWithBreakpoints();
577
+ this._totalSteps = getTotalSteps(this._steps);
578
+ this.activeSteps.clear();
579
+ this.emitter.emit("timeline:reconfigure", {
580
+ steps: this._steps,
581
+ totalSteps: this._totalSteps
582
+ });
583
+ if (this.devtoolsEnabled && typeof window !== "undefined") {
584
+ this.updateDevtools(this.scrollPercentage, this.currentStep);
585
+ }
586
+ }
587
+ updateDevtools(scrollPercentage, currentStep) {
588
+ const serializableSteps = this._steps.map((s) => ({
589
+ name: s.name,
590
+ start: s.start,
591
+ end: s.end,
592
+ track: s.track,
593
+ easing: typeof s.easing === "function" ? "custom" : s.easing
594
+ }));
595
+ const state = {
596
+ steps: serializableSteps,
597
+ currentStep,
598
+ totalSteps: this._totalSteps,
599
+ opacities: this.getOpacities(scrollPercentage),
600
+ scrollPercentage
601
+ };
602
+ window.__MULTITRACK_DEVTOOLS__ = state;
603
+ }
604
+ };
605
+
606
+ export { Emitter, MultitrackError, Scope, ScrollDriver, Timeline, calculateAllOpacities, calculateStepOpacity, easeIn, easeInOut, easeOut, easingPresets, getCurrentSteps, getStepRange, getTotalSteps, linear, resetWarnings, resolveEasing, resolveSteps, snap };
607
+ //# sourceMappingURL=index.js.map
608
+ //# sourceMappingURL=index.js.map