@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 +37 -9
- package/package.json +7 -4
- package/src/i18n/index.ts +40 -51
- package/src/i18n/locales/ar.ts +19 -0
- package/src/i18n/locales/da.ts +19 -0
- package/src/i18n/locales/de.ts +19 -0
- package/src/i18n/locales/en.ts +19 -0
- package/src/i18n/locales/es.ts +19 -0
- package/src/i18n/locales/fr.ts +19 -0
- package/src/i18n/locales/it.ts +19 -0
- package/src/i18n/locales/ja.ts +19 -0
- package/src/i18n/locales/ko.ts +19 -0
- package/src/i18n/locales/nl.ts +19 -0
- package/src/i18n/locales/no.ts +19 -0
- package/src/i18n/locales/pl.ts +19 -0
- package/src/i18n/locales/ptBR.ts +19 -0
- package/src/i18n/locales/ru.ts +19 -0
- package/src/i18n/locales/sv.ts +19 -0
- package/src/i18n/locales/tr.ts +19 -0
- package/src/i18n/locales/zh.ts +19 -0
- package/src/i18n/types.ts +22 -0
- 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/src/index.ts +1 -1
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`,
|
|
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
|
|
37
|
-
|
|
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
|
-
|
|
49
|
-
fullscreen dialog, keyboard shortcuts (`+`/`-`, `0`
|
|
50
|
-
progressive loading with LQIP, checkerboard transparency
|
|
51
|
-
dimension display.
|
|
52
|
-
|
|
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.
|
|
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/
|
|
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/
|
|
66
|
-
"@djangocfg/
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
|
58
|
-
|
|
59
|
-
return { ...
|
|
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
|
-
|
|
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;
|
package/src/index.ts
CHANGED