@streamscloud/embeddable 6.0.4 → 6.2.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.
@@ -29,3 +29,9 @@ export declare enum ImageScale {
29
29
  OriginalEncoded = "ORIGINAL_ENCODED",
30
30
  Small = "SMALL"
31
31
  }
32
+ export declare enum PostType {
33
+ ShortVideo = "SHORT_VIDEO"
34
+ }
35
+ export declare enum Status {
36
+ Published = "PUBLISHED"
37
+ }
@@ -35,3 +35,11 @@ export var ImageScale;
35
35
  ImageScale["OriginalEncoded"] = "ORIGINAL_ENCODED";
36
36
  ImageScale["Small"] = "SMALL";
37
37
  })(ImageScale || (ImageScale = {}));
38
+ export var PostType;
39
+ (function (PostType) {
40
+ PostType["ShortVideo"] = "SHORT_VIDEO";
41
+ })(PostType || (PostType = {}));
42
+ export var Status;
43
+ (function (Status) {
44
+ Status["Published"] = "PUBLISHED";
45
+ })(Status || (Status = {}));
@@ -1,3 +1,4 @@
1
+ import { PostType, Status } from '../../core/enums';
1
2
  import { createLocalGQLClient } from '../../core/graphql';
2
3
  import { mapToShortVideoViewerModel } from '../../short-videos/short-video-viewer';
3
4
  import { GetMediaPageConfigDocument, GetShortVideosDocument } from './operations.generated';
@@ -26,7 +27,8 @@ export class InternalMediaCenterDataProvider {
26
27
  input: {
27
28
  filter: {
28
29
  mediaPageId: this.mediaPageId,
29
- types: ['SHORT_VIDEO'],
30
+ types: [PostType.ShortVideo],
31
+ statuses: [Status.Published],
30
32
  categoryId: filter.categoryId,
31
33
  excludeIds: filter.excludeIds
32
34
  },
@@ -143,8 +143,16 @@ export const GetShortVideosDocument = {
143
143
  selectionSet: {
144
144
  kind: 'SelectionSet',
145
145
  selections: [
146
- { kind: 'Field', name: { kind: 'Name', value: 'url' } },
147
- { kind: 'Field', name: { kind: 'Name', value: 'thumbnailUrl' } },
146
+ {
147
+ kind: 'Field',
148
+ name: { kind: 'Name', value: 'url' },
149
+ arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'scale' }, value: { kind: 'EnumValue', value: 'SMALL' } }]
150
+ },
151
+ {
152
+ kind: 'Field',
153
+ name: { kind: 'Name', value: 'thumbnailUrl' },
154
+ arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'scale' }, value: { kind: 'EnumValue', value: 'SMALL' } }]
155
+ },
148
156
  { kind: 'Field', name: { kind: 'Name', value: 'type' } }
149
157
  ]
150
158
  }
@@ -213,8 +221,16 @@ export const GetShortVideosDocument = {
213
221
  selectionSet: {
214
222
  kind: 'SelectionSet',
215
223
  selections: [
216
- { kind: 'Field', name: { kind: 'Name', value: 'url' } },
217
- { kind: 'Field', name: { kind: 'Name', value: 'thumbnailUrl' } },
224
+ {
225
+ kind: 'Field',
226
+ name: { kind: 'Name', value: 'url' },
227
+ arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'scale' }, value: { kind: 'EnumValue', value: 'SMALL' } }]
228
+ },
229
+ {
230
+ kind: 'Field',
231
+ name: { kind: 'Name', value: 'thumbnailUrl' },
232
+ arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'scale' }, value: { kind: 'EnumValue', value: 'SMALL' } }]
233
+ },
218
234
  { kind: 'Field', name: { kind: 'Name', value: 'type' } }
219
235
  ]
220
236
  }
@@ -89,6 +89,7 @@ const selectCategory = (categoryId) => {
89
89
  Utils.assertUnreachable(mediaCenterMode);
90
90
  }
