@djangocfg/widget-media 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 +58 -0
- package/package.json +81 -0
- package/src/code/code.tsx +116 -0
- package/src/code/index.ts +10 -0
- package/src/diagram/diagram-source.tsx +29 -0
- package/src/diagram/diagram.tsx +64 -0
- package/src/diagram/index.ts +22 -0
- package/src/diagram/lazy.tsx +45 -0
- package/src/diagram/plugin.ts +62 -0
- package/src/diagram/render.ts +328 -0
- package/src/i18n/index.ts +60 -0
- package/src/image-viewer/components/ImageInfo.tsx +55 -0
- package/src/image-viewer/components/ImageToolbar.tsx +167 -0
- package/src/image-viewer/components/ImageViewer.tsx +508 -0
- package/src/image-viewer/components/LightboxChrome.tsx +157 -0
- package/src/image-viewer/components/MiddleEllipsis.tsx +46 -0
- package/src/image-viewer/components/index.ts +9 -0
- package/src/image-viewer/hooks/index.ts +12 -0
- package/src/image-viewer/hooks/useIdleChrome.ts +73 -0
- package/src/image-viewer/hooks/useImageLoading.ts +220 -0
- package/src/image-viewer/hooks/useImageTransform.ts +102 -0
- package/src/image-viewer/index.ts +65 -0
- package/src/image-viewer/lazy.tsx +33 -0
- package/src/image-viewer/types.ts +139 -0
- package/src/image-viewer/utils/constants.ts +90 -0
- package/src/image-viewer/utils/debug.ts +14 -0
- package/src/image-viewer/utils/index.ts +22 -0
- package/src/image-viewer/utils/lqip.ts +47 -0
- package/src/index.ts +10 -0
- package/src/internal/media-cache.ts +537 -0
- package/src/markdown/index.ts +6 -0
- package/src/markdown/markdown.tsx +113 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ImageViewer - Image viewer with zoom, pan, rotate, flip, gallery navigation
|
|
5
|
+
*
|
|
6
|
+
* Two presentations share one engine (react-zoom-pan-pinch):
|
|
7
|
+
* - Embedded (default): checkerboard canvas + editing toolbar (zoom presets,
|
|
8
|
+
* rotate, flip, expand-to-fullscreen).
|
|
9
|
+
* - Lightbox (`lightbox` / `inDialog`): macOS Quick-Look-grade fullscreen —
|
|
10
|
+
* clean dark backdrop, idle-fading floating chrome, no rotate/flip slab.
|
|
11
|
+
*
|
|
12
|
+
* Gesture model (macOS-native):
|
|
13
|
+
* - Trackpad pinch arrives as wheel events with `ctrlKey=true`; the library's
|
|
14
|
+
* `smooth` path maps `|deltaY|` to the zoom magnitude so scale tracks the
|
|
15
|
+
* gesture 1:1. We raise `smoothStep` (see WHEEL_SMOOTH_STEP) so a real pinch
|
|
16
|
+
* reaches a meaningful zoom in one motion.
|
|
17
|
+
* - Zoom is anchored to the FOCAL POINT (cursor / pinch midpoint) — the point
|
|
18
|
+
* under the fingers stays put — handled natively by the library.
|
|
19
|
+
* - Pan by drag when zoomed; double-click/tap toggles fit ↔ zoom toward the
|
|
20
|
+
* point; keyboard +/-/0 and arrows (pan when zoomed, gallery nav at fit).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
|
24
|
+
import { ImageIcon, AlertCircle, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
|
25
|
+
import {
|
|
26
|
+
TransformWrapper,
|
|
27
|
+
TransformComponent,
|
|
28
|
+
type ReactZoomPanPinchRef,
|
|
29
|
+
} from 'react-zoom-pan-pinch';
|
|
30
|
+
import { cn, Dialog, DialogContent, DialogTitle, Alert, AlertDescription, Tooltip, TooltipContent, TooltipTrigger } from '@djangocfg/ui-core';
|
|
31
|
+
import { useMediaCopy } from '../../i18n';
|
|
32
|
+
import { useHotkey } from '@djangocfg/ui-core/hooks';
|
|
33
|
+
|
|
34
|
+
import { ImageToolbar } from './ImageToolbar';
|
|
35
|
+
import { ImageInfo } from './ImageInfo';
|
|
36
|
+
import { LightboxChrome } from './LightboxChrome';
|
|
37
|
+
import { useImageTransform, useImageLoading, useIdleChrome } from '../hooks';
|
|
38
|
+
import {
|
|
39
|
+
KEYBOARD_PAN_STEP,
|
|
40
|
+
MIN_ZOOM,
|
|
41
|
+
MAX_ZOOM,
|
|
42
|
+
WHEEL_SMOOTH_STEP,
|
|
43
|
+
PINCH_STEP,
|
|
44
|
+
DOUBLE_CLICK_STEP,
|
|
45
|
+
} from '../utils';
|
|
46
|
+
import type { ImageViewerProps } from '../types';
|
|
47
|
+
|
|
48
|
+
// =============================================================================
|
|
49
|
+
// COMPONENT
|
|
50
|
+
// =============================================================================
|
|
51
|
+
|
|
52
|
+
export function ImageViewer({
|
|
53
|
+
images,
|
|
54
|
+
initialIndex = 0,
|
|
55
|
+
inDialog = false,
|
|
56
|
+
lightbox = false,
|
|
57
|
+
onRequestClose,
|
|
58
|
+
autoFocus = false,
|
|
59
|
+
fitScale = 1,
|
|
60
|
+
copy,
|
|
61
|
+
}: ImageViewerProps) {
|
|
62
|
+
const labels = useMediaCopy(copy);
|
|
63
|
+
|
|
64
|
+
// `lightbox` implies the in-dialog presentation.
|
|
65
|
+
const isLightbox = lightbox || inDialog;
|
|
66
|
+
|
|
67
|
+
// Cap the at-rest image size to `fitScale` of the viewport in the lightbox so
|
|
68
|
+
// it opens with a margin instead of edge-to-edge. Clamp to a sane range; a
|
|
69
|
+
// value of 1 (the default) is a no-op (falls back to the max-w/h-full class).
|
|
70
|
+
const fit = Math.min(1, Math.max(0.1, fitScale));
|
|
71
|
+
const fitStyle: React.CSSProperties | undefined =
|
|
72
|
+
isLightbox && fit < 1
|
|
73
|
+
? { maxWidth: `${fit * 100}%`, maxHeight: `${fit * 100}%` }
|
|
74
|
+
: undefined;
|
|
75
|
+
|
|
76
|
+
const [currentIndex, setCurrentIndex] = useState(() =>
|
|
77
|
+
Math.max(0, Math.min(initialIndex, images.length - 1))
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Reset index when initialIndex changes (e.g. opening different image)
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
setCurrentIndex(Math.max(0, Math.min(initialIndex, images.length - 1)));
|
|
83
|
+
}, [initialIndex, images.length]);
|
|
84
|
+
|
|
85
|
+
const current = images[currentIndex];
|
|
86
|
+
const hasMultiple = images.length > 1;
|
|
87
|
+
|
|
88
|
+
const [scale, setScale] = useState(1);
|
|
89
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
90
|
+
const [loadError, setLoadError] = useState(false);
|
|
91
|
+
const [imgDecoded, setImgDecoded] = useState(false);
|
|
92
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
93
|
+
const controlsRef = useRef<ReactZoomPanPinchRef | null>(null);
|
|
94
|
+
const imgRef = useRef<HTMLImageElement>(null);
|
|
95
|
+
|
|
96
|
+
// Idle-fading chrome (lightbox only). Pointer movement over the canvas keeps
|
|
97
|
+
// the controls visible; resting for CHROME_IDLE_MS fades them out.
|
|
98
|
+
const { active: chromeActive, onActivity, setPinned } = useIdleChrome(isLightbox);
|
|
99
|
+
|
|
100
|
+
const {
|
|
101
|
+
src,
|
|
102
|
+
lqip,
|
|
103
|
+
isFullyLoaded,
|
|
104
|
+
useProgressiveLoading,
|
|
105
|
+
error,
|
|
106
|
+
hasContent,
|
|
107
|
+
} = useImageLoading({
|
|
108
|
+
content: current?.content ?? '',
|
|
109
|
+
mimeType: current?.file.mimeType,
|
|
110
|
+
src: current?.src,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Save the image the viewer RESOLVED, not the prop: `src` is already a blob
|
|
114
|
+
// URL when the caller passed bytes, so one anchor covers both a hosted URL
|
|
115
|
+
// and an in-memory image without branching on which arrived.
|
|
116
|
+
//
|
|
117
|
+
// A generated picture is served as /api/admin/attachments/<sha256> — a path
|
|
118
|
+
// with no extension — so the store's file name is the only source of a
|
|
119
|
+
// meaningful one, and the hash is a poor thing to hand a user's Downloads
|
|
120
|
+
// folder. Undefined without a src: a control that cannot act is worse absent.
|
|
121
|
+
// `downloadSrc` wins when the host supplied one: `download` is honoured only
|
|
122
|
+
// for a same-origin address, so a picture displayed from a CDN must be SAVED
|
|
123
|
+
// from wherever this page can serve the same bytes. The resolved `src` is the
|
|
124
|
+
// fallback and covers the in-memory case, where it is already a blob URL.
|
|
125
|
+
const saveFrom = current?.downloadSrc ?? src;
|
|
126
|
+
const handleDownload = useMemo(() => {
|
|
127
|
+
if (!saveFrom) return undefined;
|
|
128
|
+
return () => {
|
|
129
|
+
const link = document.createElement('a');
|
|
130
|
+
link.href = saveFrom;
|
|
131
|
+
link.download = current?.file.name ?? 'image';
|
|
132
|
+
link.rel = 'noopener';
|
|
133
|
+
document.body.appendChild(link);
|
|
134
|
+
link.click();
|
|
135
|
+
document.body.removeChild(link);
|
|
136
|
+
};
|
|
137
|
+
}, [saveFrom, current?.file.name]);
|
|
138
|
+
|
|
139
|
+
// Reset per-source load flags whenever the source changes.
|
|
140
|
+
//
|
|
141
|
+
// Race guard (first-open stuck on "Loading…"): the `<img onLoad>` handler is
|
|
142
|
+
// the ONLY thing that clears `imgDecoded`, but the browser can finish
|
|
143
|
+
// decoding — and fire `load` — BEFORE React commits the element and attaches
|
|
144
|
+
// that handler (a same-origin blob/attachment URL that resolves synchronously,
|
|
145
|
+
// or an already-cached image). The event is then missed and the spinner hangs
|
|
146
|
+
// forever; reopening "fixes" it only because the fresh mount happens to win
|
|
147
|
+
// the race. So after resetting, synchronously reconcile against the live img:
|
|
148
|
+
// if it's already `complete` with real pixels, mark it decoded now instead of
|
|
149
|
+
// waiting for an onLoad that already fired.
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
setLoadError(false);
|
|
152
|
+
const img = imgRef.current;
|
|
153
|
+
if (img && img.complete && img.naturalWidth > 0) {
|
|
154
|
+
setImgDecoded(true);
|
|
155
|
+
} else {
|
|
156
|
+
setImgDecoded(false);
|
|
157
|
+
}
|
|
158
|
+
}, [src]);
|
|
159
|
+
|
|
160
|
+
const { transform, rotate, flipH, flipV, transformStyle } = useImageTransform({
|
|
161
|
+
resetKey: current?.file.path ?? '',
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const handleZoomPreset = useCallback((value: number | 'fit') => {
|
|
165
|
+
const controls = controlsRef.current;
|
|
166
|
+
if (!controls) return;
|
|
167
|
+
if (value === 'fit') {
|
|
168
|
+
controls.resetTransform();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
// Anchor preset zoom to the canvas center instead of the top-left origin
|
|
172
|
+
const el = controls.instance.wrapperComponent;
|
|
173
|
+
if (el) {
|
|
174
|
+
const cx = el.offsetWidth / 2;
|
|
175
|
+
const cy = el.offsetHeight / 2;
|
|
176
|
+
const x = cx - cx * value;
|
|
177
|
+
const y = cy - cy * value;
|
|
178
|
+
controls.setTransform(x, y, value);
|
|
179
|
+
} else {
|
|
180
|
+
controls.setTransform(0, 0, value);
|
|
181
|
+
}
|
|
182
|
+
}, []);
|
|
183
|
+
|
|
184
|
+
const handleExpand = useCallback(() => setDialogOpen(true), []);
|
|
185
|
+
|
|
186
|
+
// Reset zoom/pan to fit whenever the gallery image changes — a fresh image
|
|
187
|
+
// should never inherit the previous one's pan offset.
|
|
188
|
+
const goTo = useCallback((next: number) => {
|
|
189
|
+
setCurrentIndex(next);
|
|
190
|
+
controlsRef.current?.resetTransform();
|
|
191
|
+
}, []);
|
|
192
|
+
|
|
193
|
+
const prev = useCallback(() =>
|
|
194
|
+
goTo((currentIndex - 1 + images.length) % images.length),
|
|
195
|
+
[goTo, currentIndex, images.length]
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const next = useCallback(() =>
|
|
199
|
+
goTo((currentIndex + 1) % images.length),
|
|
200
|
+
[goTo, currentIndex, images.length]
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Pan the image by a fixed offset (arrow keys)
|
|
204
|
+
const panBy = useCallback((dx: number, dy: number) => {
|
|
205
|
+
const controls = controlsRef.current;
|
|
206
|
+
if (!controls) return false;
|
|
207
|
+
const { positionX, positionY, scale: s } = controls.instance.transformState;
|
|
208
|
+
// Only pan when the image is zoomed in — otherwise it cannot move
|
|
209
|
+
if (s <= 1) return false;
|
|
210
|
+
controls.setTransform(positionX + dx, positionY + dy, s);
|
|
211
|
+
return true;
|
|
212
|
+
}, []);
|
|
213
|
+
|
|
214
|
+
// Declarative autoFocus: focus the container once on mount. Pair with
|
|
215
|
+
// `key={src}` upstream for per-source focus reset.
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (!autoFocus) return;
|
|
218
|
+
containerRef.current?.focus({ preventScroll: true });
|
|
219
|
+
}, [autoFocus]);
|
|
220
|
+
|
|
221
|
+
// Keyboard: zoom / rotate / pan (only when container focused)
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
224
|
+
if (!containerRef.current?.contains(document.activeElement) &&
|
|
225
|
+
document.activeElement !== containerRef.current) return;
|
|
226
|
+
const controls = controlsRef.current;
|
|
227
|
+
if (!controls) return;
|
|
228
|
+
switch (e.key) {
|
|
229
|
+
case '+': case '=': e.preventDefault(); controls.zoomIn(); break;
|
|
230
|
+
case '-': case '_': e.preventDefault(); controls.zoomOut(); break;
|
|
231
|
+
case '0': e.preventDefault(); controls.resetTransform(); break;
|
|
232
|
+
case 'r': case 'R':
|
|
233
|
+
// Rotate is an editing affordance — embedded mode only.
|
|
234
|
+
if (!isLightbox && !e.metaKey && !e.ctrlKey) { e.preventDefault(); rotate(); }
|
|
235
|
+
break;
|
|
236
|
+
case 'Escape':
|
|
237
|
+
if (isLightbox && onRequestClose) { e.preventDefault(); onRequestClose(); }
|
|
238
|
+
break;
|
|
239
|
+
// Arrow keys pan only when zoomed in. While at fit scale they
|
|
240
|
+
// fall through to gallery navigation (handled by useHotkey).
|
|
241
|
+
case 'ArrowUp': if (panBy(0, KEYBOARD_PAN_STEP)) e.preventDefault(); break;
|
|
242
|
+
case 'ArrowDown': if (panBy(0, -KEYBOARD_PAN_STEP)) e.preventDefault(); break;
|
|
243
|
+
case 'ArrowLeft': if (panBy(KEYBOARD_PAN_STEP, 0)) e.preventDefault(); break;
|
|
244
|
+
case 'ArrowRight': if (panBy(-KEYBOARD_PAN_STEP, 0)) e.preventDefault(); break;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
248
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
249
|
+
}, [rotate, panBy, isLightbox, onRequestClose]);
|
|
250
|
+
|
|
251
|
+
// Keyboard: gallery navigation (global, only while not zoomed in so it
|
|
252
|
+
// never competes with arrow-key panning)
|
|
253
|
+
useHotkey('ArrowLeft', prev, {
|
|
254
|
+
enabled: hasMultiple && scale <= 1,
|
|
255
|
+
preventDefault: true,
|
|
256
|
+
});
|
|
257
|
+
useHotkey('ArrowRight', next, {
|
|
258
|
+
enabled: hasMultiple && scale <= 1,
|
|
259
|
+
preventDefault: true,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
if (!current) {
|
|
263
|
+
return (
|
|
264
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-2 bg-muted/30">
|
|
265
|
+
<ImageIcon className="w-12 h-12 text-muted-foreground/50" />
|
|
266
|
+
<p className="text-sm text-muted-foreground">{labels.noImage}</p>
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (error || loadError) {
|
|
272
|
+
return (
|
|
273
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-3 bg-muted/30 p-4">
|
|
274
|
+
<AlertCircle className="w-12 h-12 text-destructive/70" />
|
|
275
|
+
<Alert variant="destructive" className="max-w-md">
|
|
276
|
+
<AlertCircle className="h-4 w-4" />
|
|
277
|
+
<AlertDescription>{error || labels.failedToLoad}</AlertDescription>
|
|
278
|
+
</Alert>
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!hasContent) {
|
|
284
|
+
return (
|
|
285
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-2 bg-muted/30">
|
|
286
|
+
<ImageIcon className="w-12 h-12 text-muted-foreground/50" />
|
|
287
|
+
<p className="text-sm text-muted-foreground">{labels.noImage}</p>
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
<div
|
|
294
|
+
ref={containerRef}
|
|
295
|
+
tabIndex={0}
|
|
296
|
+
// `group`, not `img`: this element is the viewer's keyboard scope (zoom,
|
|
297
|
+
// rotate, gallery hotkeys) and in the lightbox it CONTAINS the save and
|
|
298
|
+
// close controls. `img` is a leaf role, so focusable descendants inside
|
|
299
|
+
// it are an a11y violation and a screen reader stops at the label without
|
|
300
|
+
// reaching the buttons. The picture itself is the inner <img>, which
|
|
301
|
+
// carries its own alt.
|
|
302
|
+
role="group"
|
|
303
|
+
aria-label={current.file.name}
|
|
304
|
+
onMouseMove={isLightbox ? onActivity : undefined}
|
|
305
|
+
className={cn(
|
|
306
|
+
'flex-1 h-full relative overflow-hidden outline-none',
|
|
307
|
+
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
|
308
|
+
isLightbox
|
|
309
|
+
// Lightbox: a deep, slightly translucent vibrancy backdrop. The
|
|
310
|
+
// saturate+blur is the macOS Quick Look recipe — the page behind
|
|
311
|
+
// the dialog's scrim bleeds through softly so the image floats.
|
|
312
|
+
? cn(
|
|
313
|
+
'[background-color:color-mix(in_oklab,#000_82%,transparent)]',
|
|
314
|
+
'backdrop-blur-2xl backdrop-saturate-150',
|
|
315
|
+
)
|
|
316
|
+
// Embedded: the classic transparency checkerboard.
|
|
317
|
+
: cn(
|
|
318
|
+
'bg-[length:16px_16px]',
|
|
319
|
+
'[background-color:color-mix(in_oklab,var(--muted)_20%,transparent)]',
|
|
320
|
+
'[background-image:linear-gradient(45deg,color-mix(in_oklab,var(--muted)_40%,transparent)_25%,transparent_25%),linear-gradient(-45deg,color-mix(in_oklab,var(--muted)_40%,transparent)_25%,transparent_25%),linear-gradient(45deg,transparent_75%,color-mix(in_oklab,var(--muted)_40%,transparent)_75%),linear-gradient(-45deg,transparent_75%,color-mix(in_oklab,var(--muted)_40%,transparent)_75%)]',
|
|
321
|
+
'[background-position:0_0,0_8px,8px_-8px,-8px_0px]',
|
|
322
|
+
),
|
|
323
|
+
)}
|
|
324
|
+
>
|
|
325
|
+
{/* Dimensions badge — embedded only (lightbox keeps chrome minimal). */}
|
|
326
|
+
{!isLightbox && src && <ImageInfo src={src} />}
|
|
327
|
+
|
|
328
|
+
{useProgressiveLoading && !isFullyLoaded && (
|
|
329
|
+
<div className="absolute top-3 left-3 z-10 px-2 py-1 bg-black/60 backdrop-blur-sm border border-white/10 rounded text-[10px] text-white/80 font-mono flex items-center gap-1.5">
|
|
330
|
+
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
|
|
331
|
+
{labels.loading}
|
|
332
|
+
</div>
|
|
333
|
+
)}
|
|
334
|
+
|
|
335
|
+
{/* Spinner while a non-progressive image is still decoding */}
|
|
336
|
+
{!useProgressiveLoading && !imgDecoded && !loadError && (
|
|
337
|
+
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 pointer-events-none">
|
|
338
|
+
<Loader2 className="w-8 h-8 text-muted-foreground/60 animate-spin" />
|
|
339
|
+
<span className="text-xs text-muted-foreground">{labels.loading}</span>
|
|
340
|
+
</div>
|
|
341
|
+
)}
|
|
342
|
+
|
|
343
|
+
<TransformWrapper
|
|
344
|
+
initialScale={1}
|
|
345
|
+
minScale={MIN_ZOOM}
|
|
346
|
+
maxScale={MAX_ZOOM}
|
|
347
|
+
centerOnInit
|
|
348
|
+
centerZoomedOut
|
|
349
|
+
// `smooth` (default) makes the wheel/trackpad zoom magnitude scale with
|
|
350
|
+
// `|deltaY|`, i.e. 1:1 with the gesture. The raised `smoothStep` gives
|
|
351
|
+
// a macOS trackpad pinch real reach in a single motion (the library's
|
|
352
|
+
// 0.001 default felt dead). Focal-point anchoring is handled natively.
|
|
353
|
+
smooth
|
|
354
|
+
wheel={{ smoothStep: WHEEL_SMOOTH_STEP }}
|
|
355
|
+
pinch={{ step: PINCH_STEP }}
|
|
356
|
+
doubleClick={{ mode: 'toggle', step: DOUBLE_CLICK_STEP }}
|
|
357
|
+
panning={{ velocityDisabled: false }}
|
|
358
|
+
onTransformed={(ref, state) => {
|
|
359
|
+
controlsRef.current = ref;
|
|
360
|
+
// Avoid a re-render on every pan frame — only when zoom changes
|
|
361
|
+
setScale((prev) => (prev === state.scale ? prev : state.scale));
|
|
362
|
+
}}
|
|
363
|
+
onInit={(ref) => { controlsRef.current = ref; }}
|
|
364
|
+
>
|
|
365
|
+
{/* Embedded editing toolbar (rotate/flip/expand) — not in the lightbox. */}
|
|
366
|
+
{!isLightbox && (
|
|
367
|
+
<ImageToolbar
|
|
368
|
+
scale={scale}
|
|
369
|
+
transform={transform}
|
|
370
|
+
onRotate={rotate}
|
|
371
|
+
onFlipH={flipH}
|
|
372
|
+
onFlipV={flipV}
|
|
373
|
+
onZoomPreset={handleZoomPreset}
|
|
374
|
+
onExpand={!inDialog ? handleExpand : undefined}
|
|
375
|
+
/>
|
|
376
|
+
)}
|
|
377
|
+
|
|
378
|
+
<TransformComponent
|
|
379
|
+
wrapperClass="!w-full !h-full cursor-grab active:cursor-grabbing"
|
|
380
|
+
contentClass="!w-full !h-full flex items-center justify-center"
|
|
381
|
+
>
|
|
382
|
+
{/*
|
|
383
|
+
Fill the TransformComponent content box so the children's
|
|
384
|
+
`max-w-full / max-h-full` resolve against the actual viewport
|
|
385
|
+
instead of the image's natural box. Without `w-full h-full`
|
|
386
|
+
this wrapper shrink-fits the image, which collapses the
|
|
387
|
+
max-* constraints and renders the image at intrinsic size —
|
|
388
|
+
visible as cropping / half-height in tall containers.
|
|
389
|
+
*/}
|
|
390
|
+
<div className="relative w-full h-full flex items-center justify-center">
|
|
391
|
+
{useProgressiveLoading && lqip && !isFullyLoaded && (
|
|
392
|
+
<img
|
|
393
|
+
src={lqip}
|
|
394
|
+
alt=""
|
|
395
|
+
aria-hidden="true"
|
|
396
|
+
className="absolute max-w-full max-h-full object-contain select-none"
|
|
397
|
+
style={{ transform: transformStyle, filter: 'blur(20px)', transition: 'opacity 0.3s ease-out', opacity: isFullyLoaded ? 0 : 1, ...fitStyle }}
|
|
398
|
+
draggable={false}
|
|
399
|
+
/>
|
|
400
|
+
)}
|
|
401
|
+
{src && (
|
|
402
|
+
<img
|
|
403
|
+
ref={imgRef}
|
|
404
|
+
src={src}
|
|
405
|
+
alt={current.file.name}
|
|
406
|
+
className={cn(
|
|
407
|
+
'max-w-full max-h-full object-contain select-none',
|
|
408
|
+
// A soft drop shadow lifts the image off the dark lightbox
|
|
409
|
+
// backdrop (Preview / Photos do the same).
|
|
410
|
+
isLightbox && 'drop-shadow-[0_8px_40px_rgba(0,0,0,0.55)]',
|
|
411
|
+
)}
|
|
412
|
+
style={{
|
|
413
|
+
transform: transformStyle,
|
|
414
|
+
transition: 'transform 0.15s ease-out, opacity 0.3s ease-out',
|
|
415
|
+
opacity:
|
|
416
|
+
(useProgressiveLoading && !isFullyLoaded) || !imgDecoded ? 0 : 1,
|
|
417
|
+
...fitStyle,
|
|
418
|
+
}}
|
|
419
|
+
draggable={false}
|
|
420
|
+
onLoad={() => setImgDecoded(true)}
|
|
421
|
+
onError={() => setLoadError(true)}
|
|
422
|
+
/>
|
|
423
|
+
)}
|
|
424
|
+
</div>
|
|
425
|
+
</TransformComponent>
|
|
426
|
+
</TransformWrapper>
|
|
427
|
+
|
|
428
|
+
{/* Lightbox: restrained macOS-grade chrome that idle-fades. */}
|
|
429
|
+
{isLightbox ? (
|
|
430
|
+
<LightboxChrome
|
|
431
|
+
active={chromeActive}
|
|
432
|
+
index={currentIndex + 1}
|
|
433
|
+
total={images.length}
|
|
434
|
+
scale={scale}
|
|
435
|
+
fileName={current.file.name}
|
|
436
|
+
onPrev={prev}
|
|
437
|
+
onNext={next}
|
|
438
|
+
onClose={() => onRequestClose?.()}
|
|
439
|
+
onDownload={handleDownload}
|
|
440
|
+
onPinChange={setPinned}
|
|
441
|
+
labels={{
|
|
442
|
+
prev: labels.previous,
|
|
443
|
+
next: labels.next,
|
|
444
|
+
close: labels.close,
|
|
445
|
+
download: labels.download,
|
|
446
|
+
}}
|
|
447
|
+
/>
|
|
448
|
+
) : (
|
|
449
|
+
hasMultiple && (
|
|
450
|
+
// Embedded gallery nav (checkerboard canvas).
|
|
451
|
+
<>
|
|
452
|
+
<Tooltip>
|
|
453
|
+
<TooltipTrigger asChild>
|
|
454
|
+
<button
|
|
455
|
+
type="button"
|
|
456
|
+
onClick={prev}
|
|
457
|
+
aria-label={labels.previous}
|
|
458
|
+
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
|
|
459
|
+
>
|
|
460
|
+
<ChevronLeft className="h-5 w-5" />
|
|
461
|
+
</button>
|
|
462
|
+
</TooltipTrigger>
|
|
463
|
+
<TooltipContent side="right">{labels.previous}</TooltipContent>
|
|
464
|
+
</Tooltip>
|
|
465
|
+
<Tooltip>
|
|
466
|
+
<TooltipTrigger asChild>
|
|
467
|
+
<button
|
|
468
|
+
type="button"
|
|
469
|
+
onClick={next}
|
|
470
|
+
aria-label={labels.next}
|
|
471
|
+
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
|
|
472
|
+
>
|
|
473
|
+
<ChevronRight className="h-5 w-5" />
|
|
474
|
+
</button>
|
|
475
|
+
</TooltipTrigger>
|
|
476
|
+
<TooltipContent side="left">{labels.next}</TooltipContent>
|
|
477
|
+
</Tooltip>
|
|
478
|
+
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-10 bg-black/60 backdrop-blur-sm border border-white/10 text-white/80 text-xs font-mono px-2 py-0.5 rounded-full pointer-events-none">
|
|
479
|
+
{currentIndex + 1} / {images.length}
|
|
480
|
+
</div>
|
|
481
|
+
</>
|
|
482
|
+
)
|
|
483
|
+
)}
|
|
484
|
+
|
|
485
|
+
{/* Embedded expand-to-fullscreen dialog (uses the lightbox presentation). */}
|
|
486
|
+
{!inDialog && !lightbox && (
|
|
487
|
+
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
488
|
+
<DialogContent
|
|
489
|
+
fullscreen
|
|
490
|
+
closeButton={false}
|
|
491
|
+
className="border-0 bg-transparent p-0 shadow-none"
|
|
492
|
+
>
|
|
493
|
+
<DialogTitle className="sr-only">{current.file.name}</DialogTitle>
|
|
494
|
+
{dialogOpen && (
|
|
495
|
+
<ImageViewer
|
|
496
|
+
images={images}
|
|
497
|
+
initialIndex={currentIndex}
|
|
498
|
+
lightbox
|
|
499
|
+
autoFocus
|
|
500
|
+
onRequestClose={() => setDialogOpen(false)}
|
|
501
|
+
/>
|
|
502
|
+
)}
|
|
503
|
+
</DialogContent>
|
|
504
|
+
</Dialog>
|
|
505
|
+
)}
|
|
506
|
+
</div>
|
|
507
|
+
);
|
|
508
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LightboxChrome - Restrained, macOS-grade floating controls for the
|
|
5
|
+
* fullscreen image lightbox.
|
|
6
|
+
*
|
|
7
|
+
* Design intent (Apple HIG / Quick Look / Preview):
|
|
8
|
+
* - Minimal chrome: a single close affordance (top-right), prev/next only
|
|
9
|
+
* when there is more than one image, and one unobtrusive status pill
|
|
10
|
+
* (counter + live zoom %).
|
|
11
|
+
* - Everything floats on liquid-glass and fades together when the pointer
|
|
12
|
+
* goes idle, reappearing on the slightest movement.
|
|
13
|
+
* - No checkerboard, no rotate/flip slab — the image is the subject.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { ChevronLeft, ChevronRight, Download, X } from 'lucide-react';
|
|
17
|
+
|
|
18
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
19
|
+
|
|
20
|
+
import { MiddleEllipsis } from './MiddleEllipsis';
|
|
21
|
+
|
|
22
|
+
interface LightboxChromeProps {
|
|
23
|
+
/** Visible (recent pointer activity) vs faded (idle). */
|
|
24
|
+
active: boolean;
|
|
25
|
+
/** 1-based index for the counter. */
|
|
26
|
+
index: number;
|
|
27
|
+
total: number;
|
|
28
|
+
/** Live zoom scale (1 = fit). The % pill only shows when zoomed in/out. */
|
|
29
|
+
scale: number;
|
|
30
|
+
fileName?: string;
|
|
31
|
+
onPrev: () => void;
|
|
32
|
+
onNext: () => void;
|
|
33
|
+
onClose: () => void;
|
|
34
|
+
/** Save the shown image. Undefined hides the control: a viewer with no
|
|
35
|
+
* resolved source has nothing to save, and a button that cannot act is
|
|
36
|
+
* worse than an absent one. */
|
|
37
|
+
onDownload?: () => void;
|
|
38
|
+
/** Pin chrome open while the pointer hovers a control. */
|
|
39
|
+
onPinChange: (pinned: boolean) => void;
|
|
40
|
+
labels: { prev: string; next: string; close: string; download: string };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Liquid-glass circular control — light icon on translucent vibrancy so it
|
|
44
|
+
// stays legible over any photo (bright or dark) without a hard slab.
|
|
45
|
+
const GLASS_BTN =
|
|
46
|
+
'glass-liquid pointer-events-auto inline-flex items-center justify-center ' +
|
|
47
|
+
'rounded-full text-white/90 transition-[background-color,transform,opacity] ' +
|
|
48
|
+
'hover:text-white hover:bg-white/15 active:scale-95 ' +
|
|
49
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70';
|
|
50
|
+
|
|
51
|
+
export function LightboxChrome({
|
|
52
|
+
active,
|
|
53
|
+
index,
|
|
54
|
+
total,
|
|
55
|
+
scale,
|
|
56
|
+
fileName,
|
|
57
|
+
onPrev,
|
|
58
|
+
onNext,
|
|
59
|
+
onClose,
|
|
60
|
+
onDownload,
|
|
61
|
+
onPinChange,
|
|
62
|
+
labels,
|
|
63
|
+
}: LightboxChromeProps) {
|
|
64
|
+
const hasMultiple = total > 1;
|
|
65
|
+
// Only surface the zoom pill once the image is meaningfully off fit — at
|
|
66
|
+
// exactly fit (1.0) the % is noise.
|
|
67
|
+
const zoomed = Math.abs(scale - 1) > 0.01;
|
|
68
|
+
const zoomPct = `${Math.round(scale * 100)}%`;
|
|
69
|
+
|
|
70
|
+
// Whole chrome layer fades as one. `pointer-events-none` on the layer means
|
|
71
|
+
// the faded state never eats clicks meant for the image (pan/double-click).
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
className={cn(
|
|
75
|
+
'pointer-events-none absolute inset-0 z-20 transition-opacity duration-300 ease-out',
|
|
76
|
+
active ? 'opacity-100' : 'opacity-0',
|
|
77
|
+
)}
|
|
78
|
+
onMouseEnter={() => onPinChange(true)}
|
|
79
|
+
onMouseLeave={() => onPinChange(false)}
|
|
80
|
+
>
|
|
81
|
+
{/* Top bar: filename (left, whisper-quiet) + close (right).
|
|
82
|
+
The chip caps its width and middle-truncates long names so the start
|
|
83
|
+
and extension stay readable (Finder convention); `gap` + the pinned
|
|
84
|
+
close button guarantee no overlap. */}
|
|
85
|
+
<div className="absolute inset-x-0 top-0 flex items-start justify-between gap-3 p-3 sm:p-4">
|
|
86
|
+
{fileName ? (
|
|
87
|
+
<MiddleEllipsis
|
|
88
|
+
text={fileName}
|
|
89
|
+
className="glass-liquid pointer-events-auto max-w-[clamp(8rem,40vw,28rem)] rounded-full px-3 py-1.5 text-xs font-medium text-white/85"
|
|
90
|
+
/>
|
|
91
|
+
) : (
|
|
92
|
+
<span aria-hidden />
|
|
93
|
+
)}
|
|
94
|
+
{/* Save sits beside close, not in the bottom pill: that pill is status
|
|
95
|
+
(counter, zoom) and reads as output, while these two are what the
|
|
96
|
+
viewer can DO. Close stays rightmost so its position is unchanged. */}
|
|
97
|
+
<div className="flex items-center gap-2">
|
|
98
|
+
{onDownload && (
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
aria-label={labels.download}
|
|
102
|
+
onClick={onDownload}
|
|
103
|
+
className={cn(GLASS_BTN, 'h-9 w-9')}
|
|
104
|
+
>
|
|
105
|
+
<Download className="h-[18px] w-[18px]" />
|
|
106
|
+
</button>
|
|
107
|
+
)}
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
aria-label={labels.close}
|
|
111
|
+
onClick={onClose}
|
|
112
|
+
className={cn(GLASS_BTN, 'h-9 w-9')}
|
|
113
|
+
>
|
|
114
|
+
<X className="h-[18px] w-[18px]" />
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{/* Prev / next — edge-anchored, only when there is a gallery. */}
|
|
120
|
+
{hasMultiple && (
|
|
121
|
+
<>
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
aria-label={labels.prev}
|
|
125
|
+
onClick={onPrev}
|
|
126
|
+
className={cn(GLASS_BTN, 'absolute left-3 top-1/2 h-11 w-11 -translate-y-1/2 sm:left-5')}
|
|
127
|
+
>
|
|
128
|
+
<ChevronLeft className="h-5 w-5" />
|
|
129
|
+
</button>
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
aria-label={labels.next}
|
|
133
|
+
onClick={onNext}
|
|
134
|
+
className={cn(GLASS_BTN, 'absolute right-3 top-1/2 h-11 w-11 -translate-y-1/2 sm:right-5')}
|
|
135
|
+
>
|
|
136
|
+
<ChevronRight className="h-5 w-5" />
|
|
137
|
+
</button>
|
|
138
|
+
</>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{/* Bottom status pill: counter + live zoom %. One quiet line. */}
|
|
142
|
+
{(hasMultiple || zoomed) && (
|
|
143
|
+
<div className="absolute inset-x-0 bottom-0 flex justify-center p-3 sm:p-4">
|
|
144
|
+
<div className="glass-liquid flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium tabular-nums text-white/85">
|
|
145
|
+
{hasMultiple && (
|
|
146
|
+
<span>
|
|
147
|
+
{index} / {total}
|
|
148
|
+
</span>
|
|
149
|
+
)}
|
|
150
|
+
{hasMultiple && zoomed && <span className="h-3 w-px bg-white/25" />}
|
|
151
|
+
{zoomed && <span className="font-mono text-white/70">{zoomPct}</span>}
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|