@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 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`, its hooks, types and constants | `react-zoom-pan-pinch` |
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
- Zoom (wheel + presets 25/50/100/200/400%), pan by drag, rotate 90°, flip H/V,
51
- fullscreen dialog, keyboard shortcuts (`+`/`-`, `0` reset, `r` rotate),
52
- progressive loading with LQIP, checkerboard transparency ground, and cached
53
- dimension display. `LazyImageViewer` ships from the same entry, so choosing it
54
- costs no extra subpath.
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.12",
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.586",
60
- "@djangocfg/ui-core": "^2.1.586",
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.586",
67
- "@djangocfg/typescript-config": "^2.1.586",
68
- "@djangocfg/ui-core": "^2.1.586",
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
- DOUBLE_CLICK_STEP,
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
- doubleClick={{ mode: 'toggle', step: DOUBLE_CLICK_STEP }}
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="!w-full !h-full cursor-grab active:cursor-grabbing"
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,2 @@
1
+ export { ImageLightbox } from './ImageLightbox';
2
+ export { LazyImageLightbox } from './lazy';
@@ -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
- /** Double-click / double-tap zoom multiplier (fit ↔ zoomed toward the point). */
66
- export const DOUBLE_CLICK_STEP = 1.4;
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;
@@ -14,7 +14,8 @@ export {
14
14
  KEYBOARD_PAN_STEP,
15
15
  WHEEL_SMOOTH_STEP,
16
16
  PINCH_STEP,
17
- DOUBLE_CLICK_STEP,
17
+ DOUBLE_CLICK_ZOOM,
18
+ FIT_EPSILON,
18
19
  CHROME_IDLE_MS,
19
20
  ZOOM_PRESETS,
20
21
  DEFAULT_TRANSFORM,