@lobehub/ui 5.25.0 → 5.26.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.
Files changed (62) hide show
  1. package/es/Image/Image.mjs +138 -37
  2. package/es/Image/Image.mjs.map +1 -1
  3. package/es/Image/PreviewGroup.d.mts +1 -0
  4. package/es/Image/PreviewGroup.mjs +33 -10
  5. package/es/Image/PreviewGroup.mjs.map +1 -1
  6. package/es/Image/index.d.mts +2 -2
  7. package/es/Image/index.mjs +1 -1
  8. package/es/Image/style.mjs +126 -71
  9. package/es/Image/style.mjs.map +1 -1
  10. package/es/Image/type.d.mts +14 -17
  11. package/es/Image/viewer/GalleryNav.mjs +44 -0
  12. package/es/Image/viewer/GalleryNav.mjs.map +1 -0
  13. package/es/Image/viewer/ImageViewer.mjs +275 -0
  14. package/es/Image/viewer/ImageViewer.mjs.map +1 -0
  15. package/es/Image/viewer/PreviewOutlet.mjs +19 -0
  16. package/es/Image/viewer/PreviewOutlet.mjs.map +1 -0
  17. package/es/Image/viewer/Toolbar.mjs +184 -0
  18. package/es/Image/viewer/Toolbar.mjs.map +1 -0
  19. package/es/Image/viewer/ViewerChrome.mjs +54 -0
  20. package/es/Image/viewer/ViewerChrome.mjs.map +1 -0
  21. package/es/Image/viewer/geometry.mjs +85 -0
  22. package/es/Image/viewer/geometry.mjs.map +1 -0
  23. package/es/Image/viewer/registry.d.mts +2 -0
  24. package/es/Image/viewer/registry.mjs +52 -0
  25. package/es/Image/viewer/registry.mjs.map +1 -0
  26. package/es/Image/viewer/useChromeIdle.mjs +72 -0
  27. package/es/Image/viewer/useChromeIdle.mjs.map +1 -0
  28. package/es/Image/viewer/useFinalFocus.mjs +10 -0
  29. package/es/Image/viewer/useFinalFocus.mjs.map +1 -0
  30. package/es/Image/viewer/useFlipTransition.mjs +321 -0
  31. package/es/Image/viewer/useFlipTransition.mjs.map +1 -0
  32. package/es/Image/viewer/useGalleryNav.mjs +89 -0
  33. package/es/Image/viewer/useGalleryNav.mjs.map +1 -0
  34. package/es/Image/viewer/useRefitTransition.mjs +47 -0
  35. package/es/Image/viewer/useRefitTransition.mjs.map +1 -0
  36. package/es/Image/viewer/useViewerGestures.mjs +148 -0
  37. package/es/Image/viewer/useViewerGestures.mjs.map +1 -0
  38. package/es/Image/viewer/useZoomPan.mjs +346 -0
  39. package/es/Image/viewer/useZoomPan.mjs.map +1 -0
  40. package/es/base-ui/Toast/hostGuard.mjs +26 -0
  41. package/es/base-ui/Toast/hostGuard.mjs.map +1 -0
  42. package/es/base-ui/Toast/imperative.mjs +43 -20
  43. package/es/base-ui/Toast/imperative.mjs.map +1 -1
  44. package/es/base-ui/Toast/pendingQueue.mjs +30 -0
  45. package/es/base-ui/Toast/pendingQueue.mjs.map +1 -0
  46. package/es/base-ui/index.mjs +1 -1
  47. package/es/i18n/resources/en/image.d.mts +5 -0
  48. package/es/i18n/resources/en/image.mjs +6 -1
  49. package/es/i18n/resources/en/image.mjs.map +1 -1
  50. package/es/i18n/resources/zhCn/image.d.mts +5 -0
  51. package/es/i18n/resources/zhCn/image.mjs +6 -1
  52. package/es/i18n/resources/zhCn/image.mjs.map +1 -1
  53. package/es/index.mjs +8 -8
  54. package/package.json +1 -1
  55. package/es/Image/components/Preview.mjs +0 -23
  56. package/es/Image/components/Preview.mjs.map +0 -1
  57. package/es/Image/components/Toolbar.mjs +0 -129
  58. package/es/Image/components/Toolbar.mjs.map +0 -1
  59. package/es/Image/components/usePreview.mjs +0 -58
  60. package/es/Image/components/usePreview.mjs.map +0 -1
  61. package/es/Image/components/usePreviewGroup.mjs +0 -57
  62. package/es/Image/components/usePreviewGroup.mjs.map +0 -1
@@ -1,27 +1,19 @@
1
- import { CSSProperties, ReactNode, Ref } from "react";
2
- import { ImageProps } from "antd";
3
- import { PreviewConfig } from "antd/es/image/index.js";
4
- import { GroupPreviewConfig } from "antd/es/image/PreviewGroup.js";
1
+ import { CSSProperties, ComponentProps, ReactNode, Ref } from "react";
5
2
  //#region src/Image/type.d.ts
