@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,129 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } 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
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface ZoomState {
|
|
6
|
+
scale: number
|
|
7
|
+
x: number
|
|
8
|
+
y: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface UseZoomOptions {
|
|
12
|
+
minScale?: number
|
|
13
|
+
maxScale?: number
|
|
14
|
+
desktopScale?: number
|
|
15
|
+
onZoomChange?: (zoomed: boolean) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UseZoomReturn {
|
|
19
|
+
state: ZoomState
|
|
20
|
+
isZoomed: boolean
|
|
21
|
+
isDragging: boolean
|
|
22
|
+
handlers: {
|
|
23
|
+
onTouchStart: (e: React.TouchEvent) => void
|
|
24
|
+
onTouchMove: (e: React.TouchEvent) => void
|
|
25
|
+
onTouchEnd: (e: React.TouchEvent) => void
|
|
26
|
+
onClick: (e: React.MouseEvent) => void
|
|
27
|
+
onMouseDown: (e: React.MouseEvent) => void
|
|
28
|
+
onMouseMove: (e: React.MouseEvent) => void
|
|
29
|
+
onMouseUp: () => void
|
|
30
|
+
onMouseLeave: () => void
|
|
31
|
+
}
|
|
32
|
+
reset: () => void
|
|
33
|
+
zoomTo: (scale: number, x?: number, y?: number) => void
|
|
34
|
+
toggleZoom: () => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const INITIAL_STATE: ZoomState = { scale: 1, x: 0, y: 0 }
|
|
38
|
+
const ZOOM_THRESHOLD = 1.05
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Unified zoom hook for both desktop (click/drag) and mobile (pinch/tap)
|
|
42
|
+
*/
|
|
43
|
+
export function useZoom(options: UseZoomOptions = {}): UseZoomReturn {
|
|
44
|
+
const { minScale = 1, maxScale = 3, desktopScale = 2, onZoomChange } = options
|
|
45
|
+
|
|
46
|
+
const [state, setState] = useState<ZoomState>(INITIAL_STATE)
|
|
47
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
48
|
+
|
|
49
|
+
const touchRef = useRef<{
|
|
50
|
+
// Touch tracking
|
|
51
|
+
initialDistance: number
|
|
52
|
+
initialScale: number
|
|
53
|
+
initialX: number
|
|
54
|
+
initialY: number
|
|
55
|
+
centerX: number
|
|
56
|
+
centerY: number
|
|
57
|
+
isPinching: boolean
|
|
58
|
+
lastTap: number
|
|
59
|
+
// Container dimensions
|
|
60
|
+
containerWidth: number
|
|
61
|
+
containerHeight: number
|
|
62
|
+
// Mouse drag tracking
|
|
63
|
+
dragStartX: number
|
|
64
|
+
dragStartY: number
|
|
65
|
+
}>({
|
|
66
|
+
initialDistance: 0,
|
|
67
|
+
initialScale: 1,
|
|
68
|
+
initialX: 0,
|
|
69
|
+
initialY: 0,
|
|
70
|
+
centerX: 0,
|
|
71
|
+
centerY: 0,
|
|
72
|
+
isPinching: false,
|
|
73
|
+
lastTap: 0,
|
|
74
|
+
containerWidth: 0,
|
|
75
|
+
containerHeight: 0,
|
|
76
|
+
dragStartX: 0,
|
|
77
|
+
dragStartY: 0,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const isZoomed = state.scale > ZOOM_THRESHOLD
|
|
81
|
+
|
|
82
|
+
// Calculate distance between two touch points
|
|
83
|
+
const getDistance = useCallback((touches: React.TouchList): number => {
|
|
84
|
+
const a = touches[0]
|
|
85
|
+
const b = touches[1]
|
|
86
|
+
if (!a || !b) return 0
|
|
87
|
+
const dx = a.clientX - b.clientX
|
|
88
|
+
const dy = a.clientY - b.clientY
|
|
89
|
+
return Math.sqrt(dx * dx + dy * dy)
|
|
90
|
+
}, [])
|
|
91
|
+
|
|
92
|
+
// Get center point between two touches
|
|
93
|
+
const getCenter = useCallback((touches: React.TouchList): { x: number; y: number } => {
|
|
94
|
+
const a = touches[0]
|
|
95
|
+
const b = touches[1]
|
|
96
|
+
if (!a) return { x: 0, y: 0 }
|
|
97
|
+
if (!b) return { x: a.clientX, y: a.clientY }
|
|
98
|
+
return {
|
|
99
|
+
x: (a.clientX + b.clientX) / 2,
|
|
100
|
+
y: (a.clientY + b.clientY) / 2,
|
|
101
|
+
}
|
|
102
|
+
}, [])
|
|
103
|
+
|
|
104
|
+
// Calculate max pan based on container size and scale
|
|
105
|
+
const getMaxPan = useCallback((scale: number) => {
|
|
106
|
+
const { containerWidth, containerHeight } = touchRef.current
|
|
107
|
+
return {
|
|
108
|
+
x: (containerWidth * (scale - 1)) / 2,
|
|
109
|
+
y: (containerHeight * (scale - 1)) / 2,
|
|
110
|
+
}
|
|
111
|
+
}, [])
|
|
112
|
+
|
|
113
|
+
// Clamp pan position within bounds
|
|
114
|
+
const clampPan = useCallback((x: number, y: number, scale: number) => {
|
|
115
|
+
const maxPan = getMaxPan(scale)
|
|
116
|
+
return {
|
|
117
|
+
x: Math.max(-maxPan.x, Math.min(maxPan.x, x)),
|
|
118
|
+
y: Math.max(-maxPan.y, Math.min(maxPan.y, y)),
|
|
119
|
+
}
|
|
120
|
+
}, [getMaxPan])
|
|
121
|
+
|
|
122
|
+
// ========== TOUCH HANDLERS (MOBILE) ==========
|
|
123
|
+
|
|
124
|
+
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
|
125
|
+
const touches = e.touches
|
|
126
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
127
|
+
touchRef.current.containerWidth = rect.width
|
|
128
|
+
touchRef.current.containerHeight = rect.height
|
|
129
|
+
|
|
130
|
+
// Double tap to zoom
|
|
131
|
+
const touch0 = touches[0]
|
|
132
|
+
if (touches.length === 1 && touch0) {
|
|
133
|
+
const now = Date.now()
|
|
134
|
+
const timeSinceLastTap = now - touchRef.current.lastTap
|
|
135
|
+
|
|
136
|
+
if (timeSinceLastTap < 300) {
|
|
137
|
+
e.preventDefault()
|
|
138
|
+
if (isZoomed) {
|
|
139
|
+
setState(INITIAL_STATE)
|
|
140
|
+
onZoomChange?.(false)
|
|
141
|
+
} else {
|
|
142
|
+
const x = touch0.clientX - rect.left - rect.width / 2
|
|
143
|
+
const y = touch0.clientY - rect.top - rect.height / 2
|
|
144
|
+
setState({ scale: desktopScale, x: -x * 0.5, y: -y * 0.5 })
|
|
145
|
+
onZoomChange?.(true)
|
|
146
|
+
}
|
|
147
|
+
touchRef.current.lastTap = 0
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
touchRef.current.lastTap = now
|
|
152
|
+
|
|
153
|
+
// Single finger - start pan (if zoomed)
|
|
154
|
+
if (isZoomed) {
|
|
155
|
+
touchRef.current.initialX = touch0.clientX - state.x
|
|
156
|
+
touchRef.current.initialY = touch0.clientY - state.y
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Two finger pinch
|
|
161
|
+
if (touches.length === 2) {
|
|
162
|
+
e.preventDefault()
|
|
163
|
+
touchRef.current.isPinching = true
|
|
164
|
+
touchRef.current.initialDistance = getDistance(touches)
|
|
165
|
+
touchRef.current.initialScale = state.scale
|
|
166
|
+
|
|
167
|
+
const center = getCenter(touches)
|
|
168
|
+
touchRef.current.centerX = center.x
|
|
169
|
+
touchRef.current.centerY = center.y
|
|
170
|
+
touchRef.current.initialX = state.x
|
|
171
|
+
touchRef.current.initialY = state.y
|
|
172
|
+
}
|
|
173
|
+
}, [isZoomed, state, getDistance, getCenter, desktopScale, onZoomChange])
|
|
174
|
+
|
|
175
|
+
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
|
176
|
+
const touches = e.touches
|
|
177
|
+
|
|
178
|
+
// Pinch zoom
|
|
179
|
+
if (touches.length === 2 && touchRef.current.isPinching) {
|
|
180
|
+
e.preventDefault()
|
|
181
|
+
|
|
182
|
+
const currentDistance = getDistance(touches)
|
|
183
|
+
const scaleDelta = currentDistance / touchRef.current.initialDistance
|
|
184
|
+
let newScale = touchRef.current.initialScale * scaleDelta
|
|
185
|
+
newScale = Math.max(minScale, Math.min(maxScale, newScale))
|
|
186
|
+
|
|
187
|
+
const center = getCenter(touches)
|
|
188
|
+
const dx = center.x - touchRef.current.centerX
|
|
189
|
+
const dy = center.y - touchRef.current.centerY
|
|
190
|
+
|
|
191
|
+
const { x: clampedX, y: clampedY } = clampPan(
|
|
192
|
+
touchRef.current.initialX + dx,
|
|
193
|
+
touchRef.current.initialY + dy,
|
|
194
|
+
newScale
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
setState({ scale: newScale, x: clampedX, y: clampedY })
|
|
198
|
+
|
|
199
|
+
if (newScale > ZOOM_THRESHOLD !== isZoomed) {
|
|
200
|
+
onZoomChange?.(newScale > ZOOM_THRESHOLD)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Pan when zoomed (single finger)
|
|
205
|
+
const panTouch = touches[0]
|
|
206
|
+
if (touches.length === 1 && panTouch && isZoomed && !touchRef.current.isPinching) {
|
|
207
|
+
e.preventDefault()
|
|
208
|
+
|
|
209
|
+
const newX = panTouch.clientX - touchRef.current.initialX
|
|
210
|
+
const newY = panTouch.clientY - touchRef.current.initialY
|
|
211
|
+
const { x: clampedX, y: clampedY } = clampPan(newX, newY, state.scale)
|
|
212
|
+
|
|
213
|
+
setState((prev) => ({ ...prev, x: clampedX, y: clampedY }))
|
|
214
|
+
}
|
|
215
|
+
}, [isZoomed, state.scale, minScale, maxScale, getDistance, getCenter, clampPan, onZoomChange])
|
|
216
|
+
|
|
217
|
+
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
|
218
|
+
if (e.touches.length < 2) {
|
|
219
|
+
touchRef.current.isPinching = false
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Snap to 1 if close
|
|
223
|
+
if (state.scale < 1.1 && state.scale > 0.9) {
|
|
224
|
+
setState(INITIAL_STATE)
|
|
225
|
+
onZoomChange?.(false)
|
|
226
|
+
}
|
|
227
|
+
}, [state.scale, onZoomChange])
|
|
228
|
+
|
|
229
|
+
// ========== MOUSE HANDLERS (DESKTOP) ==========
|
|
230
|
+
|
|
231
|
+
const handleClick = useCallback((e: React.MouseEvent) => {
|
|
232
|
+
// Skip if we were dragging
|
|
233
|
+
if (isDragging) return
|
|
234
|
+
|
|
235
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
236
|
+
touchRef.current.containerWidth = rect.width
|
|
237
|
+
touchRef.current.containerHeight = rect.height
|
|
238
|
+
|
|
239
|
+
if (isZoomed) {
|
|
240
|
+
setState(INITIAL_STATE)
|
|
241
|
+
onZoomChange?.(false)
|
|
242
|
+
} else {
|
|
243
|
+
// Zoom to click position
|
|
244
|
+
const x = ((e.clientX - rect.left) / rect.width - 0.5) * -100
|
|
245
|
+
const y = ((e.clientY - rect.top) / rect.height - 0.5) * -100
|
|
246
|
+
setState({ scale: desktopScale, x, y })
|
|
247
|
+
onZoomChange?.(true)
|
|
248
|
+
}
|
|
249
|
+
}, [isZoomed, isDragging, desktopScale, onZoomChange])
|
|
250
|
+
|
|
251
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
252
|
+
if (!isZoomed) return
|
|
253
|
+
e.preventDefault()
|
|
254
|
+
setIsDragging(true)
|
|
255
|
+
touchRef.current.dragStartX = e.clientX - state.x
|
|
256
|
+
touchRef.current.dragStartY = e.clientY - state.y
|
|
257
|
+
|
|
258
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
259
|
+
touchRef.current.containerWidth = rect.width
|
|
260
|
+
touchRef.current.containerHeight = rect.height
|
|
261
|
+
}, [isZoomed, state.x, state.y])
|
|
262
|
+
|
|
263
|
+
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
|
264
|
+
if (!isDragging) return
|
|
265
|
+
|
|
266
|
+
const newX = e.clientX - touchRef.current.dragStartX
|
|
267
|
+
const newY = e.clientY - touchRef.current.dragStartY
|
|
268
|
+
const { x: clampedX, y: clampedY } = clampPan(newX, newY, state.scale)
|
|
269
|
+
|
|
270
|
+
setState((prev) => ({ ...prev, x: clampedX, y: clampedY }))
|
|
271
|
+
}, [isDragging, state.scale, clampPan])
|
|
272
|
+
|
|
273
|
+
const handleMouseUp = useCallback(() => {
|
|
274
|
+
// Small delay to prevent click from triggering after drag
|
|
275
|
+
setTimeout(() => setIsDragging(false), 10)
|
|
276
|
+
}, [])
|
|
277
|
+
|
|
278
|
+
// ========== COMMON ACTIONS ==========
|
|
279
|
+
|
|
280
|
+
const reset = useCallback(() => {
|
|
281
|
+
setState(INITIAL_STATE)
|
|
282
|
+
setIsDragging(false)
|
|
283
|
+
onZoomChange?.(false)
|
|
284
|
+
}, [onZoomChange])
|
|
285
|
+
|
|
286
|
+
const zoomTo = useCallback((scale: number, x = 0, y = 0) => {
|
|
287
|
+
const clampedScale = Math.max(minScale, Math.min(maxScale, scale))
|
|
288
|
+
const { x: clampedX, y: clampedY } = clampPan(x, y, clampedScale)
|
|
289
|
+
setState({ scale: clampedScale, x: clampedX, y: clampedY })
|
|
290
|
+
onZoomChange?.(clampedScale > ZOOM_THRESHOLD)
|
|
291
|
+
}, [minScale, maxScale, clampPan, onZoomChange])
|
|
292
|
+
|
|
293
|
+
const toggleZoom = useCallback(() => {
|
|
294
|
+
if (isZoomed) {
|
|
295
|
+
setState(INITIAL_STATE)
|
|
296
|
+
onZoomChange?.(false)
|
|
297
|
+
} else {
|
|
298
|
+
setState({ scale: desktopScale, x: 0, y: 0 })
|
|
299
|
+
onZoomChange?.(true)
|
|
300
|
+
}
|
|
301
|
+
}, [isZoomed, desktopScale, onZoomChange])
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
state,
|
|
305
|
+
isZoomed,
|
|
306
|
+
isDragging,
|
|
307
|
+
handlers: {
|
|
308
|
+
onTouchStart: handleTouchStart,
|
|
309
|
+
onTouchMove: handleTouchMove,
|
|
310
|
+
onTouchEnd: handleTouchEnd,
|
|
311
|
+
onClick: handleClick,
|
|
312
|
+
onMouseDown: handleMouseDown,
|
|
313
|
+
onMouseMove: handleMouseMove,
|
|
314
|
+
onMouseUp: handleMouseUp,
|
|
315
|
+
onMouseLeave: handleMouseUp,
|
|
316
|
+
},
|
|
317
|
+
reset,
|
|
318
|
+
zoomTo,
|
|
319
|
+
toggleZoom,
|
|
320
|
+
}
|
|
321
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Components
|
|
2
|
+
export {
|
|
3
|
+
Gallery,
|
|
4
|
+
GalleryCompact,
|
|
5
|
+
GalleryGrid,
|
|
6
|
+
GalleryImage,
|
|
7
|
+
GalleryVideo,
|
|
8
|
+
GalleryMedia,
|
|
9
|
+
GalleryThumbnails,
|
|
10
|
+
GalleryThumbnailsVirtual,
|
|
11
|
+
GalleryLightbox,
|
|
12
|
+
} from './components'
|
|
13
|
+
export type { GalleryCompactProps, GalleryAspectRatio, GalleryGridProps, GalleryGridLayout } from './components'
|
|
14
|
+
|
|
15
|
+
// Hooks
|
|
16
|
+
export {
|
|
17
|
+
useGallery,
|
|
18
|
+
useSwipe,
|
|
19
|
+
usePreloadImages,
|
|
20
|
+
preloadImage,
|
|
21
|
+
preloadImages,
|
|
22
|
+
usePinchZoom,
|
|
23
|
+
useVirtualList,
|
|
24
|
+
useImageDimensions,
|
|
25
|
+
clearDimensionsCache,
|
|
26
|
+
getCachedDimensions,
|
|
27
|
+
} from './hooks'
|
|
28
|
+
export type {
|
|
29
|
+
UseGalleryOptions,
|
|
30
|
+
UseSwipeOptions,
|
|
31
|
+
PinchZoomState,
|
|
32
|
+
UsePinchZoomOptions,
|
|
33
|
+
UsePinchZoomReturn,
|
|
34
|
+
VirtualListOptions,
|
|
35
|
+
VirtualListReturn,
|
|
36
|
+
VirtualItem,
|
|
37
|
+
ImageWithDimensions,
|
|
38
|
+
UseImageDimensionsResult,
|
|
39
|
+
UseImageDimensionsOptions,
|
|
40
|
+
} from './hooks'
|
|
41
|
+
|
|
42
|
+
// Utils
|
|
43
|
+
export {
|
|
44
|
+
getOrientation,
|
|
45
|
+
getAspectRatio,
|
|
46
|
+
analyzeImage,
|
|
47
|
+
analyzeImages,
|
|
48
|
+
} from './utils'
|
|
49
|
+
export type {
|
|
50
|
+
ImageOrientation,
|
|
51
|
+
AnalyzedImage,
|
|
52
|
+
} from './utils'
|
|
53
|
+
|
|
54
|
+
// Types
|
|
55
|
+
export type {
|
|
56
|
+
GalleryMediaType,
|
|
57
|
+
GalleryPreviewMode,
|
|
58
|
+
GalleryMediaItem,
|
|
59
|
+
GalleryState,
|
|
60
|
+
GalleryActions,
|
|
61
|
+
GalleryContextValue,
|
|
62
|
+
GalleryProps,
|
|
63
|
+
GalleryImageProps,
|
|
64
|
+
GalleryThumbnailsProps,
|
|
65
|
+
GalleryLightboxProps,
|
|
66
|
+
} from './types'
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import {
|
|
4
|
+
GalleryThumbnails,
|
|
5
|
+
GalleryThumbnailsVirtual,
|
|
6
|
+
type GalleryMediaItem,
|
|
7
|
+
} from '.';
|
|
8
|
+
|
|
9
|
+
const IMAGES: GalleryMediaItem[] = [
|
|
10
|
+
{ id: '1', src: 'https://images.unsplash.com/photo-1518791841217-8f162f1e1131?w=900', alt: 'Cat' },
|
|
11
|
+
{ id: '2', src: 'https://images.unsplash.com/photo-1546182990-dffeafbe841d?w=900', alt: 'Dog' },
|
|
12
|
+
{ id: '3', src: 'https://images.unsplash.com/photo-1444464666168-49d633b86797?w=900', alt: 'Birds' },
|
|
13
|
+
{ id: '4', src: 'https://images.unsplash.com/photo-1551279880-03041531948f?w=900', alt: 'Mountains' },
|
|
14
|
+
{ id: '5', src: 'https://images.unsplash.com/photo-1502082553048-f009c37129b9?w=900', alt: 'Forest' },
|
|
15
|
+
{ id: '6', src: 'https://images.unsplash.com/photo-1500382017468-9049fed747ef?w=900', alt: 'Field' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
// Every index used below is in range; this only satisfies the compiler's
|
|
19
|
+
// `noUncheckedIndexedAccess` and is never reached at runtime.
|
|
20
|
+
const FALLBACK: GalleryMediaItem = { id: 'fallback', src: '', alt: '' };
|
|
21
|
+
|
|
22
|
+
// Synthesize a large list for the virtual variant by recycling the 6 fixtures
|
|
23
|
+
const MANY: GalleryMediaItem[] = Array.from({ length: 240 }, (_, i) => {
|
|
24
|
+
const base = IMAGES[i % IMAGES.length] ?? FALLBACK;
|
|
25
|
+
return { ...base, id: `${base.id}-${i}`, alt: `${base.alt} #${i + 1}` };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const meta = {
|
|
29
|
+
title: 'Widgets/Visual/Gallery Thumbnails',
|
|
30
|
+
component: GalleryThumbnails,
|
|
31
|
+
args: { images: IMAGES, currentIndex: 0, onSelect: () => {}, size: 'md' },
|
|
32
|
+
tags: ['autodocs'],
|
|
33
|
+
parameters: {
|
|
34
|
+
layout: 'centered',
|
|
35
|
+
docs: {
|
|
36
|
+
description: {
|
|
37
|
+
component:
|
|
38
|
+
'Standalone thumbnail strips. `GalleryThumbnails` is a flex row sized for short lists; `GalleryThumbnailsVirtual` windowing-renders large lists (hundreds of items) via the internal `useVirtualList` hook.',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
} satisfies Meta<typeof GalleryThumbnails>;
|
|
43
|
+
|
|
44
|
+
export default meta;
|
|
45
|
+
|
|
46
|
+
type Story = StoryObj<typeof meta>;
|
|
47
|
+
|
|
48
|
+
function ThumbnailsDemo({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
|
|
49
|
+
const [index, setIndex] = useState(0);
|
|
50
|
+
const current = IMAGES[index] ?? FALLBACK;
|
|
51
|
+
return (
|
|
52
|
+
<div className="w-[720px] space-y-3">
|
|
53
|
+
<div className="rounded-lg overflow-hidden border border-border aspect-[16/9] bg-muted">
|
|
54
|
+
<img
|
|
55
|
+
src={current.src}
|
|
56
|
+
alt={current.alt ?? ''}
|
|
57
|
+
className="w-full h-full object-cover"
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
<GalleryThumbnails
|
|
61
|
+
images={IMAGES}
|
|
62
|
+
currentIndex={index}
|
|
63
|
+
onSelect={setIndex}
|
|
64
|
+
size={size}
|
|
65
|
+
/>
|
|
66
|
+
<p className="text-xs text-muted-foreground">
|
|
67
|
+
Selected #{index + 1} — {current.alt}
|
|
68
|
+
</p>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Standard thumbnail strip — sized for small-to-medium image counts.
|
|
75
|
+
*/
|
|
76
|
+
export const Standard: Story = {
|
|
77
|
+
args: { size: 'md' },
|
|
78
|
+
argTypes: {
|
|
79
|
+
size: {
|
|
80
|
+
control: 'inline-radio',
|
|
81
|
+
options: ['sm', 'md', 'lg'],
|
|
82
|
+
table: { category: 'Layout' },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
render: (args) => <ThumbnailsDemo size={args.size} />,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function VirtualDemo({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
|
|
89
|
+
const [index, setIndex] = useState(0);
|
|
90
|
+
const current = MANY[index] ?? FALLBACK;
|
|
91
|
+
return (
|
|
92
|
+
<div className="w-[720px] space-y-3">
|
|
93
|
+
<div className="rounded-lg overflow-hidden border border-border aspect-[16/9] bg-muted">
|
|
94
|
+
<img
|
|
95
|
+
src={current.src}
|
|
96
|
+
alt={current.alt ?? ''}
|
|
97
|
+
className="w-full h-full object-cover"
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
<GalleryThumbnailsVirtual
|
|
101
|
+
images={MANY}
|
|
102
|
+
currentIndex={index}
|
|
103
|
+
onSelect={setIndex}
|
|
104
|
+
size={size}
|
|
105
|
+
/>
|
|
106
|
+
<p className="text-xs text-muted-foreground">
|
|
107
|
+
{MANY.length} thumbnails virtualized — selected #{index + 1}
|
|
108
|
+
</p>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Virtualised variant — drop-in replacement when the image count would otherwise
|
|
115
|
+
* blow the DOM up. Same props as the standard component.
|
|
116
|
+
*/
|
|
117
|
+
export const Virtualized: Story = {
|
|
118
|
+
args: { size: 'md' },
|
|
119
|
+
argTypes: {
|
|
120
|
+
size: {
|
|
121
|
+
control: 'inline-radio',
|
|
122
|
+
options: ['sm', 'md', 'lg'],
|
|
123
|
+
table: { category: 'Layout' },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
render: (args) => <VirtualDemo size={args.size} />,
|
|
127
|
+
};
|