@djangocfg/ui-tools 2.1.207 → 2.1.209
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 +47 -1
- package/dist/{chunk-MADKYFI3.mjs → chunk-BEURMR25.mjs} +73 -27
- package/dist/chunk-BEURMR25.mjs.map +1 -0
- package/dist/{chunk-6RG7HOVF.cjs → chunk-IPRNIM7L.cjs} +72 -26
- package/dist/chunk-IPRNIM7L.cjs.map +1 -0
- package/dist/components-3DASJBTX.mjs +5 -0
- package/dist/{components-GGZJFEII.mjs.map → components-3DASJBTX.mjs.map} +1 -1
- package/dist/components-NW2ZF6TG.cjs +22 -0
- package/dist/{components-YC32T5D7.cjs.map → components-NW2ZF6TG.cjs.map} +1 -1
- package/dist/index.cjs +3 -3
- package/dist/index.d.cts +19 -9
- package/dist/index.d.ts +19 -9
- package/dist/index.mjs +2 -2
- package/package.json +6 -6
- package/src/tools/ImageViewer/ImageViewer.story.tsx +85 -0
- package/src/tools/ImageViewer/README.md +73 -93
- package/src/tools/ImageViewer/components/ImageViewer.tsx +94 -75
- package/src/tools/ImageViewer/types.ts +19 -8
- package/dist/chunk-6RG7HOVF.cjs.map +0 -1
- package/dist/chunk-MADKYFI3.mjs.map +0 -1
- package/dist/components-GGZJFEII.mjs +0 -5
- package/dist/components-YC32T5D7.cjs +0 -22
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ImageViewer
|
|
2
2
|
|
|
3
|
-
Image viewer with zoom, pan, rotate, and
|
|
3
|
+
Image viewer with zoom, pan, rotate, flip and gallery navigation.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
@@ -10,26 +10,44 @@ Image viewer with zoom, pan, rotate, and flip functionality.
|
|
|
10
10
|
- Rotate 90°
|
|
11
11
|
- Flip horizontal/vertical
|
|
12
12
|
- Fullscreen dialog mode
|
|
13
|
-
-
|
|
13
|
+
- **Gallery mode** — multiple images with prev/next buttons
|
|
14
|
+
- **Keyboard shortcuts** — `+/-/0/r` for zoom/rotate, `←/→` for gallery navigation
|
|
14
15
|
- Checkerboard background for transparency
|
|
15
16
|
- Image dimensions display
|
|
16
17
|
|
|
17
18
|
## Installation
|
|
18
19
|
|
|
19
20
|
```tsx
|
|
20
|
-
import { ImageViewer } from '@djangocfg/ui-
|
|
21
|
+
import { ImageViewer } from '@djangocfg/ui-tools';
|
|
21
22
|
```
|
|
22
23
|
|
|
23
24
|
## Basic Usage
|
|
24
25
|
|
|
25
26
|
```tsx
|
|
27
|
+
// Single image — URL
|
|
26
28
|
<ImageViewer
|
|
27
|
-
|
|
28
|
-
name: 'photo.jpg',
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
images={[{
|
|
30
|
+
file: { name: 'photo.jpg', path: '/images/photo.jpg' },
|
|
31
|
+
src: 'https://example.com/photo.jpg',
|
|
32
|
+
}]}
|
|
33
|
+
/>
|
|
34
|
+
|
|
35
|
+
// Single image — raw bytes / base64
|
|
36
|
+
<ImageViewer
|
|
37
|
+
images={[{
|
|
38
|
+
file: { name: 'photo.png', path: 'unique-key', mimeType: 'image/png' },
|
|
39
|
+
content: arrayBuffer, // ArrayBuffer or base64 string
|
|
40
|
+
}]}
|
|
41
|
+
/>
|
|
42
|
+
|
|
43
|
+
// Gallery
|
|
44
|
+
<ImageViewer
|
|
45
|
+
images={[
|
|
46
|
+
{ file: { name: 'Photo 1', path: 'p1' }, src: 'https://example.com/1.jpg' },
|
|
47
|
+
{ file: { name: 'Photo 2', path: 'p2' }, src: 'https://example.com/2.jpg' },
|
|
48
|
+
{ file: { name: 'Photo 3', path: 'p3' }, src: 'https://example.com/3.jpg' },
|
|
49
|
+
]}
|
|
50
|
+
initialIndex={0}
|
|
33
51
|
/>
|
|
34
52
|
```
|
|
35
53
|
|
|
@@ -37,66 +55,51 @@ import { ImageViewer } from '@djangocfg/ui-nextjs';
|
|
|
37
55
|
|
|
38
56
|
| Prop | Type | Default | Description |
|
|
39
57
|
|------|------|---------|-------------|
|
|
40
|
-
| `
|
|
41
|
-
| `
|
|
42
|
-
| `src` | `string` | - | Direct URL (bypasses content→blob conversion) |
|
|
58
|
+
| `images` | `ImageItem[]` | required | Array of images to display |
|
|
59
|
+
| `initialIndex` | `number` | `0` | Index of the image to show first |
|
|
43
60
|
| `inDialog` | `boolean` | `false` | Hide expand button (for nested usage) |
|
|
44
61
|
|
|
45
|
-
##
|
|
62
|
+
## Types
|
|
46
63
|
|
|
47
64
|
```typescript
|
|
65
|
+
interface ImageItem {
|
|
66
|
+
file: ImageFile;
|
|
67
|
+
src?: string; // URL (CDN / server)
|
|
68
|
+
content?: string | ArrayBuffer; // Raw bytes or base64 (memory / blob)
|
|
69
|
+
}
|
|
70
|
+
|
|
48
71
|
interface ImageFile {
|
|
49
72
|
name: string; // Display name
|
|
50
|
-
path: string; // File path (for state tracking)
|
|
73
|
+
path: string; // File path (for state tracking / cache key)
|
|
51
74
|
mimeType?: string; // MIME type (e.g., 'image/png')
|
|
52
75
|
}
|
|
53
76
|
```
|
|
54
77
|
|
|
55
|
-
|
|
78
|
+
Both `src` and `content` are optional — supply whichever you have. When both are provided, `src` takes precedence (passed directly to `useImageLoading` as the direct URL).
|
|
56
79
|
|
|
57
|
-
|
|
80
|
+
### content support
|
|
58
81
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// ArrayBuffer (from fetch or file read)
|
|
65
|
-
const response = await fetch('/image.png');
|
|
66
|
-
const buffer = await response.arrayBuffer();
|
|
67
|
-
<ImageViewer file={file} content={buffer} />
|
|
68
|
-
|
|
69
|
-
// Data URL
|
|
70
|
-
<ImageViewer file={file} content="data:image/png;base64,iVBORw0KGgo..." />
|
|
71
|
-
|
|
72
|
-
// Base64 string (auto-converted to data URL)
|
|
73
|
-
<ImageViewer file={file} content="iVBORw0KGgo..." />
|
|
74
|
-
|
|
75
|
-
// Direct URL (HTTP streaming for large files)
|
|
76
|
-
<ImageViewer
|
|
77
|
-
file={file}
|
|
78
|
-
content=""
|
|
79
|
-
src="https://api.example.com/images/large-photo.jpg"
|
|
80
|
-
/>
|
|
81
|
-
```
|
|
82
|
+
| Value | Behaviour |
|
|
83
|
+
|-------|-----------|
|
|
84
|
+
| `data:image/...` string | used directly as `<img src>` |
|
|
85
|
+
| binary string | encoded → Blob URL via `TextEncoder` |
|
|
86
|
+
| `ArrayBuffer` | converted to Blob URL with caching |
|
|
82
87
|
|
|
83
|
-
##
|
|
88
|
+
## Gallery Navigation
|
|
84
89
|
|
|
85
|
-
|
|
90
|
+
When `images` has more than one item, prev/next buttons appear and keyboard navigation is enabled:
|
|
86
91
|
|
|
87
92
|
```tsx
|
|
88
|
-
// When src is provided, content is ignored
|
|
89
93
|
<ImageViewer
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
src={streamingUrl}
|
|
94
|
+
images={photos}
|
|
95
|
+
initialIndex={2} // open at 3rd photo
|
|
93
96
|
/>
|
|
94
97
|
```
|
|
95
98
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
| Key | Action |
|
|
100
|
+
|-----|--------|
|
|
101
|
+
| `←` | Previous image |
|
|
102
|
+
| `→` | Next image |
|
|
100
103
|
|
|
101
104
|
## Keyboard Shortcuts
|
|
102
105
|
|
|
@@ -106,10 +109,12 @@ This is useful for:
|
|
|
106
109
|
| `-` | Zoom out |
|
|
107
110
|
| `0` | Reset to fit |
|
|
108
111
|
| `R` | Rotate 90° |
|
|
112
|
+
| `←` | Previous image (gallery) |
|
|
113
|
+
| `→` | Next image (gallery) |
|
|
109
114
|
|
|
110
115
|
## Toolbar Controls
|
|
111
116
|
|
|
112
|
-
The floating toolbar
|
|
117
|
+
The floating toolbar provides:
|
|
113
118
|
|
|
114
119
|
- **Zoom out** button
|
|
115
120
|
- **Zoom level** dropdown with presets
|
|
@@ -122,63 +127,38 @@ The floating toolbar at the bottom provides:
|
|
|
122
127
|
|
|
123
128
|
## Fullscreen Mode
|
|
124
129
|
|
|
125
|
-
Click the expand button to open
|
|
130
|
+
Click the expand button to open in a fullscreen dialog. Gallery navigation works inside the dialog too.
|
|
126
131
|
|
|
127
132
|
```tsx
|
|
128
|
-
// Fullscreen
|
|
129
|
-
<ImageViewer
|
|
133
|
+
// Fullscreen available by default
|
|
134
|
+
<ImageViewer images={images} />
|
|
130
135
|
|
|
131
|
-
//
|
|
136
|
+
// Disable expand button when embedding in your own dialog
|
|
132
137
|
<Dialog>
|
|
133
|
-
<ImageViewer
|
|
138
|
+
<ImageViewer images={images} inDialog />
|
|
134
139
|
</Dialog>
|
|
135
140
|
```
|
|
136
141
|
|
|
137
142
|
## Styling
|
|
138
143
|
|
|
139
|
-
The component fills its container
|
|
144
|
+
The component fills its container. Wrap in a sized element:
|
|
140
145
|
|
|
141
146
|
```tsx
|
|
142
147
|
<div className="w-full h-[500px]">
|
|
143
|
-
<ImageViewer
|
|
148
|
+
<ImageViewer images={images} />
|
|
144
149
|
</div>
|
|
145
150
|
```
|
|
146
151
|
|
|
147
|
-
## Error State
|
|
148
|
-
|
|
149
|
-
When content is empty or invalid, displays an error placeholder:
|
|
150
|
-
|
|
151
|
-
```tsx
|
|
152
|
-
// Shows "Failed to load image" with icon
|
|
153
|
-
<ImageViewer file={file} content="" />
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
## Example: File Browser Integration
|
|
157
|
-
|
|
158
|
-
```tsx
|
|
159
|
-
function FilePreview({ file, content }: { file: OpenFile; content: ArrayBuffer }) {
|
|
160
|
-
const imageFile: ImageFile = {
|
|
161
|
-
name: file.name,
|
|
162
|
-
path: file.path,
|
|
163
|
-
mimeType: file.mimeType,
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
return (
|
|
167
|
-
<div className="h-full">
|
|
168
|
-
<ImageViewer file={imageFile} content={content} />
|
|
169
|
-
</div>
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
```
|
|
173
|
-
|
|
174
152
|
## Architecture
|
|
175
153
|
|
|
176
154
|
```
|
|
177
155
|
ImageViewer/
|
|
178
156
|
├── index.ts # Public API exports
|
|
179
157
|
├── types.ts # Type definitions
|
|
158
|
+
├── ImageViewer.story.tsx # Playground stories
|
|
159
|
+
├── README.md
|
|
180
160
|
├── components/
|
|
181
|
-
│ ├── index.ts
|
|
161
|
+
│ ├── index.ts
|
|
182
162
|
│ ├── ImageViewer.tsx # Main viewer component
|
|
183
163
|
│ ├── ImageToolbar.tsx # Zoom/rotate/flip controls
|
|
184
164
|
│ └── ImageInfo.tsx # Dimensions display
|
|
@@ -186,15 +166,15 @@ ImageViewer/
|
|
|
186
166
|
│ ├── index.ts
|
|
187
167
|
│ ├── useImageLoading.ts # Blob URL & LQIP management
|
|
188
168
|
│ └── useImageTransform.ts # Rotation/flip state
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
└── README.md
|
|
169
|
+
└── utils/
|
|
170
|
+
├── index.ts
|
|
171
|
+
├── constants.ts # Size limits, zoom presets
|
|
172
|
+
└── lqip.ts # Low-Quality Image Placeholder
|
|
194
173
|
```
|
|
195
174
|
|
|
196
175
|
## Dependencies
|
|
197
176
|
|
|
198
|
-
- `react-zoom-pan-pinch`
|
|
199
|
-
- `lucide-react`
|
|
200
|
-
- `@djangocfg/ui-core`
|
|
177
|
+
- `react-zoom-pan-pinch` — zoom and pan
|
|
178
|
+
- `lucide-react` — icons
|
|
179
|
+
- `@djangocfg/ui-core` — UI components (Button, Dialog, etc.)
|
|
180
|
+
- `@djangocfg/ui-core/hooks` — `useHotkey` for keyboard navigation
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* ImageViewer - Image viewer with zoom, pan, rotate, flip
|
|
4
|
+
* ImageViewer - Image viewer with zoom, pan, rotate, flip, gallery navigation
|
|
5
5
|
*
|
|
6
6
|
* Features:
|
|
7
7
|
* - Zoom with mouse wheel and presets
|
|
@@ -9,26 +9,45 @@
|
|
|
9
9
|
* - Rotate 90°
|
|
10
10
|
* - Flip horizontal/vertical
|
|
11
11
|
* - Fullscreen dialog
|
|
12
|
-
* - Keyboard shortcuts (+/-, 0, r)
|
|
12
|
+
* - Keyboard shortcuts (+/-, 0, r, ←/→ for gallery)
|
|
13
|
+
* - Gallery mode: pass images[] with multiple items
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
|
16
|
-
import { ImageIcon, AlertCircle } from 'lucide-react';
|
|
17
|
+
import { ImageIcon, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
17
18
|
import { TransformWrapper, TransformComponent, useControls } from 'react-zoom-pan-pinch';
|
|
18
19
|
import { cn, Dialog, DialogContent, DialogTitle, Alert, AlertDescription } from '@djangocfg/ui-core';
|
|
19
20
|
import { useTypedT, type I18nTranslations } from '@djangocfg/i18n';
|
|
21
|
+
import { useHotkey } from '@djangocfg/ui-core/hooks';
|
|
20
22
|
|
|
21
23
|
import { ImageToolbar } from './ImageToolbar';
|
|
22
24
|
import { ImageInfo } from './ImageInfo';
|
|
23
25
|
import { useImageTransform, useImageLoading } from '../hooks';
|
|
24
|
-
import type { ImageViewerProps } from '../types';
|
|
26
|
+
import type { ImageViewerProps, ImageItem } from '../types';
|
|
25
27
|
|
|
26
28
|
// =============================================================================
|
|
27
29
|
// COMPONENT
|
|
28
30
|
// =============================================================================
|
|
29
31
|
|
|
30
|
-
export function ImageViewer({
|
|
32
|
+
export function ImageViewer({
|
|
33
|
+
images,
|
|
34
|
+
initialIndex = 0,
|
|
35
|
+
inDialog = false,
|
|
36
|
+
}: ImageViewerProps) {
|
|
31
37
|
const t = useTypedT<I18nTranslations>();
|
|
38
|
+
|
|
39
|
+
const [currentIndex, setCurrentIndex] = useState(() =>
|
|
40
|
+
Math.max(0, Math.min(initialIndex, images.length - 1))
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// Reset index when initialIndex changes (e.g. opening different image)
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
setCurrentIndex(Math.max(0, Math.min(initialIndex, images.length - 1)));
|
|
46
|
+
}, [initialIndex, images.length]);
|
|
47
|
+
|
|
48
|
+
const current = images[currentIndex];
|
|
49
|
+
const hasMultiple = images.length > 1;
|
|
50
|
+
|
|
32
51
|
const [scale, setScale] = useState(1);
|
|
33
52
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
34
53
|
const [loadError, setLoadError] = useState(false);
|
|
@@ -41,7 +60,6 @@ export function ImageViewer({ file, content, src: directSrc, inDialog = false }:
|
|
|
41
60
|
loading: t('ui.form.loading'),
|
|
42
61
|
}), [t]);
|
|
43
62
|
|
|
44
|
-
// Loading state
|
|
45
63
|
const {
|
|
46
64
|
src,
|
|
47
65
|
lqip,
|
|
@@ -49,72 +67,67 @@ export function ImageViewer({ file, content, src: directSrc, inDialog = false }:
|
|
|
49
67
|
useProgressiveLoading,
|
|
50
68
|
error,
|
|
51
69
|
hasContent,
|
|
52
|
-
} = useImageLoading({
|
|
70
|
+
} = useImageLoading({
|
|
71
|
+
content: current?.content ?? '',
|
|
72
|
+
mimeType: current?.file.mimeType,
|
|
73
|
+
src: current?.src,
|
|
74
|
+
});
|
|
53
75
|
|
|
54
|
-
|
|
55
|
-
useEffect(() => {
|
|
56
|
-
setLoadError(false);
|
|
57
|
-
}, [src]);
|
|
76
|
+
useEffect(() => { setLoadError(false); }, [src]);
|
|
58
77
|
|
|
59
|
-
// Transform state
|
|
60
78
|
const { transform, rotate, flipH, flipV, transformStyle } = useImageTransform({
|
|
61
|
-
resetKey: file.path,
|
|
79
|
+
resetKey: current?.file.path ?? '',
|
|
62
80
|
});
|
|
63
81
|
|
|
64
|
-
// Zoom preset handler
|
|
65
82
|
const handleZoomPreset = useCallback((value: number | 'fit') => {
|
|
66
83
|
if (!controlsRef.current) return;
|
|
67
|
-
if (value === 'fit')
|
|
68
|
-
|
|
69
|
-
} else {
|
|
70
|
-
controlsRef.current.setTransform(0, 0, value);
|
|
71
|
-
}
|
|
84
|
+
if (value === 'fit') controlsRef.current.resetTransform();
|
|
85
|
+
else controlsRef.current.setTransform(0, 0, value);
|
|
72
86
|
}, []);
|
|
73
87
|
|
|
74
|
-
|
|
75
|
-
const handleExpand = useCallback(() => {
|
|
76
|
-
setDialogOpen(true);
|
|
77
|
-
}, []);
|
|
88
|
+
const handleExpand = useCallback(() => setDialogOpen(true), []);
|
|
78
89
|
|
|
79
|
-
|
|
90
|
+
const prev = useCallback(() =>
|
|
91
|
+
setCurrentIndex((i) => (i - 1 + images.length) % images.length),
|
|
92
|
+
[images.length]
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const next = useCallback(() =>
|
|
96
|
+
setCurrentIndex((i) => (i + 1) % images.length),
|
|
97
|
+
[images.length]
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Keyboard: zoom/rotate (only when container focused)
|
|
80
101
|
useEffect(() => {
|
|
81
102
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
82
103
|
if (!containerRef.current?.contains(document.activeElement) &&
|
|
83
|
-
document.activeElement !== containerRef.current)
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
|
|
104
|
+
document.activeElement !== containerRef.current) return;
|
|
87
105
|
const controls = controlsRef.current;
|
|
88
106
|
if (!controls) return;
|
|
89
|
-
|
|
90
107
|
switch (e.key) {
|
|
91
|
-
case '+':
|
|
92
|
-
case '
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
break;
|
|
96
|
-
case '-':
|
|
97
|
-
e.preventDefault();
|
|
98
|
-
controls.zoomOut();
|
|
99
|
-
break;
|
|
100
|
-
case '0':
|
|
101
|
-
e.preventDefault();
|
|
102
|
-
controls.resetTransform();
|
|
103
|
-
break;
|
|
104
|
-
case 'r':
|
|
105
|
-
if (!e.metaKey && !e.ctrlKey) {
|
|
106
|
-
e.preventDefault();
|
|
107
|
-
rotate();
|
|
108
|
-
}
|
|
109
|
-
break;
|
|
108
|
+
case '+': case '=': e.preventDefault(); controls.zoomIn(); break;
|
|
109
|
+
case '-': e.preventDefault(); controls.zoomOut(); break;
|
|
110
|
+
case '0': e.preventDefault(); controls.resetTransform(); break;
|
|
111
|
+
case 'r': if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); rotate(); } break;
|
|
110
112
|
}
|
|
111
113
|
};
|
|
112
|
-
|
|
113
114
|
window.addEventListener('keydown', handleKeyDown);
|
|
114
115
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
115
116
|
}, [rotate]);
|
|
116
117
|
|
|
117
|
-
//
|
|
118
|
+
// Keyboard: gallery navigation (global when open)
|
|
119
|
+
useHotkey('ArrowLeft', prev, { enabled: hasMultiple, preventDefault: true });
|
|
120
|
+
useHotkey('ArrowRight', next, { enabled: hasMultiple, preventDefault: true });
|
|
121
|
+
|
|
122
|
+
if (!current) {
|
|
123
|
+
return (
|
|
124
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-2 bg-muted/30">
|
|
125
|
+
<ImageIcon className="w-12 h-12 text-muted-foreground/50" />
|
|
126
|
+
<p className="text-sm text-muted-foreground">{labels.noImage}</p>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
118
131
|
if (error || loadError) {
|
|
119
132
|
return (
|
|
120
133
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 bg-muted/30 p-4">
|
|
@@ -127,7 +140,6 @@ export function ImageViewer({ file, content, src: directSrc, inDialog = false }:
|
|
|
127
140
|
);
|
|
128
141
|
}
|
|
129
142
|
|
|
130
|
-
// No content
|
|
131
143
|
if (!hasContent) {
|
|
132
144
|
return (
|
|
133
145
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 bg-muted/30">
|
|
@@ -151,7 +163,6 @@ export function ImageViewer({ file, content, src: directSrc, inDialog = false }:
|
|
|
151
163
|
>
|
|
152
164
|
{src && <ImageInfo src={src} />}
|
|
153
165
|
|
|
154
|
-
{/* Progressive loading indicator */}
|
|
155
166
|
{useProgressiveLoading && !isFullyLoaded && (
|
|
156
167
|
<div className="absolute top-3 left-3 z-10 px-2 py-1 bg-background/80 backdrop-blur-sm border rounded text-[10px] text-muted-foreground font-mono flex items-center gap-1.5">
|
|
157
168
|
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
|
@@ -165,13 +176,8 @@ export function ImageViewer({ file, content, src: directSrc, inDialog = false }:
|
|
|
165
176
|
maxScale={8}
|
|
166
177
|
centerOnInit
|
|
167
178
|
centerZoomedOut
|
|
168
|
-
onTransformed={(ref, state) => {
|
|
169
|
-
|
|
170
|
-
controlsRef.current = ref;
|
|
171
|
-
}}
|
|
172
|
-
onInit={(ref) => {
|
|
173
|
-
controlsRef.current = ref;
|
|
174
|
-
}}
|
|
179
|
+
onTransformed={(ref, state) => { setScale(state.scale); controlsRef.current = ref; }}
|
|
180
|
+
onInit={(ref) => { controlsRef.current = ref; }}
|
|
175
181
|
wheel={{ step: 0.1 }}
|
|
176
182
|
doubleClick={{ mode: 'toggle', step: 2 }}
|
|
177
183
|
panning={{ velocityDisabled: false }}
|
|
@@ -191,34 +197,24 @@ export function ImageViewer({ file, content, src: directSrc, inDialog = false }:
|
|
|
191
197
|
contentClass="!w-full !h-full flex items-center justify-center"
|
|
192
198
|
>
|
|
193
199
|
<div className="relative">
|
|
194
|
-
{/* LQIP Placeholder (blurred, shown while loading) */}
|
|
195
200
|
{useProgressiveLoading && lqip && !isFullyLoaded && (
|
|
196
201
|
<img
|
|
197
202
|
src={lqip}
|
|
198
203
|
alt=""
|
|
199
204
|
aria-hidden="true"
|
|
200
205
|
className="absolute inset-0 max-w-full max-h-full object-contain select-none"
|
|
201
|
-
style={{
|
|
202
|
-
transform: transformStyle,
|
|
203
|
-
filter: 'blur(20px)',
|
|
204
|
-
transition: 'opacity 0.3s ease-out',
|
|
205
|
-
opacity: isFullyLoaded ? 0 : 1,
|
|
206
|
-
}}
|
|
206
|
+
style={{ transform: transformStyle, filter: 'blur(20px)', transition: 'opacity 0.3s ease-out', opacity: isFullyLoaded ? 0 : 1 }}
|
|
207
207
|
draggable={false}
|
|
208
208
|
/>
|
|
209
209
|
)}
|
|
210
|
-
|
|
211
|
-
{/* Full Image */}
|
|
212
210
|
{src && (
|
|
213
211
|
<img
|
|
214
212
|
src={src}
|
|
215
|
-
alt={file.name}
|
|
213
|
+
alt={current.file.name}
|
|
216
214
|
className="max-w-full max-h-full object-contain select-none"
|
|
217
215
|
style={{
|
|
218
216
|
transform: transformStyle,
|
|
219
|
-
transition: useProgressiveLoading
|
|
220
|
-
? 'transform 0.15s ease-out, opacity 0.3s ease-out'
|
|
221
|
-
: 'transform 0.15s ease-out',
|
|
217
|
+
transition: useProgressiveLoading ? 'transform 0.15s ease-out, opacity 0.3s ease-out' : 'transform 0.15s ease-out',
|
|
222
218
|
opacity: useProgressiveLoading && !isFullyLoaded ? 0 : 1,
|
|
223
219
|
}}
|
|
224
220
|
draggable={false}
|
|
@@ -230,16 +226,39 @@ export function ImageViewer({ file, content, src: directSrc, inDialog = false }:
|
|
|
230
226
|
</TransformComponent>
|
|
231
227
|
</TransformWrapper>
|
|
232
228
|
|
|
229
|
+
{/* Gallery navigation */}
|
|
230
|
+
{hasMultiple && (
|
|
231
|
+
<>
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
onClick={prev}
|
|
235
|
+
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-1.5 transition-colors"
|
|
236
|
+
>
|
|
237
|
+
<ChevronLeft className="h-5 w-5" />
|
|
238
|
+
</button>
|
|
239
|
+
<button
|
|
240
|
+
type="button"
|
|
241
|
+
onClick={next}
|
|
242
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-1.5 transition-colors"
|
|
243
|
+
>
|
|
244
|
+
<ChevronRight className="h-5 w-5" />
|
|
245
|
+
</button>
|
|
246
|
+
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 bg-black/50 text-white text-xs px-2 py-0.5 rounded-full pointer-events-none">
|
|
247
|
+
{currentIndex + 1} / {images.length}
|
|
248
|
+
</div>
|
|
249
|
+
</>
|
|
250
|
+
)}
|
|
251
|
+
|
|
233
252
|
{/* Fullscreen dialog */}
|
|
234
253
|
{!inDialog && (
|
|
235
254
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
236
255
|
<DialogContent className="max-w-[95vw] max-h-[95vh] w-[95vw] h-[95vh] p-0 overflow-hidden [&>button]:hidden flex flex-col">
|
|
237
|
-
<DialogTitle className="sr-only">{file.name}</DialogTitle>
|
|
256
|
+
<DialogTitle className="sr-only">{current.file.name}</DialogTitle>
|
|
238
257
|
<div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
|
|
239
|
-
<span className="text-sm font-medium truncate">{file.name}</span>
|
|
258
|
+
<span className="text-sm font-medium truncate">{current.file.name}</span>
|
|
240
259
|
</div>
|
|
241
260
|
<div className="flex-1 min-h-0">
|
|
242
|
-
<ImageViewer
|
|
261
|
+
<ImageViewer images={images} initialIndex={currentIndex} inDialog />
|
|
243
262
|
</div>
|
|
244
263
|
</DialogContent>
|
|
245
264
|
</Dialog>
|
|
@@ -15,21 +15,32 @@ export interface ImageFile {
|
|
|
15
15
|
mimeType?: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export interface ImageItem {
|
|
19
|
+
/** File info for this image */
|
|
20
|
+
file: ImageFile;
|
|
21
|
+
/** Direct image URL (use when serving from CDN/server) */
|
|
22
|
+
src?: string;
|
|
23
|
+
/** Raw image content — ArrayBuffer or base64/binary string (use when serving from memory) */
|
|
24
|
+
content?: string | ArrayBuffer;
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
// =============================================================================
|
|
19
28
|
// COMPONENT PROPS
|
|
20
29
|
// =============================================================================
|
|
21
30
|
|
|
22
31
|
export interface ImageViewerProps {
|
|
23
|
-
/** File info (name, path, mimeType) */
|
|
24
|
-
file: ImageFile;
|
|
25
|
-
/** Image content as string (data URL or base64) or ArrayBuffer */
|
|
26
|
-
content: string | ArrayBuffer;
|
|
27
32
|
/**
|
|
28
|
-
*
|
|
29
|
-
* When provided,
|
|
30
|
-
*
|
|
33
|
+
* Gallery images array.
|
|
34
|
+
* When provided, enables gallery mode with prev/next navigation.
|
|
35
|
+
* Single-image mode: pass one item.
|
|
36
|
+
* Each item can have `src` (URL) or `content` (ArrayBuffer/base64) or both.
|
|
31
37
|
*/
|
|
32
|
-
|
|
38
|
+
images: ImageItem[];
|
|
39
|
+
/**
|
|
40
|
+
* Initial index to show (default: 0).
|
|
41
|
+
* Useful when opening a specific image from a grid.
|
|
42
|
+
*/
|
|
43
|
+
initialIndex?: number;
|
|
33
44
|
/** Hide expand button when already in dialog */
|
|
34
45
|
inDialog?: boolean;
|
|
35
46
|
}
|