6
- interface PreviewGroupPreviewOptions extends GroupPreviewConfig {
3
+ interface ImagePreviewOptions {
4
+ maxScale?: number;
5
+ onOpenChange?: (open: boolean) => void;
6
+ src?: string;
7
7
  toolbarAddon?: ReactNode;
8
8
  }
9
- interface PreviewGroupProps {
10
- children?: ReactNode;
11
- enable?: boolean;
12
- items?: string[];
13
- preview?: boolean | PreviewGroupPreviewOptions;
14
- }
15
- interface ImagePreviewOptions extends PreviewConfig {
16
- toolbarAddon?: ReactNode;
17
- }
18
- interface ImageProps$1 extends Omit<ImageProps, 'preview'> {
9
+ type ImageProps = Omit<ComponentProps<'img'>, 'width' | 'height'> & {
19
10
  actions?: ReactNode;
20
11
  alwaysShowActions?: boolean;
21
12
  classNames?: {
22
13
  image?: string;
23
14
  wrapper?: string;
24
15
  };
16
+ height?: number | string;
25
17
  isLoading?: boolean;
26
18
  maxHeight?: number | string;
27
19
  maxWidth?: number | string;
@@ -35,9 +27,14 @@ interface ImageProps$1 extends Omit<ImageProps, 'preview'> {
35
27
  image?: CSSProperties;
36
28
  wrapper?: CSSProperties;
37
29
  };
38
- toolbarAddon?: ReactNode;
39
30
  variant?: 'borderless' | 'filled' | 'outlined';
31
+ width?: number | string;
32
+ };
33
+ interface PreviewGroupProps {
34
+ children?: ReactNode;
35
+ enable?: boolean;
36
+ preview?: boolean | ImagePreviewOptions;
40
37
  }
41
38
  //#endregion
42
- export { ImagePreviewOptions, ImageProps$1 as ImageProps, PreviewGroupPreviewOptions, PreviewGroupProps };
39
+ export { ImagePreviewOptions, ImageProps, PreviewGroupProps };
43
40
  //# sourceMappingURL=type.d.mts.map
@@ -0,0 +1,44 @@
1
+ "use client";
2
+ import Center from "../../Flex/Center.mjs";
3
+ import ActionIcon from "../../ActionIcon/ActionIcon.mjs";
4
+ import { useTranslation } from "../../i18n/useTranslation.mjs";
5
+ import { styles } from "../style.mjs";
6
+ import image_default from "../../i18n/resources/en/image.mjs";
7
+ import { memo } from "react";
8
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
9
+ import { cx } from "antd-style";
10
+ import { ChevronLeft, ChevronRight } from "lucide-react";
11
+ //#region src/Image/viewer/GalleryNav.tsx
12
+ const GalleryNav = memo(({ current, hasNext, hasPrev, next, prev, total }) => {
13
+ const { t } = useTranslation(image_default);
14
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [
15
+ hasPrev && /* @__PURE__ */ jsx(ActionIcon, {
16
+ className: cx(styles.viewerNavButton, styles.viewerNavPrev),
17
+ icon: ChevronLeft,
18
+ size: "large",
19
+ title: t("image.prev"),
20
+ onClick: prev
21
+ }),
22
+ hasNext && /* @__PURE__ */ jsx(ActionIcon, {
23
+ className: cx(styles.viewerNavButton, styles.viewerNavNext),
24
+ icon: ChevronRight,
25
+ size: "large",
26
+ title: t("image.next"),
27
+ onClick: next
28
+ }),
29
+ /* @__PURE__ */ jsxs(Center, {
30
+ horizontal: true,
31
+ className: styles.viewerCounter,
32
+ children: [
33
+ current + 1,
34
+ " / ",
35
+ total
36
+ ]
37
+ })
38
+ ] });
39
+ });
40
+ GalleryNav.displayName = "GalleryNav";
41
+ //#endregion
42
+ export { GalleryNav as default };
43
+
44
+ //# sourceMappingURL=GalleryNav.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GalleryNav.mjs","names":["imageMessages"],"sources":["../../../src/Image/viewer/GalleryNav.tsx"],"sourcesContent":["'use client';\n\nimport { cx } from 'antd-style';\nimport { ChevronLeft, ChevronRight } from 'lucide-react';\nimport { memo } from 'react';\n\nimport ActionIcon from '@/ActionIcon';\nimport { Center } from '@/Flex';\nimport imageMessages from '@/i18n/resources/en/image';\nimport { useTranslation } from '@/i18n/useTranslation';\n\nimport { styles } from '../style';\n\nexport interface GalleryNavProps {\n current: number;\n hasNext: boolean;\n hasPrev: boolean;\n next: () => void;\n prev: () => void;\n total: number;\n}\n\nconst GalleryNav = memo<GalleryNavProps>(({ current, hasNext, hasPrev, next, prev, total }) => {\n const { t } = useTranslation(imageMessages);\n\n return (\n <>\n {hasPrev && (\n <ActionIcon\n className={cx(styles.viewerNavButton, styles.viewerNavPrev)}\n icon={ChevronLeft}\n size={'large'}\n title={t('image.prev')}\n onClick={prev}\n />\n )}\n {hasNext && (\n <ActionIcon\n className={cx(styles.viewerNavButton, styles.viewerNavNext)}\n icon={ChevronRight}\n size={'large'}\n title={t('image.next')}\n onClick={next}\n />\n )}\n <Center horizontal className={styles.viewerCounter}>\n {current + 1} / {total}\n </Center>\n </>\n );\n});\n\nGalleryNav.displayName = 'GalleryNav';\n\nexport default GalleryNav;\n"],"mappings":";;;;;;;;;;;AAsBA,MAAM,aAAa,MAAuB,EAAE,SAAS,SAAS,SAAS,MAAM,MAAM,YAAY;CAC7F,MAAM,EAAE,MAAM,eAAeA,aAAa;CAE1C,OACE,qBAAA,YAAA,EAAA,UAAA;EACG,WACC,oBAAC,YAAD;GACE,WAAW,GAAG,OAAO,iBAAiB,OAAO,aAAa;GAC1D,MAAM;GACN,MAAM;GACN,OAAO,EAAE,YAAY;GACrB,SAAS;EACV,CAAA;EAEF,WACC,oBAAC,YAAD;GACE,WAAW,GAAG,OAAO,iBAAiB,OAAO,aAAa;GAC1D,MAAM;GACN,MAAM;GACN,OAAO,EAAE,YAAY;GACrB,SAAS;EACV,CAAA;EAEH,qBAAC,QAAD;GAAQ,YAAA;GAAW,WAAW,OAAO;GAArC,UAAA;IACG,UAAU;IAAE;IAAI;GACX;;CACR,EAAA,CAAA;AAEN,CAAC;AAED,WAAW,cAAc"}
@@ -0,0 +1,275 @@
1
+ "use client";
2
+ import { MotionComponent } from "../../MotionProvider/index.mjs";
3
+ import { useAppElement } from "../../ThemeProvider/AppElementContext.mjs";
4
+ import { useLayerZIndex } from "../../base-ui/zIndex/useLayerZIndex.mjs";
5
+ import { styles } from "../style.mjs";
6
+ import { ToastHost } from "../../base-ui/Toast/imperative.mjs";
7
+ import { computeFit, unrotatedRect } from "./geometry.mjs";
8
+ import { beginClosePreview, endClosePreview } from "./registry.mjs";
9
+ import { useFinalFocus } from "./useFinalFocus.mjs";
10
+ import { useFlipTransition } from "./useFlipTransition.mjs";
11
+ import { useViewerGestures } from "./useViewerGestures.mjs";
12
+ import { readNatural, useGalleryNav } from "./useGalleryNav.mjs";
13
+ import { useRefitTransition } from "./useRefitTransition.mjs";
14
+ import { useZoomPan } from "./useZoomPan.mjs";
15
+ import ViewerChrome from "./ViewerChrome.mjs";
16
+ import { memo, use, useCallback, useEffect, useMemo, useRef, useState } from "react";
17
+ import { jsx, jsxs } from "react/jsx-runtime";
18
+ import { useMergeRefs } from "react-merge-refs";
19
+ import { Dialog } from "@base-ui/react/dialog";
20
+ //#region src/Image/viewer/ImageViewer.tsx
21
+ const readViewport = () => ({
22
+ height: window.innerHeight,
23
+ width: window.innerWidth
24
+ });
25
+ const prefersReducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
26
+ const ImageViewer = memo(({ entries, index, openerFocusElement, token }) => {
27
+ const openerEntry = entries[index];
28
+ const appElement = useAppElement();
29
+ const motionComponent = use(MotionComponent);
30
+ const [animated] = useState(() => !!motionComponent && !prefersReducedMotion());
31
+ const { ref: layerRef, zIndex } = useLayerZIndex("modal");
32
+ const [currentIndex, setCurrentIndex] = useState(index);
33
+ const currentEntry = entries[currentIndex];
34
+ const currentEntryRef = useRef(currentEntry);
35
+ currentEntryRef.current = currentEntry;
36
+ const [source, setSource] = useState(openerEntry.src);
37
+ const [natural, setNatural] = useState(() => readNatural(openerEntry.element));
38
+ const [viewport, setViewport] = useState(readViewport);
39
+ const closeRef = useRef(null);
40
+ const requestClose = useCallback(() => closeRef.current?.(), []);
41
+ const isClosingIndirectRef = useRef(() => false);
42
+ const isClosingIndirect = useCallback(() => isClosingIndirectRef.current(), []);
43
+ const fillViewport = Boolean(currentEntry.previewSrc && currentEntry.previewSrc !== currentEntry.src);
44
+ const { canZoomIn, canZoomOut, dragBy, dragEnd, escIntent, flipHorizontal, flipVertical, flipX, flipY, handleDoubleClick, handleWheel, isClean, isZoomed, reset, resetCloseTracking, rotate, rotateLeft, rotateRight, rotation, scale, setNatural: syncZoomNatural, setViewport: syncZoomViewport, x, y, zoomIn, zoomOut } = useZoomPan({
45
+ fillViewport,
46
+ isClosing: isClosingIndirect,
47
+ maxScale: currentEntry.options.maxScale,
48
+ natural,
49
+ onCloseRequest: requestClose,
50
+ viewport
51
+ });
52
+ const fitRect = useMemo(() => computeFit(natural, viewport, rotation, fillViewport), [
53
+ natural,
54
+ viewport,
55
+ rotation,
56
+ fillViewport
57
+ ]);
58
+ const fitRectRef = useRef(fitRect);
59
+ fitRectRef.current = fitRect;
60
+ const getFitRect = useCallback(() => fitRectRef.current, []);
61
+ const imageRect = useMemo(() => unrotatedRect(fitRect, rotation), [fitRect, rotation]);
62
+ const viewportRef = useRef(viewport);
63
+ viewportRef.current = viewport;
64
+ const getViewportWidth = useCallback(() => viewportRef.current.width, []);
65
+ const getCloseSource = useCallback(() => currentEntryRef.current.element, []);
66
+ const transform = useMemo(() => ({
67
+ flipX,
68
+ flipY,
69
+ rotate,
70
+ scale,
71
+ x,
72
+ y
73
+ }), [
74
+ flipX,
75
+ flipY,
76
+ rotate,
77
+ scale,
78
+ x,
79
+ y
80
+ ]);
81
+ const handleClosed = useCallback(() => endClosePreview(token), [token]);
82
+ const { backdropRef, chromeRef, close, imageRef: flipImageRef, isClosing, isTransitioning, switchTo } = useFlipTransition({
83
+ animated,
84
+ getCloseSource,
85
+ getFitRect,
86
+ getViewportWidth,
87
+ onClosed: handleClosed,
88
+ source: openerEntry.element,
89
+ transform
90
+ });
91
+ isClosingIndirectRef.current = isClosing;
92
+ useRefitTransition({
93
+ animated,
94
+ fillViewport,
95
+ isTransitioning,
96
+ natural,
97
+ rotation,
98
+ scale,
99
+ viewport,
100
+ x,
101
+ y
102
+ });
103
+ const finalFocus = useFinalFocus(openerFocusElement);
104
+ const isCleanRef = useRef(isClean);
105
+ isCleanRef.current = isClean;
106
+ const handleClose = useCallback(() => {
107
+ beginClosePreview(token);
108
+ close({ fade: !isCleanRef.current });
109
+ }, [close, token]);
110
+ closeRef.current = handleClose;
111
+ const handleDialogOpenChange = useCallback((open, eventDetails) => {
112
+ if (open) return;
113
+ if (isClosing()) return;
114
+ if (eventDetails.reason === "escape-key" && escIntent() === "reset") {
115
+ eventDetails.cancel();
116
+ reset();
117
+ return;
118
+ }
119
+ handleClose();
120
+ }, [
121
+ escIntent,
122
+ handleClose,
123
+ isClosing,
124
+ reset
125
+ ]);
126
+ const gestures = useViewerGestures({
127
+ dragBy,
128
+ dragEnd,
129
+ handleDoubleClick,
130
+ handleWheel,
131
+ isClean,
132
+ isZoomed,
133
+ onClose: handleClose,
134
+ reset,
135
+ zoomIn,
136
+ zoomOut
137
+ });
138
+ const { hasNext, hasPrev, next, prev } = useGalleryNav({
139
+ cancelPendingClose: gestures.cancelPendingClose,
140
+ currentIndex,
141
+ entries,
142
+ flipX,
143
+ flipY,
144
+ resetCloseTracking,
145
+ rotate,
146
+ scale,
147
+ setCurrentIndex,
148
+ setNatural,
149
+ setSource,
150
+ switchTo,
151
+ syncZoomNatural,
152
+ x,
153
+ y
154
+ });
155
+ const popupNodeRef = useRef(null);
156
+ const popupRef = useMergeRefs([
157
+ layerRef,
158
+ gestures.popupRef,
159
+ popupNodeRef
160
+ ]);
161
+ const imageRef = useMergeRefs([flipImageRef, gestures.imageRef]);
162
+ const handleLoad = useCallback((event) => {
163
+ const loaded = event.currentTarget;
164
+ if (!loaded.naturalWidth || !loaded.naturalHeight) return;
165
+ const next = {
166
+ height: loaded.naturalHeight,
167
+ width: loaded.naturalWidth
168
+ };
169
+ setNatural((prev) => prev.width === next.width && prev.height === next.height ? prev : next);
170
+ syncZoomNatural(next);
171
+ }, [syncZoomNatural]);
172
+ useEffect(() => {
173
+ const handleResize = () => {
174
+ const next = readViewport();
175
+ setViewport(next);
176
+ syncZoomViewport(next);
177
+ };
178
+ window.addEventListener("resize", handleResize);
179
+ return () => window.removeEventListener("resize", handleResize);
180
+ }, [syncZoomViewport]);
181
+ useEffect(() => {
182
+ const { previewSrc, src } = currentEntry;
183
+ if (!previewSrc || previewSrc === src) return;
184
+ let cancelled = false;
185
+ const loader = new window.Image();
186
+ const handleLoaded = () => {
187
+ if (!cancelled) setSource(previewSrc);
188
+ };
189
+ loader.addEventListener("load", handleLoaded);
190
+ loader.src = previewSrc;
191
+ return () => {
192
+ cancelled = true;
193
+ loader.removeEventListener("load", handleLoaded);
194
+ };
195
+ }, [currentEntry]);
196
+ useEffect(() => () => {
197
+ if (!openerEntry.element.isConnected) endClosePreview(token);
198
+ }, [openerEntry, token]);
199
+ return /* @__PURE__ */ jsx(Dialog.Root, {
200
+ modal: true,
201
+ open: true,
202
+ onOpenChange: handleDialogOpenChange,
203
+ children: /* @__PURE__ */ jsxs(Dialog.Portal, {
204
+ container: appElement ?? void 0,
205
+ children: [
206
+ /* @__PURE__ */ jsx(Dialog.Backdrop, {
207
+ className: styles.viewerBackdrop,
208
+ ref: backdropRef,
209
+ style: zIndex === void 0 ? void 0 : { zIndex },
210
+ onClick: gestures.onSurfaceClick
211
+ }),
212
+ /* @__PURE__ */ jsxs(Dialog.Popup, {
213
+ "aria-label": currentEntry.element.alt || void 0,
214
+ className: styles.viewerPopup,
215
+ finalFocus,
216
+ initialFocus: popupNodeRef,
217
+ ref: popupRef,
218
+ style: zIndex === void 0 ? void 0 : { zIndex: zIndex + 1 },
219
+ tabIndex: -1,
220
+ onClick: gestures.onSurfaceClick,
221
+ onPointerCancel: gestures.onPointerFinish,
222
+ onPointerDown: gestures.onPointerDown,
223
+ onPointerMove: gestures.onPointerMove,
224
+ onPointerUp: gestures.onPointerFinish,
225
+ children: [/* @__PURE__ */ jsx("img", {
226
+ alt: currentEntry.element.alt,
227
+ className: styles.viewerImage,
228
+ ref: imageRef,
229
+ src: source,
230
+ style: {
231
+ cursor: gestures.cursor,
232
+ height: imageRect.height,
233
+ left: imageRect.x,
234
+ top: imageRect.y,
235
+ width: imageRect.width
236
+ },
237
+ onClick: gestures.onImageClick,
238
+ onDoubleClick: gestures.onImageDoubleClick,
239
+ onLoad: handleLoad
240
+ }), /* @__PURE__ */ jsx(ViewerChrome, {
241
+ canZoomIn,
242
+ canZoomOut,
243
+ chromeRef,
244
+ current: currentIndex,
245
+ fitRect,
246
+ flipHorizontal,
247
+ flipVertical,
248
+ hasNext,
249
+ hasPrev,
250
+ natural,
251
+ next,
252
+ prev,
253
+ reset,
254
+ rotateLeft,
255
+ rotateRight,
256
+ rotation,
257
+ scale,
258
+ source,
259
+ toolbarAddon: currentEntry.options.toolbarAddon,
260
+ total: entries.length,
261
+ zoomIn,
262
+ zoomOut,
263
+ onClose: handleClose
264
+ })]
265
+ }),
266
+ /* @__PURE__ */ jsx(ToastHost, { root: appElement ?? void 0 })
267
+ ]
268
+ })
269
+ });
270
+ });
271
+ ImageViewer.displayName = "ImageViewer";
272
+ //#endregion
273
+ export { ImageViewer as default };
274
+
275
+ //# sourceMappingURL=ImageViewer.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ImageViewer.mjs","names":[],"sources":["../../../src/Image/viewer/ImageViewer.tsx"],"sourcesContent":["'use client';\n\nimport { Dialog, type DialogRootProps } from '@base-ui/react/dialog';\nimport {\n memo,\n type SyntheticEvent,\n use,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from 'react';\nimport { useMergeRefs } from 'react-merge-refs';\n\nimport { ToastHost } from '@/base-ui/Toast';\nimport { useLayerZIndex } from '@/base-ui/zIndex';\nimport { MotionComponent } from '@/MotionProvider';\nimport { useAppElement } from '@/ThemeProvider';\n\nimport { styles } from '../style';\nimport { computeFit, type Size, unrotatedRect } from './geometry';\nimport { beginClosePreview, endClosePreview, type PreviewEntry } from './registry';\nimport { useFinalFocus } from './useFinalFocus';\nimport { useFlipTransition } from './useFlipTransition';\nimport { readNatural, useGalleryNav } from './useGalleryNav';\nimport { useRefitTransition } from './useRefitTransition';\nimport { useViewerGestures } from './useViewerGestures';\nimport { useZoomPan } from './useZoomPan';\nimport ViewerChrome from './ViewerChrome';\n\nexport interface ImageViewerProps {\n entries: PreviewEntry[];\n index: number;\n openerFocusElement: HTMLElement | null;\n token: number;\n}\n\nconst readViewport = (): Size => ({ height: window.innerHeight, width: window.innerWidth });\n\nconst prefersReducedMotion = () =>\n window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;\n\nconst ImageViewer = memo<ImageViewerProps>(({ entries, index, openerFocusElement, token }) => {\n const openerEntry = entries[index];\n const appElement = useAppElement();\n const motionComponent = use(MotionComponent);\n const [animated] = useState(() => !!motionComponent && !prefersReducedMotion());\n\n const { ref: layerRef, zIndex } = useLayerZIndex<HTMLDivElement>('modal');\n\n const [currentIndex, setCurrentIndex] = useState(index);\n const currentEntry = entries[currentIndex];\n const currentEntryRef = useRef(currentEntry);\n currentEntryRef.current = currentEntry;\n\n const [source, setSource] = useState(openerEntry.src);\n const [natural, setNatural] = useState<Size>(() => readNatural(openerEntry.element));\n const [viewport, setViewport] = useState<Size>(readViewport);\n\n const closeRef = useRef<(() => void) | null>(null);\n const requestClose = useCallback(() => closeRef.current?.(), []);\n\n // useZoomPan needs to gate its wheel/toolbar entry points on close-in-flight,\n // but that state only exists once useFlipTransition (below) has run — and it\n // needs useZoomPan's own transform values first. Break the cycle with a\n // stable indirection: the ref is repointed at the real isClosing once it's\n // available, written directly during render like the other *Ref fields here.\n const isClosingIndirectRef = useRef<() => boolean>(() => false);\n const isClosingIndirect = useCallback(() => isClosingIndirectRef.current(), []);\n\n const fillViewport = Boolean(\n currentEntry.previewSrc && currentEntry.previewSrc !== currentEntry.src,\n );\n\n const {\n canZoomIn,\n canZoomOut,\n dragBy,\n dragEnd,\n escIntent,\n flipHorizontal,\n flipVertical,\n flipX,\n flipY,\n handleDoubleClick,\n handleWheel,\n isClean,\n isZoomed,\n reset,\n resetCloseTracking,\n rotate,\n rotateLeft,\n rotateRight,\n rotation,\n scale,\n setNatural: syncZoomNatural,\n setViewport: syncZoomViewport,\n x,\n y,\n zoomIn,\n zoomOut,\n } = useZoomPan({\n fillViewport,\n isClosing: isClosingIndirect,\n maxScale: currentEntry.options.maxScale,\n natural,\n onCloseRequest: requestClose,\n viewport,\n });\n\n const fitRect = useMemo(\n () => computeFit(natural, viewport, rotation, fillViewport),\n [natural, viewport, rotation, fillViewport],\n );\n const fitRectRef = useRef(fitRect);\n fitRectRef.current = fitRect;\n const getFitRect = useCallback(() => fitRectRef.current, []);\n\n const imageRect = useMemo(() => unrotatedRect(fitRect, rotation), [fitRect, rotation]);\n\n const viewportRef = useRef(viewport);\n viewportRef.current = viewport;\n const getViewportWidth = useCallback(() => viewportRef.current.width, []);\n\n const getCloseSource = useCallback(() => currentEntryRef.current.element, []);\n\n const transform = useMemo(\n () => ({ flipX, flipY, rotate, scale, x, y }),\n [flipX, flipY, rotate, scale, x, y],\n );\n const handleClosed = useCallback(() => endClosePreview(token), [token]);\n\n const {\n backdropRef,\n chromeRef,\n close,\n imageRef: flipImageRef,\n isClosing,\n isTransitioning,\n switchTo,\n } = useFlipTransition({\n animated,\n getCloseSource,\n getFitRect,\n getViewportWidth,\n onClosed: handleClosed,\n source: openerEntry.element,\n transform,\n });\n isClosingIndirectRef.current = isClosing;\n\n useRefitTransition({\n animated,\n fillViewport,\n isTransitioning,\n natural,\n rotation,\n scale,\n viewport,\n x,\n y,\n });\n const finalFocus = useFinalFocus(openerFocusElement);\n\n const isCleanRef = useRef(isClean);\n isCleanRef.current = isClean;\n\n const handleClose = useCallback(() => {\n beginClosePreview(token);\n close({ fade: !isCleanRef.current });\n }, [close, token]);\n closeRef.current = handleClose;\n\n const handleDialogOpenChange = useCallback<NonNullable<DialogRootProps['onOpenChange']>>(\n (open, eventDetails) => {\n if (open) return;\n // Base UI fires this on every Esc regardless of whether we're already\n // mid-close (our Dialog.Root's open prop never actually flips to\n // false), so without this guard a second Esc during the close window\n // reads escIntent() off the close spring's mid-flight scale, decides\n // 'reset', and cancels the close animation out from under itself.\n if (isClosing()) return;\n if (eventDetails.reason === 'escape-key' && escIntent() === 'reset') {\n eventDetails.cancel();\n reset();\n return;\n }\n handleClose();\n },\n [escIntent, handleClose, isClosing, reset],\n );\n\n const gestures = useViewerGestures({\n dragBy,\n dragEnd,\n handleDoubleClick,\n handleWheel,\n isClean,\n isZoomed,\n onClose: handleClose,\n reset,\n zoomIn,\n zoomOut,\n });\n\n const { hasNext, hasPrev, next, prev } = useGalleryNav({\n cancelPendingClose: gestures.cancelPendingClose,\n currentIndex,\n entries,\n flipX,\n flipY,\n resetCloseTracking,\n rotate,\n scale,\n setCurrentIndex,\n setNatural,\n setSource,\n switchTo,\n syncZoomNatural,\n x,\n y,\n });\n\n const popupNodeRef = useRef<HTMLDivElement | null>(null);\n const popupRef = useMergeRefs<HTMLDivElement>([layerRef, gestures.popupRef, popupNodeRef]);\n const imageRef = useMergeRefs<HTMLImageElement>([flipImageRef, gestures.imageRef]);\n\n const handleLoad = useCallback(\n (event: SyntheticEvent<HTMLImageElement>) => {\n const loaded = event.currentTarget;\n if (!loaded.naturalWidth || !loaded.naturalHeight) return;\n const next = { height: loaded.naturalHeight, width: loaded.naturalWidth };\n setNatural((prev) =>\n prev.width === next.width && prev.height === next.height ? prev : next,\n );\n syncZoomNatural(next);\n },\n [syncZoomNatural],\n );\n\n useEffect(() => {\n const handleResize = () => {\n const next = readViewport();\n setViewport(next);\n syncZoomViewport(next);\n };\n window.addEventListener('resize', handleResize);\n return () => window.removeEventListener('resize', handleResize);\n }, [syncZoomViewport]);\n\n useEffect(() => {\n const { previewSrc, src } = currentEntry;\n if (!previewSrc || previewSrc === src) return;\n let cancelled = false;\n const loader = new window.Image();\n const handleLoaded = () => {\n if (!cancelled) setSource(previewSrc);\n };\n loader.addEventListener('load', handleLoaded);\n loader.src = previewSrc;\n return () => {\n cancelled = true;\n loader.removeEventListener('load', handleLoaded);\n };\n }, [currentEntry]);\n\n useEffect(\n () => () => {\n const thumbnailWasRemoved = !openerEntry.element.isConnected;\n if (thumbnailWasRemoved) endClosePreview(token);\n },\n [openerEntry, token],\n );\n\n return (\n <Dialog.Root modal open onOpenChange={handleDialogOpenChange}>\n <Dialog.Portal container={appElement ?? undefined}>\n <Dialog.Backdrop\n className={styles.viewerBackdrop}\n ref={backdropRef}\n style={zIndex === undefined ? undefined : { zIndex }}\n onClick={gestures.onSurfaceClick}\n />\n <Dialog.Popup\n aria-label={currentEntry.element.alt || undefined}\n className={styles.viewerPopup}\n finalFocus={finalFocus}\n // Focus the popup itself instead of Base UI's default (the first\n // tabbable — the close button): a focus-visible close button pins\n // the idle auto-hide open forever and pops its tooltip on open.\n initialFocus={popupNodeRef}\n ref={popupRef}\n style={zIndex === undefined ? undefined : { zIndex: zIndex + 1 }}\n tabIndex={-1}\n onClick={gestures.onSurfaceClick}\n onPointerCancel={gestures.onPointerFinish}\n onPointerDown={gestures.onPointerDown}\n onPointerMove={gestures.onPointerMove}\n onPointerUp={gestures.onPointerFinish}\n >\n <img\n alt={currentEntry.element.alt}\n className={styles.viewerImage}\n ref={imageRef}\n src={source}\n style={{\n cursor: gestures.cursor,\n height: imageRect.height,\n left: imageRect.x,\n top: imageRect.y,\n width: imageRect.width,\n }}\n onClick={gestures.onImageClick}\n onDoubleClick={gestures.onImageDoubleClick}\n onLoad={handleLoad}\n />\n <ViewerChrome\n canZoomIn={canZoomIn}\n canZoomOut={canZoomOut}\n chromeRef={chromeRef}\n current={currentIndex}\n fitRect={fitRect}\n flipHorizontal={flipHorizontal}\n flipVertical={flipVertical}\n hasNext={hasNext}\n hasPrev={hasPrev}\n natural={natural}\n next={next}\n prev={prev}\n reset={reset}\n rotateLeft={rotateLeft}\n rotateRight={rotateRight}\n rotation={rotation}\n scale={scale}\n source={source}\n toolbarAddon={currentEntry.options.toolbarAddon}\n total={entries.length}\n zoomIn={zoomIn}\n zoomOut={zoomOut}\n onClose={handleClose}\n />\n </Dialog.Popup>\n <ToastHost root={appElement ?? undefined} />\n </Dialog.Portal>\n </Dialog.Root>\n );\n});\n\nImageViewer.displayName = 'ImageViewer';\n\nexport default ImageViewer;\n"],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ "use client";
2
+ import { usePreviewSession } from "./registry.mjs";
3
+ import ImageViewer from "./ImageViewer.mjs";
4
+ import { jsx } from "react/jsx-runtime";
5
+ //#region src/Image/viewer/PreviewOutlet.tsx
6
+ const PreviewOutlet = ({ elementRef }) => {
7
+ const session = usePreviewSession(elementRef);
8
+ if (!session) return null;
9
+ return /* @__PURE__ */ jsx(ImageViewer, {
10
+ entries: session.entries,
11
+ index: session.index,
12
+ openerFocusElement: session.openerFocusElement,
13
+ token: session.token
14
+ }, session.token);
15
+ };
16
+ //#endregion
17
+ export { PreviewOutlet as default };
18
+
19
+ //# sourceMappingURL=PreviewOutlet.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PreviewOutlet.mjs","names":[],"sources":["../../../src/Image/viewer/PreviewOutlet.tsx"],"sourcesContent":["'use client';\n\nimport type { RefObject } from 'react';\n\nimport ImageViewer from './ImageViewer';\nimport { usePreviewSession } from './registry';\n\nexport interface PreviewOutletProps {\n elementRef: RefObject<HTMLImageElement | null>;\n}\n\nconst PreviewOutlet = ({ elementRef }: PreviewOutletProps) => {\n const session = usePreviewSession(elementRef);\n if (!session) return null;\n return (\n <ImageViewer\n entries={session.entries}\n index={session.index}\n key={session.token}\n openerFocusElement={session.openerFocusElement}\n token={session.token}\n />\n );\n};\n\nexport default PreviewOutlet;\n"],"mappings":";;;;;AAWA,MAAM,iBAAiB,EAAE,iBAAqC;CAC5D,MAAM,UAAU,kBAAkB,UAAU;CAC5C,IAAI,CAAC,SAAS,OAAO;CACrB,OACE,oBAAC,aAAD;EACE,SAAS,QAAQ;EACjB,OAAO,QAAQ;EAEf,oBAAoB,QAAQ;EAC5B,OAAO,QAAQ;CAChB,GAHM,QAAQ,KAGd;AAEL"}
@@ -0,0 +1,184 @@
1
+ "use client";
2
+ import FlexBasic_default from "../../Flex/FlexBasic.mjs";
3
+ import Center from "../../Flex/Center.mjs";
4
+ import { Tooltip } from "../../base-ui/Tooltip/Tooltip.mjs";
5
+ import TooltipGroup from "../../base-ui/Tooltip/TooltipGroup.mjs";
6
+ import ActionIcon from "../../ActionIcon/ActionIcon.mjs";
7
+ import DropdownMenu from "../../base-ui/DropdownMenu/DropdownMenu.mjs";
8
+ import { useTranslation } from "../../i18n/useTranslation.mjs";
9
+ import { downloadBlob } from "../../utils/downloadBlob.mjs";
10
+ import { styles } from "../style.mjs";
11
+ import { toast } from "../../base-ui/Toast/imperative.mjs";
12
+ import { naturalScale } from "./geometry.mjs";
13
+ import image_default from "../../i18n/resources/en/image.mjs";
14
+ import { getClipboardBlob } from "../../utils/blobToPng.mjs";
15
+ import { memo, useCallback, useMemo, useState, useSyncExternalStore } from "react";
16
+ import { jsx, jsxs } from "react/jsx-runtime";
17
+ import { Copy, Download, Ellipsis, FlipHorizontal, FlipVertical, RotateCcw, RotateCw, ZoomIn, ZoomOut } from "lucide-react";
18
+ //#region src/Image/viewer/Toolbar.tsx
19
+ const getFileNameFromUrl = (url) => {
20
+ try {
21
+ const match = new URL(url).pathname.match(/\/([^/]+)$/);
22
+ return match ? decodeURIComponent(match[1]) : "image";
23
+ } catch {
24
+ return "image";
25
+ }
26
+ };
27
+ const getExtensionFromMimeType = (mimeType) => {
28
+ return {
29
+ "image/svg+xml": "svg",
30
+ "image/png": "png",
31
+ "image/jpeg": "jpg",
32
+ "image/jpg": "jpg",
33
+ "image/webp": "webp",
34
+ "image/gif": "gif"
35
+ }[mimeType?.toLowerCase()] || mimeType?.split("/")[1]?.split("+")[0] || "png";
36
+ };
37
+ const usePercentage = (scale, matchScale) => {
38
+ const subscribe = useCallback((onChange) => scale.on("change", onChange), [scale]);
39
+ const getSnapshot = useCallback(() => Math.round(scale.get() / matchScale * 100), [matchScale, scale]);
40
+ return useSyncExternalStore(subscribe, getSnapshot);
41
+ };
42
+ const Toolbar = memo(({ canZoomIn, canZoomOut, fitRect, flipHorizontal, flipVertical, natural, onMoreOpenChange, reset, rotateLeft, rotateRight, rotation, scale, source, toolbarAddon, zoomIn, zoomOut }) => {
43
+ const { t } = useTranslation(image_default);
44
+ const [containerEl, setContainerEl] = useState(null);
45
+ const [copyLoading, setCopyLoading] = useState(false);
46
+ const [downloadLoading, setDownloadLoading] = useState(false);
47
+ const matchScale = naturalScale(natural, fitRect, rotation);
48
+ const percentage = usePercentage(scale, matchScale);
49
+ const handleDownload = useCallback(async () => {
50
+ setDownloadLoading(true);
51
+ try {
52
+ const blob = await (await fetch(source, { mode: "cors" })).blob();
53
+ const blobUrl = URL.createObjectURL(blob);
54
+ let fileName = getFileNameFromUrl(source);
55
+ const ext = getExtensionFromMimeType(blob.type);
56
+ if (!fileName.includes(".")) fileName = `${fileName}.${ext}`;
57
+ else if (fileName.endsWith(".svg+xml")) fileName = fileName.replace(/\.svg\+xml$/i, ".svg");
58
+ await downloadBlob(blobUrl, fileName);
59
+ URL.revokeObjectURL(blobUrl);
60
+ toast.success(t("image.downloadSuccess"));
61
+ } catch {
62
+ toast.error(t("image.downloadFailed"));
63
+ } finally {
64
+ setDownloadLoading(false);
65
+ }
66
+ }, [source, t]);
67
+ const handleCopy = useCallback(async () => {
68
+ setCopyLoading(true);
69
+ try {
70
+ const blob = await (await fetch(source, { mode: "cors" })).blob();
71
+ const clipboardBlob = await getClipboardBlob(blob);
72
+ await navigator.clipboard.write([new ClipboardItem(clipboardBlob)]);
73
+ toast.success(t("image.copySuccess"));
74
+ } catch {
75
+ toast.error(t("image.copyFailed"));
76
+ } finally {
77
+ setCopyLoading(false);
78
+ }
79
+ }, [source, t]);
80
+ const moreItems = useMemo(() => [
81
+ {
82
+ icon: FlipHorizontal,
83
+ key: "flip-horizontal",
84
+ label: t("image.flipHorizontal"),
85
+ onClick: flipHorizontal
86
+ },
87
+ {
88
+ icon: FlipVertical,
89
+ key: "flip-vertical",
90
+ label: t("image.flipVertical"),
91
+ onClick: flipVertical
92
+ },
93
+ {
94
+ icon: RotateCcw,
95
+ key: "rotate-left",
96
+ label: t("image.rotateLeft"),
97
+ onClick: rotateLeft
98
+ },
99
+ {
100
+ icon: RotateCw,
101
+ key: "rotate-right",
102
+ label: t("image.rotateRight"),
103
+ onClick: rotateRight
104
+ },
105
+ {
106
+ icon: Copy,
107
+ key: "copy",
108
+ label: t("image.copy"),
109
+ onClick: handleCopy
110
+ }
111
+ ], [
112
+ flipHorizontal,
113
+ flipVertical,
114
+ handleCopy,
115
+ rotateLeft,
116
+ rotateRight,
117
+ t
118
+ ]);
119
+ return /* @__PURE__ */ jsx(TooltipGroup, {
120
+ popupContainer: containerEl ?? void 0,
121
+ children: /* @__PURE__ */ jsx("div", {
122
+ className: styles.toolbar,
123
+ ref: setContainerEl,
124
+ children: /* @__PURE__ */ jsxs(FlexBasic_default, {
125
+ horizontal: true,
126
+ align: "center",
127
+ className: styles.toolbarRow,
128
+ gap: 8,
129
+ children: [
130
+ /* @__PURE__ */ jsx(ActionIcon, {
131
+ disabled: !canZoomOut,
132
+ icon: ZoomOut,
133
+ style: { borderRadius: 999 },
134
+ title: t("image.zoomOut"),
135
+ onClick: zoomOut
136
+ }),
137
+ /* @__PURE__ */ jsx(Tooltip, {
138
+ title: t("image.zoomReset"),
139
+ children: /* @__PURE__ */ jsxs(Center, {
140
+ horizontal: true,
141
+ className: styles.toolbarPercentage,
142
+ role: "button",
143
+ tabIndex: 0,
144
+ onClick: reset,
145
+ children: [percentage, "%"]
146
+ })
147
+ }),
148
+ /* @__PURE__ */ jsx(ActionIcon, {
149
+ disabled: !canZoomIn,
150
+ icon: ZoomIn,
151
+ style: { borderRadius: 999 },
152
+ title: t("image.zoomIn"),
153
+ onClick: zoomIn
154
+ }),
155
+ /* @__PURE__ */ jsx(ActionIcon, {
156
+ icon: Download,
157
+ loading: downloadLoading,
158
+ style: { borderRadius: 999 },
159
+ title: t("image.download"),
160
+ onClick: handleDownload
161
+ }),
162
+ /* @__PURE__ */ jsx(DropdownMenu, {
163
+ items: moreItems,
164
+ placement: "top",
165
+ portalProps: { container: containerEl ?? void 0 },
166
+ onOpenChange: (open) => onMoreOpenChange?.(open),
167
+ children: /* @__PURE__ */ jsx(ActionIcon, {
168
+ icon: Ellipsis,
169
+ loading: copyLoading,
170
+ style: { borderRadius: 999 },
171
+ title: t("image.more")
172
+ })
173
+ }),
174
+ toolbarAddon
175
+ ]
176
+ })
177
+ })
178
+ });
179
+ });
180
+ Toolbar.displayName = "Toolbar";
181
+ //#endregion
182
+ export { Toolbar as default };
183
+
184
+ //# sourceMappingURL=Toolbar.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Toolbar.mjs","names":["imageMessages","Flexbox"],"sources":["../../../src/Image/viewer/Toolbar.tsx"],"sourcesContent":["'use client';\n\nimport {\n Copy,\n Download,\n Ellipsis,\n FlipHorizontal,\n FlipVertical,\n RotateCcw,\n RotateCw,\n ZoomIn,\n ZoomOut,\n} from 'lucide-react';\nimport type { MotionValue } from 'motion/react';\nimport { memo, type ReactNode, useCallback, useMemo, useState, useSyncExternalStore } from 'react';\n\nimport ActionIcon from '@/ActionIcon';\nimport DropdownMenu, { type DropdownItem } from '@/base-ui/DropdownMenu';\nimport { toast } from '@/base-ui/Toast';\nimport { Center, Flexbox } from '@/Flex';\nimport imageMessages from '@/i18n/resources/en/image';\nimport { useTranslation } from '@/i18n/useTranslation';\nimport Tooltip, { TooltipGroup } from '@/Tooltip';\nimport { getClipboardBlob } from '@/utils/blobToPng';\nimport { downloadBlob } from '@/utils/downloadBlob';\n\nimport { styles } from '../style';\nimport { naturalScale, type Rect, type Rotation, type Size } from './geometry';\n\nconst getFileNameFromUrl = (url: string): string => {\n try {\n const pathname = new URL(url).pathname;\n const match = pathname.match(/\\/([^/]+)$/);\n return match ? decodeURIComponent(match[1]) : 'image';\n } catch {\n return 'image';\n }\n};\n\nconst getExtensionFromMimeType = (mimeType: string): string => {\n const map: Record<string, string> = {\n 'image/svg+xml': 'svg',\n 'image/png': 'png',\n 'image/jpeg': 'jpg',\n 'image/jpg': 'jpg',\n 'image/webp': 'webp',\n 'image/gif': 'gif',\n };\n return map[mimeType?.toLowerCase()] || mimeType?.split('/')[1]?.split('+')[0] || 'png';\n};\n\nconst usePercentage = (scale: MotionValue<number>, matchScale: number): number => {\n const subscribe = useCallback((onChange: () => void) => scale.on('change', onChange), [scale]);\n const getSnapshot = useCallback(\n () => Math.round((scale.get() / matchScale) * 100),\n [matchScale, scale],\n );\n\n return useSyncExternalStore(subscribe, getSnapshot);\n};\n\nexport interface ToolbarProps {\n canZoomIn: boolean;\n canZoomOut: boolean;\n fitRect: Rect;\n flipHorizontal: () => void;\n flipVertical: () => void;\n natural: Size;\n onMoreOpenChange?: (open: boolean) => void;\n reset: () => void;\n rotateLeft: () => void;\n rotateRight: () => void;\n rotation: Rotation;\n scale: MotionValue<number>;\n source: string;\n toolbarAddon?: ReactNode;\n zoomIn: () => void;\n zoomOut: () => void;\n}\n\nconst Toolbar = memo<ToolbarProps>(\n ({\n canZoomIn,\n canZoomOut,\n fitRect,\n flipHorizontal,\n flipVertical,\n natural,\n onMoreOpenChange,\n reset,\n rotateLeft,\n rotateRight,\n rotation,\n scale,\n source,\n toolbarAddon,\n zoomIn,\n zoomOut,\n }) => {\n const { t } = useTranslation(imageMessages);\n const [containerEl, setContainerEl] = useState<HTMLElement | null>(null);\n const [copyLoading, setCopyLoading] = useState(false);\n const [downloadLoading, setDownloadLoading] = useState(false);\n\n const matchScale = naturalScale(natural, fitRect, rotation);\n const percentage = usePercentage(scale, matchScale);\n\n const handleDownload = useCallback(async () => {\n setDownloadLoading(true);\n try {\n const response = await fetch(source, { mode: 'cors' });\n const blob = await response.blob();\n const blobUrl = URL.createObjectURL(blob);\n let fileName = getFileNameFromUrl(source);\n const ext = getExtensionFromMimeType(blob.type);\n if (!fileName.includes('.')) {\n fileName = `${fileName}.${ext}`;\n } else if (fileName.endsWith('.svg+xml')) {\n fileName = fileName.replace(/\\.svg\\+xml$/i, '.svg');\n }\n await downloadBlob(blobUrl, fileName);\n URL.revokeObjectURL(blobUrl);\n toast.success(t('image.downloadSuccess'));\n } catch {\n toast.error(t('image.downloadFailed'));\n } finally {\n setDownloadLoading(false);\n }\n }, [source, t]);\n\n const handleCopy = useCallback(async () => {\n setCopyLoading(true);\n try {\n const response = await fetch(source, { mode: 'cors' });\n const blob = await response.blob();\n const clipboardBlob = await getClipboardBlob(blob);\n await navigator.clipboard.write([new ClipboardItem(clipboardBlob)]);\n toast.success(t('image.copySuccess'));\n } catch {\n toast.error(t('image.copyFailed'));\n } finally {\n setCopyLoading(false);\n }\n }, [source, t]);\n\n const moreItems = useMemo<DropdownItem[]>(\n () => [\n {\n icon: FlipHorizontal,\n key: 'flip-horizontal',\n label: t('image.flipHorizontal'),\n onClick: flipHorizontal,\n },\n {\n icon: FlipVertical,\n key: 'flip-vertical',\n label: t('image.flipVertical'),\n onClick: flipVertical,\n },\n { icon: RotateCcw, key: 'rotate-left', label: t('image.rotateLeft'), onClick: rotateLeft },\n {\n icon: RotateCw,\n key: 'rotate-right',\n label: t('image.rotateRight'),\n onClick: rotateRight,\n },\n { icon: Copy, key: 'copy', label: t('image.copy'), onClick: handleCopy },\n ],\n [flipHorizontal, flipVertical, handleCopy, rotateLeft, rotateRight, t],\n );\n\n return (\n <TooltipGroup popupContainer={containerEl ?? undefined}>\n <div className={styles.toolbar} ref={setContainerEl}>\n <Flexbox horizontal align=\"center\" className={styles.toolbarRow} gap={8}>\n <ActionIcon\n disabled={!canZoomOut}\n icon={ZoomOut}\n style={{ borderRadius: 999 }}\n title={t('image.zoomOut')}\n onClick={zoomOut}\n />\n <Tooltip title={t('image.zoomReset')}>\n <Center\n horizontal\n className={styles.toolbarPercentage}\n role=\"button\"\n tabIndex={0}\n onClick={reset}\n >\n {percentage}%\n </Center>\n </Tooltip>\n <ActionIcon\n disabled={!canZoomIn}\n icon={ZoomIn}\n style={{ borderRadius: 999 }}\n title={t('image.zoomIn')}\n onClick={zoomIn}\n />\n <ActionIcon\n icon={Download}\n loading={downloadLoading}\n style={{ borderRadius: 999 }}\n title={t('image.download')}\n onClick={handleDownload}\n />\n <DropdownMenu\n items={moreItems}\n placement=\"top\"\n portalProps={{ container: containerEl ?? undefined }}\n onOpenChange={(open) => onMoreOpenChange?.(open)}\n >\n <ActionIcon\n icon={Ellipsis}\n loading={copyLoading}\n style={{ borderRadius: 999 }}\n title={t('image.more')}\n />\n </DropdownMenu>\n {toolbarAddon}\n </Flexbox>\n </div>\n </TooltipGroup>\n );\n },\n);\n\nToolbar.displayName = 'Toolbar';\n\nexport default Toolbar;\n"],"mappings":"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"}