@djangocfg/ui-tools 2.1.91
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/dist/LottiePlayer.client-LBEC2JKY.mjs +161 -0
- package/dist/LottiePlayer.client-LBEC2JKY.mjs.map +1 -0
- package/dist/LottiePlayer.client-WFMG2OOW.cjs +168 -0
- package/dist/LottiePlayer.client-WFMG2OOW.cjs.map +1 -0
- package/dist/Mermaid.client-4TU2TSH3.mjs +477 -0
- package/dist/Mermaid.client-4TU2TSH3.mjs.map +1 -0
- package/dist/Mermaid.client-SBYY364Q.cjs +483 -0
- package/dist/Mermaid.client-SBYY364Q.cjs.map +1 -0
- package/dist/PlaygroundLayout-3YVSAEAF.cjs +1003 -0
- package/dist/PlaygroundLayout-3YVSAEAF.cjs.map +1 -0
- package/dist/PlaygroundLayout-4DYBORAS.mjs +996 -0
- package/dist/PlaygroundLayout-4DYBORAS.mjs.map +1 -0
- package/dist/PrettyCode.client-LCBPPTIX.mjs +152 -0
- package/dist/PrettyCode.client-LCBPPTIX.mjs.map +1 -0
- package/dist/PrettyCode.client-PNPLXRH6.cjs +154 -0
- package/dist/PrettyCode.client-PNPLXRH6.cjs.map +1 -0
- package/dist/chunk-37ZI6VD4.mjs +12 -0
- package/dist/chunk-37ZI6VD4.mjs.map +1 -0
- package/dist/chunk-3HK2OE62.cjs +81 -0
- package/dist/chunk-3HK2OE62.cjs.map +1 -0
- package/dist/chunk-7DGDQVQW.cjs +591 -0
- package/dist/chunk-7DGDQVQW.cjs.map +1 -0
- package/dist/chunk-M6P2FU7L.mjs +572 -0
- package/dist/chunk-M6P2FU7L.mjs.map +1 -0
- package/dist/chunk-UQ3XI5MY.cjs +15 -0
- package/dist/chunk-UQ3XI5MY.cjs.map +1 -0
- package/dist/chunk-YFRNE2IR.mjs +79 -0
- package/dist/chunk-YFRNE2IR.mjs.map +1 -0
- package/dist/index.cjs +5042 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1591 -0
- package/dist/index.d.ts +1591 -0
- package/dist/index.mjs +4941 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +86 -0
- package/src/components/markdown/MarkdownMessage.tsx +340 -0
- package/src/components/markdown/index.ts +5 -0
- package/src/index.ts +26 -0
- package/src/stores/index.ts +9 -0
- package/src/stores/mediaCache.ts +534 -0
- package/src/tools/AudioPlayer/README.md +206 -0
- package/src/tools/AudioPlayer/components/HybridAudioPlayer.tsx +216 -0
- package/src/tools/AudioPlayer/components/HybridSimplePlayer.tsx +280 -0
- package/src/tools/AudioPlayer/components/HybridWaveform.tsx +279 -0
- package/src/tools/AudioPlayer/components/ReactiveCover/AudioReactiveCover.tsx +149 -0
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/GlowEffect.tsx +110 -0
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/MeshEffect.tsx +58 -0
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/OrbsEffect.tsx +45 -0
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/SpotlightEffect.tsx +82 -0
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/index.ts +8 -0
- package/src/tools/AudioPlayer/components/ReactiveCover/index.ts +6 -0
- package/src/tools/AudioPlayer/components/index.ts +22 -0
- package/src/tools/AudioPlayer/context/HybridAudioProvider.tsx +158 -0
- package/src/tools/AudioPlayer/context/index.ts +16 -0
- package/src/tools/AudioPlayer/effects/index.ts +412 -0
- package/src/tools/AudioPlayer/hooks/index.ts +35 -0
- package/src/tools/AudioPlayer/hooks/useHybridAudio.ts +387 -0
- package/src/tools/AudioPlayer/hooks/useHybridAudioAnalysis.ts +95 -0
- package/src/tools/AudioPlayer/hooks/useVisualization.tsx +207 -0
- package/src/tools/AudioPlayer/index.ts +133 -0
- package/src/tools/AudioPlayer/types/effects.ts +73 -0
- package/src/tools/AudioPlayer/types/index.ts +27 -0
- package/src/tools/AudioPlayer/utils/debug.ts +14 -0
- package/src/tools/AudioPlayer/utils/formatTime.ts +10 -0
- package/src/tools/AudioPlayer/utils/index.ts +6 -0
- package/src/tools/ImageViewer/@refactoring/00-PLAN.md +71 -0
- package/src/tools/ImageViewer/@refactoring/01-TYPES.md +121 -0
- package/src/tools/ImageViewer/@refactoring/02-UTILS.md +143 -0
- package/src/tools/ImageViewer/@refactoring/03-HOOKS.md +261 -0
- package/src/tools/ImageViewer/@refactoring/04-COMPONENTS.md +427 -0
- package/src/tools/ImageViewer/@refactoring/05-EXECUTION-CHECKLIST.md +126 -0
- package/src/tools/ImageViewer/README.md +200 -0
- package/src/tools/ImageViewer/components/ImageInfo.tsx +44 -0
- package/src/tools/ImageViewer/components/ImageToolbar.tsx +145 -0
- package/src/tools/ImageViewer/components/ImageViewer.tsx +241 -0
- package/src/tools/ImageViewer/components/index.ts +7 -0
- package/src/tools/ImageViewer/hooks/index.ts +9 -0
- package/src/tools/ImageViewer/hooks/useImageLoading.ts +204 -0
- package/src/tools/ImageViewer/hooks/useImageTransform.ts +101 -0
- package/src/tools/ImageViewer/index.ts +60 -0
- package/src/tools/ImageViewer/types.ts +81 -0
- package/src/tools/ImageViewer/utils/constants.ts +59 -0
- package/src/tools/ImageViewer/utils/debug.ts +14 -0
- package/src/tools/ImageViewer/utils/index.ts +17 -0
- package/src/tools/ImageViewer/utils/lqip.ts +47 -0
- package/src/tools/JsonForm/JsonSchemaForm.tsx +197 -0
- package/src/tools/JsonForm/examples/BotConfigExample.tsx +249 -0
- package/src/tools/JsonForm/examples/RealBotConfigExample.tsx +161 -0
- package/src/tools/JsonForm/index.ts +46 -0
- package/src/tools/JsonForm/templates/ArrayFieldItemTemplate.tsx +47 -0
- package/src/tools/JsonForm/templates/ArrayFieldTemplate.tsx +74 -0
- package/src/tools/JsonForm/templates/BaseInputTemplate.tsx +107 -0
- package/src/tools/JsonForm/templates/ErrorListTemplate.tsx +35 -0
- package/src/tools/JsonForm/templates/FieldTemplate.tsx +62 -0
- package/src/tools/JsonForm/templates/ObjectFieldTemplate.tsx +116 -0
- package/src/tools/JsonForm/templates/index.ts +12 -0
- package/src/tools/JsonForm/types.ts +83 -0
- package/src/tools/JsonForm/utils.ts +213 -0
- package/src/tools/JsonForm/widgets/CheckboxWidget.tsx +37 -0
- package/src/tools/JsonForm/widgets/ColorWidget.tsx +219 -0
- package/src/tools/JsonForm/widgets/NumberWidget.tsx +89 -0
- package/src/tools/JsonForm/widgets/SelectWidget.tsx +97 -0
- package/src/tools/JsonForm/widgets/SliderWidget.tsx +148 -0
- package/src/tools/JsonForm/widgets/SwitchWidget.tsx +35 -0
- package/src/tools/JsonForm/widgets/TextWidget.tsx +96 -0
- package/src/tools/JsonForm/widgets/index.ts +14 -0
- package/src/tools/JsonTree/index.tsx +243 -0
- package/src/tools/LottiePlayer/LottiePlayer.client.tsx +213 -0
- package/src/tools/LottiePlayer/index.tsx +56 -0
- package/src/tools/LottiePlayer/types.ts +108 -0
- package/src/tools/LottiePlayer/useLottie.ts +164 -0
- package/src/tools/Mermaid/Mermaid.client.tsx +82 -0
- package/src/tools/Mermaid/components/MermaidCodeViewer.tsx +95 -0
- package/src/tools/Mermaid/components/MermaidFullscreenModal.tsx +103 -0
- package/src/tools/Mermaid/hooks/index.ts +4 -0
- package/src/tools/Mermaid/hooks/useMermaidCleanup.ts +73 -0
- package/src/tools/Mermaid/hooks/useMermaidFullscreen.ts +46 -0
- package/src/tools/Mermaid/hooks/useMermaidRenderer.ts +226 -0
- package/src/tools/Mermaid/hooks/useMermaidValidation.ts +29 -0
- package/src/tools/Mermaid/index.tsx +44 -0
- package/src/tools/Mermaid/utils/mermaid-helpers.ts +33 -0
- package/src/tools/OpenapiViewer/components/EndpointInfo.tsx +149 -0
- package/src/tools/OpenapiViewer/components/EndpointsLibrary.tsx +263 -0
- package/src/tools/OpenapiViewer/components/PlaygroundLayout.tsx +125 -0
- package/src/tools/OpenapiViewer/components/PlaygroundStepper.tsx +100 -0
- package/src/tools/OpenapiViewer/components/RequestBuilder.tsx +157 -0
- package/src/tools/OpenapiViewer/components/RequestParametersForm.tsx +253 -0
- package/src/tools/OpenapiViewer/components/ResponseViewer.tsx +173 -0
- package/src/tools/OpenapiViewer/components/VersionSelector.tsx +68 -0
- package/src/tools/OpenapiViewer/components/index.ts +14 -0
- package/src/tools/OpenapiViewer/constants.ts +39 -0
- package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +337 -0
- package/src/tools/OpenapiViewer/hooks/index.ts +8 -0
- package/src/tools/OpenapiViewer/hooks/useMobile.ts +10 -0
- package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +199 -0
- package/src/tools/OpenapiViewer/index.tsx +37 -0
- package/src/tools/OpenapiViewer/types.ts +151 -0
- package/src/tools/OpenapiViewer/utils/apiKeyManager.ts +149 -0
- package/src/tools/OpenapiViewer/utils/formatters.ts +71 -0
- package/src/tools/OpenapiViewer/utils/index.ts +9 -0
- package/src/tools/OpenapiViewer/utils/versionManager.ts +161 -0
- package/src/tools/PrettyCode/PrettyCode.client.tsx +208 -0
- package/src/tools/PrettyCode/index.tsx +47 -0
- package/src/tools/VideoPlayer/@refactoring/00-PLAN.md +91 -0
- package/src/tools/VideoPlayer/@refactoring/01-TYPES.md +284 -0
- package/src/tools/VideoPlayer/@refactoring/02-UTILS.md +141 -0
- package/src/tools/VideoPlayer/@refactoring/03-HOOKS.md +178 -0
- package/src/tools/VideoPlayer/@refactoring/04-COMPONENTS.md +95 -0
- package/src/tools/VideoPlayer/@refactoring/05-EXECUTION-CHECKLIST.md +139 -0
- package/src/tools/VideoPlayer/README.md +264 -0
- package/src/tools/VideoPlayer/components/VideoControls.tsx +138 -0
- package/src/tools/VideoPlayer/components/VideoErrorFallback.tsx +172 -0
- package/src/tools/VideoPlayer/components/VideoPlayer.tsx +201 -0
- package/src/tools/VideoPlayer/components/index.ts +14 -0
- package/src/tools/VideoPlayer/context/VideoPlayerContext.tsx +52 -0
- package/src/tools/VideoPlayer/context/index.ts +8 -0
- package/src/tools/VideoPlayer/hooks/index.ts +12 -0
- package/src/tools/VideoPlayer/hooks/useVideoPlayerSettings.ts +70 -0
- package/src/tools/VideoPlayer/hooks/useVideoPositionCache.ts +116 -0
- package/src/tools/VideoPlayer/index.ts +77 -0
- package/src/tools/VideoPlayer/providers/NativeProvider.tsx +284 -0
- package/src/tools/VideoPlayer/providers/StreamProvider.tsx +505 -0
- package/src/tools/VideoPlayer/providers/VidstackProvider.tsx +400 -0
- package/src/tools/VideoPlayer/providers/index.ts +8 -0
- package/src/tools/VideoPlayer/types/index.ts +38 -0
- package/src/tools/VideoPlayer/types/player.ts +116 -0
- package/src/tools/VideoPlayer/types/provider.ts +93 -0
- package/src/tools/VideoPlayer/types/sources.ts +97 -0
- package/src/tools/VideoPlayer/utils/debug.ts +14 -0
- package/src/tools/VideoPlayer/utils/fileSource.ts +78 -0
- package/src/tools/VideoPlayer/utils/index.ts +12 -0
- package/src/tools/VideoPlayer/utils/resolvers.ts +75 -0
- package/src/tools/_shared.ts +29 -0
- package/src/tools/index.ts +172 -0
|
@@ -0,0 +1,200 @@
|
|
|
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
|
+
| `src` | `string` | - | Direct URL (bypasses content→blob conversion) |
|
|
43
|
+
| `inDialog` | `boolean` | `false` | Hide expand button (for nested usage) |
|
|
44
|
+
|
|
45
|
+
## ImageFile Type
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
interface ImageFile {
|
|
49
|
+
name: string; // Display name
|
|
50
|
+
path: string; // File path (for state tracking)
|
|
51
|
+
mimeType?: string; // MIME type (e.g., 'image/png')
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Content Formats
|
|
56
|
+
|
|
57
|
+
The `content` prop accepts:
|
|
58
|
+
|
|
59
|
+
- **ArrayBuffer**: Binary image data (creates blob URL)
|
|
60
|
+
- **Data URL**: Base64 encoded string starting with `data:`
|
|
61
|
+
- **Base64 string**: Raw base64 (auto-prefixed with data URL)
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
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
|
+
|
|
83
|
+
## HTTP Streaming
|
|
84
|
+
|
|
85
|
+
For large images, use the `src` prop to stream directly from a URL instead of loading the entire file into memory:
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
// When src is provided, content is ignored
|
|
89
|
+
<ImageViewer
|
|
90
|
+
file={{ name: 'large-photo.jpg', path: '/photos/large.jpg' }}
|
|
91
|
+
content="" // Empty - not used when src is provided
|
|
92
|
+
src={streamingUrl}
|
|
93
|
+
/>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
This is useful for:
|
|
97
|
+
- Large images that shouldn't be loaded into memory
|
|
98
|
+
- HTTP Range request streaming
|
|
99
|
+
- Pre-signed URLs with authentication tokens
|
|
100
|
+
|
|
101
|
+
## Keyboard Shortcuts
|
|
102
|
+
|
|
103
|
+
| Key | Action |
|
|
104
|
+
|-----|--------|
|
|
105
|
+
| `+` / `=` | Zoom in |
|
|
106
|
+
| `-` | Zoom out |
|
|
107
|
+
| `0` | Reset to fit |
|
|
108
|
+
| `R` | Rotate 90° |
|
|
109
|
+
|
|
110
|
+
## Toolbar Controls
|
|
111
|
+
|
|
112
|
+
The floating toolbar at the bottom provides:
|
|
113
|
+
|
|
114
|
+
- **Zoom out** button
|
|
115
|
+
- **Zoom level** dropdown with presets
|
|
116
|
+
- **Zoom in** button
|
|
117
|
+
- **Fit to view** button
|
|
118
|
+
- **Flip horizontal** toggle
|
|
119
|
+
- **Flip vertical** toggle
|
|
120
|
+
- **Rotate 90°** button
|
|
121
|
+
- **Expand** fullscreen button
|
|
122
|
+
|
|
123
|
+
## Fullscreen Mode
|
|
124
|
+
|
|
125
|
+
Click the expand button to open the image in a fullscreen dialog. The dialog includes the same toolbar and supports all interactions.
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
// Fullscreen is automatically available unless inDialog is true
|
|
129
|
+
<ImageViewer file={file} content={content} />
|
|
130
|
+
|
|
131
|
+
// When embedding in your own dialog, disable the expand button
|
|
132
|
+
<Dialog>
|
|
133
|
+
<ImageViewer file={file} content={content} inDialog />
|
|
134
|
+
</Dialog>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Styling
|
|
138
|
+
|
|
139
|
+
The component fills its container and displays a checkerboard pattern behind transparent images.
|
|
140
|
+
|
|
141
|
+
```tsx
|
|
142
|
+
<div className="w-full h-[500px]">
|
|
143
|
+
<ImageViewer file={file} content={content} />
|
|
144
|
+
</div>
|
|
145
|
+
```
|
|
146
|
+
|
|
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
|
+
## Architecture
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
ImageViewer/
|
|
178
|
+
├── index.ts # Public API exports
|
|
179
|
+
├── types.ts # Type definitions
|
|
180
|
+
├── components/
|
|
181
|
+
│ ├── index.ts # Component exports
|
|
182
|
+
│ ├── ImageViewer.tsx # Main viewer component
|
|
183
|
+
│ ├── ImageToolbar.tsx # Zoom/rotate/flip controls
|
|
184
|
+
│ └── ImageInfo.tsx # Dimensions display
|
|
185
|
+
├── hooks/
|
|
186
|
+
│ ├── index.ts
|
|
187
|
+
│ ├── useImageLoading.ts # Blob URL & LQIP management
|
|
188
|
+
│ └── useImageTransform.ts # Rotation/flip state
|
|
189
|
+
├── utils/
|
|
190
|
+
│ ├── index.ts
|
|
191
|
+
│ ├── constants.ts # Size limits, zoom presets
|
|
192
|
+
│ └── lqip.ts # Low-Quality Image Placeholder
|
|
193
|
+
└── README.md
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Dependencies
|
|
197
|
+
|
|
198
|
+
- `react-zoom-pan-pinch` - Zoom and pan functionality
|
|
199
|
+
- `lucide-react` - Icons
|
|
200
|
+
- `@djangocfg/ui-core` - UI components (Button, Dialog, etc.)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ImageInfo - Displays image dimensions badge
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useState } from 'react';
|
|
8
|
+
import { useImageCache } from '../../../stores/mediaCache';
|
|
9
|
+
import type { ImageInfoProps } from '../types';
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// COMPONENT
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
export function ImageInfo({ src }: ImageInfoProps) {
|
|
16
|
+
const { getDimensions, cacheDimensions } = useImageCache();
|
|
17
|
+
const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
// Check cache first
|
|
21
|
+
const cached = getDimensions(src);
|
|
22
|
+
if (cached) {
|
|
23
|
+
setDimensions({ w: cached.width, h: cached.height });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Load and cache dimensions
|
|
28
|
+
const img = new Image();
|
|
29
|
+
img.onload = () => {
|
|
30
|
+
const dims = { w: img.naturalWidth, h: img.naturalHeight };
|
|
31
|
+
setDimensions(dims);
|
|
32
|
+
cacheDimensions(src, { width: dims.w, height: dims.h });
|
|
33
|
+
};
|
|
34
|
+
img.src = src;
|
|
35
|
+
}, [src, getDimensions, cacheDimensions]);
|
|
36
|
+
|
|
37
|
+
if (!dimensions) return null;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<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">
|
|
41
|
+
{dimensions.w} × {dimensions.h}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ImageToolbar - Floating toolbar for image controls
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useMemo, useCallback } from 'react';
|
|
8
|
+
import { ZoomIn, ZoomOut, RotateCw, FlipHorizontal, FlipVertical, Maximize2, Expand } from 'lucide-react';
|
|
9
|
+
import { useControls } from 'react-zoom-pan-pinch';
|
|
10
|
+
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@djangocfg/ui-core/components';
|
|
11
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
12
|
+
import { ZOOM_PRESETS } from '../utils';
|
|
13
|
+
import type { ImageToolbarProps } from '../types';
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// COMPONENT
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
export function ImageToolbar({
|
|
20
|
+
scale,
|
|
21
|
+
transform,
|
|
22
|
+
onRotate,
|
|
23
|
+
onFlipH,
|
|
24
|
+
onFlipV,
|
|
25
|
+
onZoomPreset,
|
|
26
|
+
onExpand,
|
|
27
|
+
}: ImageToolbarProps) {
|
|
28
|
+
const { zoomIn, zoomOut, resetTransform } = useControls();
|
|
29
|
+
|
|
30
|
+
const zoomLabel = useMemo(() => {
|
|
31
|
+
const percent = Math.round(scale * 100);
|
|
32
|
+
return `${percent}%`;
|
|
33
|
+
}, [scale]);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<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">
|
|
37
|
+
{/* Zoom controls */}
|
|
38
|
+
<Button
|
|
39
|
+
variant="ghost"
|
|
40
|
+
size="icon"
|
|
41
|
+
className="h-7 w-7"
|
|
42
|
+
onClick={() => zoomOut()}
|
|
43
|
+
title="Zoom out"
|
|
44
|
+
>
|
|
45
|
+
<ZoomOut className="h-3.5 w-3.5" />
|
|
46
|
+
</Button>
|
|
47
|
+
|
|
48
|
+
<DropdownMenu>
|
|
49
|
+
<DropdownMenuTrigger asChild>
|
|
50
|
+
<Button variant="ghost" size="sm" className="h-7 px-2 min-w-[52px] font-mono text-xs">
|
|
51
|
+
{zoomLabel}
|
|
52
|
+
</Button>
|
|
53
|
+
</DropdownMenuTrigger>
|
|
54
|
+
<DropdownMenuContent align="center" className="min-w-[80px]">
|
|
55
|
+
{ZOOM_PRESETS.map((preset) => (
|
|
56
|
+
<DropdownMenuItem
|
|
57
|
+
key={preset.label}
|
|
58
|
+
onClick={() => onZoomPreset(preset.value)}
|
|
59
|
+
className="text-xs justify-center"
|
|
60
|
+
>
|
|
61
|
+
{preset.label}
|
|
62
|
+
</DropdownMenuItem>
|
|
63
|
+
))}
|
|
64
|
+
</DropdownMenuContent>
|
|
65
|
+
</DropdownMenu>
|
|
66
|
+
|
|
67
|
+
<Button
|
|
68
|
+
variant="ghost"
|
|
69
|
+
size="icon"
|
|
70
|
+
className="h-7 w-7"
|
|
71
|
+
onClick={() => zoomIn()}
|
|
72
|
+
title="Zoom in"
|
|
73
|
+
>
|
|
74
|
+
<ZoomIn className="h-3.5 w-3.5" />
|
|
75
|
+
</Button>
|
|
76
|
+
|
|
77
|
+
<div className="w-px h-4 bg-border mx-1" />
|
|
78
|
+
|
|
79
|
+
{/* Fit to view */}
|
|
80
|
+
<Button
|
|
81
|
+
variant="ghost"
|
|
82
|
+
size="icon"
|
|
83
|
+
className="h-7 w-7"
|
|
84
|
+
onClick={() => resetTransform()}
|
|
85
|
+
title="Fit to view"
|
|
86
|
+
>
|
|
87
|
+
<Maximize2 className="h-3.5 w-3.5" />
|
|
88
|
+
</Button>
|
|
89
|
+
|
|
90
|
+
<div className="w-px h-4 bg-border mx-1" />
|
|
91
|
+
|
|
92
|
+
{/* Transform controls */}
|
|
93
|
+
<Button
|
|
94
|
+
variant="ghost"
|
|
95
|
+
size="icon"
|
|
96
|
+
className={cn('h-7 w-7', transform.flipH && 'bg-accent')}
|
|
97
|
+
onClick={onFlipH}
|
|
98
|
+
title="Flip horizontal"
|
|
99
|
+
>
|
|
100
|
+
<FlipHorizontal className="h-3.5 w-3.5" />
|
|
101
|
+
</Button>
|
|
102
|
+
|
|
103
|
+
<Button
|
|
104
|
+
variant="ghost"
|
|
105
|
+
size="icon"
|
|
106
|
+
className={cn('h-7 w-7', transform.flipV && 'bg-accent')}
|
|
107
|
+
onClick={onFlipV}
|
|
108
|
+
title="Flip vertical"
|
|
109
|
+
>
|
|
110
|
+
<FlipVertical className="h-3.5 w-3.5" />
|
|
111
|
+
</Button>
|
|
112
|
+
|
|
113
|
+
<Button
|
|
114
|
+
variant="ghost"
|
|
115
|
+
size="icon"
|
|
116
|
+
className="h-7 w-7"
|
|
117
|
+
onClick={onRotate}
|
|
118
|
+
title="Rotate 90°"
|
|
119
|
+
>
|
|
120
|
+
<RotateCw className="h-3.5 w-3.5" />
|
|
121
|
+
</Button>
|
|
122
|
+
|
|
123
|
+
{transform.rotation !== 0 && (
|
|
124
|
+
<span className="text-[10px] text-muted-foreground font-mono pl-1">
|
|
125
|
+
{transform.rotation}°
|
|
126
|
+
</span>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{onExpand && (
|
|
130
|
+
<>
|
|
131
|
+
<div className="w-px h-4 bg-border mx-1" />
|
|
132
|
+
<Button
|
|
133
|
+
variant="ghost"
|
|
134
|
+
size="icon"
|
|
135
|
+
className="h-7 w-7"
|
|
136
|
+
onClick={onExpand}
|
|
137
|
+
title="Open in fullscreen"
|
|
138
|
+
>
|
|
139
|
+
<Expand className="h-3.5 w-3.5" />
|
|
140
|
+
</Button>
|
|
141
|
+
</>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
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 } from 'react';
|
|
16
|
+
import { ImageIcon, AlertCircle } from 'lucide-react';
|
|
17
|
+
import { TransformWrapper, TransformComponent, useControls } from 'react-zoom-pan-pinch';
|
|
18
|
+
import { cn, Dialog, DialogContent, DialogTitle, Alert, AlertDescription } from '@djangocfg/ui-core';
|
|
19
|
+
|
|
20
|
+
import { ImageToolbar } from './ImageToolbar';
|
|
21
|
+
import { ImageInfo } from './ImageInfo';
|
|
22
|
+
import { useImageTransform, useImageLoading } from '../hooks';
|
|
23
|
+
import type { ImageViewerProps } from '../types';
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// COMPONENT
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
export function ImageViewer({ file, content, src: directSrc, inDialog = false }: ImageViewerProps) {
|
|
30
|
+
const [scale, setScale] = useState(1);
|
|
31
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
32
|
+
const [loadError, setLoadError] = useState(false);
|
|
33
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
34
|
+
const controlsRef = useRef<ReturnType<typeof useControls> | null>(null);
|
|
35
|
+
|
|
36
|
+
// Loading state
|
|
37
|
+
const {
|
|
38
|
+
src,
|
|
39
|
+
lqip,
|
|
40
|
+
isFullyLoaded,
|
|
41
|
+
useProgressiveLoading,
|
|
42
|
+
error,
|
|
43
|
+
hasContent,
|
|
44
|
+
} = useImageLoading({ content, mimeType: file.mimeType, src: directSrc });
|
|
45
|
+
|
|
46
|
+
// Reset load error when src changes
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
setLoadError(false);
|
|
49
|
+
}, [src]);
|
|
50
|
+
|
|
51
|
+
// Transform state
|
|
52
|
+
const { transform, rotate, flipH, flipV, transformStyle } = useImageTransform({
|
|
53
|
+
resetKey: file.path,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Zoom preset handler
|
|
57
|
+
const handleZoomPreset = useCallback((value: number | 'fit') => {
|
|
58
|
+
if (!controlsRef.current) return;
|
|
59
|
+
if (value === 'fit') {
|
|
60
|
+
controlsRef.current.resetTransform();
|
|
61
|
+
} else {
|
|
62
|
+
controlsRef.current.setTransform(0, 0, value);
|
|
63
|
+
}
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
// Expand to fullscreen
|
|
67
|
+
const handleExpand = useCallback(() => {
|
|
68
|
+
setDialogOpen(true);
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
// Keyboard shortcuts
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
74
|
+
if (!containerRef.current?.contains(document.activeElement) &&
|
|
75
|
+
document.activeElement !== containerRef.current) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const controls = controlsRef.current;
|
|
80
|
+
if (!controls) return;
|
|
81
|
+
|
|
82
|
+
switch (e.key) {
|
|
83
|
+
case '+':
|
|
84
|
+
case '=':
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
controls.zoomIn();
|
|
87
|
+
break;
|
|
88
|
+
case '-':
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
controls.zoomOut();
|
|
91
|
+
break;
|
|
92
|
+
case '0':
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
controls.resetTransform();
|
|
95
|
+
break;
|
|
96
|
+
case 'r':
|
|
97
|
+
if (!e.metaKey && !e.ctrlKey) {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
rotate();
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
106
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
107
|
+
}, [rotate]);
|
|
108
|
+
|
|
109
|
+
// Show error for oversized images or load errors
|
|
110
|
+
if (error || loadError) {
|
|
111
|
+
return (
|
|
112
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-3 bg-muted/30 p-4">
|
|
113
|
+
<AlertCircle className="w-12 h-12 text-destructive/70" />
|
|
114
|
+
<Alert variant="destructive" className="max-w-md">
|
|
115
|
+
<AlertCircle className="h-4 w-4" />
|
|
116
|
+
<AlertDescription>{error || 'Failed to load image'}</AlertDescription>
|
|
117
|
+
</Alert>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// No content
|
|
123
|
+
if (!hasContent) {
|
|
124
|
+
return (
|
|
125
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-2 bg-muted/30">
|
|
126
|
+
<ImageIcon className="w-12 h-12 text-muted-foreground/50" />
|
|
127
|
+
<p className="text-sm text-muted-foreground">No image content</p>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div
|
|
134
|
+
ref={containerRef}
|
|
135
|
+
tabIndex={0}
|
|
136
|
+
className={cn(
|
|
137
|
+
'flex-1 h-full relative overflow-hidden outline-none',
|
|
138
|
+
'bg-[length:16px_16px]',
|
|
139
|
+
'[background-color:hsl(var(--muted)/0.2)]',
|
|
140
|
+
'[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%)]',
|
|
141
|
+
'[background-position:0_0,0_8px,8px_-8px,-8px_0px]'
|
|
142
|
+
)}
|
|
143
|
+
>
|
|
144
|
+
{src && <ImageInfo src={src} />}
|
|
145
|
+
|
|
146
|
+
{/* Progressive loading indicator */}
|
|
147
|
+
{useProgressiveLoading && !isFullyLoaded && (
|
|
148
|
+
<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">
|
|
149
|
+
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
|
150
|
+
Loading...
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
<TransformWrapper
|
|
155
|
+
initialScale={1}
|
|
156
|
+
minScale={0.1}
|
|
157
|
+
maxScale={8}
|
|
158
|
+
centerOnInit
|
|
159
|
+
centerZoomedOut
|
|
160
|
+
onTransformed={(ref, state) => {
|
|
161
|
+
setScale(state.scale);
|
|
162
|
+
controlsRef.current = ref;
|
|
163
|
+
}}
|
|
164
|
+
onInit={(ref) => {
|
|
165
|
+
controlsRef.current = ref;
|
|
166
|
+
}}
|
|
167
|
+
wheel={{ step: 0.1 }}
|
|
168
|
+
doubleClick={{ mode: 'toggle', step: 2 }}
|
|
169
|
+
panning={{ velocityDisabled: false }}
|
|
170
|
+
>
|
|
171
|
+
<ImageToolbar
|
|
172
|
+
scale={scale}
|
|
173
|
+
transform={transform}
|
|
174
|
+
onRotate={rotate}
|
|
175
|
+
onFlipH={flipH}
|
|
176
|
+
onFlipV={flipV}
|
|
177
|
+
onZoomPreset={handleZoomPreset}
|
|
178
|
+
onExpand={!inDialog ? handleExpand : undefined}
|
|
179
|
+
/>
|
|
180
|
+
|
|
181
|
+
<TransformComponent
|
|
182
|
+
wrapperClass="!w-full !h-full cursor-grab active:cursor-grabbing"
|
|
183
|
+
contentClass="!w-full !h-full flex items-center justify-center"
|
|
184
|
+
>
|
|
185
|
+
<div className="relative">
|
|
186
|
+
{/* LQIP Placeholder (blurred, shown while loading) */}
|
|
187
|
+
{useProgressiveLoading && lqip && !isFullyLoaded && (
|
|
188
|
+
<img
|
|
189
|
+
src={lqip}
|
|
190
|
+
alt=""
|
|
191
|
+
aria-hidden="true"
|
|
192
|
+
className="absolute inset-0 max-w-full max-h-full object-contain select-none"
|
|
193
|
+
style={{
|
|
194
|
+
transform: transformStyle,
|
|
195
|
+
filter: 'blur(20px)',
|
|
196
|
+
transition: 'opacity 0.3s ease-out',
|
|
197
|
+
opacity: isFullyLoaded ? 0 : 1,
|
|
198
|
+
}}
|
|
199
|
+
draggable={false}
|
|
200
|
+
/>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
{/* Full Image */}
|
|
204
|
+
{src && (
|
|
205
|
+
<img
|
|
206
|
+
src={src}
|
|
207
|
+
alt={file.name}
|
|
208
|
+
className="max-w-full max-h-full object-contain select-none"
|
|
209
|
+
style={{
|
|
210
|
+
transform: transformStyle,
|
|
211
|
+
transition: useProgressiveLoading
|
|
212
|
+
? 'transform 0.15s ease-out, opacity 0.3s ease-out'
|
|
213
|
+
: 'transform 0.15s ease-out',
|
|
214
|
+
opacity: useProgressiveLoading && !isFullyLoaded ? 0 : 1,
|
|
215
|
+
}}
|
|
216
|
+
draggable={false}
|
|
217
|
+
crossOrigin="anonymous"
|
|
218
|
+
onError={() => setLoadError(true)}
|
|
219
|
+
/>
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
</TransformComponent>
|
|
223
|
+
</TransformWrapper>
|
|
224
|
+
|
|
225
|
+
{/* Fullscreen dialog */}
|
|
226
|
+
{!inDialog && (
|
|
227
|
+
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
228
|
+
<DialogContent className="max-w-[95vw] max-h-[95vh] w-[95vw] h-[95vh] p-0 overflow-hidden [&>button]:hidden flex flex-col">
|
|
229
|
+
<DialogTitle className="sr-only">{file.name}</DialogTitle>
|
|
230
|
+
<div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
|
|
231
|
+
<span className="text-sm font-medium truncate">{file.name}</span>
|
|
232
|
+
</div>
|
|
233
|
+
<div className="flex-1 min-h-0">
|
|
234
|
+
<ImageViewer file={file} content={content} src={directSrc} inDialog />
|
|
235
|
+
</div>
|
|
236
|
+
</DialogContent>
|
|
237
|
+
</Dialog>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageViewer hooks - Public API
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { useImageTransform } from './useImageTransform';
|
|
6
|
+
export type { UseImageTransformOptions, UseImageTransformReturn } from './useImageTransform';
|
|
7
|
+
|
|
8
|
+
export { useImageLoading } from './useImageLoading';
|
|
9
|
+
export type { UseImageLoadingOptions, UseImageLoadingReturn } from './useImageLoading';
|