91
91
  selectedCategoryId = categoryId;
92
+ overviewOpened = false;
92
93
  };
93
94
  const activateSelectedShortVideoFeed = (shortVideo) => {
94
95
  if (!dataProvider || !shortVideoProps) {
@@ -331,6 +332,7 @@ const onScrollMounted = (node) => {
331
332
  .media-center__category-button {
332
333
  pointer-events: auto;
333
334
  font-size: 0.875rem;
335
+ line-height: 1;
334
336
  padding: 0.5rem 1.5rem;
335
337
  white-space: nowrap;
336
338
  width: auto;
@@ -6,7 +6,7 @@ let { shortVideo, localization: localizationInit = 'en', on } = $props();
6
6
  const localization = $derived(new ShortVideoAttachmentsLocalization(localizationInit));
7
7
  </script>
8
8
 
9
- {#if shortVideo.products.length || shortVideo.ad}
9
+ {#if shortVideo.hasAttachments}
10
10
  <div class="short-video-attachments">
11
11
  {#if shortVideo.ad}
12
12
  <AdCard ad={shortVideo.ad} />
@@ -18,6 +18,7 @@ export const mapToShortVideoViewerModel = (payload) => {
18
18
  text: payload.postData.shortVideoData.text,
19
19
  enableSocialInteractions: payload.enableSocialInteractions,
20
20
  heading: null,
21
+ hasAttachments: !!(payload.allProducts.length || payload.ad),
21
22
  ad: payload.ad ? mapToShortVideoAdCardModel(payload.ad) : null,
22
23
  products: payload.allProducts.map((x) => mapToShortVideoProductCard(x))
23
24
  // uncomment if you want to test many products behavior
@@ -26,8 +26,16 @@ export const ShortVideoViewerPayloadFragmentDoc = {
26
26
  selectionSet: {
27
27
  kind: 'SelectionSet',
28
28
  selections: [
29
- { kind: 'Field', name: { kind: 'Name', value: 'url' } },
30
- { kind: 'Field', name: { kind: 'Name', value: 'thumbnailUrl' } },
29
+ {
30
+ kind: 'Field',
31
+ name: { kind: 'Name', value: 'url' },
32
+ arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'scale' }, value: { kind: 'EnumValue', value: 'SMALL' } }]
33
+ },
34
+ {
35
+ kind: 'Field',
36
+ name: { kind: 'Name', value: 'thumbnailUrl' },
37
+ arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'scale' }, value: { kind: 'EnumValue', value: 'SMALL' } }]
38
+ },
31
39
  { kind: 'Field', name: { kind: 'Name', value: 'type' } }
32
40
  ]
33
41
  }
@@ -96,8 +104,16 @@ export const ShortVideoViewerPayloadFragmentDoc = {
96
104
  selectionSet: {
97
105
  kind: 'SelectionSet',
98
106
  selections: [
99
- { kind: 'Field', name: { kind: 'Name', value: 'url' } },
100
- { kind: 'Field', name: { kind: 'Name', value: 'thumbnailUrl' } },
107
+ {
108
+ kind: 'Field',
109
+ name: { kind: 'Name', value: 'url' },
110
+ arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'scale' }, value: { kind: 'EnumValue', value: 'SMALL' } }]
111
+ },
112
+ {
113
+ kind: 'Field',
114
+ name: { kind: 'Name', value: 'thumbnailUrl' },
115
+ arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'scale' }, value: { kind: 'EnumValue', value: 'SMALL' } }]
116
+ },
101
117
  { kind: 'Field', name: { kind: 'Name', value: 'type' } }
102
118
  ]
103
119
  }
