@djangocfg/widget-media 0.1.9 → 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,11 +17,11 @@ 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 |
24
- | `./i18n` | `MediaCopy`, `DEFAULT_MEDIA_COPY` | — |
24
+ | `./i18n` | `MediaCopy`, `DEFAULT_MEDIA_COPY`, `MEDIA_DICTIONARIES`, `useMediaCopy` | — |
25
25
 
26
26
  ```tsx
27
27
  import { ImageViewer } from '@djangocfg/widget-media';
@@ -33,8 +33,10 @@ import { LazyDiagram } from '@djangocfg/widget-media/diagram';
33
33
 
34
34
  ## Copy
35
35
 
36
- The package owns its copy, typed. Pass `copy` to override any subset; keys you
37
- omit fall back to English, so translating one label does not blank the rest.
36
+ The package owns its copy, typed, in the 17 `@djangocfg/i18n` locales. The
37
+ language comes from the `@djangocfg/i18n` provider (English without one). Pass
38
+ `copy` to override any subset; keys you omit keep the dictionary's text, so
39
+ changing one label does not blank the rest.
38
40
 
39
41
  ```tsx
40
42
  <ImageViewer images={images} copy={{ zoomIn: 'Приблизить', close: 'Закрыть' }} />
@@ -43,13 +45,39 @@ omit fall back to English, so translating one label does not blank the rest.
43
45
  `MediaCopy` is the schema of record — a misspelled key is a `tsc` error, not
44
46
  `undefined` rendered into a toolbar button.
45
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
+
46
73
  ## Image viewer
47
74
 
48
- Zoom (wheel + presets 25/50/100/200/400%), pan by drag, rotate 90°, flip H/V,
49
- fullscreen dialog, keyboard shortcuts (`+`/`-`, `0` reset, `r` rotate),
50
- progressive loading with LQIP, checkerboard transparency ground, and cached
51
- dimension display. `LazyImageViewer` ships from the same entry, so choosing it
52
- 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.
53
81
 
54
82
  ## Verify
55
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/widget-media",
3
- "version": "0.1.9",
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,17 +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/ui-core": "^2.1.550",
60
+ "@djangocfg/i18n": "^2.1.592",
61
+ "@djangocfg/ui-core": "^2.1.592",
60
62
  "lucide-react": "^0.545.0",
61
63
  "react": "^19.0.0",
62
64
  "streamdown": "^2.6.0"
63
65
  },
