@lobehub/ui 5.26.0 → 5.28.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/README.md +6 -6
- package/es/Image/Image.mjs +3 -2
- package/es/Image/Image.mjs.map +1 -1
- package/es/Image/style.mjs +19 -13
- package/es/Image/style.mjs.map +1 -1
- package/es/Image/type.d.mts +3 -0
- package/es/Image/viewer/ActualSizeIcon.mjs +44 -0
- package/es/Image/viewer/ActualSizeIcon.mjs.map +1 -0
- package/es/Image/viewer/ImageViewer.mjs +12 -3
- package/es/Image/viewer/ImageViewer.mjs.map +1 -1
- package/es/Image/viewer/Toolbar.mjs +17 -13
- package/es/Image/viewer/Toolbar.mjs.map +1 -1
- package/es/Image/viewer/ViewerChrome.mjs.map +1 -1
- package/es/Image/viewer/geometry.d.mts +5 -0
- package/es/Image/viewer/geometry.mjs +13 -1
- package/es/Image/viewer/geometry.mjs.map +1 -1
- package/es/Image/viewer/registry.mjs.map +1 -1
- package/es/Image/viewer/useFlipTransition.mjs +7 -4
- package/es/Image/viewer/useFlipTransition.mjs.map +1 -1
- package/es/Image/viewer/useGalleryNav.mjs +6 -5
- package/es/Image/viewer/useGalleryNav.mjs.map +1 -1
- package/es/Image/viewer/useViewerGestures.mjs +2 -6
- package/es/Image/viewer/useViewerGestures.mjs.map +1 -1
- package/es/Image/viewer/useZoomPan.mjs +80 -23
- package/es/Image/viewer/useZoomPan.mjs.map +1 -1
- package/es/Markdown/SyntaxMarkdown/CachedMarkdown.mjs +1 -1
- package/es/Markdown/plugins/rehypeCustomFootnotes.mjs +1 -1
- package/es/Markdown/plugins/rehypeKatexDir.d.mts +1 -1
- package/es/Markdown/plugins/rehypeKatexDir.mjs +1 -1
- package/es/Markdown/plugins/rehypeStreamAnimated.d.mts +1 -1
- package/es/Markdown/plugins/rehypeStreamAnimated.mjs +1 -1
- package/es/Markdown/plugins/remarkBr.mjs +1 -1
- package/es/Markdown/plugins/remarkColor.mjs +1 -1
- package/es/Markdown/plugins/remarkCustomFootnotes.mjs +2 -2
- package/es/Markdown/plugins/remarkGfmPlus.mjs +1 -1
- package/es/Markdown/plugins/remarkVideo.mjs +1 -1
- package/es/base-ui/Toast/imperative.mjs +30 -15
- package/es/base-ui/Toast/imperative.mjs.map +1 -1
- package/es/base-ui/Toast/type.d.mts +7 -0
- package/es/i18n/resources/en/image.d.mts +2 -1
- package/es/i18n/resources/en/image.mjs +3 -2
- package/es/i18n/resources/en/image.mjs.map +1 -1
- package/es/i18n/resources/zhCn/image.d.mts +2 -1
- package/es/i18n/resources/zhCn/image.mjs +3 -2
- package/es/i18n/resources/zhCn/image.mjs.map +1 -1
- package/es/index.d.mts +2 -2
- package/es/mdx/mdxComponents/index.d.mts +1 -1
- package/es/node_modules/{.bun → .pnpm}/@types_hast@3.0.5/node_modules/@types/hast/index.d.mts +1 -1
- package/es/node_modules/{.bun → .pnpm}/@types_mdx@2.0.14/node_modules/@types/mdx/types.d.mts +1 -1
- package/es/node_modules/{.bun → .pnpm}/@types_unist@3.0.3/node_modules/@types/unist/index.d.mts +1 -1
- package/es/node_modules/{.bun → .pnpm}/unist-util-is@6.0.1/node_modules/unist-util-is/lib/index.mjs +1 -1
- package/es/node_modules/.pnpm/unist-util-is@6.0.1/node_modules/unist-util-is/lib/index.mjs.map +1 -0
- package/es/node_modules/.pnpm/unist-util-visit-parents@6.0.2/node_modules/unist-util-visit-parents/lib/color.node.mjs +12 -0
- package/es/node_modules/.pnpm/unist-util-visit-parents@6.0.2/node_modules/unist-util-visit-parents/lib/color.node.mjs.map +1 -0
- package/es/node_modules/{.bun → .pnpm}/unist-util-visit-parents@6.0.2/node_modules/unist-util-visit-parents/lib/index.mjs +1 -1
- package/es/node_modules/.pnpm/unist-util-visit-parents@6.0.2/node_modules/unist-util-visit-parents/lib/index.mjs.map +1 -0
- package/es/node_modules/{.bun → .pnpm}/unist-util-visit@5.1.0/node_modules/unist-util-visit/lib/index.mjs +1 -1
- package/es/node_modules/.pnpm/unist-util-visit@5.1.0/node_modules/unist-util-visit/lib/index.mjs.map +1 -0
- package/package.json +4 -5
- package/es/node_modules/.bun/unist-util-is@6.0.1/node_modules/unist-util-is/lib/index.mjs.map +0 -1
- package/es/node_modules/.bun/unist-util-visit-parents@6.0.2/node_modules/unist-util-visit-parents/lib/color.node.mjs +0 -12
- package/es/node_modules/.bun/unist-util-visit-parents@6.0.2/node_modules/unist-util-visit-parents/lib/color.node.mjs.map +0 -1
- package/es/node_modules/.bun/unist-util-visit-parents@6.0.2/node_modules/unist-util-visit-parents/lib/index.mjs.map +0 -1
- package/es/node_modules/.bun/unist-util-visit@5.1.0/node_modules/unist-util-visit/lib/index.mjs.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"geometry.mjs","names":[],"sources":["../../../src/Image/viewer/geometry.ts"],"sourcesContent":["export interface Size {\n height: number;\n width: number;\n}\n\nexport interface Point {\n x: number;\n y: number;\n}\n\nexport interface Rect {\n height: number;\n width: number;\n x: number;\n y: number;\n}\n\nexport type Rotation = 0 | 90 | 180 | 270;\n\nexport interface TransformState {\n scale: number;\n x: number;\n y: number;\n}\n\nexport interface AxisBounds {\n max: number;\n min: number;\n}\n\nexport const VIEWPORT_MARGIN = 24;\nexport const MIN_SCALE = 1;\nexport const DEFAULT_MAX_SCALE = 8;\nexport const DEFAULT_RUBBER_BAND_FACTOR = 0.15;\nexport const WHEEL_ZOOM_SENSITIVITY = 0.002;\n\nexport const normalizeRotation = (degrees: number): Rotation =>\n (((degrees % 360) + 360) % 360) as Rotation;\n\nconst effectiveSize = (natural: Size, rotate: Rotation): Size =>\n rotate === 90 || rotate === 270 ? { height: natural.width, width: natural.height } : natural;\n\nexport const computeFit = (\n natural: Size,\n viewport: Size,\n rotate: Rotation,\n fillViewport = false,\n): Rect => {\n const effective = effectiveSize(natural, rotate);\n const availableWidth = Math.max(viewport.width - VIEWPORT_MARGIN * 2, 0);\n const availableHeight = Math.max(viewport.height - VIEWPORT_MARGIN * 2, 0);\n // fillViewport drops the no-upscale cap: a dual-source thumbnail is a\n // stand-in for the hi-res image, so it must open at the viewport fit the\n // hi-res will land in — otherwise the swap re-fits mid-open.\n const containScale = Math.min(\n availableWidth / effective.width,\n availableHeight / effective.height,\n ...(fillViewport ? [] : [1]),\n );\n const width = effective.width * containScale;\n const height = effective.height * containScale;\n\n return {\n height,\n width,\n x: (viewport.width - width) / 2,\n y: (viewport.height - height) / 2,\n };\n};\n\n// computeFit returns the box the image occupies *after* the CSS rotation, but\n// the element it is applied to is sized before that rotation runs. For quarter\n// turns the two disagree, so the element box has to be the transpose about the\n// same center — otherwise `object-fit: contain` letterboxes the image down to\n// the short side and everything downstream (pan bounds, natural scale) is\n// measuring a box the viewer never actually paints.\nexport const unrotatedRect = (fit: Rect, rotate: Rotation): Rect => {\n if (rotate !== 90 && rotate !== 270) return fit;\n\n const centerX = fit.x + fit.width / 2;\n const centerY = fit.y + fit.height / 2;\n\n return {\n height: fit.width,\n width: fit.height,\n x: centerX - fit.height / 2,\n y: centerY - fit.width / 2,\n };\n};\n\nexport const anchoredZoom = (\n current: TransformState,\n targetScale: number,\n anchor: Point,\n fitRect: Rect,\n): TransformState => {\n const centerX = fitRect.x + fitRect.width / 2;\n const centerY = fitRect.y + fitRect.height / 2;\n const ratio = targetScale / current.scale;\n\n return {\n scale: targetScale,\n x: anchor.x - centerX - ratio * (anchor.x - centerX - current.x),\n y: anchor.y - centerY - ratio * (anchor.y - centerY - current.y),\n };\n};\n\nexport const panBounds = (\n state: TransformState,\n fitRect: Rect,\n viewport: Size,\n): { x: AxisBounds; y: AxisBounds } => {\n const centerX = fitRect.x + fitRect.width / 2;\n const centerY = fitRect.y + fitRect.height / 2;\n const scaledWidth = fitRect.width * state.scale;\n const scaledHeight = fitRect.height * state.scale;\n\n const axis = (center: number, scaledSize: number, viewportSize: number): AxisBounds => {\n const overflow = scaledSize - viewportSize;\n if (overflow <= 0) return { max: 0, min: 0 };\n return { max: scaledSize / 2 - center, min: viewportSize - center - scaledSize / 2 };\n };\n\n return {\n x: axis(centerX, scaledWidth, viewport.width),\n y: axis(centerY, scaledHeight, viewport.height),\n };\n};\n\nexport const clampPan = (state: TransformState, fitRect: Rect, viewport: Size): Point => {\n const bounds = panBounds(state, fitRect, viewport);\n return {\n x: Math.min(Math.max(state.x, bounds.x.min), bounds.x.max),\n y: Math.min(Math.max(state.y, bounds.y.min), bounds.y.max),\n };\n};\n\nexport const rubberBand = (\n value: number,\n min: number,\n max: number,\n factor: number = DEFAULT_RUBBER_BAND_FACTOR,\n): number => {\n if (value < min) return min - (min - value) * factor;\n if (value > max) return max + (value - max) * factor;\n return value;\n};\n\nexport const clampScale = (scale: number, maxScale: number = DEFAULT_MAX_SCALE): number =>\n Math.min(Math.max(scale, MIN_SCALE), maxScale);\n\nexport const wheelZoomFactor = (deltaY: number): number =>\n Math.exp(-deltaY * WHEEL_ZOOM_SENSITIVITY);\n\nexport const naturalScale = (natural: Size, fitRect: Rect, rotate: Rotation): number =>\n effectiveSize(natural, rotate).width / fitRect.width;\n\nexport const doubleClickTarget = (\n currentScale: number,\n natural: Size,\n fitRect: Rect,\n rotate: Rotation,\n): number => {\n if (currentScale > 1) return MIN_SCALE;\n return Math.max(2, naturalScale(natural, fitRect, rotate));\n};\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"geometry.mjs","names":[],"sources":["../../../src/Image/viewer/geometry.ts"],"sourcesContent":["export interface Size {\n height: number;\n width: number;\n}\n\nexport interface Point {\n x: number;\n y: number;\n}\n\nexport interface Rect {\n height: number;\n width: number;\n x: number;\n y: number;\n}\n\nexport type Rotation = 0 | 90 | 180 | 270;\n\nexport type ZoomPolicy = 'auto' | 'actual' | 'fit';\n\nexport interface TransformState {\n scale: number;\n x: number;\n y: number;\n}\n\nexport interface AxisBounds {\n max: number;\n min: number;\n}\n\nexport const VIEWPORT_MARGIN = 24;\nexport const MIN_SCALE = 1;\nexport const DEFAULT_MAX_SCALE = 8;\nexport const DEFAULT_RUBBER_BAND_FACTOR = 0.15;\nexport const WHEEL_ZOOM_SENSITIVITY = 0.002;\nexport const DEFAULT_AUTO_ZOOM_THRESHOLD = 2;\nexport const WHEEL_LINE_HEIGHT = 16;\n\nexport const normalizeRotation = (degrees: number): Rotation =>\n (((degrees % 360) + 360) % 360) as Rotation;\n\nconst effectiveSize = (natural: Size, rotate: Rotation): Size =>\n rotate === 90 || rotate === 270 ? { height: natural.width, width: natural.height } : natural;\n\nexport const computeFit = (\n natural: Size,\n viewport: Size,\n rotate: Rotation,\n fillViewport = false,\n): Rect => {\n const effective = effectiveSize(natural, rotate);\n const availableWidth = Math.max(viewport.width - VIEWPORT_MARGIN * 2, 0);\n const availableHeight = Math.max(viewport.height - VIEWPORT_MARGIN * 2, 0);\n // fillViewport drops the no-upscale cap: a dual-source thumbnail is a\n // stand-in for the hi-res image, so it must open at the viewport fit the\n // hi-res will land in — otherwise the swap re-fits mid-open.\n const containScale = Math.min(\n availableWidth / effective.width,\n availableHeight / effective.height,\n ...(fillViewport ? [] : [1]),\n );\n const width = effective.width * containScale;\n const height = effective.height * containScale;\n\n return {\n height,\n width,\n x: (viewport.width - width) / 2,\n y: (viewport.height - height) / 2,\n };\n};\n\n// computeFit returns the box the image occupies *after* the CSS rotation, but\n// the element it is applied to is sized before that rotation runs. For quarter\n// turns the two disagree, so the element box has to be the transpose about the\n// same center — otherwise `object-fit: contain` letterboxes the image down to\n// the short side and everything downstream (pan bounds, natural scale) is\n// measuring a box the viewer never actually paints.\nexport const unrotatedRect = (fit: Rect, rotate: Rotation): Rect => {\n if (rotate !== 90 && rotate !== 270) return fit;\n\n const centerX = fit.x + fit.width / 2;\n const centerY = fit.y + fit.height / 2;\n\n return {\n height: fit.width,\n width: fit.height,\n x: centerX - fit.height / 2,\n y: centerY - fit.width / 2,\n };\n};\n\nexport const anchoredZoom = (\n current: TransformState,\n targetScale: number,\n anchor: Point,\n fitRect: Rect,\n): TransformState => {\n const centerX = fitRect.x + fitRect.width / 2;\n const centerY = fitRect.y + fitRect.height / 2;\n const ratio = targetScale / current.scale;\n\n return {\n scale: targetScale,\n x: anchor.x - centerX - ratio * (anchor.x - centerX - current.x),\n y: anchor.y - centerY - ratio * (anchor.y - centerY - current.y),\n };\n};\n\nexport const panBounds = (\n state: TransformState,\n fitRect: Rect,\n viewport: Size,\n): { x: AxisBounds; y: AxisBounds } => {\n const centerX = fitRect.x + fitRect.width / 2;\n const centerY = fitRect.y + fitRect.height / 2;\n const scaledWidth = fitRect.width * state.scale;\n const scaledHeight = fitRect.height * state.scale;\n\n const axis = (center: number, scaledSize: number, viewportSize: number): AxisBounds => {\n const overflow = scaledSize - viewportSize;\n if (overflow <= 0) return { max: 0, min: 0 };\n return { max: scaledSize / 2 - center, min: viewportSize - center - scaledSize / 2 };\n };\n\n return {\n x: axis(centerX, scaledWidth, viewport.width),\n y: axis(centerY, scaledHeight, viewport.height),\n };\n};\n\nexport const clampPan = (state: TransformState, fitRect: Rect, viewport: Size): Point => {\n const bounds = panBounds(state, fitRect, viewport);\n return {\n x: Math.min(Math.max(state.x, bounds.x.min), bounds.x.max),\n y: Math.min(Math.max(state.y, bounds.y.min), bounds.y.max),\n };\n};\n\nexport const rubberBand = (\n value: number,\n min: number,\n max: number,\n factor: number = DEFAULT_RUBBER_BAND_FACTOR,\n): number => {\n if (value < min) return min - (min - value) * factor;\n if (value > max) return max + (value - max) * factor;\n return value;\n};\n\nexport const clampScale = (scale: number, maxScale: number = DEFAULT_MAX_SCALE): number =>\n Math.min(Math.max(scale, MIN_SCALE), maxScale);\n\nexport const wheelZoomFactor = (deltaY: number): number =>\n Math.exp(-deltaY * WHEEL_ZOOM_SENSITIVITY);\n\n// Firefox on Windows/Linux reports wheel notches as DOM_DELTA_LINE with a\n// deltaY around 3, where Chrome/Safari report ~100 pixels for the same notch.\n// Feeding those raw into wheelZoomFactor is a ~30x under-zoom (0.6% per notch,\n// ~115 notches to double) and an equally dead scroll-to-close accumulator.\nexport const normalizeWheelDelta = (deltaY: number, deltaMode = 0, viewportHeight = 0): number => {\n if (deltaMode === 1) return deltaY * WHEEL_LINE_HEIGHT;\n if (deltaMode === 2) return deltaY * (viewportHeight || WHEEL_LINE_HEIGHT * 40);\n return deltaY;\n};\n\nexport const naturalScale = (natural: Size, fitRect: Rect, rotate: Rotation): number =>\n effectiveSize(natural, rotate).width / fitRect.width;\n\n// The scale the viewer opens at, expressed like every other scale here:\n// relative to the fit rect, so MIN_SCALE is always still fit. Opening above\n// MIN_SCALE is what makes a screenshot legible on open and — because the image\n// then overflows the viewport — what gives panBounds something to work with.\nexport const resolveInitialScale = (\n policy: ZoomPolicy,\n natural: Size,\n fitRect: Rect,\n rotate: Rotation,\n threshold: number = DEFAULT_AUTO_ZOOM_THRESHOLD,\n): number => {\n if (policy === 'fit') return MIN_SCALE;\n\n const target = naturalScale(natural, fitRect, rotate);\n if (!Number.isFinite(target) || target <= 0) return MIN_SCALE;\n if (policy === 'auto' && target > threshold) return MIN_SCALE;\n\n // fillViewport drops computeFit's no-upscale cap, so the fit rect can be\n // larger than the image and target can land below the zoom floor.\n return Math.max(MIN_SCALE, target);\n};\n\nexport const doubleClickTarget = (\n currentScale: number,\n natural: Size,\n fitRect: Rect,\n rotate: Rotation,\n): number => {\n if (currentScale > 1) return MIN_SCALE;\n return Math.max(2, naturalScale(natural, fitRect, rotate));\n};\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.mjs","names":[],"sources":["../../../src/Image/viewer/registry.ts"],"sourcesContent":["import { type RefObject, useCallback, useSyncExternalStore } from 'react';\n\nimport type { ImagePreviewOptions } from '../type';\n\nexport interface ResolvedPreviewOptions extends Omit
|
|
1
|
+
{"version":3,"file":"registry.mjs","names":[],"sources":["../../../src/Image/viewer/registry.ts"],"sourcesContent":["import { type RefObject, useCallback, useSyncExternalStore } from 'react';\n\nimport type { ImagePreviewOptions } from '../type';\nimport type { ZoomPolicy } from './geometry';\n\nexport interface ResolvedPreviewOptions extends Omit<\n ImagePreviewOptions,\n 'autoZoomThreshold' | 'defaultZoom' | 'maxScale'\n> {\n autoZoomThreshold: number;\n defaultZoom: ZoomPolicy;\n maxScale: number;\n}\n\nexport interface PreviewEntry {\n element: HTMLImageElement;\n options: ResolvedPreviewOptions;\n previewSrc?: string;\n src: string;\n}\n\nexport interface PreviewSession {\n entries: PreviewEntry[];\n index: number;\n openerFocusElement: HTMLElement | null;\n token: number;\n}\n\ninterface HostState extends PreviewSession {\n closing: boolean;\n onOpenChange?: (open: boolean) => void;\n}\n\nlet state: HostState | null = null;\nlet nextToken = 0;\n\nconst listeners = new Set<() => void>();\n\nconst emit = () => {\n for (const listener of listeners) listener();\n};\n\nconst flushClosed = () => {\n if (!state || state.closing) return;\n state.closing = true;\n state.onOpenChange?.(false);\n};\n\n// openerFocusElement must be captured by the caller at pointerdown, not read here:\n// clicking a non-focusable thumbnail already blurs to document.body before its\n// click handler (and therefore this call) runs.\nexport const openPreview = (\n entry: PreviewEntry,\n entries?: PreviewEntry[],\n index = 0,\n openerFocusElement: HTMLElement | null = null,\n): void => {\n flushClosed();\n nextToken += 1;\n const resolvedEntries = entries && entries.length > 0 ? entries : [entry];\n const resolvedIndex =\n entries && entries.length > 0 ? Math.min(Math.max(index, 0), entries.length - 1) : 0;\n state = {\n closing: false,\n entries: resolvedEntries,\n index: resolvedIndex,\n onOpenChange: entry.options.onOpenChange,\n openerFocusElement,\n token: nextToken,\n };\n emit();\n entry.options.onOpenChange?.(true);\n};\n\nexport const beginClosePreview = (token: number): void => {\n if (state?.token !== token) return;\n flushClosed();\n};\n\nexport const endClosePreview = (token: number): void => {\n if (state?.token !== token) return;\n flushClosed();\n state = null;\n emit();\n};\n\nconst subscribe = (listener: () => void) => {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n};\n\nconst getServerSnapshot = (): PreviewSession | null => null;\n\nexport const usePreviewSession = (\n elementRef: RefObject<HTMLImageElement | null>,\n): PreviewSession | null => {\n const getSnapshot = useCallback(\n () => (state && state.entries[state.index]?.element === elementRef.current ? state : null),\n [elementRef],\n );\n\n return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);\n};\n"],"mappings":";;AAiCA,IAAI,QAA0B;AAC9B,IAAI,YAAY;AAEhB,MAAM,4BAAY,IAAI,IAAgB;AAEtC,MAAM,aAAa;CACjB,KAAK,MAAM,YAAY,WAAW,SAAS;AAC7C;AAEA,MAAM,oBAAoB;CACxB,IAAI,CAAC,SAAS,MAAM,SAAS;CAC7B,MAAM,UAAU;CAChB,MAAM,eAAe,KAAK;AAC5B;AAKA,MAAa,eACX,OACA,SACA,QAAQ,GACR,qBAAyC,SAChC;CACT,YAAY;CACZ,aAAa;CAIb,QAAQ;EACN,SAAS;EACT,SALsB,WAAW,QAAQ,SAAS,IAAI,UAAU,CAAC,KAAK;EAMtE,OAJA,WAAW,QAAQ,SAAS,IAAI,KAAK,IAAI,KAAK,IAAI,OAAO,CAAC,GAAG,QAAQ,SAAS,CAAC,IAAI;EAKnF,cAAc,MAAM,QAAQ;EAC5B;EACA,OAAO;CACT;CACA,KAAK;CACL,MAAM,QAAQ,eAAe,IAAI;AACnC;AAEA,MAAa,qBAAqB,UAAwB;CACxD,IAAI,OAAO,UAAU,OAAO;CAC5B,YAAY;AACd;AAEA,MAAa,mBAAmB,UAAwB;CACtD,IAAI,OAAO,UAAU,OAAO;CAC5B,YAAY;CACZ,QAAQ;CACR,KAAK;AACP;AAEA,MAAM,aAAa,aAAyB;CAC1C,UAAU,IAAI,QAAQ;CACtB,aAAa;EACX,UAAU,OAAO,QAAQ;CAC3B;AACF;AAEA,MAAM,0BAAiD;AAEvD,MAAa,qBACX,eAC0B;CAC1B,MAAM,cAAc,kBACX,SAAS,MAAM,QAAQ,MAAM,MAAM,EAAE,YAAY,WAAW,UAAU,QAAQ,MACrF,CAAC,UAAU,CACb;CAEA,OAAO,qBAAqB,WAAW,aAAa,iBAAiB;AACvE"}
|
|
@@ -66,7 +66,7 @@ const useBoundNode = (apply) => {
|
|
|
66
66
|
setNode
|
|
67
67
|
]);
|
|
68
68
|
};
|
|
69
|
-
const useFlipTransition = ({ animated, getCloseSource, getFitRect, getViewportWidth, onClosed, source, transform }) => {
|
|
69
|
+
const useFlipTransition = ({ animated, getCloseSource, getFitRect, getInitialScale, getViewportWidth, onClosed, source, transform }) => {
|
|
70
70
|
const [opacity] = useState(() => ({
|
|
71
71
|
backdrop: motionValue(0),
|
|
72
72
|
chrome: motionValue(0),
|
|
@@ -91,6 +91,8 @@ const useFlipTransition = ({ animated, getCloseSource, getFitRect, getViewportWi
|
|
|
91
91
|
getFitRectRef.current = getFitRect;
|
|
92
92
|
const getCloseSourceRef = useRef(getCloseSource);
|
|
93
93
|
getCloseSourceRef.current = getCloseSource;
|
|
94
|
+
const getInitialScaleRef = useRef(getInitialScale);
|
|
95
|
+
getInitialScaleRef.current = getInitialScale;
|
|
94
96
|
const getViewportWidthRef = useRef(getViewportWidth);
|
|
95
97
|
getViewportWidthRef.current = getViewportWidth;
|
|
96
98
|
const ghostRef = useRef(null);
|
|
@@ -159,6 +161,7 @@ const useFlipTransition = ({ animated, getCloseSource, getFitRect, getViewportWi
|
|
|
159
161
|
const markSettled = () => {
|
|
160
162
|
transitioningRef.current = false;
|
|
161
163
|
};
|
|
164
|
+
const resting = getInitialScaleRef.current();
|
|
162
165
|
if (rect) {
|
|
163
166
|
const start = sourceTransform(rect, getFitRectRef.current());
|
|
164
167
|
transform.scale.set(start.scale);
|
|
@@ -166,18 +169,18 @@ const useFlipTransition = ({ animated, getCloseSource, getFitRect, getViewportWi
|
|
|
166
169
|
transform.y.set(start.y);
|
|
167
170
|
opacity.image.set(1);
|
|
168
171
|
animateSettling([
|
|
169
|
-
[transform.scale,
|
|
172
|
+
[transform.scale, resting],
|
|
170
173
|
[transform.x, 0],
|
|
171
174
|
[transform.y, 0]
|
|
172
175
|
], markSettled);
|
|
173
176
|
} else {
|
|
174
177
|
opacity.image.set(0);
|
|
175
|
-
if (animated) transform.scale.set(FADE_SCALE);
|
|
178
|
+
if (animated) transform.scale.set(resting * FADE_SCALE);
|
|
176
179
|
run([animate(opacity.image, 1, {
|
|
177
180
|
...FADE,
|
|
178
181
|
onComplete: markSettled
|
|
179
182
|
})]);
|
|
180
|
-
if (animated) run([animate(transform.scale,
|
|
183
|
+
if (animated) run([animate(transform.scale, resting, FADE)]);
|
|
181
184
|
trackTimeout(markSettled, SETTLE_FALLBACK_MS);
|
|
182
185
|
}
|
|
183
186
|
run([animate(opacity.backdrop, 1, FADE), animate(opacity.chrome, 1, CHROME_FADE)]);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useFlipTransition.mjs","names":[],"sources":["../../../src/Image/viewer/useFlipTransition.ts"],"sourcesContent":["import type { MotionValue } from 'motion/react';\nimport { animate, motionValue } from 'motion/react';\nimport { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';\n\nimport type { Rect } from './geometry';\n\nexport const OPEN_SPRING = { bounce: 0.15, type: 'spring' as const, visualDuration: 0.3 };\n// bounce: 0 — a full-viewport push with overshoot would visibly slide past\n// the resting position and snap back.\nconst PUSH_SPRING = { bounce: 0, type: 'spring' as const, visualDuration: 0.3 };\nconst FADE = { duration: 0.15, ease: 'easeOut' as const };\nconst CHROME_FADE = { delay: 0.15, duration: 0.2, ease: 'easeOut' as const };\nconst FADE_SCALE = 0.92;\nconst SWITCH_FADE_SCALE = 0.96;\nconst SETTLE_FALLBACK_MS = 1000;\n\ninterface Animation {\n stop: () => void;\n}\n\nexport interface FlipTransformValues {\n flipX: MotionValue<boolean>;\n flipY: MotionValue<boolean>;\n rotate: MotionValue<number>;\n scale: MotionValue<number>;\n x: MotionValue<number>;\n y: MotionValue<number>;\n}\n\nexport interface UseFlipTransitionOptions {\n animated: boolean;\n getCloseSource: () => HTMLImageElement;\n getFitRect: () => Rect;\n getViewportWidth: () => number;\n onClosed: () => void;\n source: HTMLImageElement;\n transform: FlipTransformValues;\n}\n\nexport interface CloseTransitionOptions {\n fade?: boolean;\n}\n\nexport interface UseFlipTransitionResult {\n backdropRef: (node: HTMLElement | null) => void;\n chromeRef: (node: HTMLElement | null) => void;\n close: (options?: CloseTransitionOptions) => void;\n imageRef: (node: HTMLImageElement | null) => void;\n isClosing: () => boolean;\n isTransitioning: () => boolean;\n switchTo: (apply: () => void, direction?: 1 | -1) => void;\n}\n\nconst measureSource = (element: HTMLImageElement): DOMRect | null => {\n if (!element.isConnected) return null;\n const rect = element.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return null;\n if (rect.bottom <= 0 || rect.right <= 0) return null;\n if (rect.top >= window.innerHeight || rect.left >= window.innerWidth) return null;\n return rect;\n};\n\nconst sourceTransform = (rect: DOMRect, fit: Rect) => ({\n scale:\n fit.width > 0 && fit.height > 0\n ? Math.max(rect.width / fit.width, rect.height / fit.height)\n : 1,\n x: rect.left + rect.width / 2 - (fit.x + fit.width / 2),\n y: rect.top + rect.height / 2 - (fit.y + fit.height / 2),\n});\n\nconst readTransform = (transform: FlipTransformValues) =>\n [\n `translate3d(${transform.x.get()}px, ${transform.y.get()}px, 0)`,\n `scale(${transform.scale.get()})`,\n `rotate(${transform.rotate.get()}deg)`,\n `scaleX(${transform.flipX.get() ? -1 : 1})`,\n `scaleY(${transform.flipY.get() ? -1 : 1})`,\n ].join(' ');\n\nconst useBoundNode = <T extends HTMLElement>(apply: (node: T) => void) => {\n const nodeRef = useRef<T | null>(null);\n const applyRef = useRef(apply);\n applyRef.current = apply;\n\n const setNode = useCallback((node: T | null) => {\n nodeRef.current = node;\n if (node) applyRef.current(node);\n }, []);\n\n const refresh = useCallback(() => {\n const node = nodeRef.current;\n if (node) applyRef.current(node);\n }, []);\n\n const getNode = useCallback(() => nodeRef.current, []);\n\n return useMemo(() => ({ getNode, refresh, setNode }), [getNode, refresh, setNode]);\n};\n\nexport const useFlipTransition = ({\n animated,\n getCloseSource,\n getFitRect,\n getViewportWidth,\n onClosed,\n source,\n transform,\n}: UseFlipTransitionOptions): UseFlipTransitionResult => {\n const [opacity] = useState(() => ({\n backdrop: motionValue(0),\n chrome: motionValue(0),\n image: motionValue(animated ? 1 : 0),\n }));\n\n const image = useBoundNode<HTMLImageElement>(\n useCallback(\n (node) => {\n node.style.transform = readTransform(transform);\n node.style.opacity = String(opacity.image.get());\n },\n [opacity, transform],\n ),\n );\n\n const backdrop = useBoundNode<HTMLElement>(\n useCallback(\n (node) => {\n node.style.opacity = String(opacity.backdrop.get());\n },\n [opacity],\n ),\n );\n\n const chrome = useBoundNode<HTMLElement>(\n useCallback(\n (node) => {\n node.style.opacity = String(opacity.chrome.get());\n },\n [opacity],\n ),\n );\n\n const runningRef = useRef<Animation[]>([]);\n const closingRef = useRef(false);\n const transitioningRef = useRef(true);\n\n const onClosedRef = useRef(onClosed);\n onClosedRef.current = onClosed;\n\n const getFitRectRef = useRef(getFitRect);\n getFitRectRef.current = getFitRect;\n\n const getCloseSourceRef = useRef(getCloseSource);\n getCloseSourceRef.current = getCloseSource;\n\n const getViewportWidthRef = useRef(getViewportWidth);\n getViewportWidthRef.current = getViewportWidth;\n\n const ghostRef = useRef<HTMLImageElement | null>(null);\n const removeGhost = useCallback(() => {\n ghostRef.current?.remove();\n ghostRef.current = null;\n }, []);\n\n const run = useCallback((animations: Animation[]) => {\n runningRef.current = [...runningRef.current, ...animations];\n }, []);\n\n const stopAll = useCallback(() => {\n for (const animation of runningRef.current) animation.stop();\n runningRef.current = [];\n }, []);\n\n const isTransitioning = useCallback(() => transitioningRef.current, []);\n const isClosing = useCallback(() => closingRef.current, []);\n\n // stopAll() only stops tracked Animation objects; a bare window.setTimeout\n // has no way to be cancelled by it. Track a fallback timer the same way, so\n // a later stopAll() (a new open/close/switch superseding an interrupted one)\n // clears the old timer along with the animations it was backing — otherwise\n // it can fire mid-way through the new phase and flip transitioningRef false\n // while something else is legitimately still in flight.\n const trackTimeout = useCallback(\n (callback: () => void, ms: number) => {\n const id = window.setTimeout(callback, ms);\n run([{ stop: () => window.clearTimeout(id) }]);\n },\n [run],\n );\n\n // A future animate() call on one of these shared motion values (e.g. a refit\n // triggered by a natural-size change) silently cancels whichever animation is\n // already running on it — motion's animate() stops the prior one without ever\n // firing its onComplete. Track completion by count for the common case, but\n // always back it with a fallback timer so onSettled fires exactly once no\n // matter what: a cancelled axis can never leave this permanently unfired.\n const animateSettling = useCallback(\n (axes: [MotionValue<number>, number][], onSettled: () => void) => {\n let pending = axes.length;\n let settled = false;\n const finish = () => {\n if (settled) return;\n settled = true;\n onSettled();\n };\n const onAxisComplete = () => {\n pending -= 1;\n if (pending === 0) finish();\n };\n run(\n axes.map(([value, target]) =>\n animate(value, target, { ...OPEN_SPRING, onComplete: onAxisComplete }),\n ),\n );\n trackTimeout(finish, SETTLE_FALLBACK_MS);\n },\n [run, trackTimeout],\n );\n\n useLayoutEffect(() => {\n const unsubscribes = [\n ...[\n transform.flipX,\n transform.flipY,\n transform.rotate,\n transform.scale,\n transform.x,\n transform.y,\n opacity.image,\n ].map((value) => value.on('change', image.refresh)),\n opacity.backdrop.on('change', backdrop.refresh),\n opacity.chrome.on('change', chrome.refresh),\n ];\n\n return () => {\n for (const unsubscribe of unsubscribes) unsubscribe();\n };\n }, [backdrop, chrome, image, opacity, transform]);\n\n useLayoutEffect(() => {\n const rect = animated ? measureSource(source) : null;\n transitioningRef.current = true;\n const markSettled = () => {\n transitioningRef.current = false;\n };\n\n if (rect) {\n const start = sourceTransform(rect, getFitRectRef.current());\n transform.scale.set(start.scale);\n transform.x.set(start.x);\n transform.y.set(start.y);\n opacity.image.set(1);\n animateSettling(\n [\n [transform.scale, 1],\n [transform.x, 0],\n [transform.y, 0],\n ],\n markSettled,\n );\n } else {\n opacity.image.set(0);\n if (animated) transform.scale.set(FADE_SCALE);\n run([animate(opacity.image, 1, { ...FADE, onComplete: markSettled })]);\n if (animated) run([animate(transform.scale, 1, FADE)]);\n trackTimeout(markSettled, SETTLE_FALLBACK_MS);\n }\n\n run([animate(opacity.backdrop, 1, FADE), animate(opacity.chrome, 1, CHROME_FADE)]);\n\n return () => {\n stopAll();\n removeGhost();\n };\n }, [\n animateSettling,\n animated,\n opacity,\n removeGhost,\n run,\n source,\n stopAll,\n trackTimeout,\n transform,\n ]);\n\n const close = useCallback(\n (options?: CloseTransitionOptions) => {\n if (closingRef.current) return;\n closingRef.current = true;\n transitioningRef.current = true;\n stopAll();\n removeGhost();\n\n let finished = false;\n const finish = () => {\n if (finished) return;\n finished = true;\n transitioningRef.current = false;\n onClosedRef.current();\n };\n const rect = animated && !options?.fade ? measureSource(getCloseSourceRef.current()) : null;\n\n run([animate(opacity.backdrop, 0, FADE), animate(opacity.chrome, 0, FADE)]);\n\n if (rect) {\n const target = sourceTransform(rect, getFitRectRef.current());\n animateSettling(\n [\n [transform.x, target.x],\n [transform.y, target.y],\n [transform.scale, target.scale],\n ],\n finish,\n );\n return;\n }\n\n if (animated) run([animate(transform.scale, transform.scale.get() * FADE_SCALE, FADE)]);\n run([animate(opacity.image, 0, { ...FADE, onComplete: finish })]);\n trackTimeout(finish, SETTLE_FALLBACK_MS);\n },\n [animateSettling, animated, opacity, run, stopAll, trackTimeout, transform],\n );\n\n const switchTo = useCallback(\n (apply: () => void, direction?: 1 | -1) => {\n if (closingRef.current) return;\n stopAll();\n removeGhost();\n transitioningRef.current = true;\n\n const markSettled = () => {\n transitioningRef.current = false;\n };\n\n const node = image.getNode();\n const width = getViewportWidthRef.current();\n\n if (animated && direction && node && width > 0) {\n // The clone snapshots the outgoing visual state wholesale — src,\n // fitRect positioning and the current zoom/rotate/flip transform —\n // so the live <img> stays the single element every other system\n // (gestures, FLIP close, refit) keeps operating on.\n const ghost = node.cloneNode() as HTMLImageElement;\n ghost.dataset.ghost = 'true';\n ghost.setAttribute('aria-hidden', 'true');\n ghost.style.pointerEvents = 'none';\n const base = ghost.style.transform;\n node.parentElement?.insertBefore(ghost, node);\n ghostRef.current = ghost;\n\n const progress = motionValue(0);\n const unsubscribe = progress.on('change', (p) => {\n ghost.style.transform = `translateX(${-direction * width * p}px) ${base}`;\n });\n const finishGhost = () => {\n unsubscribe();\n if (ghostRef.current === ghost) removeGhost();\n else ghost.remove();\n };\n\n apply();\n transform.x.jump(direction * width);\n\n run([\n animate(progress, 1, { ...PUSH_SPRING, onComplete: finishGhost }),\n animate(transform.x, 0, { ...PUSH_SPRING, onComplete: markSettled }),\n { stop: finishGhost },\n ]);\n trackTimeout(() => {\n finishGhost();\n markSettled();\n }, SETTLE_FALLBACK_MS);\n return;\n }\n\n const tasks: Animation[] = [];\n if (animated) tasks.push(animate(transform.scale, SWITCH_FADE_SCALE, FADE));\n tasks.push(\n animate(opacity.image, 0, {\n ...FADE,\n onComplete: () => {\n apply();\n run([animate(opacity.image, 1, { ...FADE, onComplete: markSettled })]);\n },\n }),\n );\n run(tasks);\n trackTimeout(markSettled, SETTLE_FALLBACK_MS);\n },\n [animated, image, opacity, removeGhost, run, stopAll, trackTimeout, transform],\n );\n\n return {\n backdropRef: backdrop.setNode,\n chromeRef: chrome.setNode,\n close,\n imageRef: image.setNode,\n isClosing,\n isTransitioning,\n switchTo,\n };\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"useFlipTransition.mjs","names":[],"sources":["../../../src/Image/viewer/useFlipTransition.ts"],"sourcesContent":["import type { MotionValue } from 'motion/react';\nimport { animate, motionValue } from 'motion/react';\nimport { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';\n\nimport type { Rect } from './geometry';\n\nexport const OPEN_SPRING = { bounce: 0.15, type: 'spring' as const, visualDuration: 0.3 };\n// bounce: 0 — a full-viewport push with overshoot would visibly slide past\n// the resting position and snap back.\nconst PUSH_SPRING = { bounce: 0, type: 'spring' as const, visualDuration: 0.3 };\nconst FADE = { duration: 0.15, ease: 'easeOut' as const };\nconst CHROME_FADE = { delay: 0.15, duration: 0.2, ease: 'easeOut' as const };\nconst FADE_SCALE = 0.92;\nconst SWITCH_FADE_SCALE = 0.96;\nconst SETTLE_FALLBACK_MS = 1000;\n\ninterface Animation {\n stop: () => void;\n}\n\nexport interface FlipTransformValues {\n flipX: MotionValue<boolean>;\n flipY: MotionValue<boolean>;\n rotate: MotionValue<number>;\n scale: MotionValue<number>;\n x: MotionValue<number>;\n y: MotionValue<number>;\n}\n\nexport interface UseFlipTransitionOptions {\n animated: boolean;\n getCloseSource: () => HTMLImageElement;\n getFitRect: () => Rect;\n getInitialScale: () => number;\n getViewportWidth: () => number;\n onClosed: () => void;\n source: HTMLImageElement;\n transform: FlipTransformValues;\n}\n\nexport interface CloseTransitionOptions {\n fade?: boolean;\n}\n\nexport interface UseFlipTransitionResult {\n backdropRef: (node: HTMLElement | null) => void;\n chromeRef: (node: HTMLElement | null) => void;\n close: (options?: CloseTransitionOptions) => void;\n imageRef: (node: HTMLImageElement | null) => void;\n isClosing: () => boolean;\n isTransitioning: () => boolean;\n switchTo: (apply: () => void, direction?: 1 | -1) => void;\n}\n\nconst measureSource = (element: HTMLImageElement): DOMRect | null => {\n if (!element.isConnected) return null;\n const rect = element.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return null;\n if (rect.bottom <= 0 || rect.right <= 0) return null;\n if (rect.top >= window.innerHeight || rect.left >= window.innerWidth) return null;\n return rect;\n};\n\nconst sourceTransform = (rect: DOMRect, fit: Rect) => ({\n scale:\n fit.width > 0 && fit.height > 0\n ? Math.max(rect.width / fit.width, rect.height / fit.height)\n : 1,\n x: rect.left + rect.width / 2 - (fit.x + fit.width / 2),\n y: rect.top + rect.height / 2 - (fit.y + fit.height / 2),\n});\n\nconst readTransform = (transform: FlipTransformValues) =>\n [\n `translate3d(${transform.x.get()}px, ${transform.y.get()}px, 0)`,\n `scale(${transform.scale.get()})`,\n `rotate(${transform.rotate.get()}deg)`,\n `scaleX(${transform.flipX.get() ? -1 : 1})`,\n `scaleY(${transform.flipY.get() ? -1 : 1})`,\n ].join(' ');\n\nconst useBoundNode = <T extends HTMLElement>(apply: (node: T) => void) => {\n const nodeRef = useRef<T | null>(null);\n const applyRef = useRef(apply);\n applyRef.current = apply;\n\n const setNode = useCallback((node: T | null) => {\n nodeRef.current = node;\n if (node) applyRef.current(node);\n }, []);\n\n const refresh = useCallback(() => {\n const node = nodeRef.current;\n if (node) applyRef.current(node);\n }, []);\n\n const getNode = useCallback(() => nodeRef.current, []);\n\n return useMemo(() => ({ getNode, refresh, setNode }), [getNode, refresh, setNode]);\n};\n\nexport const useFlipTransition = ({\n animated,\n getCloseSource,\n getFitRect,\n getInitialScale,\n getViewportWidth,\n onClosed,\n source,\n transform,\n}: UseFlipTransitionOptions): UseFlipTransitionResult => {\n const [opacity] = useState(() => ({\n backdrop: motionValue(0),\n chrome: motionValue(0),\n image: motionValue(animated ? 1 : 0),\n }));\n\n const image = useBoundNode<HTMLImageElement>(\n useCallback(\n (node) => {\n node.style.transform = readTransform(transform);\n node.style.opacity = String(opacity.image.get());\n },\n [opacity, transform],\n ),\n );\n\n const backdrop = useBoundNode<HTMLElement>(\n useCallback(\n (node) => {\n node.style.opacity = String(opacity.backdrop.get());\n },\n [opacity],\n ),\n );\n\n const chrome = useBoundNode<HTMLElement>(\n useCallback(\n (node) => {\n node.style.opacity = String(opacity.chrome.get());\n },\n [opacity],\n ),\n );\n\n const runningRef = useRef<Animation[]>([]);\n const closingRef = useRef(false);\n const transitioningRef = useRef(true);\n\n const onClosedRef = useRef(onClosed);\n onClosedRef.current = onClosed;\n\n const getFitRectRef = useRef(getFitRect);\n getFitRectRef.current = getFitRect;\n\n const getCloseSourceRef = useRef(getCloseSource);\n getCloseSourceRef.current = getCloseSource;\n\n const getInitialScaleRef = useRef(getInitialScale);\n getInitialScaleRef.current = getInitialScale;\n\n const getViewportWidthRef = useRef(getViewportWidth);\n getViewportWidthRef.current = getViewportWidth;\n\n const ghostRef = useRef<HTMLImageElement | null>(null);\n const removeGhost = useCallback(() => {\n ghostRef.current?.remove();\n ghostRef.current = null;\n }, []);\n\n const run = useCallback((animations: Animation[]) => {\n runningRef.current = [...runningRef.current, ...animations];\n }, []);\n\n const stopAll = useCallback(() => {\n for (const animation of runningRef.current) animation.stop();\n runningRef.current = [];\n }, []);\n\n const isTransitioning = useCallback(() => transitioningRef.current, []);\n const isClosing = useCallback(() => closingRef.current, []);\n\n // stopAll() only stops tracked Animation objects; a bare window.setTimeout\n // has no way to be cancelled by it. Track a fallback timer the same way, so\n // a later stopAll() (a new open/close/switch superseding an interrupted one)\n // clears the old timer along with the animations it was backing — otherwise\n // it can fire mid-way through the new phase and flip transitioningRef false\n // while something else is legitimately still in flight.\n const trackTimeout = useCallback(\n (callback: () => void, ms: number) => {\n const id = window.setTimeout(callback, ms);\n run([{ stop: () => window.clearTimeout(id) }]);\n },\n [run],\n );\n\n // A future animate() call on one of these shared motion values (e.g. a refit\n // triggered by a natural-size change) silently cancels whichever animation is\n // already running on it — motion's animate() stops the prior one without ever\n // firing its onComplete. Track completion by count for the common case, but\n // always back it with a fallback timer so onSettled fires exactly once no\n // matter what: a cancelled axis can never leave this permanently unfired.\n const animateSettling = useCallback(\n (axes: [MotionValue<number>, number][], onSettled: () => void) => {\n let pending = axes.length;\n let settled = false;\n const finish = () => {\n if (settled) return;\n settled = true;\n onSettled();\n };\n const onAxisComplete = () => {\n pending -= 1;\n if (pending === 0) finish();\n };\n run(\n axes.map(([value, target]) =>\n animate(value, target, { ...OPEN_SPRING, onComplete: onAxisComplete }),\n ),\n );\n trackTimeout(finish, SETTLE_FALLBACK_MS);\n },\n [run, trackTimeout],\n );\n\n useLayoutEffect(() => {\n const unsubscribes = [\n ...[\n transform.flipX,\n transform.flipY,\n transform.rotate,\n transform.scale,\n transform.x,\n transform.y,\n opacity.image,\n ].map((value) => value.on('change', image.refresh)),\n opacity.backdrop.on('change', backdrop.refresh),\n opacity.chrome.on('change', chrome.refresh),\n ];\n\n return () => {\n for (const unsubscribe of unsubscribes) unsubscribe();\n };\n }, [backdrop, chrome, image, opacity, transform]);\n\n useLayoutEffect(() => {\n const rect = animated ? measureSource(source) : null;\n transitioningRef.current = true;\n const markSettled = () => {\n transitioningRef.current = false;\n };\n\n // Resting scale is whatever defaultZoom resolved to, not necessarily fit.\n const resting = getInitialScaleRef.current();\n\n if (rect) {\n const start = sourceTransform(rect, getFitRectRef.current());\n transform.scale.set(start.scale);\n transform.x.set(start.x);\n transform.y.set(start.y);\n opacity.image.set(1);\n animateSettling(\n [\n [transform.scale, resting],\n [transform.x, 0],\n [transform.y, 0],\n ],\n markSettled,\n );\n } else {\n opacity.image.set(0);\n if (animated) transform.scale.set(resting * FADE_SCALE);\n run([animate(opacity.image, 1, { ...FADE, onComplete: markSettled })]);\n if (animated) run([animate(transform.scale, resting, FADE)]);\n trackTimeout(markSettled, SETTLE_FALLBACK_MS);\n }\n\n run([animate(opacity.backdrop, 1, FADE), animate(opacity.chrome, 1, CHROME_FADE)]);\n\n return () => {\n stopAll();\n removeGhost();\n };\n }, [\n animateSettling,\n animated,\n opacity,\n removeGhost,\n run,\n source,\n stopAll,\n trackTimeout,\n transform,\n ]);\n\n const close = useCallback(\n (options?: CloseTransitionOptions) => {\n if (closingRef.current) return;\n closingRef.current = true;\n transitioningRef.current = true;\n stopAll();\n removeGhost();\n\n let finished = false;\n const finish = () => {\n if (finished) return;\n finished = true;\n transitioningRef.current = false;\n onClosedRef.current();\n };\n const rect = animated && !options?.fade ? measureSource(getCloseSourceRef.current()) : null;\n\n run([animate(opacity.backdrop, 0, FADE), animate(opacity.chrome, 0, FADE)]);\n\n if (rect) {\n const target = sourceTransform(rect, getFitRectRef.current());\n animateSettling(\n [\n [transform.x, target.x],\n [transform.y, target.y],\n [transform.scale, target.scale],\n ],\n finish,\n );\n return;\n }\n\n if (animated) run([animate(transform.scale, transform.scale.get() * FADE_SCALE, FADE)]);\n run([animate(opacity.image, 0, { ...FADE, onComplete: finish })]);\n trackTimeout(finish, SETTLE_FALLBACK_MS);\n },\n [animateSettling, animated, opacity, run, stopAll, trackTimeout, transform],\n );\n\n const switchTo = useCallback(\n (apply: () => void, direction?: 1 | -1) => {\n if (closingRef.current) return;\n stopAll();\n removeGhost();\n transitioningRef.current = true;\n\n const markSettled = () => {\n transitioningRef.current = false;\n };\n\n const node = image.getNode();\n const width = getViewportWidthRef.current();\n\n if (animated && direction && node && width > 0) {\n // The clone snapshots the outgoing visual state wholesale — src,\n // fitRect positioning and the current zoom/rotate/flip transform —\n // so the live <img> stays the single element every other system\n // (gestures, FLIP close, refit) keeps operating on.\n const ghost = node.cloneNode() as HTMLImageElement;\n ghost.dataset.ghost = 'true';\n ghost.setAttribute('aria-hidden', 'true');\n ghost.style.pointerEvents = 'none';\n const base = ghost.style.transform;\n node.parentElement?.insertBefore(ghost, node);\n ghostRef.current = ghost;\n\n const progress = motionValue(0);\n const unsubscribe = progress.on('change', (p) => {\n ghost.style.transform = `translateX(${-direction * width * p}px) ${base}`;\n });\n const finishGhost = () => {\n unsubscribe();\n if (ghostRef.current === ghost) removeGhost();\n else ghost.remove();\n };\n\n apply();\n transform.x.jump(direction * width);\n\n run([\n animate(progress, 1, { ...PUSH_SPRING, onComplete: finishGhost }),\n animate(transform.x, 0, { ...PUSH_SPRING, onComplete: markSettled }),\n { stop: finishGhost },\n ]);\n trackTimeout(() => {\n finishGhost();\n markSettled();\n }, SETTLE_FALLBACK_MS);\n return;\n }\n\n const tasks: Animation[] = [];\n if (animated) tasks.push(animate(transform.scale, SWITCH_FADE_SCALE, FADE));\n tasks.push(\n animate(opacity.image, 0, {\n ...FADE,\n onComplete: () => {\n apply();\n run([animate(opacity.image, 1, { ...FADE, onComplete: markSettled })]);\n },\n }),\n );\n run(tasks);\n trackTimeout(markSettled, SETTLE_FALLBACK_MS);\n },\n [animated, image, opacity, removeGhost, run, stopAll, trackTimeout, transform],\n );\n\n return {\n backdropRef: backdrop.setNode,\n chromeRef: chrome.setNode,\n close,\n imageRef: image.setNode,\n isClosing,\n isTransitioning,\n switchTo,\n };\n};\n"],"mappings":"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"}
|
|
@@ -12,7 +12,7 @@ const readNatural = (element) => {
|
|
|
12
12
|
width: Math.max(rect.width, 1)
|
|
13
13
|
};
|
|
14
14
|
};
|
|
15
|
-
const useGalleryNav = ({ cancelPendingClose, currentIndex, entries, flipX, flipY, resetCloseTracking, rotate, scale, setCurrentIndex, setNatural, setSource, switchTo, syncZoomNatural, x, y }) => {
|
|
15
|
+
const useGalleryNav = ({ cancelPendingClose, currentIndex, entries, flipX, flipY, getInitialScale, resetCloseTracking, rotate, scale, setCurrentIndex, setNatural, setSource, switchTo, syncZoomNatural, x, y }) => {
|
|
16
16
|
const indexRef = useRef(currentIndex);
|
|
17
17
|
indexRef.current = currentIndex;
|
|
18
18
|
const switchingRef = useRef(false);
|
|
@@ -32,21 +32,22 @@ const useGalleryNav = ({ cancelPendingClose, currentIndex, entries, flipX, flipY
|
|
|
32
32
|
const nextEntry = entries[nextIndex];
|
|
33
33
|
setCurrentIndex(nextIndex);
|
|
34
34
|
setSource(nextEntry.src);
|
|
35
|
+
rotate.jump(0);
|
|
36
|
+
flipX.jump(false);
|
|
37
|
+
flipY.jump(false);
|
|
35
38
|
const nextNatural = readNatural(nextEntry.element);
|
|
36
39
|
setNatural(nextNatural);
|
|
37
40
|
syncZoomNatural(nextNatural);
|
|
38
|
-
scale.jump(
|
|
41
|
+
scale.jump(getInitialScale());
|
|
39
42
|
x.jump(0);
|
|
40
43
|
y.jump(0);
|
|
41
|
-
rotate.jump(0);
|
|
42
|
-
flipX.jump(false);
|
|
43
|
-
flipY.jump(false);
|
|
44
44
|
switchingRef.current = false;
|
|
45
45
|
}, nextIndex > indexRef.current ? 1 : -1);
|
|
46
46
|
}, [
|
|
47
47
|
entries,
|
|
48
48
|
flipX,
|
|
49
49
|
flipY,
|
|
50
|
+
getInitialScale,
|
|
50
51
|
rotate,
|
|
51
52
|
scale,
|
|
52
53
|
setCurrentIndex,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useGalleryNav.mjs","names":[],"sources":["../../../src/Image/viewer/useGalleryNav.ts"],"sourcesContent":["import type { MotionValue } from 'motion/react';\nimport { useCallback, useEffect, useRef } from 'react';\n\nimport type { Size } from './geometry';\nimport type { PreviewEntry } from './registry';\nimport { isTypingTarget } from './useViewerGestures';\n\nexport const readNatural = (element: HTMLImageElement): Size => {\n if (element.naturalWidth > 0 && element.naturalHeight > 0)\n return { height: element.naturalHeight, width: element.naturalWidth };\n const rect = element.getBoundingClientRect();\n return { height: Math.max(rect.height, 1), width: Math.max(rect.width, 1) };\n};\n\nexport interface UseGalleryNavOptions {\n cancelPendingClose: () => void;\n currentIndex: number;\n entries: PreviewEntry[];\n flipX: MotionValue<boolean>;\n flipY: MotionValue<boolean>;\n resetCloseTracking: () => void;\n rotate: MotionValue<number>;\n scale: MotionValue<number>;\n setCurrentIndex: (index: number) => void;\n setNatural: (natural: Size) => void;\n setSource: (src: string) => void;\n switchTo: (apply: () => void, direction?: 1 | -1) => void;\n syncZoomNatural: (natural: Size) => void;\n x: MotionValue<number>;\n y: MotionValue<number>;\n}\n\nexport interface UseGalleryNavResult {\n hasNext: boolean;\n hasPrev: boolean;\n next: () => void;\n prev: () => void;\n}\n\nexport const useGalleryNav = ({\n 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}: UseGalleryNavOptions): UseGalleryNavResult => {\n const indexRef = useRef(currentIndex);\n indexRef.current = currentIndex;\n\n const switchingRef = useRef(false);\n\n const switchToRef = useRef(switchTo);\n switchToRef.current = switchTo;\n\n const cancelPendingCloseRef = useRef(cancelPendingClose);\n cancelPendingCloseRef.current = cancelPendingClose;\n\n const resetCloseTrackingRef = useRef(resetCloseTracking);\n resetCloseTrackingRef.current = resetCloseTracking;\n\n const goTo = useCallback(\n (nextIndex: number) => {\n if (switchingRef.current) return;\n if (nextIndex < 0 || nextIndex >= entries.length || nextIndex === indexRef.current) return;\n switchingRef.current = true;\n // A switch changes the displayed image out from under any pending\n // click-to-close or wheel-close accumulation armed for the old image,\n // so both must clear at this single choke point every switch goes through.\n cancelPendingCloseRef.current();\n resetCloseTrackingRef.current();\n switchToRef.current(\n () => {\n const nextEntry = entries[nextIndex];\n setCurrentIndex(nextIndex);\n setSource(nextEntry.src);\n const nextNatural = readNatural(nextEntry.element);\n setNatural(nextNatural);\n syncZoomNatural(nextNatural);\n // .jump(), not .set(): a surviving spring from the outgoing image (a\n // dragEnd clamp-back, a wheel snap-back, or reset()) targets the OLD\n // image's bounds and would otherwise silently overwrite this reset on\n // its next tick, since motion's set() never stops an active animation.\n scale.jump(
|
|
1
|
+
{"version":3,"file":"useGalleryNav.mjs","names":[],"sources":["../../../src/Image/viewer/useGalleryNav.ts"],"sourcesContent":["import type { MotionValue } from 'motion/react';\nimport { useCallback, useEffect, useRef } from 'react';\n\nimport type { Size } from './geometry';\nimport type { PreviewEntry } from './registry';\nimport { isTypingTarget } from './useViewerGestures';\n\nexport const readNatural = (element: HTMLImageElement): Size => {\n if (element.naturalWidth > 0 && element.naturalHeight > 0)\n return { height: element.naturalHeight, width: element.naturalWidth };\n const rect = element.getBoundingClientRect();\n return { height: Math.max(rect.height, 1), width: Math.max(rect.width, 1) };\n};\n\nexport interface UseGalleryNavOptions {\n cancelPendingClose: () => void;\n currentIndex: number;\n entries: PreviewEntry[];\n flipX: MotionValue<boolean>;\n flipY: MotionValue<boolean>;\n getInitialScale: () => number;\n resetCloseTracking: () => void;\n rotate: MotionValue<number>;\n scale: MotionValue<number>;\n setCurrentIndex: (index: number) => void;\n setNatural: (natural: Size) => void;\n setSource: (src: string) => void;\n switchTo: (apply: () => void, direction?: 1 | -1) => void;\n syncZoomNatural: (natural: Size) => void;\n x: MotionValue<number>;\n y: MotionValue<number>;\n}\n\nexport interface UseGalleryNavResult {\n hasNext: boolean;\n hasPrev: boolean;\n next: () => void;\n prev: () => void;\n}\n\nexport const useGalleryNav = ({\n cancelPendingClose,\n currentIndex,\n entries,\n flipX,\n flipY,\n getInitialScale,\n resetCloseTracking,\n rotate,\n scale,\n setCurrentIndex,\n setNatural,\n setSource,\n switchTo,\n syncZoomNatural,\n x,\n y,\n}: UseGalleryNavOptions): UseGalleryNavResult => {\n const indexRef = useRef(currentIndex);\n indexRef.current = currentIndex;\n\n const switchingRef = useRef(false);\n\n const switchToRef = useRef(switchTo);\n switchToRef.current = switchTo;\n\n const cancelPendingCloseRef = useRef(cancelPendingClose);\n cancelPendingCloseRef.current = cancelPendingClose;\n\n const resetCloseTrackingRef = useRef(resetCloseTracking);\n resetCloseTrackingRef.current = resetCloseTracking;\n\n const goTo = useCallback(\n (nextIndex: number) => {\n if (switchingRef.current) return;\n if (nextIndex < 0 || nextIndex >= entries.length || nextIndex === indexRef.current) return;\n switchingRef.current = true;\n // A switch changes the displayed image out from under any pending\n // click-to-close or wheel-close accumulation armed for the old image,\n // so both must clear at this single choke point every switch goes through.\n cancelPendingCloseRef.current();\n resetCloseTrackingRef.current();\n switchToRef.current(\n () => {\n const nextEntry = entries[nextIndex];\n setCurrentIndex(nextIndex);\n setSource(nextEntry.src);\n // Orientation first: syncZoomNatural derives the incoming image's\n // resting scale from the fit rect, which is rotation-dependent.\n rotate.jump(0);\n flipX.jump(false);\n flipY.jump(false);\n const nextNatural = readNatural(nextEntry.element);\n setNatural(nextNatural);\n syncZoomNatural(nextNatural);\n // .jump(), not .set(): a surviving spring from the outgoing image (a\n // dragEnd clamp-back, a wheel snap-back, or reset()) targets the OLD\n // image's bounds and would otherwise silently overwrite this reset on\n // its next tick, since motion's set() never stops an active animation.\n scale.jump(getInitialScale());\n x.jump(0);\n y.jump(0);\n switchingRef.current = false;\n },\n nextIndex > indexRef.current ? 1 : -1,\n );\n },\n [\n entries,\n flipX,\n flipY,\n getInitialScale,\n rotate,\n scale,\n setCurrentIndex,\n setNatural,\n setSource,\n syncZoomNatural,\n x,\n y,\n ],\n );\n\n const prev = useCallback(() => goTo(indexRef.current - 1), [goTo]);\n const next = useCallback(() => goTo(indexRef.current + 1), [goTo]);\n\n const isGallery = entries.length > 1;\n\n useEffect(() => {\n if (!isGallery) return;\n const listener = (event: KeyboardEvent) => {\n if (event.altKey || event.ctrlKey || event.metaKey || event.defaultPrevented) return;\n if (isTypingTarget(event.target)) return;\n if (event.key === 'ArrowLeft') prev();\n else if (event.key === 'ArrowRight') next();\n else return;\n event.preventDefault();\n };\n document.addEventListener('keydown', listener);\n return () => document.removeEventListener('keydown', listener);\n }, [isGallery, next, prev]);\n\n return {\n hasNext: currentIndex < entries.length - 1,\n hasPrev: currentIndex > 0,\n next,\n prev,\n };\n};\n"],"mappings":";;;AAOA,MAAa,eAAe,YAAoC;CAC9D,IAAI,QAAQ,eAAe,KAAK,QAAQ,gBAAgB,GACtD,OAAO;EAAE,QAAQ,QAAQ;EAAe,OAAO,QAAQ;CAAa;CACtE,MAAM,OAAO,QAAQ,sBAAsB;CAC3C,OAAO;EAAE,QAAQ,KAAK,IAAI,KAAK,QAAQ,CAAC;EAAG,OAAO,KAAK,IAAI,KAAK,OAAO,CAAC;CAAE;AAC5E;AA4BA,MAAa,iBAAiB,EAC5B,oBACA,cACA,SACA,OACA,OACA,iBACA,oBACA,QACA,OACA,iBACA,YACA,WACA,UACA,iBACA,GACA,QAC+C;CAC/C,MAAM,WAAW,OAAO,YAAY;CACpC,SAAS,UAAU;CAEnB,MAAM,eAAe,OAAO,KAAK;CAEjC,MAAM,cAAc,OAAO,QAAQ;CACnC,YAAY,UAAU;CAEtB,MAAM,wBAAwB,OAAO,kBAAkB;CACvD,sBAAsB,UAAU;CAEhC,MAAM,wBAAwB,OAAO,kBAAkB;CACvD,sBAAsB,UAAU;CAEhC,MAAM,OAAO,aACV,cAAsB;EACrB,IAAI,aAAa,SAAS;EAC1B,IAAI,YAAY,KAAK,aAAa,QAAQ,UAAU,cAAc,SAAS,SAAS;EACpF,aAAa,UAAU;EAIvB,sBAAsB,QAAQ;EAC9B,sBAAsB,QAAQ;EAC9B,YAAY,cACJ;GACJ,MAAM,YAAY,QAAQ;GAC1B,gBAAgB,SAAS;GACzB,UAAU,UAAU,GAAG;GAGvB,OAAO,KAAK,CAAC;GACb,MAAM,KAAK,KAAK;GAChB,MAAM,KAAK,KAAK;GAChB,MAAM,cAAc,YAAY,UAAU,OAAO;GACjD,WAAW,WAAW;GACtB,gBAAgB,WAAW;GAK3B,MAAM,KAAK,gBAAgB,CAAC;GAC5B,EAAE,KAAK,CAAC;GACR,EAAE,KAAK,CAAC;GACR,aAAa,UAAU;EACzB,GACA,YAAY,SAAS,UAAU,IAAI,EACrC;CACF,GACA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACF,CACF;CAEA,MAAM,OAAO,kBAAkB,KAAK,SAAS,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC;CACjE,MAAM,OAAO,kBAAkB,KAAK,SAAS,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC;CAEjE,MAAM,YAAY,QAAQ,SAAS;CAEnC,gBAAgB;EACd,IAAI,CAAC,WAAW;EAChB,MAAM,YAAY,UAAyB;GACzC,IAAI,MAAM,UAAU,MAAM,WAAW,MAAM,WAAW,MAAM,kBAAkB;GAC9E,IAAI,eAAe,MAAM,MAAM,GAAG;GAClC,IAAI,MAAM,QAAQ,aAAa,KAAK;QAC/B,IAAI,MAAM,QAAQ,cAAc,KAAK;QACrC;GACL,MAAM,eAAe;EACvB;EACA,SAAS,iBAAiB,WAAW,QAAQ;EAC7C,aAAa,SAAS,oBAAoB,WAAW,QAAQ;CAC/D,GAAG;EAAC;EAAW;EAAM;CAAI,CAAC;CAE1B,OAAO;EACL,SAAS,eAAe,QAAQ,SAAS;EACzC,SAAS,eAAe;EACxB;EACA;CACF;AACF"}
|
|
@@ -110,17 +110,13 @@ const useViewerGestures = ({ dragBy, dragEnd, handleDoubleClick, handleWheel, is
|
|
|
110
110
|
const onImageClick = useCallback((event) => {
|
|
111
111
|
event.stopPropagation();
|
|
112
112
|
cancelPendingClose();
|
|
113
|
-
if (movedRef.current ||
|
|
113
|
+
if (movedRef.current || !isCleanRef.current) return;
|
|
114
114
|
pendingCloseRef.current = setTimeout(() => {
|
|
115
115
|
pendingCloseRef.current = null;
|
|
116
116
|
if (!isCleanRef.current) return;
|
|
117
117
|
onClose();
|
|
118
118
|
}, DOUBLE_CLICK_WINDOW);
|
|
119
|
-
}, [
|
|
120
|
-
cancelPendingClose,
|
|
121
|
-
isZoomed,
|
|
122
|
-
onClose
|
|
123
|
-
]);
|
|
119
|
+
}, [cancelPendingClose, onClose]);
|
|
124
120
|
const onImageDoubleClick = useCallback((event) => {
|
|
125
121
|
event.stopPropagation();
|
|
126
122
|
cancelPendingClose();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useViewerGestures.mjs","names":[],"sources":["../../../src/Image/viewer/useViewerGestures.ts"],"sourcesContent":["import type { MouseEvent, PointerEvent } from 'react';\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\nimport type { Point } from './geometry';\nimport type { WheelLikeEvent } from './useZoomPan';\n\nconst DRAG_THRESHOLD = 4;\nconst DOUBLE_CLICK_WINDOW = 250;\n\nexport type ViewerCursor = 'grab' | 'grabbing' | 'zoom-out';\n\nexport interface UseViewerGesturesOptions {\n dragBy: (delta: Point) => void;\n dragEnd: () => void;\n handleDoubleClick: (point: Point) => void;\n handleWheel: (event: WheelLikeEvent) => void;\n isClean: boolean;\n isZoomed: boolean;\n onClose: () => void;\n reset: () => void;\n zoomIn: () => void;\n zoomOut: () => void;\n}\n\nexport interface UseViewerGesturesResult {\n cancelPendingClose: () => void;\n cursor: ViewerCursor;\n imageRef: (node: HTMLImageElement | null) => void;\n onImageClick: (event: MouseEvent<HTMLElement>) => void;\n onImageDoubleClick: (event: MouseEvent<HTMLElement>) => void;\n onPointerDown: (event: PointerEvent<HTMLElement>) => void;\n onPointerFinish: (event: PointerEvent<HTMLElement>) => void;\n onPointerMove: (event: PointerEvent<HTMLElement>) => void;\n onSurfaceClick: () => void;\n popupRef: (node: HTMLElement | null) => void;\n}\n\ninterface DragState {\n id: number;\n last: Point;\n panning: boolean;\n start: Point;\n}\n\nconst capturePointer = (node: Element, pointerId: number) => {\n if (typeof node.setPointerCapture === 'function') node.setPointerCapture(pointerId);\n};\n\nexport const isTypingTarget = (target: EventTarget | null) =>\n target instanceof HTMLElement &&\n (target.isContentEditable || ['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName));\n\nexport const useViewerGestures = ({\n dragBy,\n dragEnd,\n handleDoubleClick,\n handleWheel,\n isClean,\n isZoomed,\n onClose,\n reset,\n zoomIn,\n zoomOut,\n}: UseViewerGesturesOptions): UseViewerGesturesResult => {\n const [popup, setPopup] = useState<HTMLElement | null>(null);\n const [panning, setPanning] = useState(false);\n\n const imageNodeRef = useRef<HTMLImageElement | null>(null);\n const dragRef = useRef<DragState | null>(null);\n const movedRef = useRef(false);\n const pendingCloseRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const isCleanRef = useRef(isClean);\n isCleanRef.current = isClean;\n\n const cancelPendingClose = useCallback(() => {\n if (pendingCloseRef.current === null) return;\n clearTimeout(pendingCloseRef.current);\n pendingCloseRef.current = null;\n }, []);\n\n useEffect(() => cancelPendingClose, [cancelPendingClose]);\n\n const imageRef = useCallback((node: HTMLImageElement | null) => {\n imageNodeRef.current = node;\n }, []);\n\n useEffect(() => {\n if (!popup) return;\n const listener = (event: globalThis.WheelEvent) => handleWheel(event);\n popup.addEventListener('wheel', listener, { passive: false });\n return () => popup.removeEventListener('wheel', listener);\n }, [handleWheel, popup]);\n\n useEffect(() => {\n const listener = (event: KeyboardEvent) => {\n if (event.altKey || event.ctrlKey || event.metaKey || event.defaultPrevented) return;\n if (isTypingTarget(event.target)) return;\n switch (event.key) {\n case '+':\n case '=': {\n zoomIn();\n break;\n }\n case '-': {\n zoomOut();\n break;\n }\n case '0': {\n reset();\n break;\n }\n default: {\n return;\n }\n }\n event.preventDefault();\n };\n document.addEventListener('keydown', listener);\n return () => document.removeEventListener('keydown', listener);\n }, [reset, zoomIn, zoomOut]);\n\n const onPointerDown = useCallback(\n (event: PointerEvent<HTMLElement>) => {\n if (dragRef.current?.panning) return;\n if (event.button !== 0) return;\n cancelPendingClose();\n const start = { x: event.clientX, y: event.clientY };\n const panEligible = isZoomed && event.target === imageNodeRef.current;\n movedRef.current = false;\n dragRef.current = { id: event.pointerId, last: start, panning: panEligible, start };\n if (!panEligible) return;\n capturePointer(event.target as Element, event.pointerId);\n setPanning(true);\n },\n [cancelPendingClose, isZoomed],\n );\n\n const onPointerMove = useCallback(\n (event: PointerEvent<HTMLElement>) => {\n const drag = dragRef.current;\n if (!drag || drag.id !== event.pointerId) return;\n const point = { x: event.clientX, y: event.clientY };\n if (Math.hypot(point.x - drag.start.x, point.y - drag.start.y) > DRAG_THRESHOLD) {\n movedRef.current = true;\n }\n if (drag.panning) dragBy({ x: point.x - drag.last.x, y: point.y - drag.last.y });\n drag.last = point;\n },\n [dragBy],\n );\n\n const onPointerFinish = useCallback(\n (event: PointerEvent<HTMLElement>) => {\n const drag = dragRef.current;\n if (!drag || drag.id !== event.pointerId) return;\n dragRef.current = null;\n if (!drag.panning) return;\n setPanning(false);\n dragEnd();\n },\n [dragEnd],\n );\n\n const onSurfaceClick = useCallback(() => {\n if (movedRef.current) return;\n cancelPendingClose();\n onClose();\n }, [cancelPendingClose, onClose]);\n\n const onImageClick = useCallback(\n (event: MouseEvent<HTMLElement>) => {\n event.stopPropagation();\n cancelPendingClose();\n if (movedRef.current ||
|
|
1
|
+
{"version":3,"file":"useViewerGestures.mjs","names":[],"sources":["../../../src/Image/viewer/useViewerGestures.ts"],"sourcesContent":["import type { MouseEvent, PointerEvent } from 'react';\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\nimport type { Point } from './geometry';\nimport type { WheelLikeEvent } from './useZoomPan';\n\nconst DRAG_THRESHOLD = 4;\nconst DOUBLE_CLICK_WINDOW = 250;\n\nexport type ViewerCursor = 'grab' | 'grabbing' | 'zoom-out';\n\nexport interface UseViewerGesturesOptions {\n dragBy: (delta: Point) => void;\n dragEnd: () => void;\n handleDoubleClick: (point: Point) => void;\n handleWheel: (event: WheelLikeEvent) => void;\n isClean: boolean;\n isZoomed: boolean;\n onClose: () => void;\n reset: () => void;\n zoomIn: () => void;\n zoomOut: () => void;\n}\n\nexport interface UseViewerGesturesResult {\n cancelPendingClose: () => void;\n cursor: ViewerCursor;\n imageRef: (node: HTMLImageElement | null) => void;\n onImageClick: (event: MouseEvent<HTMLElement>) => void;\n onImageDoubleClick: (event: MouseEvent<HTMLElement>) => void;\n onPointerDown: (event: PointerEvent<HTMLElement>) => void;\n onPointerFinish: (event: PointerEvent<HTMLElement>) => void;\n onPointerMove: (event: PointerEvent<HTMLElement>) => void;\n onSurfaceClick: () => void;\n popupRef: (node: HTMLElement | null) => void;\n}\n\ninterface DragState {\n id: number;\n last: Point;\n panning: boolean;\n start: Point;\n}\n\nconst capturePointer = (node: Element, pointerId: number) => {\n if (typeof node.setPointerCapture === 'function') node.setPointerCapture(pointerId);\n};\n\nexport const isTypingTarget = (target: EventTarget | null) =>\n target instanceof HTMLElement &&\n (target.isContentEditable || ['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName));\n\nexport const useViewerGestures = ({\n dragBy,\n dragEnd,\n handleDoubleClick,\n handleWheel,\n isClean,\n isZoomed,\n onClose,\n reset,\n zoomIn,\n zoomOut,\n}: UseViewerGesturesOptions): UseViewerGesturesResult => {\n const [popup, setPopup] = useState<HTMLElement | null>(null);\n const [panning, setPanning] = useState(false);\n\n const imageNodeRef = useRef<HTMLImageElement | null>(null);\n const dragRef = useRef<DragState | null>(null);\n const movedRef = useRef(false);\n const pendingCloseRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const isCleanRef = useRef(isClean);\n isCleanRef.current = isClean;\n\n const cancelPendingClose = useCallback(() => {\n if (pendingCloseRef.current === null) return;\n clearTimeout(pendingCloseRef.current);\n pendingCloseRef.current = null;\n }, []);\n\n useEffect(() => cancelPendingClose, [cancelPendingClose]);\n\n const imageRef = useCallback((node: HTMLImageElement | null) => {\n imageNodeRef.current = node;\n }, []);\n\n useEffect(() => {\n if (!popup) return;\n const listener = (event: globalThis.WheelEvent) => handleWheel(event);\n popup.addEventListener('wheel', listener, { passive: false });\n return () => popup.removeEventListener('wheel', listener);\n }, [handleWheel, popup]);\n\n useEffect(() => {\n const listener = (event: KeyboardEvent) => {\n if (event.altKey || event.ctrlKey || event.metaKey || event.defaultPrevented) return;\n if (isTypingTarget(event.target)) return;\n switch (event.key) {\n case '+':\n case '=': {\n zoomIn();\n break;\n }\n case '-': {\n zoomOut();\n break;\n }\n case '0': {\n reset();\n break;\n }\n default: {\n return;\n }\n }\n event.preventDefault();\n };\n document.addEventListener('keydown', listener);\n return () => document.removeEventListener('keydown', listener);\n }, [reset, zoomIn, zoomOut]);\n\n const onPointerDown = useCallback(\n (event: PointerEvent<HTMLElement>) => {\n if (dragRef.current?.panning) return;\n if (event.button !== 0) return;\n cancelPendingClose();\n const start = { x: event.clientX, y: event.clientY };\n const panEligible = isZoomed && event.target === imageNodeRef.current;\n movedRef.current = false;\n dragRef.current = { id: event.pointerId, last: start, panning: panEligible, start };\n if (!panEligible) return;\n capturePointer(event.target as Element, event.pointerId);\n setPanning(true);\n },\n [cancelPendingClose, isZoomed],\n );\n\n const onPointerMove = useCallback(\n (event: PointerEvent<HTMLElement>) => {\n const drag = dragRef.current;\n if (!drag || drag.id !== event.pointerId) return;\n const point = { x: event.clientX, y: event.clientY };\n if (Math.hypot(point.x - drag.start.x, point.y - drag.start.y) > DRAG_THRESHOLD) {\n movedRef.current = true;\n }\n if (drag.panning) dragBy({ x: point.x - drag.last.x, y: point.y - drag.last.y });\n drag.last = point;\n },\n [dragBy],\n );\n\n const onPointerFinish = useCallback(\n (event: PointerEvent<HTMLElement>) => {\n const drag = dragRef.current;\n if (!drag || drag.id !== event.pointerId) return;\n dragRef.current = null;\n if (!drag.panning) return;\n setPanning(false);\n dragEnd();\n },\n [dragEnd],\n );\n\n const onSurfaceClick = useCallback(() => {\n if (movedRef.current) return;\n cancelPendingClose();\n onClose();\n }, [cancelPendingClose, onClose]);\n\n const onImageClick = useCallback(\n (event: MouseEvent<HTMLElement>) => {\n event.stopPropagation();\n cancelPendingClose();\n // Gated on cleanliness rather than zoom: an image that *opened* zoomed\n // (defaultZoom actual/auto) has been touched by nobody, so a plain click\n // should still dismiss it, exactly as Esc does.\n if (movedRef.current || !isCleanRef.current) return;\n pendingCloseRef.current = setTimeout(() => {\n pendingCloseRef.current = null;\n if (!isCleanRef.current) return;\n onClose();\n }, DOUBLE_CLICK_WINDOW);\n },\n [cancelPendingClose, onClose],\n );\n\n const onImageDoubleClick = useCallback(\n (event: MouseEvent<HTMLElement>) => {\n event.stopPropagation();\n cancelPendingClose();\n handleDoubleClick({ x: event.clientX, y: event.clientY });\n },\n [cancelPendingClose, handleDoubleClick],\n );\n\n return {\n cancelPendingClose,\n cursor: isZoomed ? (panning ? 'grabbing' : 'grab') : 'zoom-out',\n imageRef,\n onImageClick,\n onImageDoubleClick,\n onPointerDown,\n onPointerFinish,\n onPointerMove,\n onSurfaceClick,\n popupRef: setPopup,\n };\n};\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { anchoredZoom, clampPan, clampScale, computeFit, doubleClickTarget, normalizeRotation, panBounds, rubberBand, wheelZoomFactor } from "./geometry.mjs";
|
|
1
|
+
import { anchoredZoom, clampPan, clampScale, computeFit, doubleClickTarget, naturalScale, normalizeRotation, normalizeWheelDelta, panBounds, resolveInitialScale, rubberBand, wheelZoomFactor } from "./geometry.mjs";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { animate, motionValue } from "motion/react";
|
|
4
4
|
//#region src/Image/viewer/useZoomPan.ts
|
|
@@ -11,25 +11,39 @@ const RESET_TRANSITION = {
|
|
|
11
11
|
type: "spring"
|
|
12
12
|
};
|
|
13
13
|
const CLEAN_EPSILON = .01;
|
|
14
|
-
const useZoomPan = ({ natural, viewport, maxScale, onCloseRequest, isClosing, fillViewport }) => {
|
|
14
|
+
const useZoomPan = ({ natural, viewport, maxScale, onCloseRequest, isClosing, isTransitioning, fillViewport, defaultZoom, autoZoomThreshold }) => {
|
|
15
|
+
const naturalRef = useRef(natural);
|
|
16
|
+
const viewportRef = useRef(viewport);
|
|
17
|
+
const maxScaleRef = useRef(maxScale ?? 8);
|
|
18
|
+
maxScaleRef.current = maxScale ?? 8;
|
|
19
|
+
const fillViewportRef = useRef(fillViewport ?? false);
|
|
20
|
+
fillViewportRef.current = fillViewport ?? false;
|
|
21
|
+
const policyRef = useRef(defaultZoom ?? "auto");
|
|
22
|
+
policyRef.current = defaultZoom ?? "auto";
|
|
23
|
+
const thresholdRef = useRef(autoZoomThreshold ?? 2);
|
|
24
|
+
thresholdRef.current = autoZoomThreshold ?? 2;
|
|
25
|
+
const computeInitialScale = useCallback((rotation = 0) => {
|
|
26
|
+
const fitRect = computeFit(naturalRef.current, viewportRef.current, rotation, fillViewportRef.current);
|
|
27
|
+
return resolveInitialScale(policyRef.current, naturalRef.current, fitRect, rotation, thresholdRef.current);
|
|
28
|
+
}, []);
|
|
29
|
+
const [initialScaleSeed] = useState(computeInitialScale);
|
|
30
|
+
const initialScaleRef = useRef(initialScaleSeed);
|
|
31
|
+
const getInitialScale = useCallback(() => initialScaleRef.current, []);
|
|
15
32
|
const [{ scale, x, y, rotate, flipX, flipY }] = useState(() => ({
|
|
16
33
|
flipX: motionValue(false),
|
|
17
34
|
flipY: motionValue(false),
|
|
18
35
|
rotate: motionValue(0),
|
|
19
|
-
scale: motionValue(
|
|
36
|
+
scale: motionValue(initialScaleRef.current),
|
|
20
37
|
x: motionValue(0),
|
|
21
38
|
y: motionValue(0)
|
|
22
39
|
}));
|
|
23
|
-
const
|
|
24
|
-
const viewportRef = useRef(viewport);
|
|
25
|
-
const maxScaleRef = useRef(maxScale ?? 8);
|
|
26
|
-
maxScaleRef.current = maxScale ?? 8;
|
|
27
|
-
const fillViewportRef = useRef(fillViewport ?? false);
|
|
28
|
-
fillViewportRef.current = fillViewport ?? false;
|
|
40
|
+
const effectiveMaxScale = useCallback(() => Math.max(maxScaleRef.current, initialScaleRef.current), []);
|
|
29
41
|
const onCloseRequestRef = useRef(onCloseRequest);
|
|
30
42
|
onCloseRequestRef.current = onCloseRequest;
|
|
31
43
|
const isClosingRef = useRef(isClosing);
|
|
32
44
|
isClosingRef.current = isClosing;
|
|
45
|
+
const isTransitioningRef = useRef(isTransitioning);
|
|
46
|
+
isTransitioningRef.current = isTransitioning;
|
|
33
47
|
const closeAccumRef = useRef(0);
|
|
34
48
|
const disarmedRef = useRef(false);
|
|
35
49
|
const idleTimerRef = useRef(null);
|
|
@@ -42,22 +56,24 @@ const useZoomPan = ({ natural, viewport, maxScale, onCloseRequest, isClosing, fi
|
|
|
42
56
|
disarmedRef.current = false;
|
|
43
57
|
}, []);
|
|
44
58
|
const closing = useCallback(() => isClosingRef.current?.() ?? false, []);
|
|
45
|
-
const
|
|
59
|
+
const isAtFitFloor = useCallback(() => scale.get() <= 1.01, [scale]);
|
|
60
|
+
const isOrientationClean = useCallback(() => Math.abs(rotate.get()) < CLEAN_EPSILON && !flipX.get() && !flipY.get(), [
|
|
46
61
|
flipX,
|
|
47
62
|
flipY,
|
|
48
|
-
rotate
|
|
49
|
-
scale
|
|
63
|
+
rotate
|
|
50
64
|
]);
|
|
65
|
+
const isCleanState = useCallback(() => Math.abs(scale.get() - initialScaleRef.current) < CLEAN_EPSILON && isOrientationClean(), [isOrientationClean, scale]);
|
|
51
66
|
const computeDerived = useCallback(() => {
|
|
52
67
|
const currentScale = scale.get();
|
|
53
68
|
return {
|
|
54
|
-
canZoomIn: currentScale <
|
|
69
|
+
canZoomIn: currentScale < effectiveMaxScale(),
|
|
55
70
|
canZoomOut: currentScale > 1,
|
|
56
71
|
isClean: isCleanState(),
|
|
57
72
|
isZoomed: currentScale > 1,
|
|
58
73
|
rotation: normalizeRotation(rotate.get())
|
|
59
74
|
};
|
|
60
75
|
}, [
|
|
76
|
+
effectiveMaxScale,
|
|
61
77
|
isCleanState,
|
|
62
78
|
rotate,
|
|
63
79
|
scale
|
|
@@ -112,7 +128,7 @@ const useZoomPan = ({ natural, viewport, maxScale, onCloseRequest, isClosing, fi
|
|
|
112
128
|
const zoomBy = useCallback((factor) => {
|
|
113
129
|
if (closing()) return;
|
|
114
130
|
const currentScale = scale.get();
|
|
115
|
-
const targetScale = clampScale(currentScale * factor,
|
|
131
|
+
const targetScale = clampScale(currentScale * factor, effectiveMaxScale());
|
|
116
132
|
const fitRect = getFitRect();
|
|
117
133
|
const anchor = {
|
|
118
134
|
x: viewportRef.current.width / 2,
|
|
@@ -127,6 +143,7 @@ const useZoomPan = ({ natural, viewport, maxScale, onCloseRequest, isClosing, fi
|
|
|
127
143
|
}, [
|
|
128
144
|
animateTransform,
|
|
129
145
|
closing,
|
|
146
|
+
effectiveMaxScale,
|
|
130
147
|
getFitRect,
|
|
131
148
|
scale,
|
|
132
149
|
x,
|
|
@@ -139,7 +156,7 @@ const useZoomPan = ({ natural, viewport, maxScale, onCloseRequest, isClosing, fi
|
|
|
139
156
|
const currentScale = scale.get();
|
|
140
157
|
const fitRect = getFitRect();
|
|
141
158
|
const target = doubleClickTarget(currentScale, naturalRef.current, fitRect, normalizeRotation(rotate.get()));
|
|
142
|
-
const targetScale = clampScale(target,
|
|
159
|
+
const targetScale = clampScale(target, effectiveMaxScale());
|
|
143
160
|
const next = anchoredZoom({
|
|
144
161
|
scale: currentScale,
|
|
145
162
|
x: x.get(),
|
|
@@ -149,6 +166,34 @@ const useZoomPan = ({ natural, viewport, maxScale, onCloseRequest, isClosing, fi
|
|
|
149
166
|
}, [
|
|
150
167
|
animateTransform,
|
|
151
168
|
closing,
|
|
169
|
+
effectiveMaxScale,
|
|
170
|
+
getFitRect,
|
|
171
|
+
rotate,
|
|
172
|
+
scale,
|
|
173
|
+
x,
|
|
174
|
+
y
|
|
175
|
+
]);
|
|
176
|
+
const toggleActualSize = useCallback(() => {
|
|
177
|
+
if (closing()) return;
|
|
178
|
+
const currentScale = scale.get();
|
|
179
|
+
const fitRect = getFitRect();
|
|
180
|
+
const actual = naturalScale(naturalRef.current, fitRect, normalizeRotation(rotate.get()));
|
|
181
|
+
const atActual = Math.abs(currentScale - actual) < CLEAN_EPSILON;
|
|
182
|
+
const targetScale = clampScale(atActual ? 1 : actual, effectiveMaxScale());
|
|
183
|
+
const anchor = {
|
|
184
|
+
x: viewportRef.current.width / 2,
|
|
185
|
+
y: viewportRef.current.height / 2
|
|
186
|
+
};
|
|
187
|
+
const next = anchoredZoom({
|
|
188
|
+
scale: currentScale,
|
|
189
|
+
x: x.get(),
|
|
190
|
+
y: y.get()
|
|
191
|
+
}, targetScale, anchor, fitRect);
|
|
192
|
+
animateTransform(next, fitRect);
|
|
193
|
+
}, [
|
|
194
|
+
animateTransform,
|
|
195
|
+
closing,
|
|
196
|
+
effectiveMaxScale,
|
|
152
197
|
getFitRect,
|
|
153
198
|
rotate,
|
|
154
199
|
scale,
|
|
@@ -164,18 +209,19 @@ const useZoomPan = ({ natural, viewport, maxScale, onCloseRequest, isClosing, fi
|
|
|
164
209
|
closeAccumRef.current = 0;
|
|
165
210
|
disarmedRef.current = false;
|
|
166
211
|
}, WHEEL_IDLE_MS);
|
|
212
|
+
const delta = normalizeWheelDelta(event.deltaY, event.deltaMode, viewportRef.current.height);
|
|
167
213
|
const currentScale = scale.get();
|
|
168
|
-
if (!!(event.ctrlKey || event.metaKey) ||
|
|
214
|
+
if (!!(event.ctrlKey || event.metaKey) || delta < 0 || !isAtFitFloor()) {
|
|
169
215
|
disarmedRef.current = true;
|
|
170
216
|
closeAccumRef.current = 0;
|
|
171
|
-
const rawTarget = currentScale * wheelZoomFactor(
|
|
217
|
+
const rawTarget = currentScale * wheelZoomFactor(delta);
|
|
172
218
|
if (rawTarget <= 1) {
|
|
173
219
|
animate(scale, 1, RESET_TRANSITION);
|
|
174
220
|
animate(x, 0, RESET_TRANSITION);
|
|
175
221
|
animate(y, 0, RESET_TRANSITION);
|
|
176
222
|
return;
|
|
177
223
|
}
|
|
178
|
-
const targetScale = clampScale(rawTarget,
|
|
224
|
+
const targetScale = clampScale(rawTarget, effectiveMaxScale());
|
|
179
225
|
const fitRect = getFitRect();
|
|
180
226
|
const anchor = {
|
|
181
227
|
x: event.clientX,
|
|
@@ -189,8 +235,8 @@ const useZoomPan = ({ natural, viewport, maxScale, onCloseRequest, isClosing, fi
|
|
|
189
235
|
applyTransform(next, fitRect);
|
|
190
236
|
return;
|
|
191
237
|
}
|
|
192
|
-
if (disarmedRef.current || !
|
|
193
|
-
closeAccumRef.current += Math.abs(
|
|
238
|
+
if (disarmedRef.current || !isOrientationClean()) return;
|
|
239
|
+
closeAccumRef.current += Math.abs(delta);
|
|
194
240
|
if (closeAccumRef.current >= WHEEL_CLOSE_THRESHOLD) {
|
|
195
241
|
closeAccumRef.current = 0;
|
|
196
242
|
onCloseRequestRef.current?.();
|
|
@@ -198,8 +244,10 @@ const useZoomPan = ({ natural, viewport, maxScale, onCloseRequest, isClosing, fi
|
|
|
198
244
|
}, [
|
|
199
245
|
applyTransform,
|
|
200
246
|
closing,
|
|
247
|
+
effectiveMaxScale,
|
|
201
248
|
getFitRect,
|
|
202
|
-
|
|
249
|
+
isAtFitFloor,
|
|
250
|
+
isOrientationClean,
|
|
203
251
|
scale,
|
|
204
252
|
x,
|
|
205
253
|
y
|
|
@@ -229,7 +277,7 @@ const useZoomPan = ({ natural, viewport, maxScale, onCloseRequest, isClosing, fi
|
|
|
229
277
|
}, [closing, flipY]);
|
|
230
278
|
const reset = useCallback(() => {
|
|
231
279
|
if (closing()) return;
|
|
232
|
-
animate(scale,
|
|
280
|
+
animate(scale, initialScaleRef.current, RESET_TRANSITION);
|
|
233
281
|
animate(x, 0, RESET_TRANSITION);
|
|
234
282
|
animate(y, 0, RESET_TRANSITION);
|
|
235
283
|
animate(rotate, 0, RESET_TRANSITION);
|
|
@@ -296,8 +344,12 @@ const useZoomPan = ({ natural, viewport, maxScale, onCloseRequest, isClosing, fi
|
|
|
296
344
|
y
|
|
297
345
|
]);
|
|
298
346
|
const setNatural = useCallback((next) => {
|
|
347
|
+
const wasClean = isCleanState();
|
|
299
348
|
naturalRef.current = next;
|
|
300
|
-
const
|
|
349
|
+
const rotation = normalizeRotation(rotate.get());
|
|
350
|
+
initialScaleRef.current = computeInitialScale(rotation);
|
|
351
|
+
if (wasClean && !closing() && !isTransitioningRef.current?.()) scale.jump(initialScaleRef.current);
|
|
352
|
+
const fitRect = computeFit(next, viewportRef.current, rotation, fillViewportRef.current);
|
|
301
353
|
const clamped = clampPan({
|
|
302
354
|
scale: scale.get(),
|
|
303
355
|
x: x.get(),
|
|
@@ -306,6 +358,9 @@ const useZoomPan = ({ natural, viewport, maxScale, onCloseRequest, isClosing, fi
|
|
|
306
358
|
x.set(clamped.x);
|
|
307
359
|
y.set(clamped.y);
|
|
308
360
|
}, [
|
|
361
|
+
closing,
|
|
362
|
+
computeInitialScale,
|
|
363
|
+
isCleanState,
|
|
309
364
|
rotate,
|
|
310
365
|
scale,
|
|
311
366
|
x,
|
|
@@ -321,6 +376,7 @@ const useZoomPan = ({ natural, viewport, maxScale, onCloseRequest, isClosing, fi
|
|
|
321
376
|
flipVertical,
|
|
322
377
|
flipX,
|
|
323
378
|
flipY,
|
|
379
|
+
getInitialScale,
|
|
324
380
|
handleDoubleClick,
|
|
325
381
|
handleWheel,
|
|
326
382
|
isClean: derived.isClean,
|
|
@@ -334,6 +390,7 @@ const useZoomPan = ({ natural, viewport, maxScale, onCloseRequest, isClosing, fi
|
|
|
334
390
|
scale,
|
|
335
391
|
setNatural,
|
|
336
392
|
setViewport,
|
|
393
|
+
toggleActualSize,
|
|
337
394
|
x,
|
|
338
395
|
y,
|
|
339
396
|
zoomIn,
|