@@ -7,8 +7,8 @@ fragment ShortVideoViewerPayloadFragment on Post {
7
7
  shortDescription
8
8
  link
9
9
  media {
10
- url
11
- thumbnailUrl
10
+ url(scale: SMALL)
11
+ thumbnailUrl(scale: SMALL)
12
12
  type
13
13
  }
14
14
  brand {
@@ -40,8 +40,8 @@ fragment ShortVideoViewerPayloadFragment on Post {
40
40
  }
41
41
  type
42
42
  media {
43
- url
44
- thumbnailUrl
43
+ url(scale: SMALL)
44
+ thumbnailUrl(scale: SMALL)
45
45
  type
46
46
  }
47
47
  }
@@ -14,6 +14,7 @@ export type ShortVideoViewerModel = {
14
14
  enableSocialInteractions: boolean;
15
15
  products: ShortVideoProductCardModel[];
16
16
  ad: ShortVideoAdCardModel | null;
17
+ hasAttachments: boolean;
17
18
  };
18
19
  export type ShortVideoViewerHeadingModel = {
19
20
  image: string | null;
@@ -44,7 +44,7 @@ const changeShowAttachments = () => {
44
44
  <img src={playerLogo} class="short-videos-player-controls__logo-img" alt="Player Logo" />
45
45
  </div>
46
46
  {/if}
47
- {#if shortVideo && uiManager.showAttachments}
47
+ {#if shortVideo?.hasAttachments && uiManager.showAttachments}
48
48
  <div class="short-videos-player-controls__short-video-attachments" transition:slideHorizontally|local>
49
49
  <ShortVideoViewerAttachments
50
50
  shortVideo={shortVideo}
@@ -69,8 +69,16 @@ export const GetShortVideosDocument = {
69
69
  selectionSet: {
70
70
  kind: 'SelectionSet',
71
71
  selections: [
72
- { kind: 'Field', name: { kind: 'Name', value: 'url' } },
73
- { kind: 'Field', name: { kind: 'Name', value: 'thumbnailUrl' } },
72
+ {
73
+ kind: 'Field',
74
+ name: { kind: 'Name', value: 'url' },
75
+ arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'scale' }, value: { kind: 'EnumValue', value: 'SMALL' } }]
76
+ },
77
+ {
78
+ kind: 'Field',
79
+ name: { kind: 'Name', value: 'thumbnailUrl' },
80
+ arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'scale' }, value: { kind: 'EnumValue', value: 'SMALL' } }]
81
+ },
74
82
  { kind: 'Field', name: { kind: 'Name', value: 'type' } }
75
83
  ]
76
84
  }
@@ -139,8 +147,16 @@ export const GetShortVideosDocument = {
139
147
  selectionSet: {
140
148
  kind: 'SelectionSet',
141
149
  selections: [
142
- { kind: 'Field', name: { kind: 'Name', value: 'url' } },
143
- { kind: 'Field', name: { kind: 'Name', value: 'thumbnailUrl' } },
150
+ {
151
+ kind: 'Field',
152
+ name: { kind: 'Name', value: 'url' },
153
+ arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'scale' }, value: { kind: 'EnumValue', value: 'SMALL' } }]
154
+ },
155
+ {
156
+ kind: 'Field',
157
+ name: { kind: 'Name', value: 'thumbnailUrl' },
158
+ arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'scale' }, value: { kind: 'EnumValue', value: 'SMALL' } }]
159
+ },
144
160
  { kind: 'Field', name: { kind: 'Name', value: 'type' } }
145
161
  ]
146
162
  }
@@ -17,6 +17,7 @@ export const mapToShortVideoViewerModel = (model) => {
17
17
  viewsCount: model.header.postViewsCount
18
18
  },
19
19
  enableSocialInteractions: model.enableSocialInteractions,
20
+ hasAttachments: !!(model.products.length || model.ad),
20
21
  ad: model.ad ? mapToAdViewModel(model.ad) : null,
21
22
  products: model.products.map(mapToProductCardModel)
22
23
  // uncomment if you want to test many products behavior