64
66
  "devDependencies": {
65
- "@djangocfg/typescript-config": "^2.1.550",
66
- "@djangocfg/ui-core": "^2.1.550",
67
+ "@djangocfg/i18n": "^2.1.592",
68
+ "@djangocfg/typescript-config": "^2.1.592",
69
+ "@djangocfg/ui-core": "^2.1.592",
67
70
  "@storybook/react-vite": "^10.5.0",
68
71
  "@types/node": "^25.9.5",
69
72
  "@types/react": "19.2.15",
package/src/i18n/index.ts CHANGED
@@ -1,60 +1,49 @@
1
1
  'use client';
2
2
 
3
- /**
4
- * The package owns its copy, typed.
5
- *
6
- * `MediaCopy` is the schema of record: a missing or misspelled key is a `tsc`
7
- * error rather than `undefined` rendered into a toolbar button. Copy used to
8
- * arrive through a host i18n provider, which made the viewer unmountable
9
- * without one — a story, a preview route and a test each had to stand a
10
- * provider up to draw one image.
11
- *
12
- * A host that already translates passes `copy` to override any subset; keys it
13
- * omits fall back to English.
14
- */
3
+ import { useMemo } from 'react';
15
4
 
16
- export interface MediaCopy {
17
- close: string;
18
- diagram: string;
19
- download: string;
20
- failedToLoad: string;
21
- fitToView: string;
22
- flipHorizontal: string;
23
- flipVertical: string;
24
- fullscreen: string;
25
- loading: string;
26
- next: string;
27
- noImage: string;
28
- previous: string;
29
- rotate: string;
30
- zoomIn: string;
31
- zoomOut: string;
32
- }
5
+ import { createCopy, type Locale } from '@djangocfg/i18n/copy';
6
+
7
+ import { ar } from './locales/ar';
8
+ import { da } from './locales/da';
9
+ import { de } from './locales/de';
10
+ import { en } from './locales/en';
11
+ import { es } from './locales/es';
12
+ import { fr } from './locales/fr';
13
+ import { it } from './locales/it';
14
+ import { ja } from './locales/ja';
15
+ import { ko } from './locales/ko';
16
+ import { nl } from './locales/nl';
17
+ import { no } from './locales/no';
18
+ import { pl } from './locales/pl';
19
+ import { ptBR } from './locales/ptBR';
20
+ import { ru } from './locales/ru';
21
+ import { sv } from './locales/sv';
22
+ import { tr } from './locales/tr';
23
+ import { zh } from './locales/zh';
33
24
 
34
- export const DEFAULT_MEDIA_COPY: MediaCopy = {
35
- close: 'Close',
36
- diagram: 'Diagram',
37
- download: 'Download',
38
- failedToLoad: 'Failed to load',
39
- fitToView: 'Fit to view',
40
- flipHorizontal: 'Flip horizontal',
41
- flipVertical: 'Flip vertical',
42
- fullscreen: 'Fullscreen',
43
- loading: 'Loading…',
44
- next: 'Next',
45
- noImage: 'No image',
46
- previous: 'Previous',
47
- rotate: 'Rotate',
48
- zoomIn: 'Zoom in',
49
- zoomOut: 'Zoom out',
25
+ import type { MediaCopy } from './types';
26
+
27
+ export type { MediaCopy } from './types';
28
+
29
+ /** Every bundled dictionary, typed complete over the shared `Locale` union. */
30
+ export const MEDIA_DICTIONARIES: Readonly<Record<Locale, MediaCopy>> = {
31
+ en, ru, ko, ja, de, fr, zh, it, es, nl, ar, tr, pl, sv, no, da,
32
+ 'pt-BR': ptBR,
50
33
  };
51
34
 
35
+ /** English, for a host that wants the unlocalised defaults. */
36
+ export const DEFAULT_MEDIA_COPY: MediaCopy = en;
37
+
38
+ const kit = createCopy(MEDIA_DICTIONARIES);
39
+
52
40
  /**
53
- * Resolve the copy a component renders. Takes the host's partial override and
54
- * fills the rest from the defaults, so a caller supplying one string does not
55
- * blank the other fourteen.
41
+ * The copy a component renders: the dictionary for the active language (the
42
+ * `locale` argument, else the `@djangocfg/i18n` provider, else English), with
43
+ * the host's partial override on top. A host that overrides one string does
44
+ * not blank the other fourteen.
56
45
  */
57
- export function useMediaCopy(copy?: Partial<MediaCopy>): MediaCopy {
58
- if (!copy) return DEFAULT_MEDIA_COPY;
59
- return { ...DEFAULT_MEDIA_COPY, ...copy };
46
+ export function useMediaCopy(copy?: Partial<MediaCopy>, locale?: string): MediaCopy {
47
+ const base = kit.useCopy(locale);
48
+ return useMemo(() => (copy ? { ...base, ...copy } : base), [base, copy]);
60
49
  }
@@ -0,0 +1,19 @@
1
+ import type { MediaCopy } from '../types';
2
+
3
+ export const ar: MediaCopy = {
4
+ close: 'إغلاق',
5
+ diagram: 'مخطط',
6
+ download: 'تنزيل',
7
+ failedToLoad: 'فشل التحميل',
8
+ fitToView: 'ملاءمة للنافذة',
9
+ flipHorizontal: 'قلب أفقي',
10
+ flipVertical: 'قلب عمودي',
11
+ fullscreen: 'ملء الشاشة',
12
+ loading: 'جارٍ التحميل…',
13
+ next: 'التالي',
14
+ noImage: 'لا توجد صورة',
15
+ previous: 'السابق',
16
+ rotate: 'تدوير',
17
+ zoomIn: 'تكبير',
18
+ zoomOut: 'تصغير',
19
+ };
@@ -0,0 +1,19 @@
1
+ import type { MediaCopy } from '../types';
2
+
3
+ export const da: MediaCopy = {
4
+ close: 'Luk',
5
+ diagram: 'Diagram',
6
+ download: 'Download',
7
+ failedToLoad: 'Kunne ikke indlæses',
8
+ fitToView: 'Tilpas til vindue',
9
+ flipHorizontal: 'Spejlvend vandret',
10
+ flipVertical: 'Spejlvend lodret',
11
+ fullscreen: 'Fuld skærm',
12
+ loading: 'Indlæser…',
13
+ next: 'Næste',
14
+ noImage: 'Intet billede',
15
+ previous: 'Forrige',
16
+ rotate: 'Roter',
17
+ zoomIn: 'Zoom ind',
18
+ zoomOut: 'Zoom ud',
19
+ };
@@ -0,0 +1,19 @@
1
+ import type { MediaCopy } from '../types';
2
+
3
+ export const de: MediaCopy = {
4
+ close: 'Schließen',
5
+ diagram: 'Diagramm',
6
+ download: 'Herunterladen',
7
+ failedToLoad: 'Laden fehlgeschlagen',
8
+ fitToView: 'An Fenster anpassen',
9
+ flipHorizontal: 'Horizontal spiegeln',
10
+ flipVertical: 'Vertikal spiegeln',
11
+ fullscreen: 'Vollbild',
12
+ loading: 'Wird geladen…',
13
+ next: 'Weiter',
14
+ noImage: 'Kein Bild',
15
+ previous: 'Zurück',
16
+ rotate: 'Drehen',
17
+ zoomIn: 'Vergrößern',
18
+ zoomOut: 'Verkleinern',
19
+ };
@@ -0,0 +1,19 @@
1
+ import type { MediaCopy } from '../types';
2
+
3
+ export const en: MediaCopy = {
4
+ close: 'Close',
5
+ diagram: 'Diagram',
6
+ download: 'Download',
7
+ failedToLoad: 'Failed to load',
8
+ fitToView: 'Fit to view',
9
+ flipHorizontal: 'Flip horizontal',
10
+ flipVertical: 'Flip vertical',
11
+ fullscreen: 'Fullscreen',
12
+ loading: 'Loading…',
13
+ next: 'Next',
14
+ noImage: 'No image',
15
+ previous: 'Previous',
16
+ rotate: 'Rotate',
17
+ zoomIn: 'Zoom in',
18
+ zoomOut: 'Zoom out',
19
+ };
@@ -0,0 +1,19 @@
1
+ import type { MediaCopy } from '../types';
2
+
3
+ export const es: MediaCopy = {
4
+ close: 'Cerrar',
5
+ diagram: 'Diagrama',
6
+ download: 'Descargar',
7
+ failedToLoad: 'Error al cargar',
8
+ fitToView: 'Ajustar a la ventana',
9
+ flipHorizontal: 'Voltear horizontalmente',
10
+ flipVertical: 'Voltear verticalmente',
11
+ fullscreen: 'Pantalla completa',
12
+ loading: 'Cargando…',
13
+ next: 'Siguiente',
14
+ noImage: 'Sin imagen',
15
+ previous: 'Anterior',
16
+ rotate: 'Girar',
17
+ zoomIn: 'Acercar',
18
+ zoomOut: 'Alejar',
19
+ };
@@ -0,0 +1,19 @@
1
+ import type { MediaCopy } from '../types';
2
+
3
+ export const fr: MediaCopy = {
4
+ close: 'Fermer',
5
+ diagram: 'Diagramme',
6
+ download: 'Télécharger',
7
+ failedToLoad: 'Échec du chargement',
8
+ fitToView: 'Ajuster à la fenêtre',
9
+ flipHorizontal: 'Retourner horizontalement',
10
+ flipVertical: 'Retourner verticalement',
11
+ fullscreen: 'Plein écran',
12
+ loading: 'Chargement…',
13
+ next: 'Suivant',
14
+ noImage: 'Aucune image',
15
+ previous: 'Précédent',
16
+ rotate: 'Pivoter',
17
+ zoomIn: 'Zoom avant',
18
+ zoomOut: 'Zoom arrière',
19
+ };
@@ -0,0 +1,19 @@
1
+ import type { MediaCopy } from '../types';
2
+
3
+ export const it: MediaCopy = {
4
+ close: 'Chiudi',
5
+ diagram: 'Diagramma',
6
+ download: 'Scarica',
7
+ failedToLoad: 'Caricamento non riuscito',
8
+ fitToView: 'Adatta alla finestra',
9
+ flipHorizontal: 'Capovolgi orizzontalmente',
10
+ flipVertical: 'Capovolgi verticalmente',
11
+ fullscreen: 'Schermo intero',
12
+ loading: 'Caricamento…',
13
+ next: 'Avanti',
14
+ noImage: 'Nessuna immagine',
15
+ previous: 'Indietro',
16
+ rotate: 'Ruota',
17
+ zoomIn: 'Ingrandisci',
18
+ zoomOut: 'Riduci',
19
+ };
@@ -0,0 +1,19 @@
1
+ import type { MediaCopy } from '../types';
2
+
3
+ export const ja: MediaCopy = {
4
+ close: '閉じる',
5
+ diagram: 'ダイアグラム',
6
+ download: 'ダウンロード',
7
+ failedToLoad: '読み込めませんでした',
8
+ fitToView: '画面に合わせる',
9
+ flipHorizontal: '左右反転',
10
+ flipVertical: '上下反転',
11
+ fullscreen: '全画面表示',
12
+ loading: '読み込み中…',
13
+ next: '次へ',
14
+ noImage: '画像がありません',
15
+ previous: '前へ',
16
+ rotate: '回転',
17
+ zoomIn: '拡大',
18
+ zoomOut: '縮小',
19
+ };
@@ -0,0 +1,19 @@
1
+ import type { MediaCopy } from '../types';
2
+
3
+ export const ko: MediaCopy = {
4
+ close: '닫기',
5
+ diagram: '다이어그램',
6
+ download: '다운로드',
7
+ failedToLoad: '불러오지 못했습니다',
8
+ fitToView: '화면에 맞추기',
9
+ flipHorizontal: '좌우 반전',
10
+ flipVertical: '상하 반전',
11
+ fullscreen: '전체 화면',
12
+ loading: '로딩 중…',
13
+ next: '다음',
14
+ noImage: '이미지 없음',
15
+ previous: '이전',
16
+ rotate: '회전',
17
+ zoomIn: '확대',
18
+ zoomOut: '축소',
19
+ };
@@ -0,0 +1,19 @@
1
+ import type { MediaCopy } from '../types';
2
+
3
+ export const nl: MediaCopy = {
4
+ close: 'Sluiten',
5
+ diagram: 'Diagram',
6
+ download: 'Downloaden',
7
+ failedToLoad: 'Laden mislukt',
8
+ fitToView: 'Aanpassen aan venster',
9
+ flipHorizontal: 'Horizontaal spiegelen',
10
+ flipVertical: 'Verticaal spiegelen',
11
+ fullscreen: 'Volledig scherm',
12
+ loading: 'Laden…',
13
+ next: 'Volgende',
14
+ noImage: 'Geen afbeelding',
15
+ previous: 'Vorige',
16
+ rotate: 'Draaien',
17
+ zoomIn: 'Inzoomen',
18
+ zoomOut: 'Uitzoomen',
19
+ };
@@ -0,0 +1,19 @@
1
+ import type { MediaCopy } from '../types';
2
+
3
+ export const no: MediaCopy = {
4
+ close: 'Lukk',
5
+ diagram: 'Diagram',
6
+ download: 'Last ned',
7
+ failedToLoad: 'Kunne ikke lastes',
8
+ fitToView: 'Tilpass til vinduet',
9
+ flipHorizontal: 'Speilvend horisontalt',
10
+ flipVertical: 'Speilvend vertikalt',
11
+ fullscreen: 'Fullskjerm',
12
+ loading: 'Laster…',
13
+ next: 'Neste',
14
+ noImage: 'Ingen bilde',
15
+ previous: 'Forrige',
16
+ rotate: 'Roter',
17
+ zoomIn: 'Zoom inn',
18
+ zoomOut: 'Zoom ut',
19
+ };
@@ -0,0 +1,19 @@
1
+ import type { MediaCopy } from '../types';
2
+
3
+ export const pl: MediaCopy = {
4
+ close: 'Zamknij',
5
+ diagram: 'Diagram',
6
+ download: 'Pobierz',
7
+ failedToLoad: 'Nie udało się wczytać',
8
+ fitToView: 'Dopasuj do okna',
9
+ flipHorizontal: 'Odbij w poziomie',
10
+ flipVertical: 'Odbij w pionie',
11
+ fullscreen: 'Pełny ekran',
12
+ loading: 'Ładowanie…',
13
+ next: 'Dalej',
14
+ noImage: 'Brak obrazu',
15
+ previous: 'Wstecz',
16
+ rotate: 'Obróć',
17
+ zoomIn: 'Powiększ',
18
+ zoomOut: 'Pomniejsz',
19
+ };
@@ -0,0 +1,19 @@
1
+ import type { MediaCopy } from '../types';
2
+
3
+ export const ptBR: MediaCopy = {
4
+ close: 'Fechar',
5
+ diagram: 'Diagrama',
6
+ download: 'Baixar',
7
+ failedToLoad: 'Falha ao carregar',
8
+ fitToView: 'Ajustar à janela',
9
+ flipHorizontal: 'Espelhar na horizontal',
10
+ flipVertical: 'Espelhar na vertical',
11
+ fullscreen: 'Tela cheia',
12
+ loading: 'Carregando…',
13
+ next: 'Próximo',
14
+ noImage: 'Sem imagem',
15
+ previous: 'Anterior',
16
+ rotate: 'Girar',
17
+ zoomIn: 'Aproximar',
18
+ zoomOut: 'Afastar',
19
+ };
@@ -0,0 +1,19 @@
1
+ import type { MediaCopy } from '../types';
2
+
3
+ export const ru: MediaCopy = {
4
+ close: 'Закрыть',
5
+ diagram: 'Диаграмма',
6
+ download: 'Скачать',
7
+ failedToLoad: 'Не удалось загрузить',
8
+ fitToView: 'По размеру окна',
9
+ flipHorizontal: 'Отразить по горизонтали',
10
+ flipVertical: 'Отразить по вертикали',
11
+ fullscreen: 'Во весь экран',
12
+ loading: 'Загрузка…',
13
+ next: 'Далее',
14
+ noImage: 'Нет изображения',
15
+ previous: 'Назад',
16
+ rotate: 'Повернуть',
17
+ zoomIn: 'Увеличить',
18
+ zoomOut: 'Уменьшить',
19
+ };
@@ -0,0 +1,19 @@
1
+ import type { MediaCopy } from '../types';
2
+
3
+ export const sv: MediaCopy = {
4
+ close: 'Stäng',
5
+ diagram: 'Diagram',
6
+ download: 'Ladda ner',
7
+ failedToLoad: 'Det gick inte att ladda',
8
+ fitToView: 'Anpassa till fönstret',
9
+ flipHorizontal: 'Vänd horisontellt',
10
+ flipVertical: 'Vänd vertikalt',
11
+ fullscreen: 'Helskärm',
12
+ loading: 'Laddar…',
13
+ next: 'Nästa',
14
+ noImage: 'Ingen bild',
15
+ previous: 'Föregående',
16
+ rotate: 'Rotera',
17
+ zoomIn: 'Zooma in',
18
+ zoomOut: 'Zooma ut',
19
+ };
@@ -0,0 +1,19 @@
1
+ import type { MediaCopy } from '../types';
2
+
3
+ export const tr: MediaCopy = {
4
+ close: 'Kapat',
5
+ diagram: 'Diyagram',
6
+ download: 'İndir',
7
+ failedToLoad: 'Yüklenemedi',
8
+ fitToView: 'Pencereye sığdır',
9
+ flipHorizontal: 'Yatay çevir',
10
+ flipVertical: 'Dikey çevir',
11
+ fullscreen: 'Tam ekran',
12
+ loading: 'Yükleniyor…',
13
+ next: 'Sonraki',
14
+ noImage: 'Görsel yok',
15
+ previous: 'Önceki',
16
+ rotate: 'Döndür',
17
+ zoomIn: 'Yakınlaştır',
18
+ zoomOut: 'Uzaklaştır',
19
+ };
@@ -0,0 +1,19 @@
1
+ import type { MediaCopy } from '../types';
2
+
3
+ export const zh: MediaCopy = {
4
+ close: '关闭',
5
+ diagram: '图表',
6
+ download: '下载',
7
+ failedToLoad: '加载失败',
8
+ fitToView: '适应窗口',
9
+ flipHorizontal: '水平翻转',
10
+ flipVertical: '垂直翻转',
11
+ fullscreen: '全屏',
12
+ loading: '加载中…',
13
+ next: '下一个',
14
+ noImage: '没有图片',
15
+ previous: '上一个',
16
+ rotate: '旋转',
17
+ zoomIn: '放大',
18
+ zoomOut: '缩小',
19
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The media package's copy schema. English is the schema of record: every
3
+ * locale is typed against `MediaCopy`, so a missing key is a `tsc` error rather
4
+ * than `undefined` rendered into a toolbar button.
5
+ */
6
+ export interface MediaCopy {
7
+ close: string;
8
+ diagram: string;
9
+ download: string;
10
+ failedToLoad: string;
11
+ fitToView: string;
12
+ flipHorizontal: string;
13
+ flipVertical: string;
14
+ fullscreen: string;
15
+ loading: string;
16
+ next: string;
17
+ noImage: string;
18
+ previous: string;
19
+ rotate: string;
20
+ zoomIn: string;
21
+ zoomOut: string;
22
+ }
@@ -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,
package/src/index.ts CHANGED
@@ -7,4 +7,4 @@
7
7
  // undo the split silently.
8
8
  export * from "./image-viewer";
9
9
  export type { MediaCopy } from "./i18n";
10
- export { DEFAULT_MEDIA_COPY } from "./i18n";
10
+ export { DEFAULT_MEDIA_COPY, MEDIA_DICTIONARIES, useMediaCopy } from "./i18n";