mtrl-addons 0.1.2 → 0.2.2
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/AI.md +28 -230
- package/CLAUDE.md +882 -0
- package/build.js +253 -24
- package/package.json +14 -4
- package/scripts/debug/vlist-selection.ts +121 -0
- package/src/components/index.ts +5 -41
- package/src/components/{list → vlist}/config.ts +66 -95
- package/src/components/vlist/constants.ts +23 -0
- package/src/components/vlist/features/api.ts +626 -0
- package/src/components/vlist/features/index.ts +10 -0
- package/src/components/vlist/features/selection.ts +436 -0
- package/src/components/vlist/features/viewport.ts +59 -0
- package/src/components/vlist/index.ts +17 -0
- package/src/components/{list → vlist}/types.ts +242 -32
- package/src/components/vlist/vlist.ts +92 -0
- package/src/core/compose/features/gestures/index.ts +227 -0
- package/src/core/compose/features/gestures/longpress.ts +383 -0
- package/src/core/compose/features/gestures/pan.ts +424 -0
- package/src/core/compose/features/gestures/pinch.ts +475 -0
- package/src/core/compose/features/gestures/rotate.ts +485 -0
- package/src/core/compose/features/gestures/swipe.ts +492 -0
- package/src/core/compose/features/gestures/tap.ts +334 -0
- package/src/core/compose/features/index.ts +2 -38
- package/src/core/compose/index.ts +13 -29
- package/src/core/gestures/index.ts +31 -0
- package/src/core/gestures/longpress.ts +68 -0
- package/src/core/gestures/manager.ts +418 -0
- package/src/core/gestures/pan.ts +48 -0
- package/src/core/gestures/pinch.ts +58 -0
- package/src/core/gestures/rotate.ts +58 -0
- package/src/core/gestures/swipe.ts +66 -0
- package/src/core/gestures/tap.ts +45 -0
- package/src/core/gestures/types.ts +387 -0
- package/src/core/gestures/utils.ts +128 -0
- package/src/core/index.ts +27 -151
- package/src/core/layout/schema.ts +153 -72
- package/src/core/layout/types.ts +5 -2
- package/src/core/viewport/constants.ts +145 -0
- package/src/core/viewport/features/base.ts +73 -0
- package/src/core/viewport/features/collection.ts +1182 -0
- package/src/core/viewport/features/events.ts +130 -0
- package/src/core/viewport/features/index.ts +20 -0
- package/src/core/{list-manager/features/viewport → viewport/features}/item-size.ts +31 -34
- package/src/core/{list-manager/features/viewport → viewport/features}/loading.ts +4 -4
- package/src/core/viewport/features/momentum.ts +269 -0
- package/src/core/viewport/features/placeholders.ts +335 -0
- package/src/core/viewport/features/rendering.ts +962 -0
- package/src/core/viewport/features/scrollbar.ts +434 -0
- package/src/core/viewport/features/scrolling.ts +634 -0
- package/src/core/viewport/features/utils.ts +94 -0
- package/src/core/viewport/features/virtual.ts +525 -0
- package/src/core/viewport/index.ts +31 -0
- package/src/core/viewport/types.ts +133 -0
- package/src/core/viewport/utils/speed-tracker.ts +79 -0
- package/src/core/viewport/viewport.ts +265 -0
- package/src/index.ts +0 -7
- package/src/styles/components/_vlist.scss +352 -0
- package/src/styles/index.scss +1 -1
- package/test/components/vlist-selection.test.ts +240 -0
- package/test/components/vlist.test.ts +63 -0
- package/test/core/collection/adapter.test.ts +161 -0
- package/bun.lock +0 -792
- package/src/components/list/api.ts +0 -314
- package/src/components/list/constants.ts +0 -56
- package/src/components/list/features/api.ts +0 -428
- package/src/components/list/features/index.ts +0 -31
- package/src/components/list/features/list-manager.ts +0 -502
- package/src/components/list/index.ts +0 -39
- package/src/components/list/list.ts +0 -234
- package/src/core/collection/base-collection.ts +0 -100
- package/src/core/collection/collection-composer.ts +0 -178
- package/src/core/collection/collection.ts +0 -745
- package/src/core/collection/constants.ts +0 -172
- package/src/core/collection/events.ts +0 -428
- package/src/core/collection/features/api/loading.ts +0 -279
- package/src/core/collection/features/operations/data-operations.ts +0 -147
- package/src/core/collection/index.ts +0 -104
- package/src/core/collection/state.ts +0 -497
- package/src/core/collection/types.ts +0 -404
- package/src/core/compose/features/collection.ts +0 -119
- package/src/core/compose/features/selection.ts +0 -213
- package/src/core/compose/features/styling.ts +0 -108
- package/src/core/list-manager/api.ts +0 -599
- package/src/core/list-manager/config.ts +0 -593
- package/src/core/list-manager/constants.ts +0 -268
- package/src/core/list-manager/features/api.ts +0 -58
- package/src/core/list-manager/features/collection/collection.ts +0 -705
- package/src/core/list-manager/features/collection/index.ts +0 -17
- package/src/core/list-manager/features/viewport/constants.ts +0 -42
- package/src/core/list-manager/features/viewport/index.ts +0 -16
- package/src/core/list-manager/features/viewport/placeholders.ts +0 -281
- package/src/core/list-manager/features/viewport/rendering.ts +0 -575
- package/src/core/list-manager/features/viewport/scrollbar.ts +0 -495
- package/src/core/list-manager/features/viewport/scrolling.ts +0 -795
- package/src/core/list-manager/features/viewport/template.ts +0 -220
- package/src/core/list-manager/features/viewport/viewport.ts +0 -654
- package/src/core/list-manager/features/viewport/virtual.ts +0 -309
- package/src/core/list-manager/index.ts +0 -279
- package/src/core/list-manager/list-manager.ts +0 -206
- package/src/core/list-manager/types.ts +0 -439
- package/src/core/list-manager/utils/calculations.ts +0 -290
- package/src/core/list-manager/utils/range-calculator.ts +0 -349
- package/src/core/list-manager/utils/speed-tracker.ts +0 -273
- package/src/styles/components/_list.scss +0 -244
- package/src/types/mtrl.d.ts +0 -6
- package/test/components/list.test.ts +0 -256
- package/test/core/collection/failed-ranges.test.ts +0 -270
- package/test/core/compose/features.test.ts +0 -183
- package/test/core/list-manager/features/collection.test.ts +0 -704
- package/test/core/list-manager/features/viewport.test.ts +0 -698
- package/test/core/list-manager/list-manager.test.ts +0 -593
- package/test/core/list-manager/utils/calculations.test.ts +0 -433
- package/test/core/list-manager/utils/range-calculator.test.ts +0 -569
- package/test/core/list-manager/utils/speed-tracker.test.ts +0 -530
- package/tsconfig.build.json +0 -23
- /package/src/components/{list → vlist}/features.ts +0 -0
- /package/src/core/{compose → viewport}/features/performance.ts +0 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// src/core/viewport/features/events.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Events Feature - Centralized event system for viewport
|
|
5
|
+
* Provides event emission and subscription for inter-feature communication
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ViewportContext, ViewportComponent } from "../types";
|
|
9
|
+
|
|
10
|
+
export interface EventsConfig {
|
|
11
|
+
debug?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Events feature for viewport
|
|
16
|
+
* Centralizes all event handling for viewport features
|
|
17
|
+
*/
|
|
18
|
+
export const withEvents = (config: EventsConfig = {}) => {
|
|
19
|
+
return <T extends ViewportContext & ViewportComponent>(component: T): T => {
|
|
20
|
+
const { debug = false } = config;
|
|
21
|
+
|
|
22
|
+
// Event listeners map
|
|
23
|
+
const listeners = new Map<string, Set<Function>>();
|
|
24
|
+
|
|
25
|
+
// Emit an event
|
|
26
|
+
const emit = (event: string, data?: any) => {
|
|
27
|
+
// if (debug) {
|
|
28
|
+
// console.log(`[Events] Emit: ${event}`, data);
|
|
29
|
+
// }
|
|
30
|
+
|
|
31
|
+
const eventListeners = listeners.get(event);
|
|
32
|
+
if (eventListeners) {
|
|
33
|
+
eventListeners.forEach((listener) => {
|
|
34
|
+
try {
|
|
35
|
+
listener(data);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error(`[Events] Error in listener for ${event}:`, error);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Subscribe to an event
|
|
44
|
+
const on = (event: string, handler: Function): (() => void) => {
|
|
45
|
+
if (!listeners.has(event)) {
|
|
46
|
+
listeners.set(event, new Set());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
listeners.get(event)!.add(handler);
|
|
50
|
+
|
|
51
|
+
// if (debug) {
|
|
52
|
+
// console.log(`[Events] Subscribed to: ${event}`);
|
|
53
|
+
// }
|
|
54
|
+
|
|
55
|
+
// Return unsubscribe function
|
|
56
|
+
return () => {
|
|
57
|
+
const eventListeners = listeners.get(event);
|
|
58
|
+
if (eventListeners) {
|
|
59
|
+
eventListeners.delete(handler);
|
|
60
|
+
if (eventListeners.size === 0) {
|
|
61
|
+
listeners.delete(event);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Subscribe to an event once
|
|
68
|
+
const once = (event: string, handler: Function): (() => void) => {
|
|
69
|
+
const wrappedHandler = (data: any) => {
|
|
70
|
+
handler(data);
|
|
71
|
+
off(event, wrappedHandler);
|
|
72
|
+
};
|
|
73
|
+
return on(event, wrappedHandler);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Unsubscribe from an event
|
|
77
|
+
const off = (event: string, handler: Function) => {
|
|
78
|
+
const eventListeners = listeners.get(event);
|
|
79
|
+
if (eventListeners) {
|
|
80
|
+
eventListeners.delete(handler);
|
|
81
|
+
if (eventListeners.size === 0) {
|
|
82
|
+
listeners.delete(event);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Clear all listeners for an event
|
|
88
|
+
const clear = (event?: string) => {
|
|
89
|
+
if (event) {
|
|
90
|
+
listeners.delete(event);
|
|
91
|
+
} else {
|
|
92
|
+
listeners.clear();
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Add event methods to component
|
|
97
|
+
component.emit = emit;
|
|
98
|
+
component.on = on;
|
|
99
|
+
component.once = once;
|
|
100
|
+
component.off = off;
|
|
101
|
+
|
|
102
|
+
// Add events API to viewport
|
|
103
|
+
(component.viewport as any).events = {
|
|
104
|
+
emit,
|
|
105
|
+
on,
|
|
106
|
+
once,
|
|
107
|
+
off,
|
|
108
|
+
clear,
|
|
109
|
+
getListenerCount: (event?: string) => {
|
|
110
|
+
if (event) {
|
|
111
|
+
return listeners.get(event)?.size || 0;
|
|
112
|
+
}
|
|
113
|
+
let total = 0;
|
|
114
|
+
listeners.forEach((set) => (total += set.size));
|
|
115
|
+
return total;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Clean up on destroy
|
|
120
|
+
if ("destroy" in component && typeof component.destroy === "function") {
|
|
121
|
+
const originalDestroy = component.destroy;
|
|
122
|
+
component.destroy = () => {
|
|
123
|
+
clear();
|
|
124
|
+
originalDestroy?.();
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return component;
|
|
129
|
+
};
|
|
130
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// src/core/list-manager/features/viewport/index.ts
|
|
2
|
+
|
|
3
|
+
export { withBase } from "./base";
|
|
4
|
+
export { withCollection } from "./collection";
|
|
5
|
+
export { withEvents } from "./events";
|
|
6
|
+
export { withPlaceholders } from "./placeholders";
|
|
7
|
+
export { withRendering } from "./rendering";
|
|
8
|
+
export { withScrollbar } from "./scrollbar";
|
|
9
|
+
export { withScrolling } from "./scrolling";
|
|
10
|
+
export { withVirtual } from "./virtual";
|
|
11
|
+
export { withPerformance } from "./performance";
|
|
12
|
+
export { withMomentum } from "./momentum";
|
|
13
|
+
|
|
14
|
+
// Utility exports
|
|
15
|
+
export { createItemSizeManager } from "./item-size";
|
|
16
|
+
export { createLoadingManager } from "./loading";
|
|
17
|
+
// No createElementFromTemplate util here; remove incorrect export
|
|
18
|
+
|
|
19
|
+
// Types
|
|
20
|
+
export type { PlaceholderComponent, PlaceholderConfig } from "./placeholders";
|
|
@@ -9,7 +9,7 @@ export interface ItemSizeManager {
|
|
|
9
9
|
measureItem(
|
|
10
10
|
element: HTMLElement,
|
|
11
11
|
index: number,
|
|
12
|
-
orientation?: "vertical" | "horizontal"
|
|
12
|
+
orientation?: "vertical" | "horizontal",
|
|
13
13
|
): number;
|
|
14
14
|
|
|
15
15
|
// Cache management
|
|
@@ -20,8 +20,8 @@ export interface ItemSizeManager {
|
|
|
20
20
|
clearCache(): void;
|
|
21
21
|
|
|
22
22
|
// Size estimation
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
getItemSize(): number;
|
|
24
|
+
updateItemSize(): void;
|
|
25
25
|
|
|
26
26
|
// Additional utilities
|
|
27
27
|
calculateTotalSize(totalItems?: number): number;
|
|
@@ -29,7 +29,7 @@ export interface ItemSizeManager {
|
|
|
29
29
|
|
|
30
30
|
// Callbacks
|
|
31
31
|
onSizeUpdated?: (totalSize: number) => void;
|
|
32
|
-
|
|
32
|
+
onItemSizeChanged?: (newEstimate: number) => void;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export interface ItemSizeConfig {
|
|
@@ -37,26 +37,26 @@ export interface ItemSizeConfig {
|
|
|
37
37
|
orientation?: "vertical" | "horizontal";
|
|
38
38
|
cacheSize?: number;
|
|
39
39
|
onSizeUpdated?: (totalSize: number) => void;
|
|
40
|
-
|
|
40
|
+
onItemSizeChanged?: (newEstimate: number) => void;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* Creates an item size manager for measuring and caching item dimensions
|
|
45
45
|
*/
|
|
46
46
|
export const createItemSizeManager = (
|
|
47
|
-
config: ItemSizeConfig = {}
|
|
47
|
+
config: ItemSizeConfig = {},
|
|
48
48
|
): ItemSizeManager => {
|
|
49
49
|
const {
|
|
50
50
|
initialEstimate = 60,
|
|
51
51
|
orientation = "vertical",
|
|
52
52
|
cacheSize = 1000,
|
|
53
53
|
onSizeUpdated,
|
|
54
|
-
|
|
54
|
+
onItemSizeChanged,
|
|
55
55
|
} = config;
|
|
56
56
|
|
|
57
57
|
// Size cache - stores actual measured sizes
|
|
58
58
|
const measuredSizes = new Map<number, number>();
|
|
59
|
-
let
|
|
59
|
+
let currentItemSize = initialEstimate;
|
|
60
60
|
|
|
61
61
|
// Batching state for performance optimization
|
|
62
62
|
let batchUpdateTimeout: number | null = null;
|
|
@@ -82,7 +82,7 @@ export const createItemSizeManager = (
|
|
|
82
82
|
*/
|
|
83
83
|
const triggerBatchedUpdates = (): void => {
|
|
84
84
|
// Update estimated size based on all measurements
|
|
85
|
-
|
|
85
|
+
updateItemSize();
|
|
86
86
|
|
|
87
87
|
// Notify about total size update
|
|
88
88
|
if (onSizeUpdated) {
|
|
@@ -116,10 +116,10 @@ export const createItemSizeManager = (
|
|
|
116
116
|
const measureItem = (
|
|
117
117
|
element: HTMLElement,
|
|
118
118
|
index: number,
|
|
119
|
-
measureOrientation?: "vertical" | "horizontal"
|
|
119
|
+
measureOrientation?: "vertical" | "horizontal",
|
|
120
120
|
): number => {
|
|
121
121
|
if (!element || index < 0) {
|
|
122
|
-
return
|
|
122
|
+
return currentItemSize;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
const actualOrientation = measureOrientation || orientation;
|
|
@@ -144,13 +144,13 @@ export const createItemSizeManager = (
|
|
|
144
144
|
return size;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
return
|
|
147
|
+
return currentItemSize;
|
|
148
148
|
};
|
|
149
149
|
|
|
150
150
|
/**
|
|
151
151
|
* Update estimated item size based on measured sizes (with change threshold)
|
|
152
152
|
*/
|
|
153
|
-
const
|
|
153
|
+
const updateItemSize = (): void => {
|
|
154
154
|
if (measuredSizes.size === 0) return;
|
|
155
155
|
|
|
156
156
|
const sizes = Array.from(measuredSizes.values());
|
|
@@ -158,22 +158,19 @@ export const createItemSizeManager = (
|
|
|
158
158
|
const newEstimate = Math.max(1, Math.round(average));
|
|
159
159
|
|
|
160
160
|
// Only update if the change is significant (>2px or >5% change)
|
|
161
|
-
const changeThreshold = Math.max(
|
|
162
|
-
|
|
163
|
-
Math.round(currentEstimatedItemSize * 0.05)
|
|
164
|
-
);
|
|
165
|
-
const absoluteChange = Math.abs(newEstimate - currentEstimatedItemSize);
|
|
161
|
+
const changeThreshold = Math.max(2, Math.round(currentItemSize * 0.05));
|
|
162
|
+
const absoluteChange = Math.abs(newEstimate - currentItemSize);
|
|
166
163
|
|
|
167
164
|
if (absoluteChange >= changeThreshold) {
|
|
168
|
-
const previousEstimate =
|
|
169
|
-
|
|
165
|
+
const previousEstimate = currentItemSize;
|
|
166
|
+
currentItemSize = newEstimate;
|
|
170
167
|
|
|
171
|
-
if (
|
|
172
|
-
|
|
168
|
+
if (onItemSizeChanged) {
|
|
169
|
+
onItemSizeChanged(newEstimate);
|
|
173
170
|
}
|
|
174
171
|
} else if (absoluteChange > 0) {
|
|
175
172
|
// Silent update for small changes
|
|
176
|
-
|
|
173
|
+
currentItemSize = newEstimate;
|
|
177
174
|
}
|
|
178
175
|
};
|
|
179
176
|
|
|
@@ -185,13 +182,13 @@ export const createItemSizeManager = (
|
|
|
185
182
|
// Calculate based on measured items only
|
|
186
183
|
return Array.from(measuredSizes.values()).reduce(
|
|
187
184
|
(sum, size) => sum + size,
|
|
188
|
-
0
|
|
185
|
+
0,
|
|
189
186
|
);
|
|
190
187
|
}
|
|
191
188
|
|
|
192
189
|
let totalSize = 0;
|
|
193
190
|
for (let i = 0; i < totalItems; i++) {
|
|
194
|
-
totalSize += measuredSizes.get(i) ||
|
|
191
|
+
totalSize += measuredSizes.get(i) || currentItemSize;
|
|
195
192
|
}
|
|
196
193
|
return totalSize;
|
|
197
194
|
};
|
|
@@ -200,7 +197,7 @@ export const createItemSizeManager = (
|
|
|
200
197
|
* Get size for a specific item (measured or estimated)
|
|
201
198
|
*/
|
|
202
199
|
const getMeasuredSize = (index: number): number => {
|
|
203
|
-
return measuredSizes.get(index) ||
|
|
200
|
+
return measuredSizes.get(index) || currentItemSize;
|
|
204
201
|
};
|
|
205
202
|
|
|
206
203
|
/**
|
|
@@ -221,8 +218,8 @@ export const createItemSizeManager = (
|
|
|
221
218
|
/**
|
|
222
219
|
* Get current estimated item size
|
|
223
220
|
*/
|
|
224
|
-
const
|
|
225
|
-
return
|
|
221
|
+
const getItemSize = (): number => {
|
|
222
|
+
return currentItemSize;
|
|
226
223
|
};
|
|
227
224
|
|
|
228
225
|
/**
|
|
@@ -238,16 +235,16 @@ export const createItemSizeManager = (
|
|
|
238
235
|
const getStats = () => {
|
|
239
236
|
return {
|
|
240
237
|
cachedItems: measuredSizes.size,
|
|
241
|
-
estimatedSize:
|
|
238
|
+
estimatedSize: currentItemSize,
|
|
242
239
|
cacheSize: cacheSize,
|
|
243
240
|
minSize:
|
|
244
241
|
measuredSizes.size > 0
|
|
245
242
|
? Math.min(...measuredSizes.values())
|
|
246
|
-
:
|
|
243
|
+
: currentItemSize,
|
|
247
244
|
maxSize:
|
|
248
245
|
measuredSizes.size > 0
|
|
249
246
|
? Math.max(...measuredSizes.values())
|
|
250
|
-
:
|
|
247
|
+
: currentItemSize,
|
|
251
248
|
orientation: orientation,
|
|
252
249
|
};
|
|
253
250
|
};
|
|
@@ -259,8 +256,8 @@ export const createItemSizeManager = (
|
|
|
259
256
|
getMeasuredSize,
|
|
260
257
|
getMeasuredSizes,
|
|
261
258
|
clearCache,
|
|
262
|
-
|
|
263
|
-
|
|
259
|
+
getItemSize,
|
|
260
|
+
updateItemSize,
|
|
264
261
|
cacheItemSize,
|
|
265
262
|
|
|
266
263
|
// Additional utilities
|
|
@@ -269,6 +266,6 @@ export const createItemSizeManager = (
|
|
|
269
266
|
|
|
270
267
|
// Callbacks
|
|
271
268
|
onSizeUpdated,
|
|
272
|
-
|
|
269
|
+
onItemSizeChanged,
|
|
273
270
|
};
|
|
274
271
|
};
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* When scrolling fast, it cancels loads to prevent server overload.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type {
|
|
9
|
-
|
|
10
|
-
import { VIEWPORT_CONSTANTS } from "
|
|
8
|
+
import type { ItemRange } from "../types";
|
|
9
|
+
type ListManagerComponent = any;
|
|
10
|
+
import { VIEWPORT_CONSTANTS } from "../constants";
|
|
11
11
|
|
|
12
12
|
export interface LoadingConfig {
|
|
13
13
|
cancelLoadThreshold?: number; // Velocity (px/ms) above which all loads are cancelled
|
|
@@ -196,7 +196,7 @@ export const createLoadingManager = (
|
|
|
196
196
|
const collection = (component as any).collection;
|
|
197
197
|
if (collection && typeof collection.loadMissingRanges === "function") {
|
|
198
198
|
collection
|
|
199
|
-
.loadMissingRanges(range)
|
|
199
|
+
.loadMissingRanges(range, "loading:loadRange")
|
|
200
200
|
.then(() => {
|
|
201
201
|
activeRequests--;
|
|
202
202
|
activeRanges.delete(rangeKey);
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// src/core/viewport/features/momentum.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Momentum Feature - Adds inertial scrolling to viewport
|
|
5
|
+
* Provides smooth deceleration after touch/mouse drag gestures
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ViewportComponent, ViewportContext } from "../types";
|
|
9
|
+
import { VIEWPORT_CONSTANTS } from "../constants";
|
|
10
|
+
|
|
11
|
+
interface MomentumConfig {
|
|
12
|
+
enabled?: boolean;
|
|
13
|
+
deceleration?: number;
|
|
14
|
+
minVelocity?: number;
|
|
15
|
+
minDuration?: number;
|
|
16
|
+
minVelocityThreshold?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Adds momentum scrolling to viewport
|
|
21
|
+
*/
|
|
22
|
+
export const withMomentum = (config: MomentumConfig = {}) => {
|
|
23
|
+
return <T extends ViewportContext & ViewportComponent>(component: T): T => {
|
|
24
|
+
const {
|
|
25
|
+
enabled = VIEWPORT_CONSTANTS.MOMENTUM.ENABLED,
|
|
26
|
+
deceleration = VIEWPORT_CONSTANTS.MOMENTUM.DECELERATION_FACTOR,
|
|
27
|
+
minVelocity = VIEWPORT_CONSTANTS.MOMENTUM.MIN_VELOCITY,
|
|
28
|
+
minDuration = VIEWPORT_CONSTANTS.MOMENTUM.MIN_DURATION,
|
|
29
|
+
minVelocityThreshold = VIEWPORT_CONSTANTS.MOMENTUM.MIN_VELOCITY_THRESHOLD,
|
|
30
|
+
} = config;
|
|
31
|
+
|
|
32
|
+
// Always apply the feature, but check isEnabled at runtime
|
|
33
|
+
|
|
34
|
+
// Momentum state
|
|
35
|
+
let momentumAnimationId: number | null = null;
|
|
36
|
+
let isTouching = false;
|
|
37
|
+
let isMouseDragging = false;
|
|
38
|
+
let lastTouchPosition = 0;
|
|
39
|
+
let lastMousePosition = 0;
|
|
40
|
+
let touchStartTime = 0;
|
|
41
|
+
let viewportState: any;
|
|
42
|
+
let scrollingState: any;
|
|
43
|
+
let isEnabled = enabled; // Track current enabled state
|
|
44
|
+
|
|
45
|
+
// Get references after initialization
|
|
46
|
+
const originalInitialize = component.viewport.initialize;
|
|
47
|
+
component.viewport.initialize = () => {
|
|
48
|
+
const result = originalInitialize();
|
|
49
|
+
// Skip if already initialized
|
|
50
|
+
if (result === false) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Get viewport and scrolling states
|
|
55
|
+
viewportState = (component.viewport as any).state;
|
|
56
|
+
scrollingState = (component.viewport as any).scrollingState;
|
|
57
|
+
return result;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Start momentum animation
|
|
61
|
+
const startMomentum = (initialVelocity: number) => {
|
|
62
|
+
// Cancel any existing momentum
|
|
63
|
+
if (momentumAnimationId) {
|
|
64
|
+
cancelAnimationFrame(momentumAnimationId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let velocity = initialVelocity;
|
|
68
|
+
|
|
69
|
+
const animate = () => {
|
|
70
|
+
// Apply deceleration
|
|
71
|
+
velocity *= deceleration;
|
|
72
|
+
|
|
73
|
+
// Stop if velocity is too small
|
|
74
|
+
if (Math.abs(velocity) < minVelocity) {
|
|
75
|
+
momentumAnimationId = null;
|
|
76
|
+
// Reset velocity in scrolling feature
|
|
77
|
+
if (scrollingState) {
|
|
78
|
+
scrollingState.setVelocityToZero?.();
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Calculate scroll delta (velocity is in px/ms, so multiply by frame time)
|
|
84
|
+
const frameDelta = velocity * VIEWPORT_CONSTANTS.MOMENTUM.FRAME_TIME;
|
|
85
|
+
|
|
86
|
+
// Use scrolling feature's API to update position
|
|
87
|
+
if (component.viewport.scrollBy) {
|
|
88
|
+
component.viewport.scrollBy(frameDelta);
|
|
89
|
+
|
|
90
|
+
// Continue animation
|
|
91
|
+
momentumAnimationId = requestAnimationFrame(animate);
|
|
92
|
+
} else {
|
|
93
|
+
// No scrollBy method, stop momentum
|
|
94
|
+
momentumAnimationId = null;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
momentumAnimationId = requestAnimationFrame(animate);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Touch event handlers
|
|
102
|
+
const handleTouchStart = (e: TouchEvent) => {
|
|
103
|
+
isTouching = true;
|
|
104
|
+
const touch = e.touches[0];
|
|
105
|
+
const orientation = viewportState?.orientation || "vertical";
|
|
106
|
+
lastTouchPosition =
|
|
107
|
+
orientation === "vertical" ? touch.clientY : touch.clientX;
|
|
108
|
+
touchStartTime = Date.now();
|
|
109
|
+
|
|
110
|
+
// Cancel any ongoing momentum
|
|
111
|
+
if (momentumAnimationId) {
|
|
112
|
+
cancelAnimationFrame(momentumAnimationId);
|
|
113
|
+
momentumAnimationId = null;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const handleTouchMove = (e: TouchEvent) => {
|
|
118
|
+
if (!isTouching) return;
|
|
119
|
+
|
|
120
|
+
e.preventDefault(); // Prevent native scrolling
|
|
121
|
+
|
|
122
|
+
const touch = e.touches[0];
|
|
123
|
+
const orientation = viewportState?.orientation || "vertical";
|
|
124
|
+
const currentPosition =
|
|
125
|
+
orientation === "vertical" ? touch.clientY : touch.clientX;
|
|
126
|
+
const delta = lastTouchPosition - currentPosition; // Inverted for natural scrolling
|
|
127
|
+
lastTouchPosition = currentPosition;
|
|
128
|
+
|
|
129
|
+
// Use scrolling feature's API
|
|
130
|
+
if (component.viewport.scrollBy) {
|
|
131
|
+
component.viewport.scrollBy(delta);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const handleTouchEnd = () => {
|
|
136
|
+
if (!isTouching) return;
|
|
137
|
+
isTouching = false;
|
|
138
|
+
|
|
139
|
+
const touchEndTime = Date.now();
|
|
140
|
+
const touchDuration = touchEndTime - touchStartTime;
|
|
141
|
+
|
|
142
|
+
// Get current velocity from scrolling feature
|
|
143
|
+
const velocity = component.viewport.getVelocity?.() || 0;
|
|
144
|
+
|
|
145
|
+
// Only start momentum if the touch was quick enough and we have velocity
|
|
146
|
+
if (
|
|
147
|
+
touchDuration < minDuration &&
|
|
148
|
+
Math.abs(velocity) > minVelocityThreshold
|
|
149
|
+
) {
|
|
150
|
+
startMomentum(velocity);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Mouse event handlers for desktop
|
|
155
|
+
const handleMouseDown = (e: MouseEvent) => {
|
|
156
|
+
isMouseDragging = true;
|
|
157
|
+
const orientation = viewportState?.orientation || "vertical";
|
|
158
|
+
lastMousePosition = orientation === "vertical" ? e.clientY : e.clientX;
|
|
159
|
+
touchStartTime = Date.now();
|
|
160
|
+
|
|
161
|
+
// Cancel any ongoing momentum
|
|
162
|
+
if (momentumAnimationId) {
|
|
163
|
+
cancelAnimationFrame(momentumAnimationId);
|
|
164
|
+
momentumAnimationId = null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Prevent text selection
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
172
|
+
if (!isMouseDragging) return;
|
|
173
|
+
|
|
174
|
+
const orientation = viewportState?.orientation || "vertical";
|
|
175
|
+
const currentPosition =
|
|
176
|
+
orientation === "vertical" ? e.clientY : e.clientX;
|
|
177
|
+
const delta = lastMousePosition - currentPosition;
|
|
178
|
+
lastMousePosition = currentPosition;
|
|
179
|
+
|
|
180
|
+
// Use scrolling feature's API
|
|
181
|
+
if (component.viewport.scrollBy) {
|
|
182
|
+
component.viewport.scrollBy(delta);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const handleMouseUp = () => {
|
|
187
|
+
if (!isMouseDragging) return;
|
|
188
|
+
isMouseDragging = false;
|
|
189
|
+
|
|
190
|
+
const mouseUpTime = Date.now();
|
|
191
|
+
const dragDuration = mouseUpTime - touchStartTime;
|
|
192
|
+
|
|
193
|
+
// Get current velocity from scrolling feature
|
|
194
|
+
const velocity = component.viewport.getVelocity?.() || 0;
|
|
195
|
+
|
|
196
|
+
// Start momentum for quick drags
|
|
197
|
+
if (
|
|
198
|
+
dragDuration < minDuration &&
|
|
199
|
+
Math.abs(velocity) > minVelocityThreshold
|
|
200
|
+
) {
|
|
201
|
+
startMomentum(velocity);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Override initialize to add event listeners
|
|
206
|
+
const originalInit = component.viewport.initialize;
|
|
207
|
+
component.viewport.initialize = () => {
|
|
208
|
+
const result = originalInit();
|
|
209
|
+
// Skip if already initialized
|
|
210
|
+
if (result === false) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const viewportElement =
|
|
215
|
+
(component as any).viewportElement ||
|
|
216
|
+
(component.viewport as any).state?.viewportElement;
|
|
217
|
+
|
|
218
|
+
if (viewportElement) {
|
|
219
|
+
// Touch events
|
|
220
|
+
viewportElement.addEventListener("touchstart", handleTouchStart, {
|
|
221
|
+
passive: true,
|
|
222
|
+
});
|
|
223
|
+
viewportElement.addEventListener("touchmove", handleTouchMove, {
|
|
224
|
+
passive: false,
|
|
225
|
+
});
|
|
226
|
+
viewportElement.addEventListener("touchend", handleTouchEnd, {
|
|
227
|
+
passive: true,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Mouse events for desktop dragging
|
|
231
|
+
viewportElement.addEventListener("mousedown", handleMouseDown);
|
|
232
|
+
viewportElement.addEventListener("mousemove", handleMouseMove);
|
|
233
|
+
viewportElement.addEventListener("mouseup", handleMouseUp);
|
|
234
|
+
viewportElement.addEventListener("mouseleave", handleMouseUp);
|
|
235
|
+
|
|
236
|
+
// Store reference for cleanup
|
|
237
|
+
(component as any)._momentumViewportElement = viewportElement;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// Clean up on destroy
|
|
242
|
+
if ("destroy" in component && typeof component.destroy === "function") {
|
|
243
|
+
const originalDestroy = component.destroy;
|
|
244
|
+
component.destroy = () => {
|
|
245
|
+
// Remove event listeners
|
|
246
|
+
const viewportElement = (component as any)._momentumViewportElement;
|
|
247
|
+
if (viewportElement) {
|
|
248
|
+
viewportElement.removeEventListener("touchstart", handleTouchStart);
|
|
249
|
+
viewportElement.removeEventListener("touchmove", handleTouchMove);
|
|
250
|
+
viewportElement.removeEventListener("touchend", handleTouchEnd);
|
|
251
|
+
viewportElement.removeEventListener("mousedown", handleMouseDown);
|
|
252
|
+
viewportElement.removeEventListener("mousemove", handleMouseMove);
|
|
253
|
+
viewportElement.removeEventListener("mouseup", handleMouseUp);
|
|
254
|
+
viewportElement.removeEventListener("mouseleave", handleMouseUp);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Cancel momentum animation
|
|
258
|
+
if (momentumAnimationId) {
|
|
259
|
+
cancelAnimationFrame(momentumAnimationId);
|
|
260
|
+
momentumAnimationId = null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
originalDestroy();
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return component;
|
|
268
|
+
};
|
|
269
|
+
};
|