@djangocfg/ui-tools 2.1.110 → 2.1.112

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.
Files changed (159) hide show
  1. package/README.md +242 -49
  2. package/dist/JsonSchemaForm-65NLLK56.mjs +4 -0
  3. package/dist/JsonSchemaForm-65NLLK56.mjs.map +1 -0
  4. package/dist/JsonSchemaForm-PY6DH3HE.cjs +13 -0
  5. package/dist/JsonSchemaForm-PY6DH3HE.cjs.map +1 -0
  6. package/dist/JsonTree-6RYAOPSS.mjs +4 -0
  7. package/dist/JsonTree-6RYAOPSS.mjs.map +1 -0
  8. package/dist/JsonTree-7OH6CIHT.cjs +10 -0
  9. package/dist/JsonTree-7OH6CIHT.cjs.map +1 -0
  10. package/dist/MapContainer-GXQLP5WY.mjs +214 -0
  11. package/dist/MapContainer-GXQLP5WY.mjs.map +1 -0
  12. package/dist/MapContainer-RYG4HPH4.cjs +221 -0
  13. package/dist/MapContainer-RYG4HPH4.cjs.map +1 -0
  14. package/dist/{Mermaid.client-4OCKJ6QD.mjs → Mermaid.client-OKACITCW.mjs} +16 -7
  15. package/dist/Mermaid.client-OKACITCW.mjs.map +1 -0
  16. package/dist/{Mermaid.client-ZP6OE46Z.cjs → Mermaid.client-PNXEC6YL.cjs} +16 -7
  17. package/dist/Mermaid.client-PNXEC6YL.cjs.map +1 -0
  18. package/dist/{PlaygroundLayout-XXVBU4WZ.cjs → PlaygroundLayout-SYMEAG3J.cjs} +25 -24
  19. package/dist/PlaygroundLayout-SYMEAG3J.cjs.map +1 -0
  20. package/dist/{PlaygroundLayout-LMQTVXSP.mjs → PlaygroundLayout-UQRBU5RH.mjs} +4 -3
  21. package/dist/PlaygroundLayout-UQRBU5RH.mjs.map +1 -0
  22. package/dist/{PrettyCode.client-2CLSV2VD.cjs → PrettyCode.client-DANYYQYO.cjs} +11 -4
  23. package/dist/PrettyCode.client-DANYYQYO.cjs.map +1 -0
  24. package/dist/{PrettyCode.client-Y2BVON7R.mjs → PrettyCode.client-RS5ZTNBT.mjs} +11 -4
  25. package/dist/PrettyCode.client-RS5ZTNBT.mjs.map +1 -0
  26. package/dist/chunk-2DSR7V2L.mjs +561 -0
  27. package/dist/chunk-2DSR7V2L.mjs.map +1 -0
  28. package/dist/chunk-47T5ECYV.cjs +1357 -0
  29. package/dist/chunk-47T5ECYV.cjs.map +1 -0
  30. package/dist/chunk-5QT3QYFZ.cjs +189 -0
  31. package/dist/chunk-5QT3QYFZ.cjs.map +1 -0
  32. package/dist/chunk-7IIRYG4S.mjs +1057 -0
  33. package/dist/chunk-7IIRYG4S.mjs.map +1 -0
  34. package/dist/{chunk-FB5QBSI3.cjs → chunk-DI3HUXHK.cjs} +15 -195
  35. package/dist/chunk-DI3HUXHK.cjs.map +1 -0
  36. package/dist/chunk-EVGWYASL.cjs +1528 -0
  37. package/dist/chunk-EVGWYASL.cjs.map +1 -0
  38. package/dist/chunk-F2N7P5XU.cjs +30 -0
  39. package/dist/chunk-F2N7P5XU.cjs.map +1 -0
  40. package/dist/{chunk-L6UHASYQ.mjs → chunk-G6PRZP5I.mjs} +7 -186
  41. package/dist/chunk-G6PRZP5I.mjs.map +1 -0
  42. package/dist/chunk-JWB2EWQO.mjs +5 -0
  43. package/dist/chunk-JWB2EWQO.mjs.map +1 -0
  44. package/dist/chunk-LTJX2JXE.mjs +338 -0
  45. package/dist/chunk-LTJX2JXE.mjs.map +1 -0
  46. package/dist/chunk-OVNC4KW6.mjs +1494 -0
  47. package/dist/chunk-OVNC4KW6.mjs.map +1 -0
  48. package/dist/chunk-PNZSJN6T.cjs +1086 -0
  49. package/dist/chunk-PNZSJN6T.cjs.map +1 -0
  50. package/dist/chunk-TEFRA7GW.cjs +565 -0
  51. package/dist/chunk-TEFRA7GW.cjs.map +1 -0
  52. package/dist/chunk-UOMPPIED.mjs +1343 -0
  53. package/dist/chunk-UOMPPIED.mjs.map +1 -0
  54. package/dist/chunk-W6YHQI4F.mjs +187 -0
  55. package/dist/chunk-W6YHQI4F.mjs.map +1 -0
  56. package/dist/chunk-XTBRWVIV.cjs +346 -0
  57. package/dist/chunk-XTBRWVIV.cjs.map +1 -0
  58. package/dist/components-C7ZL7OMY.mjs +5 -0
  59. package/dist/components-C7ZL7OMY.mjs.map +1 -0
  60. package/dist/components-CJ2IB65O.cjs +27 -0
  61. package/dist/components-CJ2IB65O.cjs.map +1 -0
  62. package/dist/components-EASJYK45.mjs +6 -0
  63. package/dist/components-EASJYK45.mjs.map +1 -0
  64. package/dist/components-LDRFDV4A.cjs +22 -0
  65. package/dist/components-LDRFDV4A.cjs.map +1 -0
  66. package/dist/components-VZKUTDJK.mjs +5 -0
  67. package/dist/components-VZKUTDJK.mjs.map +1 -0
  68. package/dist/components-Y64GTIMQ.cjs +42 -0
  69. package/dist/components-Y64GTIMQ.cjs.map +1 -0
  70. package/dist/index.cjs +701 -4813
  71. package/dist/index.cjs.map +1 -1
  72. package/dist/index.d.cts +1274 -1026
  73. package/dist/index.d.ts +1274 -1026
  74. package/dist/index.mjs +358 -4730
  75. package/dist/index.mjs.map +1 -1
  76. package/package.json +27 -4
  77. package/src/components/index.ts +17 -0
  78. package/src/components/lazy-wrapper.tsx +281 -0
  79. package/src/index.ts +92 -7
  80. package/src/tools/AudioPlayer/components/HybridAudioPlayer.tsx +14 -5
  81. package/src/tools/AudioPlayer/lazy.tsx +85 -0
  82. package/src/tools/Gallery/components/Gallery.tsx +182 -0
  83. package/src/tools/Gallery/components/GalleryCarousel.tsx +251 -0
  84. package/src/tools/Gallery/components/GalleryCompact.tsx +173 -0
  85. package/src/tools/Gallery/components/GalleryGrid.tsx +493 -0
  86. package/src/tools/Gallery/components/GalleryImage.tsx +66 -0
  87. package/src/tools/Gallery/components/GalleryLightbox.tsx +331 -0
  88. package/src/tools/Gallery/components/GalleryMedia.tsx +66 -0
  89. package/src/tools/Gallery/components/GalleryThumbnails.tsx +173 -0
  90. package/src/tools/Gallery/components/GalleryThumbnailsVirtual.tsx +138 -0
  91. package/src/tools/Gallery/components/GalleryVideo.tsx +222 -0
  92. package/src/tools/Gallery/components/index.ts +13 -0
  93. package/src/tools/Gallery/hooks/index.ts +23 -0
  94. package/src/tools/Gallery/hooks/useGallery.ts +137 -0
  95. package/src/tools/Gallery/hooks/useImageDimensions.ts +223 -0
  96. package/src/tools/Gallery/hooks/usePinchZoom.ts +234 -0
  97. package/src/tools/Gallery/hooks/usePreloadImages.ts +71 -0
  98. package/src/tools/Gallery/hooks/useSwipe.ts +86 -0
  99. package/src/tools/Gallery/hooks/useVirtualList.ts +129 -0
  100. package/src/tools/Gallery/hooks/useZoom.ts +316 -0
  101. package/src/tools/Gallery/index.ts +66 -0
  102. package/src/tools/Gallery/types.ts +183 -0
  103. package/src/tools/Gallery/utils/imageAnalysis.ts +52 -0
  104. package/src/tools/Gallery/utils/index.ts +11 -0
  105. package/src/tools/ImageViewer/components/ImageToolbar.tsx +20 -8
  106. package/src/tools/ImageViewer/components/ImageViewer.tsx +12 -4
  107. package/src/tools/ImageViewer/lazy.tsx +37 -0
  108. package/src/tools/JsonForm/lazy.tsx +43 -0
  109. package/src/tools/JsonForm/widgets/ColorWidget.tsx +4 -1
  110. package/src/tools/JsonTree/lazy.tsx +45 -0
  111. package/src/tools/LottiePlayer/lazy.tsx +57 -0
  112. package/src/tools/Map/components/CustomOverlay.tsx +54 -0
  113. package/src/tools/Map/components/DrawControl.tsx +36 -0
  114. package/src/tools/Map/components/GeocoderControl.tsx +70 -0
  115. package/src/tools/Map/components/LayerSwitcher.tsx +225 -0
  116. package/src/tools/Map/components/MapCluster.tsx +273 -0
  117. package/src/tools/Map/components/MapContainer.tsx +191 -0
  118. package/src/tools/Map/components/MapControls.tsx +44 -0
  119. package/src/tools/Map/components/MapLegend.tsx +161 -0
  120. package/src/tools/Map/components/MapMarker.tsx +102 -0
  121. package/src/tools/Map/components/MapPopup.tsx +46 -0
  122. package/src/tools/Map/components/MapSource.tsx +30 -0
  123. package/src/tools/Map/components/index.ts +20 -0
  124. package/src/tools/Map/context/MapContext.tsx +89 -0
  125. package/src/tools/Map/context/index.ts +2 -0
  126. package/src/tools/Map/hooks/index.ts +9 -0
  127. package/src/tools/Map/hooks/useMap.ts +11 -0
  128. package/src/tools/Map/hooks/useMapControl.ts +99 -0
  129. package/src/tools/Map/hooks/useMapEvents.ts +147 -0
  130. package/src/tools/Map/hooks/useMapLayers.ts +83 -0
  131. package/src/tools/Map/hooks/useMapViewport.ts +62 -0
  132. package/src/tools/Map/hooks/useMarkers.ts +85 -0
  133. package/src/tools/Map/index.ts +116 -0
  134. package/src/tools/Map/layers/cluster.ts +94 -0
  135. package/src/tools/Map/layers/index.ts +15 -0
  136. package/src/tools/Map/layers/line.ts +93 -0
  137. package/src/tools/Map/layers/point.ts +61 -0
  138. package/src/tools/Map/layers/polygon.ts +73 -0
  139. package/src/tools/Map/lazy.tsx +56 -0
  140. package/src/tools/Map/styles/index.ts +15 -0
  141. package/src/tools/Map/types.ts +259 -0
  142. package/src/tools/Map/utils/geo.ts +88 -0
  143. package/src/tools/Map/utils/index.ts +16 -0
  144. package/src/tools/Map/utils/transform.ts +107 -0
  145. package/src/tools/Mermaid/Mermaid.client.tsx +12 -4
  146. package/src/tools/Mermaid/components/MermaidFullscreenModal.tsx +6 -2
  147. package/src/tools/Mermaid/lazy.tsx +46 -0
  148. package/src/tools/OpenapiViewer/lazy.tsx +72 -0
  149. package/src/tools/PrettyCode/PrettyCode.client.tsx +10 -3
  150. package/src/tools/PrettyCode/lazy.tsx +64 -0
  151. package/src/tools/VideoPlayer/lazy.tsx +63 -0
  152. package/dist/Mermaid.client-4OCKJ6QD.mjs.map +0 -1
  153. package/dist/Mermaid.client-ZP6OE46Z.cjs.map +0 -1
  154. package/dist/PlaygroundLayout-LMQTVXSP.mjs.map +0 -1
  155. package/dist/PlaygroundLayout-XXVBU4WZ.cjs.map +0 -1
  156. package/dist/PrettyCode.client-2CLSV2VD.cjs.map +0 -1
  157. package/dist/PrettyCode.client-Y2BVON7R.mjs.map +0 -1
  158. package/dist/chunk-FB5QBSI3.cjs.map +0 -1
  159. package/dist/chunk-L6UHASYQ.mjs.map +0 -1
