@djangocfg/ui-tools 2.1.431 → 2.1.433
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 +28 -14
- package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
- package/src/common/CodePanelHeader/index.ts +1 -0
- package/src/common/blocks/BlockError.tsx +46 -0
- package/src/common/blocks/index.ts +34 -0
- package/src/common/blocks/schemas.ts +143 -0
- package/src/common/blocks/types.ts +76 -0
- package/src/common/chips/README.md +67 -0
- package/src/common/chips/UrlChip.tsx +105 -0
- package/src/common/chips/index.ts +20 -0
- package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
- package/src/common/link-preview/index.ts +12 -0
- package/src/common/link-preview/types.ts +44 -0
- package/src/common/tiptap/index.ts +10 -0
- package/src/common/tiptap/useNodeAttrs.ts +128 -0
- package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
- package/src/tools/chat/README.md +88 -2
- package/src/tools/chat/composer/Composer.tsx +45 -12
- package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
- package/src/tools/chat/constants.ts +9 -2
- package/src/tools/chat/core/extractFirstUrl.ts +45 -0
- package/src/tools/chat/core/reducer.ts +32 -8
- package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
- package/src/tools/chat/core/transport/mock.ts +4 -1
- package/src/tools/chat/hooks/useChat.ts +20 -20
- package/src/tools/chat/lazy.tsx +10 -0
- package/src/tools/chat/messages/Attachments.tsx +267 -42
- package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
- package/src/tools/chat/messages/MessageBubble.tsx +175 -31
- package/src/tools/chat/messages/MessageList.tsx +107 -21
- package/src/tools/chat/messages/ToolCalls.tsx +28 -13
- package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
- package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
- package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
- package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
- package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
- package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
- package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
- package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
- package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
- package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
- package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
- package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
- package/src/tools/chat/messages/index.ts +13 -0
- package/src/tools/chat/public.ts +5 -1
- package/src/tools/chat/shell/ChatRoot.tsx +29 -0
- package/src/tools/chat/styles/bubbleTokens.ts +18 -0
- package/src/tools/chat/styles/index.ts +1 -0
- package/src/tools/chat/types/attachment.ts +26 -2
- package/src/tools/chat/types/block.ts +56 -9
- package/src/tools/chat/types/blockSchemas.ts +197 -0
- package/src/tools/chat/types/config.ts +33 -0
- package/src/tools/chat/types/events.ts +14 -7
- package/src/tools/chat/types/index.ts +4 -2
- package/src/tools/chat/types/message.ts +7 -0
- package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
- package/src/tools/dev/Map/README.md +30 -345
- package/src/tools/dev/Map/cards/index.ts +6 -0
- package/src/tools/dev/Map/cards/types.ts +67 -0
- package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
- package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
- package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
- package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
- package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
- package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
- package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
- package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
- package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
- package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
- package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
- package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
- package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
- package/src/tools/dev/Map/components/MapControls.tsx +27 -0
- package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
- package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
- package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
- package/src/tools/dev/Map/components/index.ts +35 -1
- package/src/tools/dev/Map/geocode/index.ts +2 -0
- package/src/tools/dev/Map/geocode/photon.ts +265 -0
- package/src/tools/dev/Map/hooks/index.ts +37 -0
- package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
- package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
- package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
- package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
- package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
- package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
- package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
- package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
- package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
- package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
- package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
- package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
- package/src/tools/dev/Map/index.ts +59 -2
- package/src/tools/dev/Map/layers/index.ts +1 -0
- package/src/tools/dev/Map/layers/symbol.ts +93 -0
- package/src/tools/dev/Map/lazy.tsx +120 -0
- package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
- package/src/tools/dev/Map/styles/index.ts +62 -0
- package/src/tools/dev/Map/styles/terrain.ts +29 -0
- package/src/tools/dev/Map/types.ts +31 -3
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
- package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
- package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
- package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
- package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
- package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
- package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
- package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
- package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
- package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
- package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
- package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
- package/src/tools/forms/MarkdownEditor/README.md +84 -0
- package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
- package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
- package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
- package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
- package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
- package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
- package/src/tools/forms/MarkdownEditor/index.ts +19 -0
- package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
- package/src/tools/forms/MarkdownEditor/styles.css +134 -2
- package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
- package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
- package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
- package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
- package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
- package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
- package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
- package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
- package/src/tools/forms/NotionEditor/README.md +109 -188
- package/src/tools/forms/NotionEditor/extensions.ts +15 -0
- package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
- package/src/tools/forms/NotionEditor/styles.css +25 -0
- package/src/tools/forms/NotionEditor/types.ts +9 -0
- package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
- package/src/tools/input/SpeechRecognition/types.ts +10 -0
- package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
- package/src/tools/media/ImageViewer/README.md +9 -0
- package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
- package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
- package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
- package/src/tools/media/ImageViewer/components/index.ts +2 -0
- package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
- package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
- package/src/tools/media/ImageViewer/types.ts +10 -0
- package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
- package/src/tools/media/ImageViewer/utils/index.ts +4 -0
- package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
- package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
- package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
- package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
- package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
- package/src/tools/visual/charts/SmoothLine/README.md +97 -0
- package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
- package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
- package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
- package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
- package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
- package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
- package/src/tools/visual/indicators/Fps/types.ts +1 -1
- package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
- package/src/tools/dev/Map/components/MapContainer.tsx +0 -198
|
@@ -12,6 +12,14 @@ import { GalleryMedia } from '../media'
|
|
|
12
12
|
import { GalleryThumbnails } from '../thumbnails'
|
|
13
13
|
import type { GalleryLightboxProps } from '../../types'
|
|
14
14
|
|
|
15
|
+
// Liquid-glass control — token-driven (--card / --foreground), so it stays
|
|
16
|
+
// legible on BOTH the light scrim and the dark vibrancy backdrop. Foreground
|
|
17
|
+
// icon color flips with the theme; hover wash adapts via the foreground token.
|
|
18
|
+
const GLASS_BTN =
|
|
19
|
+
'glass-liquid text-foreground/80 transition-colors ' +
|
|
20
|
+
'hover:text-foreground hover:bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)] ' +
|
|
21
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
|
22
|
+
|
|
15
23
|
/**
|
|
16
24
|
* GalleryLightbox - Fullscreen image viewer with zoom and navigation
|
|
17
25
|
*
|
|
@@ -241,11 +249,16 @@ export const GalleryLightbox = memo(function GalleryLightbox({
|
|
|
241
249
|
ref={dialogRef}
|
|
242
250
|
className={cn(
|
|
243
251
|
'fixed inset-0 z-50',
|
|
244
|
-
|
|
252
|
+
// Theme-aware Quick-Look scrim: a LIGHT neutral wash in light mode so
|
|
253
|
+
// the lightbox feels native (Quick Look matches system appearance),
|
|
254
|
+
// a deep vibrancy floor in dark mode. blur+saturate in both so the
|
|
255
|
+
// page behind bleeds through softly and the image floats.
|
|
256
|
+
'backdrop-blur-2xl backdrop-saturate-150',
|
|
257
|
+
'[background-color:color-mix(in_oklab,var(--background)_72%,transparent)]',
|
|
258
|
+
'dark:[background-color:color-mix(in_oklab,#000_82%,transparent)]',
|
|
245
259
|
'animate-in fade-in-0 duration-200',
|
|
246
260
|
'focus:outline-none'
|
|
247
261
|
)}
|
|
248
|
-
style={{ backgroundColor: 'rgba(0, 0, 0, 0.85)' }}
|
|
249
262
|
role="dialog"
|
|
250
263
|
aria-modal="true"
|
|
251
264
|
aria-label={title || labels.lightbox}
|
|
@@ -258,22 +271,28 @@ export const GalleryLightbox = memo(function GalleryLightbox({
|
|
|
258
271
|
>
|
|
259
272
|
{/* Header */}
|
|
260
273
|
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-4">
|
|
261
|
-
{/* Title & Counter
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
274
|
+
{/* Title & Counter — liquid-glass pill so it reads on both the light
|
|
275
|
+
and dark scrim (glass-liquid is token-driven, foreground text
|
|
276
|
+
flips with the theme). */}
|
|
277
|
+
{(title || hasMultiple) ? (
|
|
278
|
+
<div className="glass-liquid rounded-2xl px-3 py-1.5 text-foreground">
|
|
279
|
+
{title && <div className="font-medium">{title}</div>}
|
|
280
|
+
{hasMultiple && (
|
|
281
|
+
<div className="text-sm text-foreground/70" aria-live="polite">
|
|
282
|
+
{currentIndex + 1} / {images.length}
|
|
283
|
+
</div>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
) : (
|
|
287
|
+
<span aria-hidden />
|
|
288
|
+
)}
|
|
270
289
|
|
|
271
290
|
{/* Actions */}
|
|
272
291
|
<div className="flex items-center gap-2">
|
|
273
292
|
{enableZoom && !isVideo && (
|
|
274
293
|
<button
|
|
275
294
|
type="button"
|
|
276
|
-
className=
|
|
295
|
+
className={cn(GLASS_BTN, 'p-2 rounded-full')}
|
|
277
296
|
onClick={handleToggleZoom}
|
|
278
297
|
aria-label={isZoomed ? labels.zoomOut : labels.zoomIn}
|
|
279
298
|
>
|
|
@@ -288,7 +307,7 @@ export const GalleryLightbox = memo(function GalleryLightbox({
|
|
|
288
307
|
{enableDownload && (
|
|
289
308
|
<button
|
|
290
309
|
type="button"
|
|
291
|
-
className=
|
|
310
|
+
className={cn(GLASS_BTN, 'p-2 rounded-full')}
|
|
292
311
|
onClick={handleDownload}
|
|
293
312
|
aria-label={labels.download}
|
|
294
313
|
>
|
|
@@ -299,7 +318,7 @@ export const GalleryLightbox = memo(function GalleryLightbox({
|
|
|
299
318
|
{enableShare && (
|
|
300
319
|
<button
|
|
301
320
|
type="button"
|
|
302
|
-
className=
|
|
321
|
+
className={cn(GLASS_BTN, 'p-2 rounded-full')}
|
|
303
322
|
onClick={handleShare}
|
|
304
323
|
aria-label={labels.share}
|
|
305
324
|
>
|
|
@@ -309,7 +328,7 @@ export const GalleryLightbox = memo(function GalleryLightbox({
|
|
|
309
328
|
|
|
310
329
|
<button
|
|
311
330
|
type="button"
|
|
312
|
-
className=
|
|
331
|
+
className={cn(GLASS_BTN, 'p-2 rounded-full')}
|
|
313
332
|
onClick={onClose}
|
|
314
333
|
aria-label={labels.close}
|
|
315
334
|
>
|
|
@@ -360,11 +379,10 @@ export const GalleryLightbox = memo(function GalleryLightbox({
|
|
|
360
379
|
<button
|
|
361
380
|
type="button"
|
|
362
381
|
className={cn(
|
|
382
|
+
GLASS_BTN,
|
|
363
383
|
'absolute left-4 top-1/2 -translate-y-1/2 z-10',
|
|
364
384
|
'w-12 h-12 rounded-full',
|
|
365
|
-
'
|
|
366
|
-
'flex items-center justify-center transition-colors',
|
|
367
|
-
'focus:outline-none focus-visible:ring-2 focus-visible:ring-white'
|
|
385
|
+
'flex items-center justify-center'
|
|
368
386
|
)}
|
|
369
387
|
onClick={goToPrev}
|
|
370
388
|
aria-label={labels.previous}
|
|
@@ -375,11 +393,10 @@ export const GalleryLightbox = memo(function GalleryLightbox({
|
|
|
375
393
|
<button
|
|
376
394
|
type="button"
|
|
377
395
|
className={cn(
|
|
396
|
+
GLASS_BTN,
|
|
378
397
|
'absolute right-4 top-1/2 -translate-y-1/2 z-10',
|
|
379
398
|
'w-12 h-12 rounded-full',
|
|
380
|
-
'
|
|
381
|
-
'flex items-center justify-center transition-colors',
|
|
382
|
-
'focus:outline-none focus-visible:ring-2 focus-visible:ring-white'
|
|
399
|
+
'flex items-center justify-center'
|
|
383
400
|
)}
|
|
384
401
|
onClick={goToNext}
|
|
385
402
|
aria-label={labels.next}
|
|
@@ -391,7 +408,7 @@ export const GalleryLightbox = memo(function GalleryLightbox({
|
|
|
391
408
|
|
|
392
409
|
{/* Thumbnails */}
|
|
393
410
|
{showThumbnails && hasMultiple && !isZoomed && (
|
|
394
|
-
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/50 to-transparent">
|
|
411
|
+
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-white/40 dark:from-black/50 to-transparent">
|
|
395
412
|
<GalleryThumbnails
|
|
396
413
|
images={images}
|
|
397
414
|
currentIndex={currentIndex}
|
|
@@ -139,6 +139,15 @@ Click the expand button to open in a fullscreen dialog. Gallery navigation works
|
|
|
139
139
|
</Dialog>
|
|
140
140
|
```
|
|
141
141
|
|
|
142
|
+
## Used as the chat attachment lightbox
|
|
143
|
+
|
|
144
|
+
The [Chat tool](../../chat/README.md) uses `LazyImageViewer` as the **default**
|
|
145
|
+
lightbox for image attachments. When a host wires no `onAttachmentOpen` /
|
|
146
|
+
`onClick`, clicking an image attachment opens this viewer in a dialog
|
|
147
|
+
(`inDialog`), seeded with all of that message's image attachments so prev/next
|
|
148
|
+
gallery navigation works across them, opened at the clicked `initialIndex`. See
|
|
149
|
+
`chat/messages/Attachments.tsx` (`ImageLightboxProvider`).
|
|
150
|
+
|
|
142
151
|
## Styling
|
|
143
152
|
|
|
144
153
|
The component fills its container. Wrap in a sized element:
|
|
@@ -3,14 +3,21 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* ImageViewer - Image viewer with zoom, pan, rotate, flip, gallery navigation
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
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).
|
|
14
21
|
*/
|
|
15
22
|
|
|
16
23
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
|
@@ -26,8 +33,16 @@ import { useHotkey } from '@djangocfg/ui-core/hooks';
|
|
|
26
33
|
|
|
27
34
|
import { ImageToolbar } from './ImageToolbar';
|
|
28
35
|
import { ImageInfo } from './ImageInfo';
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
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';
|
|
31
46
|
import type { ImageViewerProps } from '../types';
|
|
32
47
|
|
|
33
48
|
// =============================================================================
|
|
@@ -38,10 +53,15 @@ export function ImageViewer({
|
|
|
38
53
|
images,
|
|
39
54
|
initialIndex = 0,
|
|
40
55
|
inDialog = false,
|
|
56
|
+
lightbox = false,
|
|
57
|
+
onRequestClose,
|
|
41
58
|
autoFocus = false,
|
|
42
59
|
}: ImageViewerProps) {
|
|
43
60
|
const t = useAppT();
|
|
44
61
|
|
|
62
|
+
// `lightbox` implies the in-dialog presentation.
|
|
63
|
+
const isLightbox = lightbox || inDialog;
|
|
64
|
+
|
|
45
65
|
const [currentIndex, setCurrentIndex] = useState(() =>
|
|
46
66
|
Math.max(0, Math.min(initialIndex, images.length - 1))
|
|
47
67
|
);
|
|
@@ -61,12 +81,17 @@ export function ImageViewer({
|
|
|
61
81
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
62
82
|
const controlsRef = useRef<ReactZoomPanPinchRef | null>(null);
|
|
63
83
|
|
|
84
|
+
// Idle-fading chrome (lightbox only). Pointer movement over the canvas keeps
|
|
85
|
+
// the controls visible; resting for CHROME_IDLE_MS fades them out.
|
|
86
|
+
const { active: chromeActive, onActivity, setPinned } = useIdleChrome(isLightbox);
|
|
87
|
+
|
|
64
88
|
const labels = useMemo(() => ({
|
|
65
89
|
noImage: t('tools.image.noImage'),
|
|
66
90
|
failedToLoad: t('tools.image.failedToLoad'),
|
|
67
91
|
loading: t('ui.form.loading'),
|
|
68
92
|
prev: t('tools.gallery.previous'),
|
|
69
93
|
next: t('tools.gallery.next'),
|
|
94
|
+
close: t('tools.gallery.close'),
|
|
70
95
|
}), [t]);
|
|
71
96
|
|
|
72
97
|
const {
|
|
@@ -114,14 +139,21 @@ export function ImageViewer({
|
|
|
114
139
|
|
|
115
140
|
const handleExpand = useCallback(() => setDialogOpen(true), []);
|
|
116
141
|
|
|
142
|
+
// Reset zoom/pan to fit whenever the gallery image changes — a fresh image
|
|
143
|
+
// should never inherit the previous one's pan offset.
|
|
144
|
+
const goTo = useCallback((next: number) => {
|
|
145
|
+
setCurrentIndex(next);
|
|
146
|
+
controlsRef.current?.resetTransform();
|
|
147
|
+
}, []);
|
|
148
|
+
|
|
117
149
|
const prev = useCallback(() =>
|
|
118
|
-
|
|
119
|
-
[images.length]
|
|
150
|
+
goTo((currentIndex - 1 + images.length) % images.length),
|
|
151
|
+
[goTo, currentIndex, images.length]
|
|
120
152
|
);
|
|
121
153
|
|
|
122
154
|
const next = useCallback(() =>
|
|
123
|
-
|
|
124
|
-
[images.length]
|
|
155
|
+
goTo((currentIndex + 1) % images.length),
|
|
156
|
+
[goTo, currentIndex, images.length]
|
|
125
157
|
);
|
|
126
158
|
|
|
127
159
|
// Pan the image by a fixed offset (arrow keys)
|
|
@@ -154,7 +186,11 @@ export function ImageViewer({
|
|
|
154
186
|
case '-': case '_': e.preventDefault(); controls.zoomOut(); break;
|
|
155
187
|
case '0': e.preventDefault(); controls.resetTransform(); break;
|
|
156
188
|
case 'r': case 'R':
|
|
157
|
-
|
|
189
|
+
// Rotate is an editing affordance — embedded mode only.
|
|
190
|
+
if (!isLightbox && !e.metaKey && !e.ctrlKey) { e.preventDefault(); rotate(); }
|
|
191
|
+
break;
|
|
192
|
+
case 'Escape':
|
|
193
|
+
if (isLightbox && onRequestClose) { e.preventDefault(); onRequestClose(); }
|
|
158
194
|
break;
|
|
159
195
|
// Arrow keys pan only when zoomed in. While at fit scale they
|
|
160
196
|
// fall through to gallery navigation (handled by useHotkey).
|
|
@@ -166,7 +202,7 @@ export function ImageViewer({
|
|
|
166
202
|
};
|
|
167
203
|
window.addEventListener('keydown', handleKeyDown);
|
|
168
204
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
169
|
-
}, [rotate, panBy]);
|
|
205
|
+
}, [rotate, panBy, isLightbox, onRequestClose]);
|
|
170
206
|
|
|
171
207
|
// Keyboard: gallery navigation (global, only while not zoomed in so it
|
|
172
208
|
// never competes with arrow-key panning)
|
|
@@ -215,20 +251,33 @@ export function ImageViewer({
|
|
|
215
251
|
tabIndex={0}
|
|
216
252
|
role="img"
|
|
217
253
|
aria-label={current.file.name}
|
|
254
|
+
onMouseMove={isLightbox ? onActivity : undefined}
|
|
218
255
|
className={cn(
|
|
219
256
|
'flex-1 h-full relative overflow-hidden outline-none',
|
|
220
257
|
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
258
|
+
isLightbox
|
|
259
|
+
// Lightbox: a deep, slightly translucent vibrancy backdrop. The
|
|
260
|
+
// saturate+blur is the macOS Quick Look recipe — the page behind
|
|
261
|
+
// the dialog's scrim bleeds through softly so the image floats.
|
|
262
|
+
? cn(
|
|
263
|
+
'[background-color:color-mix(in_oklab,#000_82%,transparent)]',
|
|
264
|
+
'backdrop-blur-2xl backdrop-saturate-150',
|
|
265
|
+
)
|
|
266
|
+
// Embedded: the classic transparency checkerboard.
|
|
267
|
+
: cn(
|
|
268
|
+
'bg-[length:16px_16px]',
|
|
269
|
+
'[background-color:color-mix(in_oklab,var(--muted)_20%,transparent)]',
|
|
270
|
+
'[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%)]',
|
|
271
|
+
'[background-position:0_0,0_8px,8px_-8px,-8px_0px]',
|
|
272
|
+
),
|
|
225
273
|
)}
|
|
226
274
|
>
|
|
227
|
-
{
|
|
275
|
+
{/* Dimensions badge — embedded only (lightbox keeps chrome minimal). */}
|
|
276
|
+
{!isLightbox && src && <ImageInfo src={src} />}
|
|
228
277
|
|
|
229
278
|
{useProgressiveLoading && !isFullyLoaded && (
|
|
230
279
|
<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">
|
|
231
|
-
<div className="w-2 h-2 bg-
|
|
280
|
+
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
|
|
232
281
|
{labels.loading}
|
|
233
282
|
</div>
|
|
234
283
|
)}
|
|
@@ -243,29 +292,38 @@ export function ImageViewer({
|
|
|
243
292
|
|
|
244
293
|
<TransformWrapper
|
|
245
294
|
initialScale={1}
|
|
246
|
-
minScale={
|
|
247
|
-
maxScale={
|
|
295
|
+
minScale={MIN_ZOOM}
|
|
296
|
+
maxScale={MAX_ZOOM}
|
|
248
297
|
centerOnInit
|
|
249
298
|
centerZoomedOut
|
|
299
|
+
// `smooth` (default) makes the wheel/trackpad zoom magnitude scale with
|
|
300
|
+
// `|deltaY|`, i.e. 1:1 with the gesture. The raised `smoothStep` gives
|
|
301
|
+
// a macOS trackpad pinch real reach in a single motion (the library's
|
|
302
|
+
// 0.001 default felt dead). Focal-point anchoring is handled natively.
|
|
303
|
+
smooth
|
|
304
|
+
wheel={{ smoothStep: WHEEL_SMOOTH_STEP }}
|
|
305
|
+
pinch={{ step: PINCH_STEP }}
|
|
306
|
+
doubleClick={{ mode: 'toggle', step: DOUBLE_CLICK_STEP }}
|
|
307
|
+
panning={{ velocityDisabled: false }}
|
|
250
308
|
onTransformed={(ref, state) => {
|
|
251
309
|
controlsRef.current = ref;
|
|
252
310
|
// Avoid a re-render on every pan frame — only when zoom changes
|
|
253
311
|
setScale((prev) => (prev === state.scale ? prev : state.scale));
|
|
254
312
|
}}
|
|
255
313
|
onInit={(ref) => { controlsRef.current = ref; }}
|
|
256
|
-
wheel={{ step: 0.1 }}
|
|
257
|
-
doubleClick={{ mode: 'toggle', step: 2 }}
|
|
258
|
-
panning={{ velocityDisabled: false }}
|
|
259
314
|
>
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
315
|
+
{/* Embedded editing toolbar (rotate/flip/expand) — not in the lightbox. */}
|
|
316
|
+
{!isLightbox && (
|
|
317
|
+
<ImageToolbar
|
|
318
|
+
scale={scale}
|
|
319
|
+
transform={transform}
|
|
320
|
+
onRotate={rotate}
|
|
321
|
+
onFlipH={flipH}
|
|
322
|
+
onFlipV={flipV}
|
|
323
|
+
onZoomPreset={handleZoomPreset}
|
|
324
|
+
onExpand={!inDialog ? handleExpand : undefined}
|
|
325
|
+
/>
|
|
326
|
+
)}
|
|
269
327
|
|
|
270
328
|
<TransformComponent
|
|
271
329
|
wrapperClass="!w-full !h-full cursor-grab active:cursor-grabbing"
|
|
@@ -294,7 +352,12 @@ export function ImageViewer({
|
|
|
294
352
|
<img
|
|
295
353
|
src={src}
|
|
296
354
|
alt={current.file.name}
|
|
297
|
-
className=
|
|
355
|
+
className={cn(
|
|
356
|
+
'max-w-full max-h-full object-contain select-none',
|
|
357
|
+
// A soft drop shadow lifts the image off the dark lightbox
|
|
358
|
+
// backdrop (Preview / Photos do the same).
|
|
359
|
+
isLightbox && 'drop-shadow-[0_8px_40px_rgba(0,0,0,0.55)]',
|
|
360
|
+
)}
|
|
298
361
|
style={{
|
|
299
362
|
transform: transformStyle,
|
|
300
363
|
transition: 'transform 0.15s ease-out, opacity 0.3s ease-out',
|
|
@@ -310,52 +373,75 @@ export function ImageViewer({
|
|
|
310
373
|
</TransformComponent>
|
|
311
374
|
</TransformWrapper>
|
|
312
375
|
|
|
313
|
-
{/*
|
|
314
|
-
{
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
376
|
+
{/* Lightbox: restrained macOS-grade chrome that idle-fades. */}
|
|
377
|
+
{isLightbox ? (
|
|
378
|
+
<LightboxChrome
|
|
379
|
+
active={chromeActive}
|
|
380
|
+
index={currentIndex + 1}
|
|
381
|
+
total={images.length}
|
|
382
|
+
scale={scale}
|
|
383
|
+
fileName={current.file.name}
|
|
384
|
+
onPrev={prev}
|
|
385
|
+
onNext={next}
|
|
386
|
+
onClose={() => onRequestClose?.()}
|
|
387
|
+
onPinChange={setPinned}
|
|
388
|
+
labels={{ prev: labels.prev, next: labels.next, close: labels.close }}
|
|
389
|
+
/>
|
|
390
|
+
) : (
|
|
391
|
+
hasMultiple && (
|
|
392
|
+
// Embedded gallery nav (checkerboard canvas).
|
|
393
|
+
<>
|
|
394
|
+
<Tooltip>
|
|
395
|
+
<TooltipTrigger asChild>
|
|
396
|
+
<button
|
|
397
|
+
type="button"
|
|
398
|
+
onClick={prev}
|
|
399
|
+
aria-label={labels.prev}
|
|
400
|
+
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"
|
|
401
|
+
>
|
|
402
|
+
<ChevronLeft className="h-5 w-5" />
|
|
403
|
+
</button>
|
|
404
|
+
</TooltipTrigger>
|
|
405
|
+
<TooltipContent side="right">{labels.prev}</TooltipContent>
|
|
406
|
+
</Tooltip>
|
|
407
|
+
<Tooltip>
|
|
408
|
+
<TooltipTrigger asChild>
|
|
409
|
+
<button
|
|
410
|
+
type="button"
|
|
411
|
+
onClick={next}
|
|
412
|
+
aria-label={labels.next}
|
|
413
|
+
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"
|
|
414
|
+
>
|
|
415
|
+
<ChevronRight className="h-5 w-5" />
|
|
416
|
+
</button>
|
|
417
|
+
</TooltipTrigger>
|
|
418
|
+
<TooltipContent side="left">{labels.next}</TooltipContent>
|
|
419
|
+
</Tooltip>
|
|
420
|
+
<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">
|
|
421
|
+
{currentIndex + 1} / {images.length}
|
|
422
|
+
</div>
|
|
423
|
+
</>
|
|
424
|
+
)
|
|
346
425
|
)}
|
|
347
426
|
|
|
348
|
-
{/*
|
|
349
|
-
{!inDialog && (
|
|
427
|
+
{/* Embedded expand-to-fullscreen dialog (uses the lightbox presentation). */}
|
|
428
|
+
{!inDialog && !lightbox && (
|
|
350
429
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
351
|
-
<DialogContent
|
|
430
|
+
<DialogContent
|
|
431
|
+
fullscreen
|
|
432
|
+
closeButton={false}
|
|
433
|
+
className="border-0 bg-transparent p-0 shadow-none"
|
|
434
|
+
>
|
|
352
435
|
<DialogTitle className="sr-only">{current.file.name}</DialogTitle>
|
|
353
|
-
|
|
354
|
-
<
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
436
|
+
{dialogOpen && (
|
|
437
|
+
<ImageViewer
|
|
438
|
+
images={images}
|
|
439
|
+
initialIndex={currentIndex}
|
|
440
|
+
lightbox
|
|
441
|
+
autoFocus
|
|
442
|
+
onRequestClose={() => setDialogOpen(false)}
|
|
443
|
+
/>
|
|
444
|
+
)}
|
|
359
445
|
</DialogContent>
|
|
360
446
|
</Dialog>
|
|
361
447
|
)}
|
|
@@ -0,0 +1,137 @@
|
|
|
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, 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
|
+
/** Pin chrome open while the pointer hovers a control. */
|
|
35
|
+
onPinChange: (pinned: boolean) => void;
|
|
36
|
+
labels: { prev: string; next: string; close: string };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Liquid-glass circular control — light icon on translucent vibrancy so it
|
|
40
|
+
// stays legible over any photo (bright or dark) without a hard slab.
|
|
41
|
+
const GLASS_BTN =
|
|
42
|
+
'glass-liquid pointer-events-auto inline-flex items-center justify-center ' +
|
|
43
|
+
'rounded-full text-white/90 transition-[background-color,transform,opacity] ' +
|
|
44
|
+
'hover:text-white hover:bg-white/15 active:scale-95 ' +
|
|
45
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70';
|
|
46
|
+
|
|
47
|
+
export function LightboxChrome({
|
|
48
|
+
active,
|
|
49
|
+
index,
|
|
50
|
+
total,
|
|
51
|
+
scale,
|
|
52
|
+
fileName,
|
|
53
|
+
onPrev,
|
|
54
|
+
onNext,
|
|
55
|
+
onClose,
|
|
56
|
+
onPinChange,
|
|
57
|
+
labels,
|
|
58
|
+
}: LightboxChromeProps) {
|
|
59
|
+
const hasMultiple = total > 1;
|
|
60
|
+
// Only surface the zoom pill once the image is meaningfully off fit — at
|
|
61
|
+
// exactly fit (1.0) the % is noise.
|
|
62
|
+
const zoomed = Math.abs(scale - 1) > 0.01;
|
|
63
|
+
const zoomPct = `${Math.round(scale * 100)}%`;
|
|
64
|
+
|
|
65
|
+
// Whole chrome layer fades as one. `pointer-events-none` on the layer means
|
|
66
|
+
// the faded state never eats clicks meant for the image (pan/double-click).
|
|
67
|
+
return (
|
|
68
|
+
<div
|
|
69
|
+
className={cn(
|
|
70
|
+
'pointer-events-none absolute inset-0 z-20 transition-opacity duration-300 ease-out',
|
|
71
|
+
active ? 'opacity-100' : 'opacity-0',
|
|
72
|
+
)}
|
|
73
|
+
onMouseEnter={() => onPinChange(true)}
|
|
74
|
+
onMouseLeave={() => onPinChange(false)}
|
|
75
|
+
>
|
|
76
|
+
{/* Top bar: filename (left, whisper-quiet) + close (right).
|
|
77
|
+
The chip caps its width and middle-truncates long names so the start
|
|
78
|
+
and extension stay readable (Finder convention); `gap` + the pinned
|
|
79
|
+
close button guarantee no overlap. */}
|
|
80
|
+
<div className="absolute inset-x-0 top-0 flex items-start justify-between gap-3 p-3 sm:p-4">
|
|
81
|
+
{fileName ? (
|
|
82
|
+
<MiddleEllipsis
|
|
83
|
+
text={fileName}
|
|
84
|
+
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"
|
|
85
|
+
/>
|
|
86
|
+
) : (
|
|
87
|
+
<span aria-hidden />
|
|
88
|
+
)}
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
aria-label={labels.close}
|
|
92
|
+
onClick={onClose}
|
|
93
|
+
className={cn(GLASS_BTN, 'h-9 w-9')}
|
|
94
|
+
>
|
|
95
|
+
<X className="h-[18px] w-[18px]" />
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{/* Prev / next — edge-anchored, only when there is a gallery. */}
|
|
100
|
+
{hasMultiple && (
|
|
101
|
+
<>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
aria-label={labels.prev}
|
|
105
|
+
onClick={onPrev}
|
|
106
|
+
className={cn(GLASS_BTN, 'absolute left-3 top-1/2 h-11 w-11 -translate-y-1/2 sm:left-5')}
|
|
107
|
+
>
|
|
108
|
+
<ChevronLeft className="h-5 w-5" />
|
|
109
|
+
</button>
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
aria-label={labels.next}
|
|
113
|
+
onClick={onNext}
|
|
114
|
+
className={cn(GLASS_BTN, 'absolute right-3 top-1/2 h-11 w-11 -translate-y-1/2 sm:right-5')}
|
|
115
|
+
>
|
|
116
|
+
<ChevronRight className="h-5 w-5" />
|
|
117
|
+
</button>
|
|
118
|
+
</>
|
|
119
|
+
)}
|
|
120
|
+
|
|
121
|
+
{/* Bottom status pill: counter + live zoom %. One quiet line. */}
|
|
122
|
+
{(hasMultiple || zoomed) && (
|
|
123
|
+
<div className="absolute inset-x-0 bottom-0 flex justify-center p-3 sm:p-4">
|
|
124
|
+
<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">
|
|
125
|
+
{hasMultiple && (
|
|
126
|
+
<span>
|
|
127
|
+
{index} / {total}
|
|
128
|
+
</span>
|
|
129
|
+
)}
|
|
130
|
+
{hasMultiple && zoomed && <span className="h-3 w-px bg-white/25" />}
|
|
131
|
+
{zoomed && <span className="font-mono text-white/70">{zoomPct}</span>}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|