@@ -1,4 +1,4 @@
1
- <script lang="ts">import { createWheelPeakDetector } from './wheel-peak-detector';
1
+ <script lang="ts">import { createWheelAdapter } from './wheel-gestures-adapter';
2
2
  import { onDestroy, onMount, untrack } from 'svelte';
3
3
  let { buffer, on, children } = $props();
4
4
  let slidesRef;
@@ -115,7 +115,7 @@ const styles = $derived.by(() => {
115
115
  ];
116
116
  return values.join(';');
117
117
  });
118
- const peakDetectorCallbacks = {
118
+ const wheelCallbacks = {
119
119
  canLoadNext: () => buffer.canLoadNext,
120
120
  canLoadPrevious: () => buffer.canLoadPrevious,
121
121
  onTrigger: (direction) => {
@@ -132,7 +132,7 @@ const peakDetectorCallbacks = {
132
132
  </script>
133
133
 
134
134
  <div class="player-slider">
135
- <div class="player-slider__slides" bind:this={slidesRef} use:createWheelPeakDetector={{ cbs: peakDetectorCallbacks }} style={styles}>
135
+ <div class="player-slider__slides" bind:this={slidesRef} use:createWheelAdapter={{ cbs: wheelCallbacks }} style={styles}>
136
136
  {#each buffer.loaded as item, index (item)}
137
137
  <div class="player-slider__slide">
138
138
  {#if index >= activeIndex - 1 && index <= activeIndex + 1}
@@ -8,7 +8,7 @@ export declare class PlayerBuffer<T extends {
8
8
  readonly canLoadNext: boolean;
9
9
  readonly canLoadPrevious: boolean;
10
10
  readonly navigationDisabled: boolean;
11
- readonly animationDuration = 300;
11
+ readonly animationDuration = 500;
12
12
  private _currentIndex;
13
13
  private _loaded;
14
14
  private loadMoreFn;
@@ -6,7 +6,7 @@ export class PlayerBuffer {
6
6
  canLoadNext = $derived(this.currentIndex < this.loaded.length - 1);
7
7
  canLoadPrevious = $derived(this.currentIndex > 0);
8
8
  navigationDisabled = $derived(!this.canLoadNext && !this.canLoadPrevious);
9
- animationDuration = 300;
9
+ animationDuration = 500;
10
10
  _currentIndex = $state(-1);
11
11
  _loaded = $state.raw([]);
12
12
  loadMoreFn;
@@ -0,0 +1,17 @@
1
+ export type WheelAdapterCallbacks = {
2
+ canLoadNext: () => boolean;
3
+ canLoadPrevious: () => boolean;
4
+ onTrigger: (direction: number) => void;
5
+ getAnimationDurationMs: () => number;
6
+ };
7
+ /**
8
+ * Minimal, robust wheel adapter:
9
+ * - EMA over axisVelocity[1] to smooth noisy streams (esp. on Windows)
10
+ * - Cooldown blocks triggers while animation runs
11
+ * - Mouse fallback: if velocity is near zero but delta is large, treat as a discrete "kick"
12
+ */
13
+ export declare const createWheelAdapter: (target: HTMLElement, params: {
14
+ cbs: WheelAdapterCallbacks;
15
+ }) => {
16
+ destroy(): void;
17
+ };
@@ -0,0 +1,79 @@
1
+ // wheel-gestures-adapter.ts
2
+ import { WheelGestures } from 'wheel-gestures';
3
+ /**
4
+ * Minimal, robust wheel adapter:
5
+ * - EMA over axisVelocity[1] to smooth noisy streams (esp. on Windows)
6
+ * - Cooldown blocks triggers while animation runs
7
+ * - Mouse fallback: if velocity is near zero but delta is large, treat as a discrete "kick"
8
+ */
9
+ export const createWheelAdapter = (target, params) => {
10
+ const { cbs } = params;
11
+ // Tunables
12
+ const PEAK_THRESHOLD = 0.4; // EMA magnitude threshold to consider as a "peak"
13
+ const ACCEL_THRESHOLD = 0.02; // minimal directional EMA rise to count as real acceleration
14
+ const EMA_ALPHA = 0.35; // EMA smoothing factor (0..1); higher = snappier, noisier
15
+ const MOUSE_DELTA_KICK = 12; // mouse: large delta step (≈12 mac / ≈100 win)
16
+ const MOUSE_STEP_RATIO_KICK = 350; // dimensionless; tune 200–500 if needed
17
+ const wheelGestures = WheelGestures({ preventWheelAction: true, reverseSign: false });
18
+ wheelGestures.observe(target);
19
+ let isAnimating = false;
20
+ let cooldownTimer = null;
21
+ // EMA state
22
+ let emaVelocity = 0;
23
+ let previousEmaVelocity = 0;
24
+ const startCooldown = () => {
25
+ if (cooldownTimer) {
26
+ clearTimeout(cooldownTimer);
27
+ }
28
+ cooldownTimer = window.setTimeout(() => {
29
+ isAnimating = false;
30
+ }, cbs.getAnimationDurationMs() + 100);
31
+ };
32
+ const fire = (direction) => {
33
+ // Respect external guards (e.g., paging boundaries)
34
+ if ((direction > 0 && !cbs.canLoadNext()) || (direction < 0 && !cbs.canLoadPrevious())) {
35
+ return;
36
+ }
37
+ isAnimating = true;
38
+ cbs.onTrigger(direction);
39
+ startCooldown();
40
+ };
41
+ wheelGestures.on('wheel', ({ axisDelta: [, axisDeltaY], axisVelocity: [, axisVelocityY] }) => {
42
+ const velocityY = axisVelocityY || 0;
43
+ const deltaY = axisDeltaY || 0;
44
+ // Tracking only: always update EMA and compute signs/acceleration
45
+ previousEmaVelocity = emaVelocity;
46
+ emaVelocity += (velocityY - emaVelocity) * EMA_ALPHA;
47
+ const emaMagnitude = Math.abs(emaVelocity);
48
+ const velocitySign = Math.sign(emaVelocity) || Math.sign(velocityY);
49
+ const emaAcceleration = emaVelocity - previousEmaVelocity;
50
+ // During animation we only track; no arming, no triggering
51
+ if (isAnimating) {
52
+ return;
53
+ }
54
+ // Path 1: mouse-like discrete kick (platform-agnostic via delta/velocity ratio)
55
+ const absDelta = Math.abs(deltaY);
56
+ const absVel = Math.abs(velocityY);
57
+ const stepRatio = absDelta / Math.max(1e-6, absVel);
58
+ const isMouseLikeKick = absDelta >= MOUSE_DELTA_KICK && stepRatio >= MOUSE_STEP_RATIO_KICK;
59
+ if (isMouseLikeKick) {
60
+ const direction = deltaY > 0 ? 1 : -1; // 1 = next/down, -1 = previous/up
61
+ fire(direction);
62
+ return;
63
+ }
64
+ // Path 2: trackpad/inertia via EMA acceleration and peak gating
65
+ const isAcceleratingInDirection = velocitySign !== 0 && emaAcceleration * velocitySign > ACCEL_THRESHOLD;
66
+ if (isAcceleratingInDirection && emaMagnitude > PEAK_THRESHOLD) {
67
+ const direction = velocitySign > 0 ? 1 : -1; // 1 = next/down, -1 = previous/up
68
+ fire(direction);
69
+ }
70
+ });
71
+ return {
72
+ destroy() {
73
+ wheelGestures.unobserve(target);
74
+ if (cooldownTimer) {
75
+ clearTimeout(cooldownTimer);
76
+ }
77
+ }
78
+ };
79
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamscloud/embeddable",
3
- "version": "6.0.4",
3
+ "version": "6.2.0",
4
4
  "author": "StreamsCloud",
5
5
  "repository": {
6
6
  "type": "git",
@@ -157,6 +157,7 @@
157
157
  "vite-tsconfig-paths": "^5.1.4"
158
158
  },
159
159
  "dependencies": {
160
- "@popperjs/core": "^2.11.8"
160
+ "@popperjs/core": "^2.11.8",
161
+ "wheel-gestures": "^2.2.48"
161
162
  }
162
163
  }
@@ -1,22 +0,0 @@
1
- export type PeakDetectorConfig = {
2
- mouseGapMs?: number;
3
- mouseDeltaThreshold?: number;
4
- interEventTimeout?: number;
5
- minPeakToTrigger?: number;
6
- waveMaxAgeMs?: number;
7
- directionFlipEndsWave?: boolean;
8
- animationCooldownMs?: number;
9
- directionChangeMinAbsDelta?: number;
10
- };
11
- export type PeakDetectorCallbacks = {
12
- canLoadNext: () => boolean;
13
- canLoadPrevious: () => boolean;
14
- onTrigger: (direction: number) => void;
15
- getAnimationDurationMs: () => number;
16
- };
17
- export declare const createWheelPeakDetector: (target: HTMLElement, params: {
18
- cbs: PeakDetectorCallbacks;
19
- cfg?: PeakDetectorConfig;
20
- }) => {
21
- destroy(): void;
22
- };
@@ -1,157 +0,0 @@
1
- import { isScrollingPrevented } from './prevent-slider-scroll';
2
- export const createWheelPeakDetector = (target, params) => {
3
- const { cbs, cfg } = params;
4
- // Defaults tuned for smooth touchpads; adjust in caller if needed.
5
- const config = {
6
- mouseGapMs: cfg?.mouseGapMs ?? 120,
7
- mouseDeltaThreshold: cfg?.mouseDeltaThreshold ?? 12,
8
- interEventTimeout: cfg?.interEventTimeout ?? 180,
9
- minPeakToTrigger: cfg?.minPeakToTrigger ?? 6,
10
- waveMaxAgeMs: cfg?.waveMaxAgeMs ?? 1800,
11
- directionFlipEndsWave: cfg?.directionFlipEndsWave ?? true,
12
- animationCooldownMs: cfg?.animationCooldownMs ?? 100, // extra after CSS transition
13
- directionChangeMinAbsDelta: cfg?.directionChangeMinAbsDelta ?? 2 // ignore tiny opposite spikes
14
- };
15
- let wave = null;
16
- let lastWheelTime = 0;
17
- let lastDelta = 0;
18
- let isAnimating = false;
19
- let cooldownTimer = null;
20
- const clearWave = () => {
21
- wave = null;
22
- };
23
- const setAnimatingWithCooldown = () => {
24
- isAnimating = true;
25
- const total = cbs.getAnimationDurationMs() + config.animationCooldownMs;
26
- if (cooldownTimer) {
27
- clearTimeout(cooldownTimer);
28
- }
29
- cooldownTimer = window.setTimeout(() => {
30
- isAnimating = false;
31
- cooldownTimer = null;
32
- }, total);
33
- };
34
- const trigger = (direction) => {
35
- if (direction > 0 && !cbs.canLoadNext()) {
36
- return;
37
- }
38
- if (direction < 0 && !cbs.canLoadPrevious()) {
39
- return;
40
- }
41
- setAnimatingWithCooldown();
42
- cbs.onTrigger(direction);
43
- };
44
- const canHandle = (node) => {
45
- while (node && node !== target) {
46
- if (isScrollingPrevented(node)) {
47
- return false;
48
- }
49
- node = node.parentElement;
50
- }
51
- return true;
52
- };
53
- const finalizeWaveWithTrigger = () => {
54
- if (wave && !isAnimating) {
55
- if (wave.maxAbsDelta >= config.minPeakToTrigger) {
56
- trigger(wave.direction);
57
- }
58
- }
59
- clearWave();
60
- };
61
- const finalizeWaveSilently = () => {
62
- // Finish the wave without triggering (used on direction flip to avoid ghost slide)
63
- clearWave();
64
- };
65
- const onWheel = (e) => {
66
- e.preventDefault();
67
- if (!canHandle(e.target)) {
68
- return;
69
- }
70
- const now = Date.now();
71
- const dy = e.deltaY;
72
- const absDelta = Math.abs(dy);
73
- console.warn(absDelta);
74
- const dir = Math.sign(dy);
75
- // Mouse branch: big, sparse deltas
76
- const timeSinceLast = now - lastWheelTime;
77
- if (absDelta >= config.mouseDeltaThreshold && timeSinceLast > config.mouseGapMs && !isAnimating) {
78
- trigger(dir);
79
- clearWave();
80
- lastWheelTime = now;
81
- lastDelta = dy;
82
- return;
83
- }
84
- // Determine direction change with hysteresis to avoid micro flips
85
- const directionChanged = !!wave && dir !== 0 && dir !== wave.direction && absDelta >= config.directionChangeMinAbsDelta;
86
- const inactiveTooLong = wave && now - wave?.lastEventTime > config.interEventTimeout;
87
- const waveTooOld = wave && now - wave?.startTime > config.waveMaxAgeMs;
88
- // If direction flip should end the wave, do it silently (no trigger of the old direction)
89
- if (wave && config.directionFlipEndsWave && directionChanged) {
90
- finalizeWaveSilently();
91
- // Start a new wave immediately in the new direction
92
- wave = {
93
- startTime: now,
94
- lastEventTime: now,
95
- direction: dir,
96
- maxAbsDelta: absDelta,
97
- sumAbsDelta: absDelta,
98
- events: 1
99
- };
100
- lastWheelTime = now;
101
- lastDelta = dy;
102
- return;
103
- }
104
- // Finalize by inactivity or age (these DO trigger)
105
- if (inactiveTooLong || waveTooOld) {
106
- finalizeWaveWithTrigger();
107
- }
108
- // Start condition for new wave:
109
- // - no wave
110
- // - current amplitude grows vs previous
111
- // - long gap since last wheel
112
- const growing = absDelta > Math.abs(lastDelta);
113
- const longGap = timeSinceLast > config.interEventTimeout;
114
- if (!wave || growing || longGap) {
115
- // Opportunistic finalize only if not animating and not caused by a flip
116
- // (flip branch handled earlier and is silent)
117
- if (wave && !isAnimating) {
118
- if (wave.maxAbsDelta >= config.minPeakToTrigger) {
119
- trigger(wave.direction);
120
- }
121
- }
122
- wave = {
123
- startTime: now,
124
- lastEventTime: now,
125
- direction: dir || (wave?.direction ?? 0),
126
- maxAbsDelta: absDelta,
127
- sumAbsDelta: absDelta,
128
- events: 1
129
- };
130
- lastWheelTime = now;
131
- lastDelta = dy;
132
- return;
133
- }
134
- // Continue current wave
135
- if (wave) {
136
- wave.lastEventTime = now;
137
- wave.events += 1;
138
- wave.sumAbsDelta += absDelta;
139
- if (absDelta > wave.maxAbsDelta) {
140
- wave.maxAbsDelta = absDelta; // peak update
141
- }
142
- }
143
- lastWheelTime = now;
144
- lastDelta = dy;
145
- };
146
- target.addEventListener('wheel', onWheel, { passive: false });
147
- return {
148
- destroy() {
149
- target.removeEventListener('wheel', onWheel);
150
- if (cooldownTimer) {
151
- clearTimeout(cooldownTimer);
152
- cooldownTimer = null;
153
- }
154
- wave = null;
155
- }
156
- };
157
- };