@djangocfg/widget-media 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -6
- package/package.json +7 -6
- package/src/image-viewer/components/ImageViewer.tsx +30 -3
- package/src/image-viewer/index.ts +6 -0
- package/src/image-viewer/lightbox/ImageLightbox.tsx +129 -0
- package/src/image-viewer/lightbox/index.ts +2 -0
- package/src/image-viewer/lightbox/lazy.tsx +24 -0
- package/src/image-viewer/lightbox/lightbox.css +84 -0
- package/src/image-viewer/types.ts +28 -0
- package/src/image-viewer/utils/constants.ts +5 -2
- package/src/image-viewer/utils/index.ts +2 -1
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ and a host mounting a lightbox must not pay for any of them.
|
|
|
17
17
|
|
|
18
18
|
| Subpath | What it is | What it costs |
|
|
19
19
|
|---|---|---|
|
|
20
|
-
| `.` | `ImageViewer`, `LazyImageViewer`,
|
|
20
|
+
| `.` | `ImageLightbox`, `LazyImageLightbox`, `ImageViewer`, `LazyImageViewer`, hooks, types, constants | `yet-another-react-lightbox` (lightbox), `react-zoom-pan-pinch` (viewer) |
|
|
21
21
|
| `./diagram` | `LazyDiagram`, `DiagramSource`, `diagramPlugin` | mermaid (~2.5 MB) — **always deferred** |
|
|
22
22
|
| `./markdown` | `Markdown` — for a reader, outside any conversation | streamdown |
|
|
23
23
|
| `./code` | `Code` — a read-only listing | streamdown + shiki |
|
|
@@ -45,13 +45,39 @@ changing one label does not blank the rest.
|
|
|
45
45
|
`MediaCopy` is the schema of record — a misspelled key is a `tsc` error, not
|
|
46
46
|
`undefined` rendered into a toolbar button.
|
|
47
47
|
|
|
48
|
+
## Image lightbox
|
|
49
|
+
|
|
50
|
+
The modal gallery a click on a picture opens, on
|
|
51
|
+
[yet-another-react-lightbox](https://github.com/igordanchenko/yet-another-react-lightbox):
|
|
52
|
+
focus trap, swipe, preload of the neighbours, keyboard, wheel/pinch zoom,
|
|
53
|
+
thumbnails and a counter. Double-click zooms in at the pointer and the next one
|
|
54
|
+
zooms back out; the cursor is a magnifier at rest and a hand only while zoomed.
|
|
55
|
+
The skin is `src/image-viewer/lightbox/lightbox.css`: YARL's `--yarl__*`
|
|
56
|
+
variables mapped to ui-core tokens, so it follows light/dark and every preset.
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
import { LazyImageLightbox } from '@djangocfg/widget-media';
|
|
60
|
+
|
|
61
|
+
<LazyImageLightbox
|
|
62
|
+
images={[{ name: 'a.jpg', src, downloadSrc }]}
|
|
63
|
+
open={index !== null}
|
|
64
|
+
index={index ?? 0}
|
|
65
|
+
onIndexChange={setIndex}
|
|
66
|
+
onClose={() => setIndex(null)}
|
|
67
|
+
/>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
It takes addresses (`LightboxImage`), never raw bytes; an in-memory image goes
|
|
71
|
+
through `ImageViewer`. `LazyImageLightbox` renders nothing until `open`.
|
|
72
|
+
|
|
48
73
|
## Image viewer
|
|
49
74
|
|
|
50
|
-
|
|
51
|
-
fullscreen dialog, keyboard shortcuts (`+`/`-`, `0`
|
|
52
|
-
progressive loading with LQIP, checkerboard transparency
|
|
53
|
-
dimension display.
|
|
54
|
-
|
|
75
|
+
The embedded editor: zoom (wheel + presets 25/50/100/200/400%), pan by drag,
|
|
76
|
+
rotate 90°, flip H/V, fullscreen dialog, keyboard shortcuts (`+`/`-`, `0`
|
|
77
|
+
reset, `r` rotate), progressive loading with LQIP, checkerboard transparency
|
|
78
|
+
ground, cached dimension display. Double-click zooms to the pointer from fit and
|
|
79
|
+
back to fit from anywhere else. The `lightbox` prop is the older fullscreen
|
|
80
|
+
presentation, superseded by `ImageLightbox` and kept so existing hosts compile.
|
|
55
81
|
|
|
56
82
|
## Verify
|
|
57
83
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/widget-media",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "Media viewers for a conversation or a file preview: zoom/pan image lightbox, mermaid diagrams, markdown and code listings",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"image-viewer",
|
|
@@ -53,19 +53,20 @@
|
|
|
53
53
|
"beautiful-mermaid": "^1.1.3",
|
|
54
54
|
"react-zoom-pan-pinch": "^3.7.0",
|
|
55
55
|
"rehype-harden": "^1.1.8",
|
|
56
|
+
"yet-another-react-lightbox": "^3.32.2",
|
|
56
57
|
"zustand": "^5.0.0"
|
|
57
58
|
},
|
|
58
59
|
"peerDependencies": {
|
|
59
|
-
"@djangocfg/i18n": "^2.1.
|
|
60
|
-
"@djangocfg/ui-core": "^2.1.
|
|
60
|
+
"@djangocfg/i18n": "^2.1.592",
|
|
61
|
+
"@djangocfg/ui-core": "^2.1.592",
|
|
61
62
|
"lucide-react": "^0.545.0",
|
|
62
63
|
"react": "^19.0.0",
|
|
63
64
|
"streamdown": "^2.6.0"
|
|
64
65
|
},
|
|
65
66
|
"devDependencies": {
|
|
66
|
-
"@djangocfg/i18n": "^2.1.
|
|
67
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
68
|
-
"@djangocfg/ui-core": "^2.1.
|
|
67
|
+
"@djangocfg/i18n": "^2.1.592",
|
|
68
|
+
"@djangocfg/typescript-config": "^2.1.592",
|
|
69
|
+
"@djangocfg/ui-core": "^2.1.592",
|
|
69
70
|
"@storybook/react-vite": "^10.5.0",
|
|
70
71
|
"@types/node": "^25.9.5",
|
|
71
72
|
"@types/react": "19.2.15",
|
|
@@ -41,7 +41,8 @@ import {
|
|
|
41
41
|
MAX_ZOOM,
|
|
42
42
|
WHEEL_SMOOTH_STEP,
|
|
43
43
|
PINCH_STEP,
|
|
44
|
-
|
|
44
|
+
DOUBLE_CLICK_ZOOM,
|
|
45
|
+
FIT_EPSILON,
|
|
45
46
|
} from '../utils';
|
|
46
47
|
import type { ImageViewerProps } from '../types';
|
|
47
48
|
|
|
@@ -91,6 +92,7 @@ export function ImageViewer({
|
|
|
91
92
|
const [imgDecoded, setImgDecoded] = useState(false);
|
|
92
93
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
93
94
|
const controlsRef = useRef<ReactZoomPanPinchRef | null>(null);
|
|
95
|
+
const atRest = Math.abs(scale - 1) < FIT_EPSILON;
|
|
94
96
|
const imgRef = useRef<HTMLImageElement>(null);
|
|
95
97
|
|
|
96
98
|
// Idle-fading chrome (lightbox only). Pointer movement over the canvas keeps
|
|
@@ -161,6 +163,24 @@ export function ImageViewer({
|
|
|
161
163
|
resetKey: current?.file.path ?? '',
|
|
162
164
|
});
|
|
163
165
|
|
|
166
|
+
// Double-click / double-tap: zoom to the pointer from fit, back to fit from
|
|
167
|
+
// anywhere else. The point under the pointer stays put (focal-point zoom).
|
|
168
|
+
const handleDoubleClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
|
169
|
+
const controls = controlsRef.current;
|
|
170
|
+
const wrapper = controls?.instance.wrapperComponent;
|
|
171
|
+
if (!controls || !wrapper) return;
|
|
172
|
+
const { scale: current, positionX, positionY } = controls.instance.transformState;
|
|
173
|
+
if (Math.abs(current - 1) >= FIT_EPSILON) {
|
|
174
|
+
controls.resetTransform(200);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const rect = wrapper.getBoundingClientRect();
|
|
178
|
+
const px = event.clientX - rect.left;
|
|
179
|
+
const py = event.clientY - rect.top;
|
|
180
|
+
const ratio = DOUBLE_CLICK_ZOOM / current;
|
|
181
|
+
controls.setTransform(px - (px - positionX) * ratio, py - (py - positionY) * ratio, DOUBLE_CLICK_ZOOM, 200);
|
|
182
|
+
}, []);
|
|
183
|
+
|
|
164
184
|
const handleZoomPreset = useCallback((value: number | 'fit') => {
|
|
165
185
|
const controls = controlsRef.current;
|
|
166
186
|
if (!controls) return;
|
|
@@ -353,7 +373,10 @@ export function ImageViewer({
|
|
|
353
373
|
smooth
|
|
354
374
|
wheel={{ smoothStep: WHEEL_SMOOTH_STEP }}
|
|
355
375
|
pinch={{ step: PINCH_STEP }}
|
|
356
|
-
|
|
376
|
+
// The library's `toggle` zooms in only at scale === 1 exactly, so after
|
|
377
|
+
// any zoom every double-click was another step OUT. Ours is handled in
|
|
378
|
+
// `handleDoubleClick`: at rest zoom to the pointer, otherwise back to fit.
|
|
379
|
+
doubleClick={{ disabled: true }}
|
|
357
380
|
panning={{ velocityDisabled: false }}
|
|
358
381
|
onTransformed={(ref, state) => {
|
|
359
382
|
controlsRef.current = ref;
|
|
@@ -376,7 +399,11 @@ export function ImageViewer({
|
|
|
376
399
|
)}
|
|
377
400
|
|
|
378
401
|
<TransformComponent
|
|
379
|
-
wrapperClass=
|
|
402
|
+
wrapperClass={cn(
|
|
403
|
+
'!w-full !h-full',
|
|
404
|
+
atRest ? 'cursor-zoom-in' : 'cursor-grab active:cursor-grabbing',
|
|
405
|
+
)}
|
|
406
|
+
wrapperProps={{ onDoubleClick: handleDoubleClick }}
|
|
380
407
|
contentClass="!w-full !h-full flex items-center justify-center"
|
|
381
408
|
>
|
|
382
409
|
{/*
|
|
@@ -24,6 +24,10 @@ export { ImageViewer, ImageToolbar, ImageInfo } from './components';
|
|
|
24
24
|
// picture. Exported from the same entry so choosing it costs no extra subpath.
|
|
25
25
|
export { LazyImageViewer } from './lazy';
|
|
26
26
|
|
|
27
|
+
// The modal gallery (yet-another-react-lightbox). Prefer it to the `lightbox`
|
|
28
|
+
// presentation of `ImageViewer`; `LazyImageLightbox` defers the engine.
|
|
29
|
+
export { ImageLightbox, LazyImageLightbox } from './lightbox';
|
|
30
|
+
|
|
27
31
|
// =============================================================================
|
|
28
32
|
// HOOKS
|
|
29
33
|
// =============================================================================
|
|
@@ -43,6 +47,8 @@ export type {
|
|
|
43
47
|
export type {
|
|
44
48
|
ImageFile,
|
|
45
49
|
ImageViewerProps,
|
|
50
|
+
ImageLightboxProps,
|
|
51
|
+
LightboxImage,
|
|
46
52
|
ImageToolbarProps,
|
|
47
53
|
ImageInfoProps,
|
|
48
54
|
ImageTransform,
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ImageLightbox - modal gallery on yet-another-react-lightbox.
|
|
5
|
+
*
|
|
6
|
+
* The engine owns what is costly to get right: focus trap, swipe, preload of
|
|
7
|
+
* the neighbours, keyboard, wheel/pinch zoom, and a double-click that zooms in
|
|
8
|
+
* and back out. This file only configures it, themes it from tokens
|
|
9
|
+
* (`lightbox.css`) and adds the SAVE button with the same same-origin rule the
|
|
10
|
+
* embedded viewer has.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Download } from 'lucide-react';
|
|
14
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
15
|
+
import Lightbox, { IconButton, useLightboxState } from 'yet-another-react-lightbox';
|
|
16
|
+
import Counter from 'yet-another-react-lightbox/plugins/counter';
|
|
17
|
+
import Thumbnails from 'yet-another-react-lightbox/plugins/thumbnails';
|
|
18
|
+
import Zoom from 'yet-another-react-lightbox/plugins/zoom';
|
|
19
|
+
|
|
20
|
+
import 'yet-another-react-lightbox/styles.css';
|
|
21
|
+
import 'yet-another-react-lightbox/plugins/counter.css';
|
|
22
|
+
import 'yet-another-react-lightbox/plugins/thumbnails.css';
|
|
23
|
+
import './lightbox.css';
|
|
24
|
+
|
|
25
|
+
import { useMediaCopy } from '../../i18n';
|
|
26
|
+
import type { ImageLightboxProps, LightboxImage } from '../types';
|
|
27
|
+
|
|
28
|
+
declare module 'yet-another-react-lightbox' {
|
|
29
|
+
interface Labels {
|
|
30
|
+
/** Title of the SAVE button this widget adds to the toolbar. */
|
|
31
|
+
Save?: string;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Double-click: one stop in, the next one out. */
|
|
36
|
+
const ZOOM = {
|
|
37
|
+
doubleClickMaxStops: 1,
|
|
38
|
+
zoomInMultiplier: 2.5,
|
|
39
|
+
maxZoomPixelRatio: 3,
|
|
40
|
+
scrollToZoom: true,
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
export function ImageLightbox({
|
|
44
|
+
images,
|
|
45
|
+
open,
|
|
46
|
+
index = 0,
|
|
47
|
+
onClose,
|
|
48
|
+
onIndexChange,
|
|
49
|
+
thumbnails = true,
|
|
50
|
+
copy,
|
|
51
|
+
}: ImageLightboxProps) {
|
|
52
|
+
const labels = useMediaCopy(copy);
|
|
53
|
+
const [zoomed, setZoomed] = useState(false);
|
|
54
|
+
|
|
55
|
+
const slides = useMemo(
|
|
56
|
+
() =>
|
|
57
|
+
images.map((image) => ({
|
|
58
|
+
src: image.src,
|
|
59
|
+
alt: image.name,
|
|
60
|
+
width: image.width,
|
|
61
|
+
height: image.height,
|
|
62
|
+
})),
|
|
63
|
+
[images],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const handleView = useCallback(
|
|
67
|
+
({ index: next }: { index: number }) => onIndexChange?.(next),
|
|
68
|
+
[onIndexChange],
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<Lightbox
|
|
73
|
+
open={open}
|
|
74
|
+
close={onClose}
|
|
75
|
+
index={index}
|
|
76
|
+
slides={slides}
|
|
77
|
+
className={zoomed ? 'dj-lightbox dj-lightbox--zoomed' : 'dj-lightbox'}
|
|
78
|
+
plugins={thumbnails && slides.length > 1 ? [Counter, Thumbnails, Zoom] : [Counter, Zoom]}
|
|
79
|
+
zoom={ZOOM}
|
|
80
|
+
carousel={{ finite: true, preload: 2 }}
|
|
81
|
+
animation={{ swipe: 250 }}
|
|
82
|
+
controller={{ closeOnBackdropClick: true, closeOnPullDown: true }}
|
|
83
|
+
thumbnails={{ position: 'bottom', vignette: false, border: 0, gap: 8 }}
|
|
84
|
+
labels={{
|
|
85
|
+
Close: labels.close,
|
|
86
|
+
Next: labels.next,
|
|
87
|
+
Previous: labels.previous,
|
|
88
|
+
Save: labels.download,
|
|
89
|
+
'Zoom in': labels.zoomIn,
|
|
90
|
+
'Zoom out': labels.zoomOut,
|
|
91
|
+
}}
|
|
92
|
+
on={{ view: handleView, zoom: ({ zoom }) => setZoomed(zoom > 1.01) }}
|
|
93
|
+
toolbar={{
|
|
94
|
+
buttons: [<SaveButton key="save" images={images} />, 'close'],
|
|
95
|
+
}}
|
|
96
|
+
render={slides.length > 1 ? undefined : { buttonPrev: () => null, buttonNext: () => null }}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Saves the slide on screen. `download` is honoured only for a same-origin
|
|
103
|
+
* address, so a picture shown from a CDN is saved from `downloadSrc` when the
|
|
104
|
+
* host has one.
|
|
105
|
+
*/
|
|
106
|
+
function SaveButton({ images }: { images: LightboxImage[] }) {
|
|
107
|
+
const { currentIndex } = useLightboxState();
|
|
108
|
+
const image = images[currentIndex];
|
|
109
|
+
const save = useCallback(() => {
|
|
110
|
+
if (!image) return;
|
|
111
|
+
const link = document.createElement('a');
|
|
112
|
+
link.href = image.downloadSrc ?? image.src;
|
|
113
|
+
link.download = image.name;
|
|
114
|
+
link.rel = 'noopener';
|
|
115
|
+
document.body.appendChild(link);
|
|
116
|
+
link.click();
|
|
117
|
+
document.body.removeChild(link);
|
|
118
|
+
}, [image]);
|
|
119
|
+
|
|
120
|
+
if (!image) return null;
|
|
121
|
+
return (
|
|
122
|
+
<IconButton
|
|
123
|
+
label="Save"
|
|
124
|
+
icon={() => null}
|
|
125
|
+
renderIcon={() => <Download className="yarl__icon" aria-hidden />}
|
|
126
|
+
onClick={save}
|
|
127
|
+
/>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lazy ImageLightbox: the engine and its three plugins load when a reader first
|
|
5
|
+
* opens a picture, not with the page. Mounted only while `open`, so nothing is
|
|
6
|
+
* fetched (and nothing renders on the server) until then.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { lazy, Suspense } from 'react';
|
|
10
|
+
|
|
11
|
+
import type { ImageLightboxProps } from '../types';
|
|
12
|
+
|
|
13
|
+
const ImageLightboxImpl = lazy(() =>
|
|
14
|
+
import('./ImageLightbox').then((mod) => ({ default: mod.ImageLightbox })),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export function LazyImageLightbox(props: ImageLightboxProps) {
|
|
18
|
+
if (!props.open) return null;
|
|
19
|
+
return (
|
|
20
|
+
<Suspense fallback={null}>
|
|
21
|
+
<ImageLightboxImpl {...props} />
|
|
22
|
+
</Suspense>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Theme for ImageLightbox: yet-another-react-lightbox reads `--yarl__*`
|
|
3
|
+
* variables, so the whole skin is this one mapping onto ui-core tokens. It
|
|
4
|
+
* follows light/dark and every preset with no JS. Scoped to `.dj-lightbox` so a
|
|
5
|
+
* host's own YARL instance is untouched.
|
|
6
|
+
*/
|
|
7
|
+
.dj-lightbox {
|
|
8
|
+
/* Above dialogs (z-1300), below popovers (z-1400): a lightbox opened from a
|
|
9
|
+
dialog must cover it, a menu opened from the toolbar must cover the box. */
|
|
10
|
+
--yarl__portal_zindex: 1350;
|
|
11
|
+
--yarl__color_backdrop: color-mix(in oklab, var(--background) 94%, transparent);
|
|
12
|
+
--yarl__container_background_color: var(--yarl__color_backdrop);
|
|
13
|
+
--yarl__color_button: var(--foreground);
|
|
14
|
+
--yarl__color_button_active: var(--foreground);
|
|
15
|
+
--yarl__color_button_disabled: var(--muted-foreground);
|
|
16
|
+
/* No drop shadow behind icons: the backdrop is a token surface, not a photo. */
|
|
17
|
+
--yarl__button_filter: none;
|
|
18
|
+
--yarl__icon_size: 20px;
|
|
19
|
+
--yarl__button_padding: 10px;
|
|
20
|
+
--yarl__toolbar_padding: 12px;
|
|
21
|
+
--yarl__navigation_button_padding: 12px;
|
|
22
|
+
--yarl__slide_icon_loading_color: var(--muted-foreground);
|
|
23
|
+
--yarl__slide_icon_error_color: var(--destructive);
|
|
24
|
+
--yarl__counter_color: var(--foreground);
|
|
25
|
+
--yarl__counter_filter: none;
|
|
26
|
+
--yarl__counter_top: 12px;
|
|
27
|
+
--yarl__counter_left: 20px;
|
|
28
|
+
--yarl__thumbnails_container_background_color: transparent;
|
|
29
|
+
--yarl__thumbnails_thumbnail_background: var(--muted);
|
|
30
|
+
--yarl__thumbnails_thumbnail_border_color: transparent;
|
|
31
|
+
--yarl__thumbnails_thumbnail_active_border_color: var(--primary);
|
|
32
|
+
--yarl__thumbnails_thumbnail_border_radius: var(--radius-control, 8px);
|
|
33
|
+
--yarl__thumbnails_thumbnail_width: 72px;
|
|
34
|
+
--yarl__thumbnails_thumbnail_height: 52px;
|
|
35
|
+
--yarl__thumbnails_thumbnail_gap: 8px;
|
|
36
|
+
--yarl__thumbnails_thumbnail_padding: 2px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.dj-lightbox .yarl__container {
|
|
40
|
+
backdrop-filter: blur(24px) saturate(1.2);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* Round controls on a translucent token surface, so an icon or the counter stays
|
|
44
|
+
legible over any photo, bright or dark. */
|
|
45
|
+
.dj-lightbox .yarl__button,
|
|
46
|
+
.dj-lightbox .yarl__counter {
|
|
47
|
+
border-radius: 9999px;
|
|
48
|
+
background-color: color-mix(in oklab, var(--background) 60%, transparent);
|
|
49
|
+
backdrop-filter: blur(12px);
|
|
50
|
+
transition: background-color 150ms ease;
|
|
51
|
+
}
|
|
52
|
+
.dj-lightbox .yarl__toolbar {
|
|
53
|
+
gap: 8px;
|
|
54
|
+
}
|
|
55
|
+
.dj-lightbox .yarl__button:hover:not(:disabled) {
|
|
56
|
+
background-color: color-mix(in oklab, var(--foreground) 14%, var(--background) 60%);
|
|
57
|
+
}
|
|
58
|
+
.dj-lightbox .yarl__button:focus-visible {
|
|
59
|
+
box-shadow: 0 0 0 2px var(--ring);
|
|
60
|
+
}
|
|
61
|
+
.dj-lightbox .yarl__navigation_prev,
|
|
62
|
+
.dj-lightbox .yarl__navigation_next {
|
|
63
|
+
margin: 0 12px;
|
|
64
|
+
padding: 12px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.dj-lightbox .yarl__counter {
|
|
68
|
+
padding: 4px 12px;
|
|
69
|
+
color: var(--foreground);
|
|
70
|
+
font-size: var(--text-sm);
|
|
71
|
+
font-variant-numeric: tabular-nums;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* The image says what a double-click does: zoom in at rest, a hand to pan once
|
|
75
|
+
zoomed. (A permanent hand promised a pan that had nowhere to go.) */
|
|
76
|
+
.dj-lightbox .yarl__slide_image {
|
|
77
|
+
cursor: zoom-in;
|
|
78
|
+
}
|
|
79
|
+
.dj-lightbox--zoomed .yarl__slide_image {
|
|
80
|
+
cursor: grab;
|
|
81
|
+
}
|
|
82
|
+
.dj-lightbox--zoomed .yarl__slide_image:active {
|
|
83
|
+
cursor: grabbing;
|
|
84
|
+
}
|
|
@@ -87,6 +87,34 @@ export interface ImageViewerProps {
|
|
|
87
87
|
copy?: Partial<MediaCopy>;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** One slide of `ImageLightbox`: an address, never raw bytes. */
|
|
91
|
+
export interface LightboxImage {
|
|
92
|
+
/** Image URL. */
|
|
93
|
+
src: string;
|
|
94
|
+
/** Shown as the alt text and used as the saved file's name. */
|
|
95
|
+
name: string;
|
|
96
|
+
/** Intrinsic size when known: lets the engine lay out before the bytes arrive. */
|
|
97
|
+
width?: number;
|
|
98
|
+
height?: number;
|
|
99
|
+
/** Same-origin address the SAVE button reads (see `ImageItem.downloadSrc`). */
|
|
100
|
+
downloadSrc?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ImageLightboxProps {
|
|
104
|
+
images: LightboxImage[];
|
|
105
|
+
/** Controlled visibility. */
|
|
106
|
+
open: boolean;
|
|
107
|
+
/** Slide on screen (default 0). */
|
|
108
|
+
index?: number;
|
|
109
|
+
/** Esc, backdrop click, pull-down or the close button. */
|
|
110
|
+
onClose: () => void;
|
|
111
|
+
onIndexChange?: (index: number) => void;
|
|
112
|
+
/** Thumbnail strip for more than one image (default true). */
|
|
113
|
+
thumbnails?: boolean;
|
|
114
|
+
/** Override any subset of the widget's English copy. */
|
|
115
|
+
copy?: Partial<MediaCopy>;
|
|
116
|
+
}
|
|
117
|
+
|
|
90
118
|
export interface ImageToolbarProps {
|
|
91
119
|
/** Current zoom scale */
|
|
92
120
|
scale: number;
|
|
@@ -62,8 +62,11 @@ export const WHEEL_SMOOTH_STEP = 0.0055;
|
|
|
62
62
|
/** Touch pinch sensitivity (two-finger pinch on touchscreens). */
|
|
63
63
|
export const PINCH_STEP = 5;
|
|
64
64
|
|
|
65
|
-
/**
|
|
66
|
-
export const
|
|
65
|
+
/** Scale a double-click / double-tap zooms to, centred on the pointer. */
|
|
66
|
+
export const DOUBLE_CLICK_ZOOM = 2.5;
|
|
67
|
+
|
|
68
|
+
/** Within this of fit (scale 1) the image counts as "at rest". */
|
|
69
|
+
export const FIT_EPSILON = 0.02;
|
|
67
70
|
|
|
68
71
|
/** Idle delay (ms) before the lightbox chrome fades out when the pointer rests. */
|
|
69
72
|
export const CHROME_IDLE_MS = 2200;
|