@synerise/ds-image 0.1.0
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/CHANGELOG.md +10 -0
- package/LICENSE.md +21 -0
- package/README.md +55 -0
- package/dist/Gallery/Gallery.d.ts +3 -0
- package/dist/Gallery/Gallery.js +45 -0
- package/dist/Gallery/Gallery.styles.d.ts +1 -0
- package/dist/Gallery/Gallery.styles.js +8 -0
- package/dist/Gallery/Gallery.types.d.ts +39 -0
- package/dist/Gallery/Gallery.types.js +1 -0
- package/dist/Preview/ImagePreview.const.d.ts +16 -0
- package/dist/Preview/ImagePreview.const.js +10 -0
- package/dist/Preview/ImagePreview.d.ts +4 -0
- package/dist/Preview/ImagePreview.js +208 -0
- package/dist/Preview/ImagePreview.styles.d.ts +13 -0
- package/dist/Preview/ImagePreview.styles.js +44 -0
- package/dist/Preview/ImagePreview.types.d.ts +39 -0
- package/dist/Preview/ImagePreview.types.js +1 -0
- package/dist/Preview/PreviewToolbar.d.ts +21 -0
- package/dist/Preview/PreviewToolbar.js +60 -0
- package/dist/Thumbnail/Thumbnail.const.d.ts +7 -0
- package/dist/Thumbnail/Thumbnail.const.js +22 -0
- package/dist/Thumbnail/Thumbnail.d.ts +3 -0
- package/dist/Thumbnail/Thumbnail.js +60 -0
- package/dist/Thumbnail/Thumbnail.styles.d.ts +24 -0
- package/dist/Thumbnail/Thumbnail.styles.js +37 -0
- package/dist/Thumbnail/Thumbnail.types.d.ts +34 -0
- package/dist/Thumbnail/Thumbnail.types.js +1 -0
- package/dist/context/ImageGalleryContext.d.ts +16 -0
- package/dist/context/ImageGalleryContext.js +7 -0
- package/dist/hooks/useImagePreview.d.ts +33 -0
- package/dist/hooks/useImagePreview.js +48 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +10 -0
- package/dist/modules.d.js +1 -0
- package/dist/modules.d.ts +0 -0
- package/dist/shared/Image.shared.types.d.ts +42 -0
- package/dist/shared/Image.shared.types.js +1 -0
- package/dist/shared/ImageContent.d.ts +27 -0
- package/dist/shared/ImageContent.js +36 -0
- package/dist/shared/ImageContent.styles.d.ts +2 -0
- package/dist/shared/ImageContent.styles.js +8 -0
- package/dist/shared/useImageStatus.d.ts +17 -0
- package/dist/shared/useImageStatus.js +28 -0
- package/dist/shared/useImageTexts.d.ts +8 -0
- package/dist/shared/useImageTexts.js +38 -0
- package/package.json +58 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Change Log
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
|
+
|
|
6
|
+
# 0.1.0 (2026-07-08)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- add ImageView component ([8bb707d](https://github.com/Synerise/synerise-design/commit/8bb707de09785d60a0cee48ae7147f3d6b117b3f))
|
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2019 Synerise
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# @synerise/ds-image
|
|
2
|
+
|
|
3
|
+
Image components for the Synerise Design System: inline **thumbnails**, a
|
|
4
|
+
**gallery** of thumbnails with uniform sizing, and a full-screen **preview**
|
|
5
|
+
(lightbox) with zoom, pan and navigation.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @synerise/ds-image
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Exports
|
|
14
|
+
|
|
15
|
+
- `Thumbnail` — a single image tile (aspect ratio, size, background, object-fit,
|
|
16
|
+
Empty state, broken-image fallback, optional delete-on-hover).
|
|
17
|
+
- `Gallery` — many thumbnails sharing the same visual settings; clicking one opens
|
|
18
|
+
the preview at that image.
|
|
19
|
+
- `ImagePreview` — a controlled full-screen lightbox with a bottom-center toolbar
|
|
20
|
+
(navigation `‹ N of M ›` + zoom/fullscreen).
|
|
21
|
+
- `useImagePreview` — open/close/next/prev state so the preview can be triggered
|
|
22
|
+
from any element (a list row, a button, a table cell).
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { Gallery, ImagePreview, Thumbnail, useImagePreview } from '@synerise/ds-image';
|
|
28
|
+
|
|
29
|
+
const images = [
|
|
30
|
+
{ src: '/a.jpg', alt: 'A' },
|
|
31
|
+
{ src: '/b.jpg', alt: 'B' },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// A single thumbnail
|
|
35
|
+
<Thumbnail src="/a.jpg" alt="A" aspectRatio="1:1" size="m" objectFit="cover" />;
|
|
36
|
+
|
|
37
|
+
// A gallery — settings apply to every thumbnail; clicking opens the preview
|
|
38
|
+
<Gallery images={images} aspectRatio="1:1" size="s" background="subtle-grey" objectFit="cover" />;
|
|
39
|
+
|
|
40
|
+
// Preview triggered from an arbitrary element
|
|
41
|
+
const Example = () => {
|
|
42
|
+
const preview = useImagePreview(images);
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
<button type="button" onClick={() => preview.open(0)}>
|
|
46
|
+
Open
|
|
47
|
+
</button>
|
|
48
|
+
<ImagePreview {...preview.previewProps} />
|
|
49
|
+
</>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
See the Storybook overview page for full props tables, behavior notes, and
|
|
55
|
+
accessibility guidance.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { GalleryProps } from './Gallery.types';
|
|
2
|
+
declare const Gallery: ({ images, aspectRatio, size, height, background, objectFit, loading, fallback, deletable, onDelete, openZoom, zoomable, zoomStep, maxZoom, initialZoom, getContainer, texts, className, }: GalleryProps) => JSX.Element;
|
|
3
|
+
export default Gallery;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { jsxs, jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useMemo } from "react";
|
|
3
|
+
import ImagePreview from "../Preview/ImagePreview.js";
|
|
4
|
+
import Thumbnail from "../Thumbnail/Thumbnail.js";
|
|
5
|
+
import { ImageGalleryContext } from "../context/ImageGalleryContext.js";
|
|
6
|
+
import { Container } from "./Gallery.styles.js";
|
|
7
|
+
const Gallery = ({
|
|
8
|
+
images,
|
|
9
|
+
aspectRatio,
|
|
10
|
+
size,
|
|
11
|
+
height,
|
|
12
|
+
background,
|
|
13
|
+
objectFit,
|
|
14
|
+
loading,
|
|
15
|
+
fallback,
|
|
16
|
+
deletable = false,
|
|
17
|
+
onDelete,
|
|
18
|
+
openZoom = true,
|
|
19
|
+
zoomable,
|
|
20
|
+
zoomStep,
|
|
21
|
+
maxZoom,
|
|
22
|
+
initialZoom,
|
|
23
|
+
getContainer,
|
|
24
|
+
texts,
|
|
25
|
+
className
|
|
26
|
+
}) => {
|
|
27
|
+
const [previewIndex, setPreviewIndex] = useState(null);
|
|
28
|
+
const contextValue = useMemo(() => ({
|
|
29
|
+
aspectRatio,
|
|
30
|
+
size,
|
|
31
|
+
height,
|
|
32
|
+
background,
|
|
33
|
+
objectFit,
|
|
34
|
+
loading,
|
|
35
|
+
fallback,
|
|
36
|
+
texts
|
|
37
|
+
}), [aspectRatio, size, height, background, objectFit, loading, fallback, texts]);
|
|
38
|
+
return /* @__PURE__ */ jsxs(ImageGalleryContext.Provider, { value: contextValue, children: [
|
|
39
|
+
/* @__PURE__ */ jsx(Container, { className, "data-testid": "image-gallery", children: images.map((image, imageIndex) => /* @__PURE__ */ jsx(Thumbnail, { src: image.src, alt: image.alt, fallback: image.fallback, deletable, onDelete: onDelete ? () => onDelete(imageIndex) : void 0, openZoom, onClick: openZoom ? () => setPreviewIndex(imageIndex) : void 0 }, `${image.src}-${imageIndex}`)) }),
|
|
40
|
+
openZoom && /* @__PURE__ */ jsx(ImagePreview, { open: previewIndex !== null, images, index: previewIndex ?? 0, onIndexChange: setPreviewIndex, onClose: () => setPreviewIndex(null), zoomable, zoomStep, maxZoom, initialZoom, fallback, getContainer, texts })
|
|
41
|
+
] });
|
|
42
|
+
};
|
|
43
|
+
export {
|
|
44
|
+
Gallery as default
|
|
45
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const Container: import('styled-components').StyledComponent<"div", any, {}, never>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import { AspectRatio, ImageSource, ImageTexts, InitialZoom, ObjectFit, ThumbnailBackground, ThumbnailSize } from '../shared/Image.shared.types';
|
|
3
|
+
export type GalleryProps = {
|
|
4
|
+
/** Images rendered as thumbnails and paged through in the preview. */
|
|
5
|
+
images: ImageSource[];
|
|
6
|
+
/** Aspect ratio applied to every thumbnail. Defaults to `1:1`. */
|
|
7
|
+
aspectRatio?: AspectRatio;
|
|
8
|
+
/** Height token applied to every thumbnail. Defaults to `m`. */
|
|
9
|
+
size?: ThumbnailSize;
|
|
10
|
+
/** Explicit height in px, used when `size` is `custom`. */
|
|
11
|
+
height?: number;
|
|
12
|
+
/** Backdrop applied to every thumbnail. Defaults to `none`. */
|
|
13
|
+
background?: ThumbnailBackground;
|
|
14
|
+
/** How images fill their tiles. Defaults to `contain`. */
|
|
15
|
+
objectFit?: ObjectFit;
|
|
16
|
+
/** Native loading hint applied to every thumbnail. Defaults to `lazy`. */
|
|
17
|
+
loading?: 'lazy' | 'eager';
|
|
18
|
+
/** Fallback for any broken image (thumbnails and preview). */
|
|
19
|
+
fallback?: ReactNode;
|
|
20
|
+
/** Show a delete affordance on each thumbnail. */
|
|
21
|
+
deletable?: boolean;
|
|
22
|
+
/** Called with the index of the thumbnail whose delete affordance fired. */
|
|
23
|
+
onDelete?: (index: number) => void;
|
|
24
|
+
/** Whether clicking a thumbnail opens the preview. Defaults to true. */
|
|
25
|
+
openZoom?: boolean;
|
|
26
|
+
/** Preview pass-through — enable zoom/pan. */
|
|
27
|
+
zoomable?: boolean;
|
|
28
|
+
/** Preview pass-through — per-click zoom multiplier. */
|
|
29
|
+
zoomStep?: number;
|
|
30
|
+
/** Preview pass-through — max zoom relative to natural size. */
|
|
31
|
+
maxZoom?: number;
|
|
32
|
+
/** Preview pass-through — where zoom starts. */
|
|
33
|
+
initialZoom?: InitialZoom;
|
|
34
|
+
/** Preview pass-through — portal target. */
|
|
35
|
+
getContainer?: () => HTMLElement;
|
|
36
|
+
/** Override tooltip / accessible labels for thumbnails and the preview. */
|
|
37
|
+
texts?: Partial<ImageTexts>;
|
|
38
|
+
className?: string;
|
|
39
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** RZPP scale at which the whole image is fitted to the working area. */
|
|
2
|
+
export declare const FIT_SCALE = 1;
|
|
3
|
+
/**
|
|
4
|
+
* Per-click zoom multiplier (Figma: "Skok zooma"). `1.4` means each zoom step
|
|
5
|
+
* scales by ×1.4. Converted to the additive step react-zoom-pan-pinch expects
|
|
6
|
+
* via `multiplier - 1`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEFAULT_ZOOM_STEP = 1.4;
|
|
9
|
+
/** Max zoom relative to the image's natural size (Figma: "Max zoom" = 300%). */
|
|
10
|
+
export declare const DEFAULT_MAX_ZOOM = 3;
|
|
11
|
+
/**
|
|
12
|
+
* Gap (px) between the previewed image and the viewport edge. Kept constant
|
|
13
|
+
* (not removed on zoom) so the zoom/pan working area never resizes mid-zoom —
|
|
14
|
+
* resizing it makes react-zoom-pan-pinch re-fit and corrupts the first zoom step.
|
|
15
|
+
*/
|
|
16
|
+
export declare const PREVIEW_PADDING = 32;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { default as React } from 'react';
|
|
2
|
+
import { ImagePreviewProps } from './ImagePreview.types';
|
|
3
|
+
declare const ImagePreview: ({ open, images, index, onIndexChange, onClose, zoomable, zoomStep, maxZoom, initialZoom, closable, maskClosable, fallback, getContainer, destroyOnClose, onZoom, texts: textsProp, }: ImagePreviewProps) => React.ReactPortal | null;
|
|
4
|
+
export default ImagePreview;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useRef, useState, useEffect, useCallback } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
|
|
5
|
+
import Icon, { ImageM, CloseM } from "@synerise/ds-icon";
|
|
6
|
+
import { ToolbarGroup, ToolbarButton } from "@synerise/ds-toolbar";
|
|
7
|
+
import { useFocusTrap } from "@synerise/ds-utils";
|
|
8
|
+
import { useImageStatus } from "../shared/useImageStatus.js";
|
|
9
|
+
import { useImageTexts } from "../shared/useImageTexts.js";
|
|
10
|
+
import { FIT_SCALE, PREVIEW_PADDING, DEFAULT_MAX_ZOOM, DEFAULT_ZOOM_STEP } from "./ImagePreview.const.js";
|
|
11
|
+
import { Image, Fallback, FallbackBox, Overlay, ImageWrapper, CloseWrapper, ToolbarWrapper } from "./ImagePreview.styles.js";
|
|
12
|
+
import PreviewToolbar from "./PreviewToolbar.js";
|
|
13
|
+
const SCALE_EPSILON = 0.01;
|
|
14
|
+
const ZOOM_ANIMATION_MS = 200;
|
|
15
|
+
const CLICK_MOVE_THRESHOLD = 5;
|
|
16
|
+
const stopPropagation = (event) => {
|
|
17
|
+
event.stopPropagation();
|
|
18
|
+
};
|
|
19
|
+
const getDownloadName = (src) => {
|
|
20
|
+
if (!src || src.startsWith("data:")) {
|
|
21
|
+
return "image";
|
|
22
|
+
}
|
|
23
|
+
const path = src.split(/[?#]/)[0];
|
|
24
|
+
return path.substring(path.lastIndexOf("/") + 1) || "image";
|
|
25
|
+
};
|
|
26
|
+
const ImagePreview = ({
|
|
27
|
+
open,
|
|
28
|
+
images,
|
|
29
|
+
index,
|
|
30
|
+
onIndexChange,
|
|
31
|
+
onClose,
|
|
32
|
+
zoomable = true,
|
|
33
|
+
zoomStep = DEFAULT_ZOOM_STEP,
|
|
34
|
+
maxZoom = DEFAULT_MAX_ZOOM,
|
|
35
|
+
initialZoom = "fit",
|
|
36
|
+
closable = true,
|
|
37
|
+
maskClosable = true,
|
|
38
|
+
fallback,
|
|
39
|
+
getContainer,
|
|
40
|
+
destroyOnClose = false,
|
|
41
|
+
onZoom,
|
|
42
|
+
texts: textsProp
|
|
43
|
+
}) => {
|
|
44
|
+
const texts = useImageTexts(textsProp);
|
|
45
|
+
const overlayRef = useRef(null);
|
|
46
|
+
const workingAreaRef = useRef(null);
|
|
47
|
+
const transformRef = useRef(null);
|
|
48
|
+
const pointerDownRef = useRef(null);
|
|
49
|
+
const total = images.length;
|
|
50
|
+
const currentImage = images[index];
|
|
51
|
+
const currentSrc = currentImage?.src ?? "";
|
|
52
|
+
const {
|
|
53
|
+
ref: imgRef,
|
|
54
|
+
status,
|
|
55
|
+
handleLoad,
|
|
56
|
+
handleError
|
|
57
|
+
} = useImageStatus(currentSrc);
|
|
58
|
+
const [naturalScaleFactor, setNaturalScaleFactor] = useState(FIT_SCALE);
|
|
59
|
+
const [maxScale, setMaxScale] = useState(FIT_SCALE);
|
|
60
|
+
const [scale, setScale] = useState(FIT_SCALE);
|
|
61
|
+
const [isPanning, setIsPanning] = useState(false);
|
|
62
|
+
useFocusTrap(overlayRef, open);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (open) {
|
|
65
|
+
overlayRef.current?.focus();
|
|
66
|
+
}
|
|
67
|
+
}, [open]);
|
|
68
|
+
const goTo = useCallback((next) => {
|
|
69
|
+
if (total < 2) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
onIndexChange((next + total) % total);
|
|
73
|
+
}, [total, onIndexChange]);
|
|
74
|
+
const measureZoom = useCallback(() => {
|
|
75
|
+
const area = workingAreaRef.current;
|
|
76
|
+
const img = imgRef.current;
|
|
77
|
+
if (!area || !img || !img.naturalWidth) {
|
|
78
|
+
return FIT_SCALE;
|
|
79
|
+
}
|
|
80
|
+
const availableWidth = area.clientWidth - 2 * PREVIEW_PADDING;
|
|
81
|
+
const availableHeight = area.clientHeight - 2 * PREVIEW_PADDING;
|
|
82
|
+
const fitToNatural = Math.max(img.naturalWidth / availableWidth, img.naturalHeight / availableHeight);
|
|
83
|
+
const factor = fitToNatural > FIT_SCALE ? fitToNatural : FIT_SCALE;
|
|
84
|
+
setNaturalScaleFactor(factor);
|
|
85
|
+
setMaxScale(factor * maxZoom);
|
|
86
|
+
return factor;
|
|
87
|
+
}, [imgRef, maxZoom]);
|
|
88
|
+
const applyInitialZoom = useCallback((factor) => {
|
|
89
|
+
if (initialZoom === "real-size" && factor > FIT_SCALE) {
|
|
90
|
+
transformRef.current?.centerView?.(factor, 0);
|
|
91
|
+
}
|
|
92
|
+
}, [initialZoom]);
|
|
93
|
+
const handleImageLoad = useCallback((event) => {
|
|
94
|
+
handleLoad(event);
|
|
95
|
+
applyInitialZoom(measureZoom());
|
|
96
|
+
}, [handleLoad, measureZoom, applyInitialZoom]);
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!open) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
transformRef.current?.resetTransform?.(0);
|
|
102
|
+
setScale(FIT_SCALE);
|
|
103
|
+
const img = imgRef.current;
|
|
104
|
+
if (img?.complete && img.naturalWidth) {
|
|
105
|
+
applyInitialZoom(measureZoom());
|
|
106
|
+
}
|
|
107
|
+
}, [open, currentSrc, imgRef, measureZoom, applyInitialZoom]);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!open) {
|
|
110
|
+
return void 0;
|
|
111
|
+
}
|
|
112
|
+
const handleKeyDown = (event) => {
|
|
113
|
+
if (event.key === "Escape" && closable) {
|
|
114
|
+
event.stopPropagation();
|
|
115
|
+
onClose();
|
|
116
|
+
} else if (event.key === "ArrowLeft") {
|
|
117
|
+
goTo(index - 1);
|
|
118
|
+
} else if (event.key === "ArrowRight") {
|
|
119
|
+
goTo(index + 1);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const handleResize = () => {
|
|
123
|
+
measureZoom();
|
|
124
|
+
};
|
|
125
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
126
|
+
window.addEventListener("resize", handleResize);
|
|
127
|
+
const previousOverflow = document.body.style.overflow;
|
|
128
|
+
document.body.style.overflow = "hidden";
|
|
129
|
+
return () => {
|
|
130
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
131
|
+
window.removeEventListener("resize", handleResize);
|
|
132
|
+
document.body.style.overflow = previousOverflow;
|
|
133
|
+
};
|
|
134
|
+
}, [open, closable, onClose, goTo, index, measureZoom]);
|
|
135
|
+
const handleTransformed = useCallback((_ref, state) => {
|
|
136
|
+
setScale(state.scale);
|
|
137
|
+
onZoom?.(state.scale);
|
|
138
|
+
}, [onZoom]);
|
|
139
|
+
const handleOverlayPointerDown = useCallback((event) => {
|
|
140
|
+
pointerDownRef.current = {
|
|
141
|
+
x: event.clientX,
|
|
142
|
+
y: event.clientY
|
|
143
|
+
};
|
|
144
|
+
}, []);
|
|
145
|
+
const handleMaskClick = useCallback((event) => {
|
|
146
|
+
const start = pointerDownRef.current;
|
|
147
|
+
pointerDownRef.current = null;
|
|
148
|
+
if (!maskClosable) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (start && (Math.abs(event.clientX - start.x) > CLICK_MOVE_THRESHOLD || Math.abs(event.clientY - start.y) > CLICK_MOVE_THRESHOLD)) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
onClose();
|
|
155
|
+
}, [maskClosable, onClose]);
|
|
156
|
+
const rzppStep = Math.max(zoomStep - 1, SCALE_EPSILON);
|
|
157
|
+
const stepZoom = useCallback((direction) => {
|
|
158
|
+
const level = Math.round(Math.log(scale / naturalScaleFactor) / Math.log(zoomStep));
|
|
159
|
+
const target = naturalScaleFactor * zoomStep ** (level + direction);
|
|
160
|
+
const clamped = Math.min(Math.max(target, FIT_SCALE), maxScale);
|
|
161
|
+
transformRef.current?.centerView?.(clamped, ZOOM_ANIMATION_MS);
|
|
162
|
+
}, [scale, naturalScaleFactor, zoomStep, maxScale]);
|
|
163
|
+
const handleZoomIn = useCallback(() => stepZoom(1), [stepZoom]);
|
|
164
|
+
const handleZoomOut = useCallback(() => stepZoom(-1), [stepZoom]);
|
|
165
|
+
if (!open && destroyOnClose) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
const isZoomed = scale > FIT_SCALE + SCALE_EPSILON;
|
|
169
|
+
const showZoomControls = zoomable && maxScale > FIT_SCALE + SCALE_EPSILON;
|
|
170
|
+
const getPanCursor = () => {
|
|
171
|
+
if (!isZoomed) {
|
|
172
|
+
return "default";
|
|
173
|
+
}
|
|
174
|
+
return isPanning ? "grabbing" : "grab";
|
|
175
|
+
};
|
|
176
|
+
const zoomPercent = naturalScaleFactor > 0 ? Math.round(scale / naturalScaleFactor * 100) : 100;
|
|
177
|
+
const hasError = status === "error" || !currentImage;
|
|
178
|
+
const showDownload = !hasError && Boolean(currentSrc);
|
|
179
|
+
const image = /* @__PURE__ */ jsx(Image, { ref: imgRef, src: currentSrc, alt: currentImage?.alt ?? "", onLoad: handleImageLoad, onError: handleError, onClick: stopPropagation, draggable: false, "data-testid": "image-preview-image" }, index);
|
|
180
|
+
const content = hasError ? /* @__PURE__ */ jsx(Fallback, { onClick: stopPropagation, "data-testid": "image-preview-fallback", children: currentImage?.fallback ?? fallback ?? /* @__PURE__ */ jsx(FallbackBox, { children: /* @__PURE__ */ jsx(Icon, { component: /* @__PURE__ */ jsx(ImageM, {}) }) }) }) : image;
|
|
181
|
+
return createPortal(/* @__PURE__ */ jsxs(Overlay, { ref: overlayRef, role: "dialog", "aria-modal": true, "aria-label": currentImage?.alt, tabIndex: -1, $hidden: !open, onMouseDown: handleOverlayPointerDown, onClick: handleMaskClick, "data-testid": "image-preview", children: [
|
|
182
|
+
/* @__PURE__ */ jsx(ImageWrapper, { ref: workingAreaRef, children: zoomable && !hasError ? /* @__PURE__ */ jsx(TransformWrapper, { ref: transformRef, minScale: FIT_SCALE, maxScale, initialScale: FIT_SCALE, centerOnInit: true, doubleClick: {
|
|
183
|
+
mode: "reset"
|
|
184
|
+
}, wheel: {
|
|
185
|
+
step: rzppStep
|
|
186
|
+
}, panning: {
|
|
187
|
+
disabled: !isZoomed
|
|
188
|
+
}, onPanningStart: () => setIsPanning(true), onPanningStop: () => setIsPanning(false), onTransformed: handleTransformed, children: /* @__PURE__ */ jsx(TransformComponent, { wrapperStyle: {
|
|
189
|
+
width: "100%",
|
|
190
|
+
height: "100%",
|
|
191
|
+
cursor: getPanCursor()
|
|
192
|
+
}, contentStyle: {
|
|
193
|
+
display: "flex",
|
|
194
|
+
alignItems: "center",
|
|
195
|
+
justifyContent: "center",
|
|
196
|
+
width: "100%",
|
|
197
|
+
height: "100%"
|
|
198
|
+
}, children: content }) }) : content }),
|
|
199
|
+
closable && /* @__PURE__ */ jsx(CloseWrapper, { onClick: stopPropagation, children: /* @__PURE__ */ jsx(ToolbarGroup, { children: /* @__PURE__ */ jsx(ToolbarButton, { mode: "single-icon", "aria-label": texts.close, tooltipProps: {
|
|
200
|
+
title: texts.close,
|
|
201
|
+
placement: "bottom"
|
|
202
|
+
}, onClick: onClose, "data-testid": "image-preview-close", children: /* @__PURE__ */ jsx(Icon, { component: /* @__PURE__ */ jsx(CloseM, {}) }) }) }) }),
|
|
203
|
+
(total > 1 || showZoomControls || showDownload) && /* @__PURE__ */ jsx(ToolbarWrapper, { onClick: stopPropagation, children: /* @__PURE__ */ jsx(PreviewToolbar, { showNavigation: total > 1, current: index + 1, total, onPrev: () => goTo(index - 1), onNext: () => goTo(index + 1), showZoom: showZoomControls, zoomPercent, canZoomIn: scale < maxScale - SCALE_EPSILON, canZoomOut: isZoomed, onZoomIn: handleZoomIn, onZoomOut: handleZoomOut, showDownload, downloadHref: currentSrc, downloadName: getDownloadName(currentSrc), texts }) })
|
|
204
|
+
] }), getContainer?.() || document.body);
|
|
205
|
+
};
|
|
206
|
+
export {
|
|
207
|
+
ImagePreview as default
|
|
208
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ThemeProps } from '@synerise/ds-core';
|
|
2
|
+
export declare const Overlay: import('styled-components').StyledComponent<"div", any, ThemeProps & {
|
|
3
|
+
$hidden?: boolean;
|
|
4
|
+
}, never>;
|
|
5
|
+
export declare const ImageWrapper: import('styled-components').StyledComponent<"div", any, {}, never>;
|
|
6
|
+
export declare const Image: import('styled-components').StyledComponent<"img", any, ThemeProps, never>;
|
|
7
|
+
export declare const Fallback: import('styled-components').StyledComponent<"div", any, {}, never>;
|
|
8
|
+
/** Default broken-image placeholder shown when an image fails to load. */
|
|
9
|
+
export declare const FallbackBox: import('styled-components').StyledComponent<"div", any, ThemeProps, never>;
|
|
10
|
+
/** Close button — kept in the top-right corner, away from the bottom toolbar. */
|
|
11
|
+
export declare const CloseWrapper: import('styled-components').StyledComponent<"div", any, {}, never>;
|
|
12
|
+
/** Navigation + zoom controls — centered along the bottom edge. */
|
|
13
|
+
export declare const ToolbarWrapper: import('styled-components').StyledComponent<"div", any, {}, never>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import styled, { keyframes } from "styled-components";
|
|
2
|
+
import { PREVIEW_PADDING } from "./ImagePreview.const.js";
|
|
3
|
+
const OVERLAY_Z_INDEX = 1050;
|
|
4
|
+
const imageFadeIn = /* @__PURE__ */ keyframes(["from{opacity:0;}to{opacity:1;}"]);
|
|
5
|
+
const BACKDROP_ALPHA = "CC";
|
|
6
|
+
const CONTROLS_INSET = 16;
|
|
7
|
+
const IMAGE_BORDER_RADIUS = 8;
|
|
8
|
+
const Overlay = /* @__PURE__ */ styled.div.withConfig({
|
|
9
|
+
displayName: "ImagePreviewstyles__Overlay",
|
|
10
|
+
componentId: "sc-17aatff-0"
|
|
11
|
+
})(["position:fixed;inset:0;z-index:", ";display:flex;align-items:center;justify-content:center;outline:none;background-color:", ";opacity:", ";visibility:", ";pointer-events:", ";transition:opacity 160ms ease,visibility 160ms ease;"], OVERLAY_Z_INDEX, (props) => `${props.theme.palette["grey-900"]}${BACKDROP_ALPHA}`, (props) => props.$hidden ? 0 : 1, (props) => props.$hidden ? "hidden" : "visible", (props) => props.$hidden ? "none" : "auto");
|
|
12
|
+
const ImageWrapper = /* @__PURE__ */ styled.div.withConfig({
|
|
13
|
+
displayName: "ImagePreviewstyles__ImageWrapper",
|
|
14
|
+
componentId: "sc-17aatff-1"
|
|
15
|
+
})(["position:relative;display:flex;flex:1 1 auto;align-items:center;justify-content:center;width:100%;height:100%;min-height:0;padding:", "px;"], PREVIEW_PADDING);
|
|
16
|
+
const Image = /* @__PURE__ */ styled.img.withConfig({
|
|
17
|
+
displayName: "ImagePreviewstyles__Image",
|
|
18
|
+
componentId: "sc-17aatff-2"
|
|
19
|
+
})(["max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;user-select:none;border-radius:", "px;box-shadow:", ";animation:", " 200ms ease;"], IMAGE_BORDER_RADIUS, (props) => props.theme.variables["box-shadow-4"], imageFadeIn);
|
|
20
|
+
const Fallback = /* @__PURE__ */ styled.div.withConfig({
|
|
21
|
+
displayName: "ImagePreviewstyles__Fallback",
|
|
22
|
+
componentId: "sc-17aatff-3"
|
|
23
|
+
})(["display:flex;align-items:center;justify-content:center;max-width:100%;max-height:100%;"]);
|
|
24
|
+
const FallbackBox = /* @__PURE__ */ styled.div.withConfig({
|
|
25
|
+
displayName: "ImagePreviewstyles__FallbackBox",
|
|
26
|
+
componentId: "sc-17aatff-4"
|
|
27
|
+
})(["display:flex;align-items:center;justify-content:center;width:240px;height:180px;border-radius:", "px;color:", ";background-color:", ";"], IMAGE_BORDER_RADIUS, (props) => props.theme.palette["grey-600"], (props) => props.theme.palette["grey-050"]);
|
|
28
|
+
const CloseWrapper = /* @__PURE__ */ styled.div.withConfig({
|
|
29
|
+
displayName: "ImagePreviewstyles__CloseWrapper",
|
|
30
|
+
componentId: "sc-17aatff-5"
|
|
31
|
+
})(["position:absolute;top:", "px;right:", "px;z-index:2;"], CONTROLS_INSET, CONTROLS_INSET);
|
|
32
|
+
const ToolbarWrapper = /* @__PURE__ */ styled.div.withConfig({
|
|
33
|
+
displayName: "ImagePreviewstyles__ToolbarWrapper",
|
|
34
|
+
componentId: "sc-17aatff-6"
|
|
35
|
+
})(["position:absolute;bottom:", "px;left:50%;transform:translateX(-50%);z-index:2;display:flex;align-items:center;gap:8px;"], CONTROLS_INSET);
|
|
36
|
+
export {
|
|
37
|
+
CloseWrapper,
|
|
38
|
+
Fallback,
|
|
39
|
+
FallbackBox,
|
|
40
|
+
Image,
|
|
41
|
+
ImageWrapper,
|
|
42
|
+
Overlay,
|
|
43
|
+
ToolbarWrapper
|
|
44
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import { ImageSource, ImageTexts, InitialZoom } from '../shared/Image.shared.types';
|
|
3
|
+
export type ImagePreviewProps = {
|
|
4
|
+
/** Controls visibility of the viewer. */
|
|
5
|
+
open: boolean;
|
|
6
|
+
/** Images to page through. Navigation is shown only when there is more than one. */
|
|
7
|
+
images: ImageSource[];
|
|
8
|
+
/** Controlled index of the currently shown image. */
|
|
9
|
+
index: number;
|
|
10
|
+
/** Called when navigation (arrows / keyboard) requests a different image. */
|
|
11
|
+
onIndexChange: (index: number) => void;
|
|
12
|
+
/** Called when the viewer requests to close (Escape, backdrop click, close button). */
|
|
13
|
+
onClose: () => void;
|
|
14
|
+
/**
|
|
15
|
+
* Whether zoom and pan are enabled. Defaults to true. Zoom runs from the
|
|
16
|
+
* fitted view up to `maxZoom` × the image's natural size.
|
|
17
|
+
*/
|
|
18
|
+
zoomable?: boolean;
|
|
19
|
+
/** Per-click zoom multiplier. Defaults to 1.4. */
|
|
20
|
+
zoomStep?: number;
|
|
21
|
+
/** Maximum zoom relative to natural size. Defaults to 3 (300%). */
|
|
22
|
+
maxZoom?: number;
|
|
23
|
+
/** Where zoom starts. Defaults to `fit`. */
|
|
24
|
+
initialZoom?: InitialZoom;
|
|
25
|
+
/** Whether to render the close button and allow Escape to close. Defaults to true. */
|
|
26
|
+
closable?: boolean;
|
|
27
|
+
/** Whether clicking the dimmed backdrop closes the viewer. Defaults to true. */
|
|
28
|
+
maskClosable?: boolean;
|
|
29
|
+
/** Fallback rendered when an image fails to load (per-image override via `ImageSource.fallback`). */
|
|
30
|
+
fallback?: ReactNode;
|
|
31
|
+
/** Portal target. Defaults to `document.body`. */
|
|
32
|
+
getContainer?: () => HTMLElement;
|
|
33
|
+
/** Unmount the viewer (and reset zoom state) when closed. Defaults to false. */
|
|
34
|
+
destroyOnClose?: boolean;
|
|
35
|
+
/** Called with the current scale whenever the zoom transform changes. */
|
|
36
|
+
onZoom?: (scale: number) => void;
|
|
37
|
+
/** Override the default tooltip / accessible labels for the controls. */
|
|
38
|
+
texts?: Partial<ImageTexts>;
|
|
39
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ImageTexts } from '../shared/Image.shared.types';
|
|
2
|
+
export type PreviewToolbarProps = {
|
|
3
|
+
showNavigation: boolean;
|
|
4
|
+
current: number;
|
|
5
|
+
total: number;
|
|
6
|
+
onPrev: () => void;
|
|
7
|
+
onNext: () => void;
|
|
8
|
+
showZoom: boolean;
|
|
9
|
+
zoomPercent: number;
|
|
10
|
+
canZoomIn: boolean;
|
|
11
|
+
canZoomOut: boolean;
|
|
12
|
+
onZoomIn: () => void;
|
|
13
|
+
onZoomOut: () => void;
|
|
14
|
+
showDownload: boolean;
|
|
15
|
+
downloadHref: string;
|
|
16
|
+
downloadName: string;
|
|
17
|
+
texts: ImageTexts;
|
|
18
|
+
};
|
|
19
|
+
/** Bottom-center controls: a navigation pill and a zoom/download pill. */
|
|
20
|
+
declare const PreviewToolbar: ({ showNavigation, current, total, onPrev, onNext, showZoom, zoomPercent, canZoomIn, canZoomOut, onZoomIn, onZoomOut, showDownload, downloadHref, downloadName, texts, }: PreviewToolbarProps) => JSX.Element;
|
|
21
|
+
export default PreviewToolbar;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { jsxs, Fragment, jsx } from "react/jsx-runtime";
|
|
2
|
+
import Icon, { AngleLeftM, AngleRightM, FormulaMinusM, FormulaPlusM, FileDownloadM } from "@synerise/ds-icon";
|
|
3
|
+
import { ToolbarGroup, ToolbarButton, ToolbarLabel } from "@synerise/ds-toolbar";
|
|
4
|
+
const TOOLTIP_PLACEMENT = "top";
|
|
5
|
+
const PreviewToolbar = ({
|
|
6
|
+
showNavigation,
|
|
7
|
+
current,
|
|
8
|
+
total,
|
|
9
|
+
onPrev,
|
|
10
|
+
onNext,
|
|
11
|
+
showZoom,
|
|
12
|
+
zoomPercent,
|
|
13
|
+
canZoomIn,
|
|
14
|
+
canZoomOut,
|
|
15
|
+
onZoomIn,
|
|
16
|
+
onZoomOut,
|
|
17
|
+
showDownload,
|
|
18
|
+
downloadHref,
|
|
19
|
+
downloadName,
|
|
20
|
+
texts
|
|
21
|
+
}) => /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
22
|
+
showNavigation && /* @__PURE__ */ jsxs(ToolbarGroup, { isCompact: true, children: [
|
|
23
|
+
/* @__PURE__ */ jsx(ToolbarButton, { mode: "single-icon", "aria-label": texts.previousImage, tooltipProps: {
|
|
24
|
+
title: texts.previousImage,
|
|
25
|
+
placement: TOOLTIP_PLACEMENT
|
|
26
|
+
}, onClick: onPrev, "data-testid": "image-preview-prev", children: /* @__PURE__ */ jsx(Icon, { component: /* @__PURE__ */ jsx(AngleLeftM, {}) }) }),
|
|
27
|
+
/* @__PURE__ */ jsxs(ToolbarLabel, { "data-testid": "image-preview-counter", children: [
|
|
28
|
+
current,
|
|
29
|
+
" of ",
|
|
30
|
+
total
|
|
31
|
+
] }),
|
|
32
|
+
/* @__PURE__ */ jsx(ToolbarButton, { mode: "single-icon", "aria-label": texts.nextImage, tooltipProps: {
|
|
33
|
+
title: texts.nextImage,
|
|
34
|
+
placement: TOOLTIP_PLACEMENT
|
|
35
|
+
}, onClick: onNext, "data-testid": "image-preview-next", children: /* @__PURE__ */ jsx(Icon, { component: /* @__PURE__ */ jsx(AngleRightM, {}) }) })
|
|
36
|
+
] }),
|
|
37
|
+
(showZoom || showDownload) && /* @__PURE__ */ jsxs(ToolbarGroup, { isCompact: true, children: [
|
|
38
|
+
showZoom && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
39
|
+
/* @__PURE__ */ jsx(ToolbarButton, { mode: "single-icon", "aria-label": texts.zoomOut, tooltipProps: {
|
|
40
|
+
title: texts.zoomOut,
|
|
41
|
+
placement: TOOLTIP_PLACEMENT
|
|
42
|
+
}, disabled: !canZoomOut, onClick: onZoomOut, "data-testid": "image-preview-zoom-out", children: /* @__PURE__ */ jsx(Icon, { component: /* @__PURE__ */ jsx(FormulaMinusM, {}) }) }),
|
|
43
|
+
/* @__PURE__ */ jsxs(ToolbarLabel, { "data-testid": "image-preview-zoom-level", children: [
|
|
44
|
+
zoomPercent,
|
|
45
|
+
"%"
|
|
46
|
+
] }),
|
|
47
|
+
/* @__PURE__ */ jsx(ToolbarButton, { mode: "single-icon", "aria-label": texts.zoomIn, tooltipProps: {
|
|
48
|
+
title: texts.zoomIn,
|
|
49
|
+
placement: TOOLTIP_PLACEMENT
|
|
50
|
+
}, disabled: !canZoomIn, onClick: onZoomIn, "data-testid": "image-preview-zoom-in", children: /* @__PURE__ */ jsx(Icon, { component: /* @__PURE__ */ jsx(FormulaPlusM, {}) }) })
|
|
51
|
+
] }),
|
|
52
|
+
showDownload && /* @__PURE__ */ jsx(ToolbarButton, { mode: "single-icon", "aria-label": texts.download, tooltipProps: {
|
|
53
|
+
title: texts.download,
|
|
54
|
+
placement: TOOLTIP_PLACEMENT
|
|
55
|
+
}, href: downloadHref, download: downloadName, target: "_blank", "data-testid": "image-preview-download", children: /* @__PURE__ */ jsx(Icon, { component: /* @__PURE__ */ jsx(FileDownloadM, {}) }) })
|
|
56
|
+
] })
|
|
57
|
+
] });
|
|
58
|
+
export {
|
|
59
|
+
PreviewToolbar as default
|
|
60
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { AspectRatio, ThumbnailSize } from '../shared/Image.shared.types';
|
|
2
|
+
/** Fixed thumbnail heights (px) per size token. `custom` defers to `height`. */
|
|
3
|
+
export declare const SIZE_MAP: Record<Exclude<ThumbnailSize, 'custom'>, number>;
|
|
4
|
+
/** CSS `aspect-ratio` values per fixed ratio. `source` keeps intrinsic ratio. */
|
|
5
|
+
export declare const ASPECT_RATIO_MAP: Record<Exclude<AspectRatio, 'source'>, string>;
|
|
6
|
+
export declare const DEFAULT_ASPECT_RATIO: AspectRatio;
|
|
7
|
+
export declare const DEFAULT_SIZE = "m";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const SIZE_MAP = {
|
|
2
|
+
xxs: 40,
|
|
3
|
+
xs: 80,
|
|
4
|
+
s: 120,
|
|
5
|
+
m: 180,
|
|
6
|
+
l: 240,
|
|
7
|
+
xl: 360,
|
|
8
|
+
xxl: 540
|
|
9
|
+
};
|
|
10
|
+
const ASPECT_RATIO_MAP = {
|
|
11
|
+
"1:1": "1 / 1",
|
|
12
|
+
"4:3": "4 / 3",
|
|
13
|
+
"16:9": "16 / 9"
|
|
14
|
+
};
|
|
15
|
+
const DEFAULT_ASPECT_RATIO = "1:1";
|
|
16
|
+
const DEFAULT_SIZE = "m";
|
|
17
|
+
export {
|
|
18
|
+
ASPECT_RATIO_MAP,
|
|
19
|
+
DEFAULT_ASPECT_RATIO,
|
|
20
|
+
DEFAULT_SIZE,
|
|
21
|
+
SIZE_MAP
|
|
22
|
+
};
|