@@ -0,0 +1,234 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useRef, useState } from 'react'
4
+
5
+ export interface PinchZoomState {
6
+ scale: number
7
+ x: number
8
+ y: number
9
+ }
10
+
11
+ export interface UsePinchZoomOptions {
12
+ minScale?: number
13
+ maxScale?: number
14
+ onZoomChange?: (zoomed: boolean) => void
15
+ }
16
+
17
+ export interface UsePinchZoomReturn {
18
+ state: PinchZoomState
19
+ isZoomed: boolean
20
+ handlers: {
21
+ onTouchStart: (e: React.TouchEvent) => void
22
+ onTouchMove: (e: React.TouchEvent) => void
23
+ onTouchEnd: (e: React.TouchEvent) => void
24
+ }
25
+ reset: () => void
26
+ zoomTo: (scale: number, x?: number, y?: number) => void
27
+ }
28
+
29
+ const INITIAL_STATE: PinchZoomState = { scale: 1, x: 0, y: 0 }
30
+
31
+ /**
32
+ * Hook for pinch-to-zoom functionality on touch devices
33
+ */
34
+ export function usePinchZoom(options: UsePinchZoomOptions = {}): UsePinchZoomReturn {
35
+ const { minScale = 1, maxScale = 3, onZoomChange } = options
36
+
37
+ const [state, setState] = useState<PinchZoomState>(INITIAL_STATE)
38
+
39
+ // Track touch state and container size
40
+ const touchRef = useRef<{
41
+ initialDistance: number
42
+ initialScale: number
43
+ initialX: number
44
+ initialY: number
45
+ centerX: number
46
+ centerY: number
47
+ isPinching: boolean
48
+ lastTap: number
49
+ containerWidth: number
50
+ containerHeight: number
51
+ }>({
52
+ initialDistance: 0,
53
+ initialScale: 1,
54
+ initialX: 0,
55
+ initialY: 0,
56
+ centerX: 0,
57
+ centerY: 0,
58
+ isPinching: false,
59
+ lastTap: 0,
60
+ containerWidth: 0,
61
+ containerHeight: 0,
62
+ })
63
+
64
+ const isZoomed = state.scale > 1.05
65
+
66
+ // Calculate distance between two touch points
67
+ const getDistance = useCallback((touches: React.TouchList): number => {
68
+ if (touches.length < 2) return 0
69
+ const dx = touches[0].clientX - touches[1].clientX
70
+ const dy = touches[0].clientY - touches[1].clientY
71
+ return Math.sqrt(dx * dx + dy * dy)
72
+ }, [])
73
+
74
+ // Get center point between two touches
75
+ const getCenter = useCallback((touches: React.TouchList): { x: number; y: number } => {
76
+ if (touches.length < 2) {
77
+ return { x: touches[0].clientX, y: touches[0].clientY }
78
+ }
79
+ return {
80
+ x: (touches[0].clientX + touches[1].clientX) / 2,
81
+ y: (touches[0].clientY + touches[1].clientY) / 2,
82
+ }
83
+ }, [])
84
+
85
+ // Calculate max pan based on container size and scale
86
+ const getMaxPan = useCallback((scale: number) => {
87
+ const { containerWidth, containerHeight } = touchRef.current
88
+ // When zoomed, allow panning up to half the "extra" size
89
+ // At 2x zoom on 400px container: can pan 200px in each direction
90
+ return {
91
+ x: (containerWidth * (scale - 1)) / 2,
92
+ y: (containerHeight * (scale - 1)) / 2,
93
+ }
94
+ }, [])
95
+
96
+ const handleTouchStart = useCallback((e: React.TouchEvent) => {
97
+ const touches = e.touches
98
+
99
+ // Store container dimensions from the element
100
+ const rect = e.currentTarget.getBoundingClientRect()
101
+ touchRef.current.containerWidth = rect.width
102
+ touchRef.current.containerHeight = rect.height
103
+
104
+ // Double tap to zoom
105
+ if (touches.length === 1) {
106
+ const now = Date.now()
107
+ const timeSinceLastTap = now - touchRef.current.lastTap
108
+
109
+ if (timeSinceLastTap < 300) {
110
+ // Double tap detected
111
+ e.preventDefault()
112
+ if (isZoomed) {
113
+ setState(INITIAL_STATE)
114
+ onZoomChange?.(false)
115
+ } else {
116
+ const x = touches[0].clientX - rect.left - rect.width / 2
117
+ const y = touches[0].clientY - rect.top - rect.height / 2
118
+ setState({ scale: 2, x: -x * 0.5, y: -y * 0.5 })
119
+ onZoomChange?.(true)
120
+ }
121
+ touchRef.current.lastTap = 0
122
+ return
123
+ }
124
+
125
+ touchRef.current.lastTap = now
126
+
127
+ // Single finger - start pan (if zoomed)
128
+ if (isZoomed) {
129
+ touchRef.current.initialX = touches[0].clientX - state.x
130
+ touchRef.current.initialY = touches[0].clientY - state.y
131
+ }
132
+ }
133
+
134
+ // Two finger pinch
135
+ if (touches.length === 2) {
136
+ e.preventDefault()
137
+ touchRef.current.isPinching = true
138
+ touchRef.current.initialDistance = getDistance(touches)
139
+ touchRef.current.initialScale = state.scale
140
+
141
+ const center = getCenter(touches)
142
+ touchRef.current.centerX = center.x
143
+ touchRef.current.centerY = center.y
144
+ touchRef.current.initialX = state.x
145
+ touchRef.current.initialY = state.y
146
+ }
147
+ }, [isZoomed, state, getDistance, getCenter, onZoomChange])
148
+
149
+ const handleTouchMove = useCallback((e: React.TouchEvent) => {
150
+ const touches = e.touches
151
+
152
+ // Pinch zoom
153
+ if (touches.length === 2 && touchRef.current.isPinching) {
154
+ e.preventDefault()
155
+
156
+ const currentDistance = getDistance(touches)
157
+ const scaleDelta = currentDistance / touchRef.current.initialDistance
158
+ let newScale = touchRef.current.initialScale * scaleDelta
159
+
160
+ // Clamp scale
161
+ newScale = Math.max(minScale, Math.min(maxScale, newScale))
162
+
163
+ // Calculate new position to zoom toward center
164
+ const center = getCenter(touches)
165
+ const dx = center.x - touchRef.current.centerX
166
+ const dy = center.y - touchRef.current.centerY
167
+
168
+ // Apply pan limits
169
+ const maxPan = getMaxPan(newScale)
170
+ const newX = Math.max(-maxPan.x, Math.min(maxPan.x, touchRef.current.initialX + dx))
171
+ const newY = Math.max(-maxPan.y, Math.min(maxPan.y, touchRef.current.initialY + dy))
172
+
173
+ setState({
174
+ scale: newScale,
175
+ x: newX,
176
+ y: newY,
177
+ })
178
+
179
+ if (newScale > 1.05 !== isZoomed) {
180
+ onZoomChange?.(newScale > 1.05)
181
+ }
182
+ }
183
+
184
+ // Pan when zoomed (single finger)
185
+ if (touches.length === 1 && isZoomed && !touchRef.current.isPinching) {
186
+ e.preventDefault()
187
+
188
+ const maxPan = getMaxPan(state.scale)
189
+ const newX = touches[0].clientX - touchRef.current.initialX
190
+ const newY = touches[0].clientY - touchRef.current.initialY
191
+
192
+ setState((prev) => ({
193
+ ...prev,
194
+ x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),
195
+ y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),
196
+ }))
197
+ }
198
+ }, [isZoomed, state.scale, minScale, maxScale, getDistance, getCenter, getMaxPan, onZoomChange])
199
+
200
+ const handleTouchEnd = useCallback((e: React.TouchEvent) => {
201
+ if (e.touches.length < 2) {
202
+ touchRef.current.isPinching = false
203
+ }
204
+
205
+ // Snap to 1 if close
206
+ if (state.scale < 1.1 && state.scale > 0.9) {
207
+ setState(INITIAL_STATE)
208
+ onZoomChange?.(false)
209
+ }
210
+ }, [state.scale, onZoomChange])
211
+
212
+ const reset = useCallback(() => {
213
+ setState(INITIAL_STATE)
214
+ onZoomChange?.(false)
215
+ }, [onZoomChange])
216
+
217
+ const zoomTo = useCallback((scale: number, x = 0, y = 0) => {
218
+ const clampedScale = Math.max(minScale, Math.min(maxScale, scale))
219
+ setState({ scale: clampedScale, x, y })
220
+ onZoomChange?.(clampedScale > 1.05)
221
+ }, [minScale, maxScale, onZoomChange])
222
+
223
+ return {
224
+ state,
225
+ isZoomed,
226
+ handlers: {
227
+ onTouchStart: handleTouchStart,
228
+ onTouchMove: handleTouchMove,
229
+ onTouchEnd: handleTouchEnd,
230
+ },
231
+ reset,
232
+ zoomTo,
233
+ }
234
+ }
@@ -0,0 +1,71 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+ import type { GalleryMediaItem } from '../types'
5
+
6
+ /**
7
+ * Preload adjacent images for smoother navigation
8
+ * Preloads prev and next images when current index changes
9
+ */
10
+ export function usePreloadImages(
11
+ images: GalleryMediaItem[],
12
+ currentIndex: number,
13
+ preloadCount: number = 1
14
+ ): void {
15
+ const preloadedRef = useRef<Set<string>>(new Set())
16
+
17
+ useEffect(() => {
18
+ if (images.length === 0) return
19
+
20
+ const toPreload: string[] = []
21
+
22
+ // Collect indices to preload (current + adjacent)
23
+ for (let offset = -preloadCount; offset <= preloadCount; offset++) {
24
+ if (offset === 0) continue // Skip current, it's already loading
25
+
26
+ const index = (currentIndex + offset + images.length) % images.length
27
+ const image = images[index]
28
+
29
+ if (image && !preloadedRef.current.has(image.src)) {
30
+ toPreload.push(image.src)
31
+ }
32
+ }
33
+
34
+ // Preload images
35
+ toPreload.forEach((src) => {
36
+ const img = new Image()
37
+ img.src = src
38
+ preloadedRef.current.add(src)
39
+ })
40
+ }, [images, currentIndex, preloadCount])
41
+ }
42
+
43
+ /**
44
+ * Preload specific image URLs
45
+ */
46
+ export function preloadImage(src: string): Promise<void> {
47
+ return new Promise((resolve, reject) => {
48
+ const img = new Image()
49
+ img.onload = () => resolve()
50
+ img.onerror = reject
51
+ img.src = src
52
+ })
53
+ }
54
+
55
+ /**
56
+ * Preload multiple images with optional concurrency limit
57
+ */
58
+ export async function preloadImages(
59
+ urls: string[],
60
+ concurrency: number = 3
61
+ ): Promise<void> {
62
+ const chunks: string[][] = []
63
+
64
+ for (let i = 0; i < urls.length; i += concurrency) {
65
+ chunks.push(urls.slice(i, i + concurrency))
66
+ }
67
+
68
+ for (const chunk of chunks) {
69
+ await Promise.allSettled(chunk.map(preloadImage))
70
+ }
71
+ }
@@ -0,0 +1,86 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback } from 'react'
4
+ import type { TouchEvent } from 'react'
5
+
6
+ export interface UseSwipeOptions {
7
+ /** Minimum swipe distance in pixels */
8
+ minDistance?: number
9
+ /** Callback when swiped left */
10
+ onSwipeLeft?: () => void
11
+ /** Callback when swiped right */
12
+ onSwipeRight?: () => void
13
+ /** Callback when swiped up */
14
+ onSwipeUp?: () => void
15
+ /** Callback when swiped down */
16
+ onSwipeDown?: () => void
17
+ }
18
+
19
+ interface TouchPosition {
20
+ x: number
21
+ y: number
22
+ }
23
+
24
+ /**
25
+ * Hook for handling swipe gestures
26
+ */
27
+ export function useSwipe({
28
+ minDistance = 50,
29
+ onSwipeLeft,
30
+ onSwipeRight,
31
+ onSwipeUp,
32
+ onSwipeDown,
33
+ }: UseSwipeOptions = {}) {
34
+ const [touchStart, setTouchStart] = useState<TouchPosition | null>(null)
35
+ const [touchEnd, setTouchEnd] = useState<TouchPosition | null>(null)
36
+
37
+ const handleTouchStart = useCallback((e: TouchEvent) => {
38
+ setTouchEnd(null)
39
+ const touch = e.targetTouches[0]
40
+ if (touch) {
41
+ setTouchStart({ x: touch.clientX, y: touch.clientY })
42
+ }
43
+ }, [])
44
+
45
+ const handleTouchMove = useCallback((e: TouchEvent) => {
46
+ const touch = e.targetTouches[0]
47
+ if (touch) {
48
+ setTouchEnd({ x: touch.clientX, y: touch.clientY })
49
+ }
50
+ }, [])
51
+
52
+ const handleTouchEnd = useCallback(() => {
53
+ if (!touchStart || !touchEnd) return
54
+
55
+ const deltaX = touchStart.x - touchEnd.x
56
+ const deltaY = touchStart.y - touchEnd.y
57
+ const absX = Math.abs(deltaX)
58
+ const absY = Math.abs(deltaY)
59
+
60
+ // Determine if horizontal or vertical swipe
61
+ if (absX > absY && absX > minDistance) {
62
+ // Horizontal swipe
63
+ if (deltaX > 0) {
64
+ onSwipeLeft?.()
65
+ } else {
66
+ onSwipeRight?.()
67
+ }
68
+ } else if (absY > absX && absY > minDistance) {
69
+ // Vertical swipe
70
+ if (deltaY > 0) {
71
+ onSwipeUp?.()
72
+ } else {
73
+ onSwipeDown?.()
74
+ }
75
+ }
76
+
77
+ setTouchStart(null)
78
+ setTouchEnd(null)
79
+ }, [touchStart, touchEnd, minDistance, onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown])
80
+
81
+ return {
82
+ onTouchStart: handleTouchStart,
83
+ onTouchMove: handleTouchMove,
84
+ onTouchEnd: handleTouchEnd,
85
+ }
86
+ }
@@ -0,0 +1,129 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
4
+
5
+ export interface VirtualListOptions {
6
+ /** Total number of items */
7
+ itemCount: number
8
+ /** Width of each item in pixels */
9
+ itemWidth: number
10
+ /** Gap between items in pixels */
11
+ gap?: number
12
+ /** Number of items to render outside visible area */
13
+ overscan?: number
14
+ }
15
+
16
+ export interface VirtualListReturn {
17
+ /** Container ref to attach to scrollable element */
18
+ containerRef: React.RefObject<HTMLDivElement | null>
19
+ /** Total width of all items (for spacer) */
20
+ totalWidth: number
21
+ /** Visible items with their positions */
22
+ virtualItems: VirtualItem[]
23
+ /** Scroll to specific index */
24
+ scrollToIndex: (index: number, behavior?: ScrollBehavior) => void
25
+ }
26
+
27
+ export interface VirtualItem {
28
+ index: number
29
+ start: number
30
+ size: number
31
+ }
32
+
33
+ /**
34
+ * Hook for virtualizing horizontal list items
35
+ * Only renders items that are visible + overscan buffer
36
+ */
37
+ export function useVirtualList({
38
+ itemCount,
39
+ itemWidth,
40
+ gap = 8,
41
+ overscan = 3,
42
+ }: VirtualListOptions): VirtualListReturn {
43
+ const containerRef = useRef<HTMLDivElement>(null)
44
+ const [scrollLeft, setScrollLeft] = useState(0)
45
+ const [containerWidth, setContainerWidth] = useState(0)
46
+
47
+ // Calculate total width
48
+ const totalWidth = useMemo(() => {
49
+ if (itemCount === 0) return 0
50
+ return itemCount * itemWidth + (itemCount - 1) * gap
51
+ }, [itemCount, itemWidth, gap])
52
+
53
+ // Handle scroll
54
+ useEffect(() => {
55
+ const container = containerRef.current
56
+ if (!container) return
57
+
58
+ const handleScroll = () => {
59
+ setScrollLeft(container.scrollLeft)
60
+ }
61
+
62
+ const handleResize = () => {
63
+ setContainerWidth(container.clientWidth)
64
+ }
65
+
66
+ // Initial measurements
67
+ handleResize()
68
+ handleScroll()
69
+
70
+ container.addEventListener('scroll', handleScroll, { passive: true })
71
+
72
+ const resizeObserver = new ResizeObserver(handleResize)
73
+ resizeObserver.observe(container)
74
+
75
+ return () => {
76
+ container.removeEventListener('scroll', handleScroll)
77
+ resizeObserver.disconnect()
78
+ }
79
+ }, [])
80
+
81
+ // Calculate visible items
82
+ const virtualItems = useMemo((): VirtualItem[] => {
83
+ if (itemCount === 0 || containerWidth === 0) return []
84
+
85
+ const itemWithGap = itemWidth + gap
86
+
87
+ // Find start and end indices
88
+ const startIndex = Math.max(0, Math.floor(scrollLeft / itemWithGap) - overscan)
89
+ const endIndex = Math.min(
90
+ itemCount - 1,
91
+ Math.ceil((scrollLeft + containerWidth) / itemWithGap) + overscan
92
+ )
93
+
94
+ const items: VirtualItem[] = []
95
+ for (let i = startIndex; i <= endIndex; i++) {
96
+ items.push({
97
+ index: i,
98
+ start: i * itemWithGap,
99
+ size: itemWidth,
100
+ })
101
+ }
102
+
103
+ return items
104
+ }, [itemCount, itemWidth, gap, scrollLeft, containerWidth, overscan])
105
+
106
+ // Scroll to index
107
+ const scrollToIndex = useCallback(
108
+ (index: number, behavior: ScrollBehavior = 'smooth') => {
109
+ const container = containerRef.current
110
+ if (!container) return
111
+
112
+ const itemWithGap = itemWidth + gap
113
+ const targetScroll = index * itemWithGap - containerWidth / 2 + itemWidth / 2
114
+
115
+ container.scrollTo({
116
+ left: Math.max(0, targetScroll),
117
+ behavior,
118
+ })
119
+ },
120
+ [itemWidth, gap, containerWidth]
121
+ )
122
+
123
+ return {
124
+ containerRef,
125
+ totalWidth,
126
+ virtualItems,
127
+ scrollToIndex,
128
+ }
129
+ }