@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.
- package/README.md +242 -49
- package/dist/JsonSchemaForm-65NLLK56.mjs +4 -0
- package/dist/JsonSchemaForm-65NLLK56.mjs.map +1 -0
- package/dist/JsonSchemaForm-PY6DH3HE.cjs +13 -0
- package/dist/JsonSchemaForm-PY6DH3HE.cjs.map +1 -0
- package/dist/JsonTree-6RYAOPSS.mjs +4 -0
- package/dist/JsonTree-6RYAOPSS.mjs.map +1 -0
- package/dist/JsonTree-7OH6CIHT.cjs +10 -0
- package/dist/JsonTree-7OH6CIHT.cjs.map +1 -0
- package/dist/MapContainer-GXQLP5WY.mjs +214 -0
- package/dist/MapContainer-GXQLP5WY.mjs.map +1 -0
- package/dist/MapContainer-RYG4HPH4.cjs +221 -0
- package/dist/MapContainer-RYG4HPH4.cjs.map +1 -0
- package/dist/{Mermaid.client-4OCKJ6QD.mjs → Mermaid.client-OKACITCW.mjs} +16 -7
- package/dist/Mermaid.client-OKACITCW.mjs.map +1 -0
- package/dist/{Mermaid.client-ZP6OE46Z.cjs → Mermaid.client-PNXEC6YL.cjs} +16 -7
- package/dist/Mermaid.client-PNXEC6YL.cjs.map +1 -0
- package/dist/{PlaygroundLayout-XXVBU4WZ.cjs → PlaygroundLayout-SYMEAG3J.cjs} +25 -24
- package/dist/PlaygroundLayout-SYMEAG3J.cjs.map +1 -0
- package/dist/{PlaygroundLayout-LMQTVXSP.mjs → PlaygroundLayout-UQRBU5RH.mjs} +4 -3
- package/dist/PlaygroundLayout-UQRBU5RH.mjs.map +1 -0
- package/dist/{PrettyCode.client-2CLSV2VD.cjs → PrettyCode.client-DANYYQYO.cjs} +11 -4
- package/dist/PrettyCode.client-DANYYQYO.cjs.map +1 -0
- package/dist/{PrettyCode.client-Y2BVON7R.mjs → PrettyCode.client-RS5ZTNBT.mjs} +11 -4
- package/dist/PrettyCode.client-RS5ZTNBT.mjs.map +1 -0
- package/dist/chunk-2DSR7V2L.mjs +561 -0
- package/dist/chunk-2DSR7V2L.mjs.map +1 -0
- package/dist/chunk-47T5ECYV.cjs +1357 -0
- package/dist/chunk-47T5ECYV.cjs.map +1 -0
- package/dist/chunk-5QT3QYFZ.cjs +189 -0
- package/dist/chunk-5QT3QYFZ.cjs.map +1 -0
- package/dist/chunk-7IIRYG4S.mjs +1057 -0
- package/dist/chunk-7IIRYG4S.mjs.map +1 -0
- package/dist/{chunk-FB5QBSI3.cjs → chunk-DI3HUXHK.cjs} +15 -195
- package/dist/chunk-DI3HUXHK.cjs.map +1 -0
- package/dist/chunk-EVGWYASL.cjs +1528 -0
- package/dist/chunk-EVGWYASL.cjs.map +1 -0
- package/dist/chunk-F2N7P5XU.cjs +30 -0
- package/dist/chunk-F2N7P5XU.cjs.map +1 -0
- package/dist/{chunk-L6UHASYQ.mjs → chunk-G6PRZP5I.mjs} +7 -186
- package/dist/chunk-G6PRZP5I.mjs.map +1 -0
- package/dist/chunk-JWB2EWQO.mjs +5 -0
- package/dist/chunk-JWB2EWQO.mjs.map +1 -0
- package/dist/chunk-LTJX2JXE.mjs +338 -0
- package/dist/chunk-LTJX2JXE.mjs.map +1 -0
- package/dist/chunk-OVNC4KW6.mjs +1494 -0
- package/dist/chunk-OVNC4KW6.mjs.map +1 -0
- package/dist/chunk-PNZSJN6T.cjs +1086 -0
- package/dist/chunk-PNZSJN6T.cjs.map +1 -0
- package/dist/chunk-TEFRA7GW.cjs +565 -0
- package/dist/chunk-TEFRA7GW.cjs.map +1 -0
- package/dist/chunk-UOMPPIED.mjs +1343 -0
- package/dist/chunk-UOMPPIED.mjs.map +1 -0
- package/dist/chunk-W6YHQI4F.mjs +187 -0
- package/dist/chunk-W6YHQI4F.mjs.map +1 -0
- package/dist/chunk-XTBRWVIV.cjs +346 -0
- package/dist/chunk-XTBRWVIV.cjs.map +1 -0
- package/dist/components-C7ZL7OMY.mjs +5 -0
- package/dist/components-C7ZL7OMY.mjs.map +1 -0
- package/dist/components-CJ2IB65O.cjs +27 -0
- package/dist/components-CJ2IB65O.cjs.map +1 -0
- package/dist/components-EASJYK45.mjs +6 -0
- package/dist/components-EASJYK45.mjs.map +1 -0
- package/dist/components-LDRFDV4A.cjs +22 -0
- package/dist/components-LDRFDV4A.cjs.map +1 -0
- package/dist/components-VZKUTDJK.mjs +5 -0
- package/dist/components-VZKUTDJK.mjs.map +1 -0
- package/dist/components-Y64GTIMQ.cjs +42 -0
- package/dist/components-Y64GTIMQ.cjs.map +1 -0
- package/dist/index.cjs +701 -4813
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1274 -1026
- package/dist/index.d.ts +1274 -1026
- package/dist/index.mjs +358 -4730
- package/dist/index.mjs.map +1 -1
- package/package.json +27 -4
- package/src/components/index.ts +17 -0
- package/src/components/lazy-wrapper.tsx +281 -0
- package/src/index.ts +92 -7
- package/src/tools/AudioPlayer/components/HybridAudioPlayer.tsx +14 -5
- package/src/tools/AudioPlayer/lazy.tsx +85 -0
- package/src/tools/Gallery/components/Gallery.tsx +182 -0
- package/src/tools/Gallery/components/GalleryCarousel.tsx +251 -0
- package/src/tools/Gallery/components/GalleryCompact.tsx +173 -0
- package/src/tools/Gallery/components/GalleryGrid.tsx +493 -0
- package/src/tools/Gallery/components/GalleryImage.tsx +66 -0
- package/src/tools/Gallery/components/GalleryLightbox.tsx +331 -0
- package/src/tools/Gallery/components/GalleryMedia.tsx +66 -0
- package/src/tools/Gallery/components/GalleryThumbnails.tsx +173 -0
- package/src/tools/Gallery/components/GalleryThumbnailsVirtual.tsx +138 -0
- package/src/tools/Gallery/components/GalleryVideo.tsx +222 -0
- package/src/tools/Gallery/components/index.ts +13 -0
- package/src/tools/Gallery/hooks/index.ts +23 -0
- package/src/tools/Gallery/hooks/useGallery.ts +137 -0
- package/src/tools/Gallery/hooks/useImageDimensions.ts +223 -0
- package/src/tools/Gallery/hooks/usePinchZoom.ts +234 -0
- package/src/tools/Gallery/hooks/usePreloadImages.ts +71 -0
- package/src/tools/Gallery/hooks/useSwipe.ts +86 -0
- package/src/tools/Gallery/hooks/useVirtualList.ts +129 -0
- package/src/tools/Gallery/hooks/useZoom.ts +316 -0
- package/src/tools/Gallery/index.ts +66 -0
- package/src/tools/Gallery/types.ts +183 -0
- package/src/tools/Gallery/utils/imageAnalysis.ts +52 -0
- package/src/tools/Gallery/utils/index.ts +11 -0
- package/src/tools/ImageViewer/components/ImageToolbar.tsx +20 -8
- package/src/tools/ImageViewer/components/ImageViewer.tsx +12 -4
- package/src/tools/ImageViewer/lazy.tsx +37 -0
- package/src/tools/JsonForm/lazy.tsx +43 -0
- package/src/tools/JsonForm/widgets/ColorWidget.tsx +4 -1
- package/src/tools/JsonTree/lazy.tsx +45 -0
- package/src/tools/LottiePlayer/lazy.tsx +57 -0
- package/src/tools/Map/components/CustomOverlay.tsx +54 -0
- package/src/tools/Map/components/DrawControl.tsx +36 -0
- package/src/tools/Map/components/GeocoderControl.tsx +70 -0
- package/src/tools/Map/components/LayerSwitcher.tsx +225 -0
- package/src/tools/Map/components/MapCluster.tsx +273 -0
- package/src/tools/Map/components/MapContainer.tsx +191 -0
- package/src/tools/Map/components/MapControls.tsx +44 -0
- package/src/tools/Map/components/MapLegend.tsx +161 -0
- package/src/tools/Map/components/MapMarker.tsx +102 -0
- package/src/tools/Map/components/MapPopup.tsx +46 -0
- package/src/tools/Map/components/MapSource.tsx +30 -0
- package/src/tools/Map/components/index.ts +20 -0
- package/src/tools/Map/context/MapContext.tsx +89 -0
- package/src/tools/Map/context/index.ts +2 -0
- package/src/tools/Map/hooks/index.ts +9 -0
- package/src/tools/Map/hooks/useMap.ts +11 -0
- package/src/tools/Map/hooks/useMapControl.ts +99 -0
- package/src/tools/Map/hooks/useMapEvents.ts +147 -0
- package/src/tools/Map/hooks/useMapLayers.ts +83 -0
- package/src/tools/Map/hooks/useMapViewport.ts +62 -0
- package/src/tools/Map/hooks/useMarkers.ts +85 -0
- package/src/tools/Map/index.ts +116 -0
- package/src/tools/Map/layers/cluster.ts +94 -0
- package/src/tools/Map/layers/index.ts +15 -0
- package/src/tools/Map/layers/line.ts +93 -0
- package/src/tools/Map/layers/point.ts +61 -0
- package/src/tools/Map/layers/polygon.ts +73 -0
- package/src/tools/Map/lazy.tsx +56 -0
- package/src/tools/Map/styles/index.ts +15 -0
- package/src/tools/Map/types.ts +259 -0
- package/src/tools/Map/utils/geo.ts +88 -0
- package/src/tools/Map/utils/index.ts +16 -0
- package/src/tools/Map/utils/transform.ts +107 -0
- package/src/tools/Mermaid/Mermaid.client.tsx +12 -4
- package/src/tools/Mermaid/components/MermaidFullscreenModal.tsx +6 -2
- package/src/tools/Mermaid/lazy.tsx +46 -0
- package/src/tools/OpenapiViewer/lazy.tsx +72 -0
- package/src/tools/PrettyCode/PrettyCode.client.tsx +10 -3
- package/src/tools/PrettyCode/lazy.tsx +64 -0
- package/src/tools/VideoPlayer/lazy.tsx +63 -0
- package/dist/Mermaid.client-4OCKJ6QD.mjs.map +0 -1
- package/dist/Mermaid.client-ZP6OE46Z.cjs.map +0 -1
- package/dist/PlaygroundLayout-LMQTVXSP.mjs.map +0 -1
- package/dist/PlaygroundLayout-XXVBU4WZ.cjs.map +0 -1
- package/dist/PrettyCode.client-2CLSV2VD.cjs.map +0 -1
- package/dist/PrettyCode.client-Y2BVON7R.mjs.map +0 -1
- package/dist/chunk-FB5QBSI3.cjs.map +0 -1
- 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
|
+
}
|