@djangocfg/ui-nextjs 2.1.64 → 2.1.66
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/package.json +9 -6
- package/src/blocks/SplitHero/SplitHeroMedia.tsx +2 -1
- package/src/tools/AudioPlayer/AudioEqualizer.tsx +235 -0
- package/src/tools/AudioPlayer/AudioPlayer.tsx +223 -0
- package/src/tools/AudioPlayer/AudioReactiveCover.tsx +389 -0
- package/src/tools/AudioPlayer/AudioShortcutsPopover.tsx +95 -0
- package/src/tools/AudioPlayer/README.md +301 -0
- package/src/tools/AudioPlayer/SimpleAudioPlayer.tsx +275 -0
- package/src/tools/AudioPlayer/VisualizationToggle.tsx +68 -0
- package/src/tools/AudioPlayer/context.tsx +426 -0
- package/src/tools/AudioPlayer/effects/index.ts +412 -0
- package/src/tools/AudioPlayer/index.ts +84 -0
- package/src/tools/AudioPlayer/types.ts +162 -0
- package/src/tools/AudioPlayer/useAudioHotkeys.ts +142 -0
- package/src/tools/AudioPlayer/useAudioVisualization.tsx +195 -0
- package/src/tools/ImageViewer/ImageViewer.tsx +416 -0
- package/src/tools/ImageViewer/README.md +161 -0
- package/src/tools/ImageViewer/index.ts +16 -0
- package/src/tools/VideoPlayer/README.md +196 -187
- package/src/tools/VideoPlayer/VideoErrorFallback.tsx +174 -0
- package/src/tools/VideoPlayer/VideoPlayer.tsx +189 -218
- package/src/tools/VideoPlayer/VideoPlayerContext.tsx +125 -0
- package/src/tools/VideoPlayer/index.ts +59 -7
- package/src/tools/VideoPlayer/providers/NativeProvider.tsx +206 -0
- package/src/tools/VideoPlayer/providers/StreamProvider.tsx +311 -0
- package/src/tools/VideoPlayer/providers/VidstackProvider.tsx +254 -0
- package/src/tools/VideoPlayer/providers/index.ts +8 -0
- package/src/tools/VideoPlayer/types.ts +320 -71
- package/src/tools/index.ts +82 -4
- package/src/tools/VideoPlayer/NativePlayer.tsx +0 -141
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ImageViewer - Image viewer with zoom, pan, rotate, flip
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Zoom with mouse wheel and presets
|
|
8
|
+
* - Pan with drag
|
|
9
|
+
* - Rotate 90°
|
|
10
|
+
* - Flip horizontal/vertical
|
|
11
|
+
* - Fullscreen dialog
|
|
12
|
+
* - Keyboard shortcuts (+/-, 0, r)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
|
16
|
+
import { ImageIcon, ZoomIn, ZoomOut, RotateCw, FlipHorizontal, FlipVertical, Maximize2, Expand } from 'lucide-react';
|
|
17
|
+
import { TransformWrapper, TransformComponent, useControls } from 'react-zoom-pan-pinch';
|
|
18
|
+
import { Button, cn, Dialog, DialogContent } from '@djangocfg/ui-core';
|
|
19
|
+
import {
|
|
20
|
+
DropdownMenu,
|
|
21
|
+
DropdownMenuContent,
|
|
22
|
+
DropdownMenuItem,
|
|
23
|
+
DropdownMenuTrigger,
|
|
24
|
+
} from '../../components/dropdown-menu';
|
|
25
|
+
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// TYPES
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
export interface ImageFile {
|
|
31
|
+
name: string;
|
|
32
|
+
path: string;
|
|
33
|
+
mimeType?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Preset zoom levels
|
|
37
|
+
const ZOOM_PRESETS = [
|
|
38
|
+
{ label: 'Fit', value: 'fit' },
|
|
39
|
+
{ label: '25%', value: 0.25 },
|
|
40
|
+
{ label: '50%', value: 0.5 },
|
|
41
|
+
{ label: '100%', value: 1 },
|
|
42
|
+
{ label: '200%', value: 2 },
|
|
43
|
+
{ label: '400%', value: 4 },
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
export interface ImageViewerProps {
|
|
47
|
+
/** File info (name, path, mimeType) */
|
|
48
|
+
file: ImageFile;
|
|
49
|
+
/** Image content as string (data URL or base64) or ArrayBuffer */
|
|
50
|
+
content: string | ArrayBuffer;
|
|
51
|
+
/** Hide expand button when already in dialog */
|
|
52
|
+
inDialog?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ImageTransform {
|
|
56
|
+
rotation: number;
|
|
57
|
+
flipH: boolean;
|
|
58
|
+
flipV: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Toolbar component
|
|
62
|
+
function ImageToolbar({
|
|
63
|
+
scale,
|
|
64
|
+
transform,
|
|
65
|
+
onRotate,
|
|
66
|
+
onFlipH,
|
|
67
|
+
onFlipV,
|
|
68
|
+
onZoomPreset,
|
|
69
|
+
onExpand,
|
|
70
|
+
}: {
|
|
71
|
+
scale: number;
|
|
72
|
+
transform: ImageTransform;
|
|
73
|
+
onRotate: () => void;
|
|
74
|
+
onFlipH: () => void;
|
|
75
|
+
onFlipV: () => void;
|
|
76
|
+
onZoomPreset: (value: number | 'fit') => void;
|
|
77
|
+
onExpand?: () => void;
|
|
78
|
+
}) {
|
|
79
|
+
const { zoomIn, zoomOut, resetTransform, centerView } = useControls();
|
|
80
|
+
|
|
81
|
+
const zoomLabel = useMemo(() => {
|
|
82
|
+
const percent = Math.round(scale * 100);
|
|
83
|
+
return `${percent}%`;
|
|
84
|
+
}, [scale]);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-0.5 bg-background/90 backdrop-blur-sm border rounded-lg p-1 shadow-lg">
|
|
88
|
+
{/* Zoom controls */}
|
|
89
|
+
<Button
|
|
90
|
+
variant="ghost"
|
|
91
|
+
size="icon"
|
|
92
|
+
className="h-7 w-7"
|
|
93
|
+
onClick={() => zoomOut()}
|
|
94
|
+
>
|
|
95
|
+
<ZoomOut className="h-3.5 w-3.5" />
|
|
96
|
+
</Button>
|
|
97
|
+
|
|
98
|
+
<DropdownMenu>
|
|
99
|
+
<DropdownMenuTrigger asChild>
|
|
100
|
+
<Button variant="ghost" size="sm" className="h-7 px-2 min-w-[52px] font-mono text-xs">
|
|
101
|
+
{zoomLabel}
|
|
102
|
+
</Button>
|
|
103
|
+
</DropdownMenuTrigger>
|
|
104
|
+
<DropdownMenuContent align="center" className="min-w-[80px]">
|
|
105
|
+
{ZOOM_PRESETS.map((preset) => (
|
|
106
|
+
<DropdownMenuItem
|
|
107
|
+
key={preset.label}
|
|
108
|
+
onClick={() => onZoomPreset(preset.value)}
|
|
109
|
+
className="text-xs justify-center"
|
|
110
|
+
>
|
|
111
|
+
{preset.label}
|
|
112
|
+
</DropdownMenuItem>
|
|
113
|
+
))}
|
|
114
|
+
</DropdownMenuContent>
|
|
115
|
+
</DropdownMenu>
|
|
116
|
+
|
|
117
|
+
<Button
|
|
118
|
+
variant="ghost"
|
|
119
|
+
size="icon"
|
|
120
|
+
className="h-7 w-7"
|
|
121
|
+
onClick={() => zoomIn()}
|
|
122
|
+
>
|
|
123
|
+
<ZoomIn className="h-3.5 w-3.5" />
|
|
124
|
+
</Button>
|
|
125
|
+
|
|
126
|
+
<div className="w-px h-4 bg-border mx-1" />
|
|
127
|
+
|
|
128
|
+
{/* Fit to view */}
|
|
129
|
+
<Button
|
|
130
|
+
variant="ghost"
|
|
131
|
+
size="icon"
|
|
132
|
+
className="h-7 w-7"
|
|
133
|
+
onClick={() => resetTransform()}
|
|
134
|
+
title="Fit to view"
|
|
135
|
+
>
|
|
136
|
+
<Maximize2 className="h-3.5 w-3.5" />
|
|
137
|
+
</Button>
|
|
138
|
+
|
|
139
|
+
<div className="w-px h-4 bg-border mx-1" />
|
|
140
|
+
|
|
141
|
+
{/* Transform controls */}
|
|
142
|
+
<Button
|
|
143
|
+
variant="ghost"
|
|
144
|
+
size="icon"
|
|
145
|
+
className={cn("h-7 w-7", transform.flipH && "bg-accent")}
|
|
146
|
+
onClick={onFlipH}
|
|
147
|
+
title="Flip horizontal"
|
|
148
|
+
>
|
|
149
|
+
<FlipHorizontal className="h-3.5 w-3.5" />
|
|
150
|
+
</Button>
|
|
151
|
+
|
|
152
|
+
<Button
|
|
153
|
+
variant="ghost"
|
|
154
|
+
size="icon"
|
|
155
|
+
className={cn("h-7 w-7", transform.flipV && "bg-accent")}
|
|
156
|
+
onClick={onFlipV}
|
|
157
|
+
title="Flip vertical"
|
|
158
|
+
>
|
|
159
|
+
<FlipVertical className="h-3.5 w-3.5" />
|
|
160
|
+
</Button>
|
|
161
|
+
|
|
162
|
+
<Button
|
|
163
|
+
variant="ghost"
|
|
164
|
+
size="icon"
|
|
165
|
+
className="h-7 w-7"
|
|
166
|
+
onClick={onRotate}
|
|
167
|
+
title="Rotate 90°"
|
|
168
|
+
>
|
|
169
|
+
<RotateCw className="h-3.5 w-3.5" />
|
|
170
|
+
</Button>
|
|
171
|
+
|
|
172
|
+
{transform.rotation !== 0 && (
|
|
173
|
+
<span className="text-[10px] text-muted-foreground font-mono pl-1">
|
|
174
|
+
{transform.rotation}°
|
|
175
|
+
</span>
|
|
176
|
+
)}
|
|
177
|
+
|
|
178
|
+
{onExpand && (
|
|
179
|
+
<>
|
|
180
|
+
<div className="w-px h-4 bg-border mx-1" />
|
|
181
|
+
<Button
|
|
182
|
+
variant="ghost"
|
|
183
|
+
size="icon"
|
|
184
|
+
className="h-7 w-7"
|
|
185
|
+
onClick={onExpand}
|
|
186
|
+
title="Open in fullscreen"
|
|
187
|
+
>
|
|
188
|
+
<Expand className="h-3.5 w-3.5" />
|
|
189
|
+
</Button>
|
|
190
|
+
</>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Image dimensions badge
|
|
197
|
+
function ImageInfo({ src }: { src: string }) {
|
|
198
|
+
const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null);
|
|
199
|
+
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
const img = new Image();
|
|
202
|
+
img.onload = () => setDimensions({ w: img.naturalWidth, h: img.naturalHeight });
|
|
203
|
+
img.src = src;
|
|
204
|
+
}, [src]);
|
|
205
|
+
|
|
206
|
+
if (!dimensions) return null;
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div className="absolute top-3 right-3 z-10 px-2 py-1 bg-background/80 backdrop-blur-sm border rounded text-[10px] text-muted-foreground font-mono">
|
|
210
|
+
{dimensions.w} × {dimensions.h}
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function ImageViewer({ file, content, inDialog = false }: ImageViewerProps) {
|
|
216
|
+
const contentSize = content ? (typeof content === 'string' ? content.length : content.byteLength) : 0;
|
|
217
|
+
const hasContent = contentSize > 0;
|
|
218
|
+
|
|
219
|
+
const [transform, setTransform] = useState<ImageTransform>({
|
|
220
|
+
rotation: 0,
|
|
221
|
+
flipH: false,
|
|
222
|
+
flipV: false,
|
|
223
|
+
});
|
|
224
|
+
const [scale, setScale] = useState(1);
|
|
225
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
226
|
+
const blobUrlRef = useRef<string | null>(null);
|
|
227
|
+
const [src, setSrc] = useState<string | null>(null);
|
|
228
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
229
|
+
const controlsRef = useRef<ReturnType<typeof useControls> | null>(null);
|
|
230
|
+
|
|
231
|
+
// Create blob URL
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (!hasContent) {
|
|
234
|
+
setSrc(null);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (typeof content === 'string') {
|
|
239
|
+
const url = content.startsWith('data:') ? content : `data:${file.mimeType};base64,${btoa(content)}`;
|
|
240
|
+
setSrc(url);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const blob = new Blob([content], { type: file.mimeType || 'image/png' });
|
|
245
|
+
const url = URL.createObjectURL(blob);
|
|
246
|
+
blobUrlRef.current = url;
|
|
247
|
+
setSrc(url);
|
|
248
|
+
|
|
249
|
+
return () => {
|
|
250
|
+
if (blobUrlRef.current) {
|
|
251
|
+
URL.revokeObjectURL(blobUrlRef.current);
|
|
252
|
+
blobUrlRef.current = null;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
}, [content, file.mimeType, hasContent]);
|
|
256
|
+
|
|
257
|
+
// Reset transform when file changes
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
setTransform({ rotation: 0, flipH: false, flipV: false });
|
|
260
|
+
}, [file.path]);
|
|
261
|
+
|
|
262
|
+
const handleRotate = useCallback(() => {
|
|
263
|
+
setTransform((prev) => ({ ...prev, rotation: (prev.rotation + 90) % 360 }));
|
|
264
|
+
}, []);
|
|
265
|
+
|
|
266
|
+
const handleFlipH = useCallback(() => {
|
|
267
|
+
setTransform((prev) => ({ ...prev, flipH: !prev.flipH }));
|
|
268
|
+
}, []);
|
|
269
|
+
|
|
270
|
+
const handleFlipV = useCallback(() => {
|
|
271
|
+
setTransform((prev) => ({ ...prev, flipV: !prev.flipV }));
|
|
272
|
+
}, []);
|
|
273
|
+
|
|
274
|
+
const handleZoomPreset = useCallback((value: number | 'fit') => {
|
|
275
|
+
if (!controlsRef.current) return;
|
|
276
|
+
if (value === 'fit') {
|
|
277
|
+
controlsRef.current.resetTransform();
|
|
278
|
+
} else {
|
|
279
|
+
controlsRef.current.setTransform(0, 0, value);
|
|
280
|
+
}
|
|
281
|
+
}, []);
|
|
282
|
+
|
|
283
|
+
const handleExpand = useCallback(() => {
|
|
284
|
+
setDialogOpen(true);
|
|
285
|
+
}, []);
|
|
286
|
+
|
|
287
|
+
// CSS transform for rotation and flip
|
|
288
|
+
const imageTransform = useMemo(() => {
|
|
289
|
+
const transforms: string[] = [];
|
|
290
|
+
if (transform.rotation) transforms.push(`rotate(${transform.rotation}deg)`);
|
|
291
|
+
if (transform.flipH) transforms.push('scaleX(-1)');
|
|
292
|
+
if (transform.flipV) transforms.push('scaleY(-1)');
|
|
293
|
+
return transforms.join(' ') || 'none';
|
|
294
|
+
}, [transform]);
|
|
295
|
+
|
|
296
|
+
// Keyboard shortcuts
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
299
|
+
if (!containerRef.current?.contains(document.activeElement) &&
|
|
300
|
+
document.activeElement !== containerRef.current) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const controls = controlsRef.current;
|
|
305
|
+
if (!controls) return;
|
|
306
|
+
|
|
307
|
+
switch (e.key) {
|
|
308
|
+
case '+':
|
|
309
|
+
case '=':
|
|
310
|
+
e.preventDefault();
|
|
311
|
+
controls.zoomIn();
|
|
312
|
+
break;
|
|
313
|
+
case '-':
|
|
314
|
+
e.preventDefault();
|
|
315
|
+
controls.zoomOut();
|
|
316
|
+
break;
|
|
317
|
+
case '0':
|
|
318
|
+
e.preventDefault();
|
|
319
|
+
controls.resetTransform();
|
|
320
|
+
break;
|
|
321
|
+
case 'r':
|
|
322
|
+
if (!e.metaKey && !e.ctrlKey) {
|
|
323
|
+
e.preventDefault();
|
|
324
|
+
handleRotate();
|
|
325
|
+
}
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
331
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
332
|
+
}, [handleRotate]);
|
|
333
|
+
|
|
334
|
+
if (!hasContent) {
|
|
335
|
+
return (
|
|
336
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-2 bg-muted/30">
|
|
337
|
+
<ImageIcon className="w-12 h-12 text-muted-foreground/50" />
|
|
338
|
+
<p className="text-sm text-muted-foreground">Failed to load image</p>
|
|
339
|
+
</div>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<div
|
|
345
|
+
ref={containerRef}
|
|
346
|
+
tabIndex={0}
|
|
347
|
+
className={cn(
|
|
348
|
+
"flex-1 h-full relative overflow-hidden outline-none",
|
|
349
|
+
"bg-[length:16px_16px]",
|
|
350
|
+
"[background-color:hsl(var(--muted)/0.2)]",
|
|
351
|
+
"[background-image:linear-gradient(45deg,hsl(var(--muted)/0.4)_25%,transparent_25%),linear-gradient(-45deg,hsl(var(--muted)/0.4)_25%,transparent_25%),linear-gradient(45deg,transparent_75%,hsl(var(--muted)/0.4)_75%),linear-gradient(-45deg,transparent_75%,hsl(var(--muted)/0.4)_75%)]",
|
|
352
|
+
"[background-position:0_0,0_8px,8px_-8px,-8px_0px]"
|
|
353
|
+
)}
|
|
354
|
+
>
|
|
355
|
+
{src && <ImageInfo src={src} />}
|
|
356
|
+
|
|
357
|
+
<TransformWrapper
|
|
358
|
+
initialScale={1}
|
|
359
|
+
minScale={0.1}
|
|
360
|
+
maxScale={8}
|
|
361
|
+
centerOnInit
|
|
362
|
+
onTransformed={(ref, state) => {
|
|
363
|
+
setScale(state.scale);
|
|
364
|
+
controlsRef.current = ref;
|
|
365
|
+
}}
|
|
366
|
+
onInit={(ref) => {
|
|
367
|
+
controlsRef.current = ref;
|
|
368
|
+
}}
|
|
369
|
+
wheel={{ step: 0.1 }}
|
|
370
|
+
doubleClick={{ mode: 'toggle', step: 2 }}
|
|
371
|
+
panning={{ velocityDisabled: false }}
|
|
372
|
+
>
|
|
373
|
+
<ImageToolbar
|
|
374
|
+
scale={scale}
|
|
375
|
+
transform={transform}
|
|
376
|
+
onRotate={handleRotate}
|
|
377
|
+
onFlipH={handleFlipH}
|
|
378
|
+
onFlipV={handleFlipV}
|
|
379
|
+
onZoomPreset={handleZoomPreset}
|
|
380
|
+
onExpand={!inDialog ? handleExpand : undefined}
|
|
381
|
+
/>
|
|
382
|
+
|
|
383
|
+
<TransformComponent
|
|
384
|
+
wrapperClass="!w-full !h-full cursor-grab active:cursor-grabbing"
|
|
385
|
+
contentClass="!w-full !h-full flex items-center justify-center"
|
|
386
|
+
>
|
|
387
|
+
<img
|
|
388
|
+
src={src!}
|
|
389
|
+
alt={file.name}
|
|
390
|
+
className="max-w-full max-h-full object-contain select-none"
|
|
391
|
+
style={{ transform: imageTransform, transition: 'transform 0.15s ease-out' }}
|
|
392
|
+
draggable={false}
|
|
393
|
+
/>
|
|
394
|
+
</TransformComponent>
|
|
395
|
+
</TransformWrapper>
|
|
396
|
+
|
|
397
|
+
{/* Fullscreen dialog */}
|
|
398
|
+
{!inDialog && (
|
|
399
|
+
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
400
|
+
<DialogContent className="max-w-[95vw] max-h-[95vh] w-[95vw] h-[95vh] p-0 overflow-hidden [&>button]:hidden">
|
|
401
|
+
<div className="h-full flex flex-col">
|
|
402
|
+
<div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
|
|
403
|
+
<span className="text-sm font-medium truncate">{file.name}</span>
|
|
404
|
+
</div>
|
|
405
|
+
<div className="flex-1 min-h-0 relative">
|
|
406
|
+
<div className="absolute inset-0">
|
|
407
|
+
<ImageViewer file={file} content={content} inDialog />
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
</DialogContent>
|
|
412
|
+
</Dialog>
|
|
413
|
+
)}
|
|
414
|
+
</div>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# ImageViewer
|
|
2
|
+
|
|
3
|
+
Image viewer with zoom, pan, rotate, and flip functionality.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Mouse wheel zoom
|
|
8
|
+
- Drag to pan
|
|
9
|
+
- Zoom presets (25%, 50%, 100%, 200%, 400%)
|
|
10
|
+
- Rotate 90°
|
|
11
|
+
- Flip horizontal/vertical
|
|
12
|
+
- Fullscreen dialog mode
|
|
13
|
+
- Keyboard shortcuts
|
|
14
|
+
- Checkerboard background for transparency
|
|
15
|
+
- Image dimensions display
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { ImageViewer } from '@djangocfg/ui-nextjs';
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Basic Usage
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
<ImageViewer
|
|
27
|
+
file={{
|
|
28
|
+
name: 'photo.jpg',
|
|
29
|
+
path: '/images/photo.jpg',
|
|
30
|
+
mimeType: 'image/jpeg',
|
|
31
|
+
}}
|
|
32
|
+
content={imageArrayBuffer}
|
|
33
|
+
/>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Props
|
|
37
|
+
|
|
38
|
+
| Prop | Type | Default | Description |
|
|
39
|
+
|------|------|---------|-------------|
|
|
40
|
+
| `file` | `ImageFile` | required | File info (name, path, mimeType) |
|
|
41
|
+
| `content` | `string \| ArrayBuffer` | required | Image data |
|
|
42
|
+
| `inDialog` | `boolean` | `false` | Hide expand button (for nested usage) |
|
|
43
|
+
|
|
44
|
+
## ImageFile Type
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
interface ImageFile {
|
|
48
|
+
name: string; // Display name
|
|
49
|
+
path: string; // File path (for state tracking)
|
|
50
|
+
mimeType?: string; // MIME type (e.g., 'image/png')
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Content Formats
|
|
55
|
+
|
|
56
|
+
The `content` prop accepts:
|
|
57
|
+
|
|
58
|
+
- **ArrayBuffer**: Binary image data (creates blob URL)
|
|
59
|
+
- **Data URL**: Base64 encoded string starting with `data:`
|
|
60
|
+
- **Base64 string**: Raw base64 (auto-prefixed with data URL)
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
// ArrayBuffer (from fetch or file read)
|
|
64
|
+
const response = await fetch('/image.png');
|
|
65
|
+
const buffer = await response.arrayBuffer();
|
|
66
|
+
<ImageViewer file={file} content={buffer} />
|
|
67
|
+
|
|
68
|
+
// Data URL
|
|
69
|
+
<ImageViewer file={file} content="data:image/png;base64,iVBORw0KGgo..." />
|
|
70
|
+
|
|
71
|
+
// Base64 string (auto-converted to data URL)
|
|
72
|
+
<ImageViewer file={file} content="iVBORw0KGgo..." />
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Keyboard Shortcuts
|
|
76
|
+
|
|
77
|
+
| Key | Action |
|
|
78
|
+
|-----|--------|
|
|
79
|
+
| `+` / `=` | Zoom in |
|
|
80
|
+
| `-` | Zoom out |
|
|
81
|
+
| `0` | Reset to fit |
|
|
82
|
+
| `R` | Rotate 90° |
|
|
83
|
+
|
|
84
|
+
## Toolbar Controls
|
|
85
|
+
|
|
86
|
+
The floating toolbar at the bottom provides:
|
|
87
|
+
|
|
88
|
+
- **Zoom out** button
|
|
89
|
+
- **Zoom level** dropdown with presets
|
|
90
|
+
- **Zoom in** button
|
|
91
|
+
- **Fit to view** button
|
|
92
|
+
- **Flip horizontal** toggle
|
|
93
|
+
- **Flip vertical** toggle
|
|
94
|
+
- **Rotate 90°** button
|
|
95
|
+
- **Expand** fullscreen button
|
|
96
|
+
|
|
97
|
+
## Fullscreen Mode
|
|
98
|
+
|
|
99
|
+
Click the expand button to open the image in a fullscreen dialog. The dialog includes the same toolbar and supports all interactions.
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
// Fullscreen is automatically available unless inDialog is true
|
|
103
|
+
<ImageViewer file={file} content={content} />
|
|
104
|
+
|
|
105
|
+
// When embedding in your own dialog, disable the expand button
|
|
106
|
+
<Dialog>
|
|
107
|
+
<ImageViewer file={file} content={content} inDialog />
|
|
108
|
+
</Dialog>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Styling
|
|
112
|
+
|
|
113
|
+
The component fills its container and displays a checkerboard pattern behind transparent images.
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
<div className="w-full h-[500px]">
|
|
117
|
+
<ImageViewer file={file} content={content} />
|
|
118
|
+
</div>
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Error State
|
|
122
|
+
|
|
123
|
+
When content is empty or invalid, displays an error placeholder:
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
// Shows "Failed to load image" with icon
|
|
127
|
+
<ImageViewer file={file} content="" />
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Example: File Browser Integration
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
function FilePreview({ file, content }: { file: OpenFile; content: ArrayBuffer }) {
|
|
134
|
+
const imageFile: ImageFile = {
|
|
135
|
+
name: file.name,
|
|
136
|
+
path: file.path,
|
|
137
|
+
mimeType: file.mimeType,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className="h-full">
|
|
142
|
+
<ImageViewer file={imageFile} content={content} />
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Architecture
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
ImageViewer/
|
|
152
|
+
├── index.ts # Exports
|
|
153
|
+
├── ImageViewer.tsx # Main component with all functionality
|
|
154
|
+
└── README.md # This file
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Dependencies
|
|
158
|
+
|
|
159
|
+
- `react-zoom-pan-pinch` - Zoom and pan functionality
|
|
160
|
+
- `lucide-react` - Icons
|
|
161
|
+
- `@djangocfg/ui-core` - UI components (Button, Dialog, etc.)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageViewer - Image viewer with zoom, pan, rotate, flip
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Zoom with mouse wheel and presets (25%, 50%, 100%, 200%, 400%)
|
|
6
|
+
* - Pan with drag
|
|
7
|
+
* - Rotate 90°
|
|
8
|
+
* - Flip horizontal/vertical
|
|
9
|
+
* - Fullscreen dialog mode
|
|
10
|
+
* - Keyboard shortcuts (+/-, 0 for reset, r for rotate)
|
|
11
|
+
* - Checkerboard background for transparency
|
|
12
|
+
* - Image dimensions display
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export { ImageViewer } from './ImageViewer';
|
|
16
|
+
export type { ImageViewerProps, ImageFile } from './ImageViewer';
|