@djangocfg/widget-visual 0.1.1
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/LICENSE +21 -0
- package/README.md +41 -0
- package/package.json +106 -0
- package/src/design/ColorPalette/ColorPalette.tsx +129 -0
- package/src/design/ColorPalette/README.md +34 -0
- package/src/design/ColorPalette/color-palette.stories.tsx +95 -0
- package/src/design/ColorPalette/components/Swatch.tsx +102 -0
- package/src/design/ColorPalette/hooks/useCopyToClipboard.ts +41 -0
- package/src/design/ColorPalette/index.ts +12 -0
- package/src/design/ColorPalette/lazy.tsx +21 -0
- package/src/design/ColorPalette/types.ts +63 -0
- package/src/design/ColorPalette/utils.ts +114 -0
- package/src/design/ColorPicker/ColorPicker.tsx +257 -0
- package/src/design/ColorPicker/color-picker.stories.tsx +113 -0
- package/src/design/ColorPicker/context/ColorPickerContext.tsx +28 -0
- package/src/design/ColorPicker/context/ColorPickerStore.tsx +166 -0
- package/src/design/ColorPicker/context/index.ts +2 -0
- package/src/design/ColorPicker/index.ts +15 -0
- package/src/design/ColorPicker/lazy.tsx +24 -0
- package/src/design/ColorPicker/lib/color-utils.ts +323 -0
- package/src/design/ColorPicker/parts/ColorPickerAlphaSlider.tsx +72 -0
- package/src/design/ColorPicker/parts/ColorPickerArea.tsx +155 -0
- package/src/design/ColorPicker/parts/ColorPickerEyeDropper.tsx +73 -0
- package/src/design/ColorPicker/parts/ColorPickerFormatSelect.tsx +64 -0
- package/src/design/ColorPicker/parts/ColorPickerHueSlider.tsx +64 -0
- package/src/design/ColorPicker/parts/ColorPickerInput.tsx +512 -0
- package/src/design/ColorPicker/parts/ColorPickerSwatch.tsx +63 -0
- package/src/design/ColorPicker/parts/index.ts +7 -0
- package/src/design/ColorPicker/types.ts +77 -0
- package/src/gallery/components/Gallery.tsx +182 -0
- package/src/gallery/components/compact/GalleryCompact.tsx +396 -0
- package/src/gallery/components/compact/index.ts +1 -0
- package/src/gallery/components/index.ts +21 -0
- package/src/gallery/components/lightbox/GalleryLightbox.tsx +426 -0
- package/src/gallery/components/lightbox/index.ts +1 -0
- package/src/gallery/components/media/GalleryImage.tsx +105 -0
- package/src/gallery/components/media/GalleryMedia.tsx +70 -0
- package/src/gallery/components/media/GalleryVideo.tsx +241 -0
- package/src/gallery/components/media/index.ts +3 -0
- package/src/gallery/components/preview/GalleryCarousel.tsx +374 -0
- package/src/gallery/components/preview/GalleryGrid.tsx +519 -0
- package/src/gallery/components/preview/index.ts +2 -0
- package/src/gallery/components/shared/ImageSpinner.tsx +37 -0
- package/src/gallery/components/shared/index.ts +1 -0
- package/src/gallery/components/thumbnails/GalleryThumbnails.tsx +198 -0
- package/src/gallery/components/thumbnails/GalleryThumbnailsVirtual.tsx +164 -0
- package/src/gallery/components/thumbnails/index.ts +2 -0
- package/src/gallery/gallery.stories.tsx +182 -0
- package/src/gallery/hooks/index.ts +23 -0
- package/src/gallery/hooks/useGallery.ts +138 -0
- package/src/gallery/hooks/useImageDimensions.ts +224 -0
- package/src/gallery/hooks/usePinchZoom.ts +239 -0
- package/src/gallery/hooks/usePreloadImages.ts +119 -0
- package/src/gallery/hooks/useSwipe.ts +87 -0
- package/src/gallery/hooks/useVirtualList.ts +129 -0
- package/src/gallery/hooks/useZoom.ts +321 -0
- package/src/gallery/index.ts +66 -0
- package/src/gallery/thumbnails.stories.tsx +127 -0
- package/src/gallery/types.ts +185 -0
- package/src/gallery/utils/imageAnalysis.ts +52 -0
- package/src/gallery/utils/index.ts +16 -0
- package/src/gallery/utils/normalizeUrl.ts +31 -0
- package/src/index.ts +18 -0
- package/src/lottie/LottiePlayer.client.tsx +319 -0
- package/src/lottie/index.tsx +66 -0
- package/src/lottie/lazy.tsx +65 -0
- package/src/lottie/lottie-player.stories.tsx +173 -0
- package/src/lottie/types.ts +138 -0
- package/src/lottie/useLottie.ts +187 -0
- package/src/lottie/usePrefersReducedMotion.ts +46 -0
- package/src/marquee/Marquee.tsx +637 -0
- package/src/marquee/index.ts +7 -0
- package/src/marquee/lazy.tsx +14 -0
- package/src/marquee/marquee.stories.tsx +118 -0
- package/src/marquee/types.ts +40 -0
- package/src/qrcode/QRCode.tsx +468 -0
- package/src/qrcode/index.ts +10 -0
- package/src/qrcode/lazy.tsx +19 -0
- package/src/qrcode/qr-code.stories.tsx +102 -0
- package/src/qrcode/types.ts +57 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { GalleryMediaItem } from '../types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Image with loaded dimensions
|
|
9
|
+
*/
|
|
10
|
+
export interface ImageWithDimensions extends GalleryMediaItem {
|
|
11
|
+
width: number
|
|
12
|
+
height: number
|
|
13
|
+
loaded: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Result of useImageDimensions hook
|
|
18
|
+
*/
|
|
19
|
+
export interface UseImageDimensionsResult {
|
|
20
|
+
/** Images with dimensions (loaded or from props) */
|
|
21
|
+
images: ImageWithDimensions[]
|
|
22
|
+
/** Whether all images have dimensions */
|
|
23
|
+
isReady: boolean
|
|
24
|
+
/** Whether currently loading */
|
|
25
|
+
isLoading: boolean
|
|
26
|
+
/** Number of images loaded */
|
|
27
|
+
loadedCount: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Options for useImageDimensions
|
|
32
|
+
*/
|
|
33
|
+
export interface UseImageDimensionsOptions {
|
|
34
|
+
/** Only load dimensions for first N images */
|
|
35
|
+
maxToLoad?: number
|
|
36
|
+
/** Skip loading if dimensions already present */
|
|
37
|
+
skipIfPresent?: boolean
|
|
38
|
+
/** Timeout per image in ms */
|
|
39
|
+
timeout?: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Cache for loaded dimensions
|
|
43
|
+
const dimensionsCache = new Map<string, { width: number; height: number }>()
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load image dimensions
|
|
47
|
+
*/
|
|
48
|
+
function loadImageDimensions(
|
|
49
|
+
src: string,
|
|
50
|
+
timeout: number = 5000
|
|
51
|
+
): Promise<{ width: number; height: number }> {
|
|
52
|
+
// Check cache
|
|
53
|
+
const cached = dimensionsCache.get(src)
|
|
54
|
+
if (cached) return Promise.resolve(cached)
|
|
55
|
+
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const img = new Image()
|
|
58
|
+
const timeoutId = setTimeout(() => {
|
|
59
|
+
reject(new Error('Timeout loading image'))
|
|
60
|
+
}, timeout)
|
|
61
|
+
|
|
62
|
+
img.onload = () => {
|
|
63
|
+
clearTimeout(timeoutId)
|
|
64
|
+
const dims = { width: img.naturalWidth, height: img.naturalHeight }
|
|
65
|
+
dimensionsCache.set(src, dims)
|
|
66
|
+
resolve(dims)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
img.onerror = () => {
|
|
70
|
+
clearTimeout(timeoutId)
|
|
71
|
+
reject(new Error('Failed to load image'))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
img.src = src
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Hook to load image dimensions for gallery images
|
|
80
|
+
*
|
|
81
|
+
* Uses thumbnail URL when available (faster to load)
|
|
82
|
+
* Caches results to avoid reloading
|
|
83
|
+
*/
|
|
84
|
+
export function useImageDimensions(
|
|
85
|
+
images: GalleryMediaItem[],
|
|
86
|
+
options: UseImageDimensionsOptions = {}
|
|
87
|
+
): UseImageDimensionsResult {
|
|
88
|
+
const {
|
|
89
|
+
maxToLoad = 5,
|
|
90
|
+
skipIfPresent = true,
|
|
91
|
+
timeout = 5000,
|
|
92
|
+
} = options
|
|
93
|
+
|
|
94
|
+
// Create stable key from image IDs
|
|
95
|
+
const imageIds = useMemo(() => {
|
|
96
|
+
return images.slice(0, maxToLoad).map(img => img.id).join(',')
|
|
97
|
+
}, [images, maxToLoad])
|
|
98
|
+
|
|
99
|
+
// Track dimensions in a map by ID (stable across re-renders)
|
|
100
|
+
const dimensionsRef = useRef(new Map<string, { width: number; height: number; loaded: boolean }>())
|
|
101
|
+
const [version, setVersion] = useState(0) // Force re-render when dimensions load
|
|
102
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
103
|
+
const loadingRef = useRef(false)
|
|
104
|
+
const prevImageIdsRef = useRef('')
|
|
105
|
+
|
|
106
|
+
// Initialize dimensions from props or cache
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const targetImages = images.slice(0, maxToLoad)
|
|
109
|
+
const idsChanged = prevImageIdsRef.current !== imageIds
|
|
110
|
+
prevImageIdsRef.current = imageIds
|
|
111
|
+
|
|
112
|
+
// Update dimensions map with any new images
|
|
113
|
+
for (const img of targetImages) {
|
|
114
|
+
if (!dimensionsRef.current.has(img.id)) {
|
|
115
|
+
// Check cache first
|
|
116
|
+
const src = img.thumbnail || img.src
|
|
117
|
+
const cached = dimensionsCache.get(src)
|
|
118
|
+
|
|
119
|
+
if (cached) {
|
|
120
|
+
dimensionsRef.current.set(img.id, { ...cached, loaded: true })
|
|
121
|
+
} else if (img.width && img.height && skipIfPresent) {
|
|
122
|
+
dimensionsRef.current.set(img.id, { width: img.width, height: img.height, loaded: true })
|
|
123
|
+
} else {
|
|
124
|
+
dimensionsRef.current.set(img.id, { width: 0, height: 0, loaded: false })
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Find images that need loading
|
|
130
|
+
const toLoad = targetImages.filter(img => {
|
|
131
|
+
const dims = dimensionsRef.current.get(img.id)
|
|
132
|
+
return !dims?.loaded
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// Nothing to load
|
|
136
|
+
if (toLoad.length === 0) {
|
|
137
|
+
setIsLoading(false)
|
|
138
|
+
if (idsChanged) setVersion(v => v + 1)
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Already loading
|
|
143
|
+
if (loadingRef.current) return
|
|
144
|
+
|
|
145
|
+
loadingRef.current = true
|
|
146
|
+
setIsLoading(true)
|
|
147
|
+
|
|
148
|
+
// Load dimensions
|
|
149
|
+
Promise.all(
|
|
150
|
+
toLoad.map(async (img) => {
|
|
151
|
+
try {
|
|
152
|
+
const src = img.thumbnail || img.src
|
|
153
|
+
const dims = await loadImageDimensions(src, timeout)
|
|
154
|
+
dimensionsRef.current.set(img.id, { ...dims, loaded: true })
|
|
155
|
+
return { id: img.id, success: true }
|
|
156
|
+
} catch {
|
|
157
|
+
// Use default dimensions on error
|
|
158
|
+
dimensionsRef.current.set(img.id, { width: 1600, height: 900, loaded: true })
|
|
159
|
+
return { id: img.id, success: false }
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
).then(() => {
|
|
163
|
+
loadingRef.current = false
|
|
164
|
+
setIsLoading(false)
|
|
165
|
+
setVersion(v => v + 1)
|
|
166
|
+
})
|
|
167
|
+
}, [imageIds, images, maxToLoad, skipIfPresent, timeout])
|
|
168
|
+
|
|
169
|
+
// Build result array
|
|
170
|
+
const result = useMemo((): ImageWithDimensions[] => {
|
|
171
|
+
return images.slice(0, maxToLoad).map(img => {
|
|
172
|
+
const dims = dimensionsRef.current.get(img.id)
|
|
173
|
+
return {
|
|
174
|
+
...img,
|
|
175
|
+
width: dims?.width || img.width || 0,
|
|
176
|
+
height: dims?.height || img.height || 0,
|
|
177
|
+
loaded: dims?.loaded || false,
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
181
|
+
}, [imageIds, version])
|
|
182
|
+
|
|
183
|
+
// Also return all images (not just maxToLoad) with available dimensions
|
|
184
|
+
const allImages = useMemo((): ImageWithDimensions[] => {
|
|
185
|
+
return images.map(img => {
|
|
186
|
+
const dims = dimensionsRef.current.get(img.id)
|
|
187
|
+
return {
|
|
188
|
+
...img,
|
|
189
|
+
width: dims?.width || img.width || 0,
|
|
190
|
+
height: dims?.height || img.height || 0,
|
|
191
|
+
loaded: dims?.loaded || Boolean(img.width && img.height),
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
195
|
+
}, [images, version])
|
|
196
|
+
|
|
197
|
+
const loadedCount = result.filter(img => img.loaded).length
|
|
198
|
+
const targetCount = Math.min(images.length, maxToLoad)
|
|
199
|
+
|
|
200
|
+
const isReady = !isLoading && loadedCount >= targetCount
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
images: allImages,
|
|
204
|
+
isReady,
|
|
205
|
+
isLoading,
|
|
206
|
+
loadedCount,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Clear dimensions cache
|
|
212
|
+
*/
|
|
213
|
+
export function clearDimensionsCache(): void {
|
|
214
|
+
dimensionsCache.clear()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get cached dimensions for an image
|
|
219
|
+
*/
|
|
220
|
+
export function getCachedDimensions(
|
|
221
|
+
src: string
|
|
222
|
+
): { width: number; height: number } | null {
|
|
223
|
+
return dimensionsCache.get(src) || null
|
|
224
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
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
|
+
const a = touches[0]
|
|
69
|
+
const b = touches[1]
|
|
70
|
+
if (!a || !b) return 0
|
|
71
|
+
const dx = a.clientX - b.clientX
|
|
72
|
+
const dy = a.clientY - b.clientY
|
|
73
|
+
return Math.sqrt(dx * dx + dy * dy)
|
|
74
|
+
}, [])
|
|
75
|
+
|
|
76
|
+
// Get center point between two touches
|
|
77
|
+
const getCenter = useCallback((touches: React.TouchList): { x: number; y: number } => {
|
|
78
|
+
const a = touches[0]
|
|
79
|
+
const b = touches[1]
|
|
80
|
+
if (!a) return { x: 0, y: 0 }
|
|
81
|
+
if (!b) return { x: a.clientX, y: a.clientY }
|
|
82
|
+
return {
|
|
83
|
+
x: (a.clientX + b.clientX) / 2,
|
|
84
|
+
y: (a.clientY + b.clientY) / 2,
|
|
85
|
+
}
|
|
86
|
+
}, [])
|
|
87
|
+
|
|
88
|
+
// Calculate max pan based on container size and scale
|
|
89
|
+
const getMaxPan = useCallback((scale: number) => {
|
|
90
|
+
const { containerWidth, containerHeight } = touchRef.current
|
|
91
|
+
// When zoomed, allow panning up to half the "extra" size
|
|
92
|
+
// At 2x zoom on 400px container: can pan 200px in each direction
|
|
93
|
+
return {
|
|
94
|
+
x: (containerWidth * (scale - 1)) / 2,
|
|
95
|
+
y: (containerHeight * (scale - 1)) / 2,
|
|
96
|
+
}
|
|
97
|
+
}, [])
|
|
98
|
+
|
|
99
|
+
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
|
100
|
+
const touches = e.touches
|
|
101
|
+
|
|
102
|
+
// Store container dimensions from the element
|
|
103
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
104
|
+
touchRef.current.containerWidth = rect.width
|
|
105
|
+
touchRef.current.containerHeight = rect.height
|
|
106
|
+
|
|
107
|
+
// Double tap to zoom
|
|
108
|
+
const touch0 = touches[0]
|
|
109
|
+
if (touches.length === 1 && touch0) {
|
|
110
|
+
const now = Date.now()
|
|
111
|
+
const timeSinceLastTap = now - touchRef.current.lastTap
|
|
112
|
+
|
|
113
|
+
if (timeSinceLastTap < 300) {
|
|
114
|
+
// Double tap detected
|
|
115
|
+
e.preventDefault()
|
|
116
|
+
if (isZoomed) {
|
|
117
|
+
setState(INITIAL_STATE)
|
|
118
|
+
onZoomChange?.(false)
|
|
119
|
+
} else {
|
|
120
|
+
const x = touch0.clientX - rect.left - rect.width / 2
|
|
121
|
+
const y = touch0.clientY - rect.top - rect.height / 2
|
|
122
|
+
setState({ scale: 2, x: -x * 0.5, y: -y * 0.5 })
|
|
123
|
+
onZoomChange?.(true)
|
|
124
|
+
}
|
|
125
|
+
touchRef.current.lastTap = 0
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
touchRef.current.lastTap = now
|
|
130
|
+
|
|
131
|
+
// Single finger - start pan (if zoomed)
|
|
132
|
+
if (isZoomed) {
|
|
133
|
+
touchRef.current.initialX = touch0.clientX - state.x
|
|
134
|
+
touchRef.current.initialY = touch0.clientY - state.y
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Two finger pinch
|
|
139
|
+
if (touches.length === 2) {
|
|
140
|
+
e.preventDefault()
|
|
141
|
+
touchRef.current.isPinching = true
|
|
142
|
+
touchRef.current.initialDistance = getDistance(touches)
|
|
143
|
+
touchRef.current.initialScale = state.scale
|
|
144
|
+
|
|
145
|
+
const center = getCenter(touches)
|
|
146
|
+
touchRef.current.centerX = center.x
|
|
147
|
+
touchRef.current.centerY = center.y
|
|
148
|
+
touchRef.current.initialX = state.x
|
|
149
|
+
touchRef.current.initialY = state.y
|
|
150
|
+
}
|
|
151
|
+
}, [isZoomed, state, getDistance, getCenter, onZoomChange])
|
|
152
|
+
|
|
153
|
+
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
|
154
|
+
const touches = e.touches
|
|
155
|
+
|
|
156
|
+
// Pinch zoom
|
|
157
|
+
if (touches.length === 2 && touchRef.current.isPinching) {
|
|
158
|
+
e.preventDefault()
|
|
159
|
+
|
|
160
|
+
const currentDistance = getDistance(touches)
|
|
161
|
+
const scaleDelta = currentDistance / touchRef.current.initialDistance
|
|
162
|
+
let newScale = touchRef.current.initialScale * scaleDelta
|
|
163
|
+
|
|
164
|
+
// Clamp scale
|
|
165
|
+
newScale = Math.max(minScale, Math.min(maxScale, newScale))
|
|
166
|
+
|
|
167
|
+
// Calculate new position to zoom toward center
|
|
168
|
+
const center = getCenter(touches)
|
|
169
|
+
const dx = center.x - touchRef.current.centerX
|
|
170
|
+
const dy = center.y - touchRef.current.centerY
|
|
171
|
+
|
|
172
|
+
// Apply pan limits
|
|
173
|
+
const maxPan = getMaxPan(newScale)
|
|
174
|
+
const newX = Math.max(-maxPan.x, Math.min(maxPan.x, touchRef.current.initialX + dx))
|
|
175
|
+
const newY = Math.max(-maxPan.y, Math.min(maxPan.y, touchRef.current.initialY + dy))
|
|
176
|
+
|
|
177
|
+
setState({
|
|
178
|
+
scale: newScale,
|
|
179
|
+
x: newX,
|
|
180
|
+
y: newY,
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
if (newScale > 1.05 !== isZoomed) {
|
|
184
|
+
onZoomChange?.(newScale > 1.05)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Pan when zoomed (single finger)
|
|
189
|
+
const panTouch = touches[0]
|
|
190
|
+
if (touches.length === 1 && panTouch && isZoomed && !touchRef.current.isPinching) {
|
|
191
|
+
e.preventDefault()
|
|
192
|
+
|
|
193
|
+
const maxPan = getMaxPan(state.scale)
|
|
194
|
+
const newX = panTouch.clientX - touchRef.current.initialX
|
|
195
|
+
const newY = panTouch.clientY - touchRef.current.initialY
|
|
196
|
+
|
|
197
|
+
setState((prev) => ({
|
|
198
|
+
...prev,
|
|
199
|
+
x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),
|
|
200
|
+
y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),
|
|
201
|
+
}))
|
|
202
|
+
}
|
|
203
|
+
}, [isZoomed, state.scale, minScale, maxScale, getDistance, getCenter, getMaxPan, onZoomChange])
|
|
204
|
+
|
|
205
|
+
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
|
206
|
+
if (e.touches.length < 2) {
|
|
207
|
+
touchRef.current.isPinching = false
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Snap to 1 if close
|
|
211
|
+
if (state.scale < 1.1 && state.scale > 0.9) {
|
|
212
|
+
setState(INITIAL_STATE)
|
|
213
|
+
onZoomChange?.(false)
|
|
214
|
+
}
|
|
215
|
+
}, [state.scale, onZoomChange])
|
|
216
|
+
|
|
217
|
+
const reset = useCallback(() => {
|
|
218
|
+
setState(INITIAL_STATE)
|
|
219
|
+
onZoomChange?.(false)
|
|
220
|
+
}, [onZoomChange])
|
|
221
|
+
|
|
222
|
+
const zoomTo = useCallback((scale: number, x = 0, y = 0) => {
|
|
223
|
+
const clampedScale = Math.max(minScale, Math.min(maxScale, scale))
|
|
224
|
+
setState({ scale: clampedScale, x, y })
|
|
225
|
+
onZoomChange?.(clampedScale > 1.05)
|
|
226
|
+
}, [minScale, maxScale, onZoomChange])
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
state,
|
|
230
|
+
isZoomed,
|
|
231
|
+
handlers: {
|
|
232
|
+
onTouchStart: handleTouchStart,
|
|
233
|
+
onTouchMove: handleTouchMove,
|
|
234
|
+
onTouchEnd: handleTouchEnd,
|
|
235
|
+
},
|
|
236
|
+
reset,
|
|
237
|
+
zoomTo,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { GalleryMediaItem } from '../types'
|
|
6
|
+
import { normalizeImageUrl } from '../utils'
|
|
7
|
+
|
|
8
|
+
/** Max number of URLs to remember as "already preloaded" before evicting oldest */
|
|
9
|
+
const MAX_PRELOAD_MEMORY = 200
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Preload adjacent images for smoother navigation
|
|
13
|
+
* Preloads prev and next images when current index changes
|
|
14
|
+
*/
|
|
15
|
+
export function usePreloadImages(
|
|
16
|
+
images: GalleryMediaItem[],
|
|
17
|
+
currentIndex: number,
|
|
18
|
+
preloadCount: number = 1
|
|
19
|
+
): void {
|
|
20
|
+
// Insertion-ordered Set, used as a bounded LRU to avoid unbounded growth
|
|
21
|
+
const preloadedRef = useRef<Set<string>>(new Set())
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (images.length === 0) return
|
|
25
|
+
|
|
26
|
+
const toPreload: string[] = []
|
|
27
|
+
|
|
28
|
+
// Collect indices to preload (current + adjacent)
|
|
29
|
+
for (let offset = -preloadCount; offset <= preloadCount; offset++) {
|
|
30
|
+
if (offset === 0) continue // Skip current, it's already loading
|
|
31
|
+
|
|
32
|
+
const index = (currentIndex + offset + images.length) % images.length
|
|
33
|
+
const image = images[index]
|
|
34
|
+
if (!image) continue
|
|
35
|
+
|
|
36
|
+
// Videos are not preloadable via Image(); skip them
|
|
37
|
+
if (image.type === 'video') continue
|
|
38
|
+
|
|
39
|
+
const src = normalizeImageUrl(image.src)
|
|
40
|
+
if (src && !preloadedRef.current.has(src)) {
|
|
41
|
+
toPreload.push(src)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (toPreload.length === 0) return
|
|
46
|
+
|
|
47
|
+
const memory = preloadedRef.current
|
|
48
|
+
const loaders: HTMLImageElement[] = []
|
|
49
|
+
|
|
50
|
+
toPreload.forEach((src) => {
|
|
51
|
+
const img = new Image()
|
|
52
|
+
// Release the loader once finished so it can be GC'd
|
|
53
|
+
const cleanup = () => {
|
|
54
|
+
img.onload = null
|
|
55
|
+
img.onerror = null
|
|
56
|
+
}
|
|
57
|
+
img.onload = cleanup
|
|
58
|
+
img.onerror = cleanup
|
|
59
|
+
img.src = src
|
|
60
|
+
loaders.push(img)
|
|
61
|
+
|
|
62
|
+
memory.add(src)
|
|
63
|
+
// Evict oldest entries to keep the Set bounded
|
|
64
|
+
if (memory.size > MAX_PRELOAD_MEMORY) {
|
|
65
|
+
const oldest = memory.values().next().value
|
|
66
|
+
if (oldest !== undefined) memory.delete(oldest)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// Cancel in-flight loaders if the effect re-runs before they settle
|
|
71
|
+
return () => {
|
|
72
|
+
loaders.forEach((img) => {
|
|
73
|
+
img.onload = null
|
|
74
|
+
img.onerror = null
|
|
75
|
+
img.src = ''
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}, [images, currentIndex, preloadCount])
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Preload specific image URLs
|
|
83
|
+
*/
|
|
84
|
+
export function preloadImage(src: string): Promise<void> {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const img = new Image()
|
|
87
|
+
const cleanup = () => {
|
|
88
|
+
img.onload = null
|
|
89
|
+
img.onerror = null
|
|
90
|
+
}
|
|
91
|
+
img.onload = () => {
|
|
92
|
+
cleanup()
|
|
93
|
+
resolve()
|
|
94
|
+
}
|
|
95
|
+
img.onerror = () => {
|
|
96
|
+
cleanup()
|
|
97
|
+
reject(new Error(`Failed to preload image: ${src}`))
|
|
98
|
+
}
|
|
99
|
+
img.src = normalizeImageUrl(src)
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Preload multiple images with optional concurrency limit
|
|
105
|
+
*/
|
|
106
|
+
export async function preloadImages(
|
|
107
|
+
urls: string[],
|
|
108
|
+
concurrency: number = 3
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
const chunks: string[][] = []
|
|
111
|
+
|
|
112
|
+
for (let i = 0; i < urls.length; i += concurrency) {
|
|
113
|
+
chunks.push(urls.slice(i, i + concurrency))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const chunk of chunks) {
|
|
117
|
+
await Promise.allSettled(chunk.map(preloadImage))
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { TouchEvent } from 'react'
|
|
6
|
+
|
|
7
|
+
export interface UseSwipeOptions {
|
|
8
|
+
/** Minimum swipe distance in pixels */
|
|
9
|
+
minDistance?: number
|
|
10
|
+
/** Callback when swiped left */
|
|
11
|
+
onSwipeLeft?: () => void
|
|
12
|
+
/** Callback when swiped right */
|
|
13
|
+
onSwipeRight?: () => void
|
|
14
|
+
/** Callback when swiped up */
|
|
15
|
+
onSwipeUp?: () => void
|
|
16
|
+
/** Callback when swiped down */
|
|
17
|
+
onSwipeDown?: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface TouchPosition {
|
|
21
|
+
x: number
|
|
22
|
+
y: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Hook for handling swipe gestures
|
|
27
|
+
*/
|
|
28
|
+
export function useSwipe({
|
|
29
|
+
minDistance = 50,
|
|
30
|
+
onSwipeLeft,
|
|
31
|
+
onSwipeRight,
|
|
32
|
+
onSwipeUp,
|
|
33
|
+
onSwipeDown,
|
|
34
|
+
}: UseSwipeOptions = {}) {
|
|
35
|
+
const [touchStart, setTouchStart] = useState<TouchPosition | null>(null)
|
|
36
|
+
const [touchEnd, setTouchEnd] = useState<TouchPosition | null>(null)
|
|
37
|
+
|
|
38
|
+
const handleTouchStart = useCallback((e: TouchEvent) => {
|
|
39
|
+
setTouchEnd(null)
|
|
40
|
+
const touch = e.targetTouches[0]
|
|
41
|
+
if (touch) {
|
|
42
|
+
setTouchStart({ x: touch.clientX, y: touch.clientY })
|
|
43
|
+
}
|
|
44
|
+
}, [])
|
|
45
|
+
|
|
46
|
+
const handleTouchMove = useCallback((e: TouchEvent) => {
|
|
47
|
+
const touch = e.targetTouches[0]
|
|
48
|
+
if (touch) {
|
|
49
|
+
setTouchEnd({ x: touch.clientX, y: touch.clientY })
|
|
50
|
+
}
|
|
51
|
+
}, [])
|
|
52
|
+
|
|
53
|
+
const handleTouchEnd = useCallback(() => {
|
|
54
|
+
if (!touchStart || !touchEnd) return
|
|
55
|
+
|
|
56
|
+
const deltaX = touchStart.x - touchEnd.x
|
|
57
|
+
const deltaY = touchStart.y - touchEnd.y
|
|
58
|
+
const absX = Math.abs(deltaX)
|
|
59
|
+
const absY = Math.abs(deltaY)
|
|
60
|
+
|
|
61
|
+
// Determine if horizontal or vertical swipe
|
|
62
|
+
if (absX > absY && absX > minDistance) {
|
|
63
|
+
// Horizontal swipe
|
|
64
|
+
if (deltaX > 0) {
|
|
65
|
+
onSwipeLeft?.()
|
|
66
|
+
} else {
|
|
67
|
+
onSwipeRight?.()
|
|
68
|
+
}
|
|
69
|
+
} else if (absY > absX && absY > minDistance) {
|
|
70
|
+
// Vertical swipe
|
|
71
|
+
if (deltaY > 0) {
|
|
72
|
+
onSwipeUp?.()
|
|
73
|
+
} else {
|
|
74
|
+
onSwipeDown?.()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setTouchStart(null)
|
|
79
|
+
setTouchEnd(null)
|
|
80
|
+
}, [touchStart, touchEnd, minDistance, onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown])
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
onTouchStart: handleTouchStart,
|
|
84
|
+
onTouchMove: handleTouchMove,
|
|
85
|
+
onTouchEnd: handleTouchEnd,
|
|
86
|
+
}
|
|
87
|
+
}
|