diff --git a/apps/web/src/modules/viewer/DOMImageViewer.tsx b/apps/web/src/modules/viewer/DOMImageViewer.tsx index 93be12d0..7cc95516 100644 --- a/apps/web/src/modules/viewer/DOMImageViewer.tsx +++ b/apps/web/src/modules/viewer/DOMImageViewer.tsx @@ -11,6 +11,8 @@ export const DOMImageViewer: FC = ({ onZoomChange, minZoom, maxZoom, + enableZoom = true, + enablePan = true, src, alt, highResLoaded, @@ -52,6 +54,7 @@ export const DOMImageViewer: FC = ({ // 双击切换 1x/fitToScreenScale,缩放中心为指针位置 const handleDoubleClick = useCallback( (event: React.MouseEvent) => { + if (!enableZoom) return const instance = activeRef.current?.instance if (!instance) return const wrapper = instance.wrapperComponent @@ -82,7 +85,7 @@ export const DOMImageViewer: FC = ({ activeRef.current?.setTransform(0, 0, 1, 200, 'easeInOutCubic') } }, - [activeRef], + [activeRef, enableZoom], ) return ( @@ -94,12 +97,17 @@ export const DOMImageViewer: FC = ({ maxScale={maxZoom} wheel={{ step: 0.1, + wheelDisabled: !enableZoom, }} pinch={{ step: 0.5, + disabled: !enableZoom, }} doubleClick={{ - disabled: true, // 禁用内置双击 + disabled: true, // 禁用内置双击,使用自定义逻辑保证 1x/fit 切换 + }} + panning={{ + disabled: !enablePan, }} limitToBounds={true} centerOnInit={true} diff --git a/apps/web/src/modules/viewer/MobilePhotoInspectorSheet.tsx b/apps/web/src/modules/viewer/MobilePhotoInspectorSheet.tsx new file mode 100644 index 00000000..6e54d641 --- /dev/null +++ b/apps/web/src/modules/viewer/MobilePhotoInspectorSheet.tsx @@ -0,0 +1,159 @@ +import type { PickedExif } from '@afilmory/builder' +import { MobileTabGroup, MobileTabItem } from '@afilmory/ui' +import { useQuery } from '@tanstack/react-query' +import { m, type MotionValue, useTransform } from 'motion/react' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { injectConfig } from '~/config' +import { useViewport } from '~/hooks/useViewport' +import { commentsApi } from '~/lib/api/comments' +import { ExifPanelContent } from '~/modules/metadata/ExifPanel' +import { CommentsPanel } from '~/modules/social/comments' +import type { PhotoManifest } from '~/types/photo' + +type Tab = 'info' | 'comments' + +interface MobilePhotoInspectorSheetProps { + currentPhoto: PhotoManifest + exifData: PickedExif | null + progress: MotionValue + onClose: () => void +} + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max) +const easeOutCubic = (value: number) => 1 - Math.pow(1 - value, 3) + +export const MobilePhotoInspectorSheet = ({ + currentPhoto, + exifData, + progress, + onClose, +}: MobilePhotoInspectorSheetProps) => { + const { t } = useTranslation() + const [activeTab, setActiveTab] = useState('info') + const [isInteractive, setIsInteractive] = useState(false) + const viewportHeight = useViewport((value) => value.h) || (typeof window !== 'undefined' ? window.innerHeight : 844) + const sheetHeight = useMemo(() => clamp(viewportHeight * 0.68, 360, viewportHeight - 72), [viewportHeight]) + + const showSocialFeatures = injectConfig.useCloud + const { data: commentCount } = useQuery({ + queryKey: ['comment-count', currentPhoto.id], + queryFn: () => commentsApi.count(currentPhoto.id), + enabled: showSocialFeatures, + }) + const hasComments = (commentCount?.count ?? 0) > 0 + + useEffect(() => { + setActiveTab('info') + }, [currentPhoto.id]) + + useEffect(() => { + const unsubscribe = progress.on('change', (latest) => { + setIsInteractive(clamp(latest, 0, 1) > 0.02) + }) + + return () => { + unsubscribe() + } + }, [progress]) + + const clampedProgress = useTransform(() => clamp(progress.get(), 0, 1)) + const sheetProgress = useTransform(() => easeOutCubic(clampedProgress.get())) + const sheetY = useTransform(() => (1 - sheetProgress.get()) * (sheetHeight + 28)) + const sheetOpacity = useTransform(() => clamp(clampedProgress.get() * 1.6, 0, 1)) + const sheetScale = useTransform(() => 0.965 + sheetProgress.get() * 0.035) + + return ( + + +
+ +
+
+
+
+ +
+ {showSocialFeatures ? ( + setActiveTab(value as Tab)} + className="mr-12" + > + + + {t('inspector.tab.info')} +
+ } + /> + + + {t('inspector.tab.comments')} + {hasComments &&
} +
+ } + /> + + ) : ( +
{t('exif.header.title')}
+ )} + + +
+
+ +
+ {activeTab === 'info' ? ( + + ) : ( +
+ +
+ )} +
+ + + ) +} diff --git a/apps/web/src/modules/viewer/PhotoViewer.tsx b/apps/web/src/modules/viewer/PhotoViewer.tsx index 5aa6928f..7710c3fb 100644 --- a/apps/web/src/modules/viewer/PhotoViewer.tsx +++ b/apps/web/src/modules/viewer/PhotoViewer.tsx @@ -21,15 +21,21 @@ import type { PhotoManifest } from '~/types/photo' import { ReactionRail } from '../social' import { PhotoViewerTransitionPreview } from './animations/PhotoViewerTransitionPreview' +import type { AnimationFrameRect } from './animations/types' import { usePhotoViewerTransitions } from './animations/usePhotoViewerTransitions' +import { computeViewerImageFrame, projectViewerImageFrame } from './animations/utils' import { GalleryThumbnail } from './GalleryThumbnail' +import { MobilePhotoInspectorSheet } from './MobilePhotoInspectorSheet' import { ProgressiveImage } from './ProgressiveImage' +import type { MobilePhotoViewerDismissSnapshot } from './usePhotoViewerMobileInteractions' +import { usePhotoViewerMobileInteractions } from './usePhotoViewerMobileInteractions' interface PhotoViewerProps { photos: PhotoManifest[] currentIndex: number isOpen: boolean onClose: () => void + onDragDismiss?: (frame: AnimationFrameRect) => void onIndexChange: (index: number) => void triggerElement: HTMLElement | null onExitComplete?: () => void @@ -40,6 +46,7 @@ export const PhotoViewer = ({ currentIndex, isOpen, onClose, + onDragDismiss, onIndexChange, triggerElement, onExitComplete, @@ -48,11 +55,11 @@ export const PhotoViewer = ({ const isMobile = useMobile() const swiperRef = useRef(null) const [isImageZoomed, setIsImageZoomed] = useState(false) - const [isInspectorVisible, setIsInspectorVisible] = useState(!isMobile) + const [isDesktopInspectorVisible, setIsDesktopInspectorVisible] = useState(!isMobile) const [currentBlobSrc, setCurrentBlobSrc] = useState(null) + const [dragDismissExitFrame, setDragDismissExitFrame] = useState(null) const currentPhoto = photos[currentIndex] - const { containerRef, entryTransition, @@ -62,9 +69,11 @@ export const PhotoViewer = ({ shouldRenderBackdrop, thumbHash: transitionThumbHash, shouldRenderThumbhash, - handleEntryAnimationComplete, + handleEntryTransitionReady, + handleEntryTransitionComplete, handleExitAnimationComplete, } = usePhotoViewerTransitions({ + exitOverrideFrame: dragDismissExitFrame, isOpen, triggerElement, currentPhoto, @@ -73,13 +82,75 @@ export const PhotoViewer = ({ onExitComplete, }) + const handleCloseRequest = useCallback(() => { + setDragDismissExitFrame(null) + onClose() + }, [onClose]) + + const handleDragDismiss = useCallback( + (snapshot: MobilePhotoViewerDismissSnapshot) => { + if (!currentPhoto) { + handleCloseRequest() + return + } + + const viewportRect = + containerRef.current?.getBoundingClientRect() ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight) + const baseFrame = computeViewerImageFrame(currentPhoto, viewportRect, true) + const projectedFrame = projectViewerImageFrame(baseFrame, viewportRect, snapshot) + + setDragDismissExitFrame(projectedFrame) + onDragDismiss?.(projectedFrame) + onClose() + }, + [containerRef, currentPhoto, handleCloseRequest, onClose, onDragDismiss], + ) + + const { + bindStage, + closeInspector, + dismissX, + inspectorProgress, + isInspectorVisible: isMobileInspectorVisible, + isVerticalGestureActive, + reset: resetMobileInteractions, + stageHintOpacity, + stageHintY, + thumbnailsOpacity, + thumbnailsY, + toggleInspector, + viewerBorderRadius, + viewerLiftY, + viewerRotate, + viewerScale, + backdropOpacity, + chromeOpacity, + chromeY, + } = usePhotoViewerMobileInteractions({ + enabled: isMobile && isOpen, + isImageZoomed, + onDismiss: handleDragDismiss, + }) + const isInspectorVisible = isMobile ? isMobileInspectorVisible : isDesktopInspectorVisible + const isMobileChromeInteractive = !isMobile || !isMobileInspectorVisible + const mobileChromeButtonClassName = isMobileChromeInteractive ? 'pointer-events-auto' : 'pointer-events-none' + + useEffect(() => { + if (isOpen) { + setDragDismissExitFrame(null) + } + }, [isOpen]) + useEffect(() => { if (!isOpen) { setIsImageZoomed(false) - setIsInspectorVisible(!isMobile) + setIsDesktopInspectorVisible(!isMobile) setCurrentBlobSrc(null) + if (!dragDismissExitFrame) { + resetMobileInteractions() + } } - }, [isMobile, isOpen]) + }, [dragDismissExitFrame, isMobile, isOpen, resetMobileInteractions]) const handlePrevious = useCallback(() => { if (currentIndex > 0) { @@ -101,13 +172,17 @@ export const PhotoViewer = ({ swiperRef.current.slideTo(currentIndex, 300) } // 切换图片时重置缩放状态 + setDragDismissExitFrame(null) setIsImageZoomed(false) - }, [currentIndex]) + if (isMobile) { + resetMobileInteractions() + } + }, [currentIndex, isMobile, resetMobileInteractions]) // 当图片缩放状态改变时,控制 Swiper 的触摸行为 useEffect(() => { if (swiperRef.current) { - if (isImageZoomed) { + if (isImageZoomed || (isMobile && (isVerticalGestureActive || isInspectorVisible))) { // 图片被缩放时,禁用 Swiper 的触摸滑动 swiperRef.current.allowTouchMove = false } else { @@ -115,7 +190,7 @@ export const PhotoViewer = ({ swiperRef.current.allowTouchMove = true } } - }, [isImageZoomed]) + }, [isImageZoomed, isInspectorVisible, isMobile, isVerticalGestureActive]) const loadingIndicatorRef = useRef(null) // 处理图片缩放状态变化 @@ -128,6 +203,12 @@ export const PhotoViewer = ({ setCurrentBlobSrc(blobSrc) }, []) + useEffect(() => { + if (isMobile && isImageZoomed && isInspectorVisible) { + closeInspector() + } + }, [closeInspector, isImageZoomed, isInspectorVisible, isMobile]) + // 键盘导航 useEffect(() => { if (!isOpen) return @@ -146,7 +227,7 @@ export const PhotoViewer = ({ } case 'Escape': { event.preventDefault() - onClose() + handleCloseRequest() break } } @@ -156,7 +237,7 @@ export const PhotoViewer = ({ return () => { document.removeEventListener('keydown', handleKeyDown) } - }, [isOpen, handlePrevious, handleNext, onClose]) + }, [isOpen, handleCloseRequest, handlePrevious, handleNext]) if (!currentPhoto) return null @@ -172,8 +253,13 @@ export const PhotoViewer = ({ animate={{ opacity: isOpen ? 1 : 0 }} exit={{ opacity: 0 }} transition={Spring.presets.snappy} - className="bg-material-opaque fixed inset-0" - /> + className="fixed inset-0" + > + + )} {/* 固定背景层防止透出 */} @@ -202,207 +288,262 @@ export const PhotoViewer = ({ touchAction: isMobile ? 'manipulation' : 'none', pointerEvents: !isViewerContentVisible || isEntryAnimating ? 'none' : 'auto', }} - initial={{ opacity: 0 }} - animate={{ opacity: isViewerContentVisible ? 1 : 0 }} + initial={false} + animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={Spring.presets.snappy} >
-
+
- {/* 顶部工具栏 */} - {/* 左侧工具按钮 */} -
- {/* 信息按钮 - 在移动设备上显示 */} - {isMobile && ( - - )} -
+ {/* 顶部工具栏 */} + + {/* 左侧工具按钮 */} +
+ {/* 信息按钮 - 在移动设备上显示 */} + {isMobile && ( + + )} +
- {/* 右侧按钮组 */} -
- {/* 分享按钮 */} - + {/* 分享按钮 */} + + + + } + /> + + {/* 展开信息面板(桌面端在折叠时显示) */} + {!isMobile && !isInspectorVisible && ( - } - /> + )} - {/* 展开信息面板(桌面端在折叠时显示) */} - {!isMobile && !isInspectorVisible && ( + {/* 关闭按钮 */} +
+
+ + {/* 加载指示器 */} + +
+ {/* Swiper 容器 */} + { + swiperRef.current = swiper + swiper.allowTouchMove = + !isImageZoomed && !(isMobile && (isVerticalGestureActive || isInspectorVisible)) + }} + onSlideChange={(swiper) => { + onIndexChange(swiper.activeIndex) + }} + className="h-full w-full" + style={{ touchAction: isMobile ? 'pan-x' : 'pan-y' }} + > + {photos.map((photo, index) => { + const isCurrentImage = index === currentIndex + const hideCurrentImage = isCurrentImage && isEntryAnimating && !isViewerContentVisible + return ( + + + + + + + ) + })} + + + {isMobile && ( + + + + + + + )} - {/* 关闭按钮 */} - + {/* 自定义导航按钮 */} + {!isMobile && ( + + {currentIndex > 0 && ( + + )} + + {currentIndex < photos.length - 1 && ( + + )} + + )}
- {/* 加载指示器 */} - - {/* Swiper 容器 */} - { - swiperRef.current = swiper - // 初始化时确保触摸滑动是启用的 - swiper.allowTouchMove = !isImageZoomed - }} - onSlideChange={(swiper) => { - onIndexChange(swiper.activeIndex) - }} - className="h-full w-full" - style={{ touchAction: isMobile ? 'pan-x' : 'pan-y' }} + - {photos.map((photo, index) => { - const isCurrentImage = index === currentIndex - const hideCurrentImage = isEntryAnimating && isCurrentImage - return ( - - - - - - - ) - })} - - - {/* 自定义导航按钮 */} - - {!isMobile && ( - - {currentIndex > 0 && ( - - )} - - {currentIndex < photos.length - 1 && ( - - )} - - )} + + + +
- - - -
{/* PhotoInspector - 根据设备与折叠状态展示 */} - - - {isInspectorVisible && ( + {isMobile ? ( + + ) : ( + isInspectorVisible && ( setIsInspectorVisible(false)} + onClose={() => setIsDesktopInspectorVisible(false)} /> - )} - + ) + )}
@@ -412,7 +553,8 @@ export const PhotoViewer = ({ )} {exitTransition && ( @@ -425,9 +567,3 @@ export const PhotoViewer = ({ ) } - -const AnimatePresenceOnlyMobile = ({ children }: { children: React.ReactNode }) => { - const isMobile = useMobile() - if (!isMobile) return children - return {children} -} diff --git a/apps/web/src/modules/viewer/ProgressiveImage.tsx b/apps/web/src/modules/viewer/ProgressiveImage.tsx index 98b89ec0..a77ea388 100644 --- a/apps/web/src/modules/viewer/ProgressiveImage.tsx +++ b/apps/web/src/modules/viewer/ProgressiveImage.tsx @@ -1,13 +1,14 @@ import { clsxm } from '@afilmory/utils' import { WebGLImageViewer } from '@afilmory/webgl-viewer' import { AnimatePresence, m } from 'motion/react' -import { useCallback, useMemo, useRef } from 'react' +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import type { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch' import { useMediaQuery } from 'usehooks-ts' import { useShowContextMenu } from '~/atoms/context-menu' import { SlidingNumber } from '~/components/ui/number/SlidingNumber' +import { isMobileDevice } from '~/lib/device-viewport' import { canUseWebGL } from '~/lib/feature' import { HDRBadge } from '~/modules/media/HDRBadge' import { LivePhotoBadge } from '~/modules/media/LivePhotoBadge' @@ -24,6 +25,8 @@ import { } from './hooks' import type { ProgressiveImageProps, WebGLImageViewerRef } from './types' +const loadedThumbnailSrcSet = new Set() + export const ProgressiveImage = ({ src, thumbnailSrc, @@ -35,6 +38,8 @@ export const ProgressiveImage = ({ onProgress, onZoomChange, onBlobSrcChange, + enableZoom = true, + enablePan = true, maxZoom = 20, minZoom = 1, isCurrentImage = false, @@ -104,8 +109,34 @@ export const ProgressiveImage = ({ const handleWebGLLoadingStateChange = useWebGLLoadingState(loadingIndicatorRef) const handleThumbnailLoad = useCallback(() => { + if (thumbnailSrc) { + loadedThumbnailSrcSet.add(thumbnailSrc) + } setState.setIsThumbnailLoaded(true) - }, [setState]) + }, [setState, thumbnailSrc]) + + useLayoutEffect(() => { + if (!thumbnailSrc) { + setState.setIsThumbnailLoaded(false) + return + } + + const thumbnailElement = thumbnailRef.current + const isAlreadyLoaded = + loadedThumbnailSrcSet.has(thumbnailSrc) || + (thumbnailElement?.currentSrc?.includes(thumbnailSrc) && + thumbnailElement.complete && + thumbnailElement.naturalWidth > 0) || + (thumbnailElement?.src === thumbnailSrc && thumbnailElement.complete && thumbnailElement.naturalWidth > 0) + + if (isAlreadyLoaded) { + loadedThumbnailSrcSet.add(thumbnailSrc) + setState.setIsThumbnailLoaded(true) + return + } + + setState.setIsThumbnailLoaded(false) + }, [setState, thumbnailSrc]) const showContextMenu = useShowContextMenu() @@ -113,6 +144,31 @@ export const ProgressiveImage = ({ // Only use HDR if the browser supports it and the image is HDR const shouldUseHDR = isHDR && isHDRSupported + const webglPinchConfig = useMemo( + () => ({ + step: 0.5, + disabled: !enableZoom, + }), + [enableZoom], + ) + + const webglDoubleClickConfig = useMemo( + () => ({ + step: 2, + disabled: !enableZoom, + mode: 'toggle' as const, + animationTime: 200, + }), + [enableZoom], + ) + + const webglPanningConfig = useMemo( + () => ({ + disabled: !enablePan, + }), + [enablePan], + ) + return (
)}
diff --git a/apps/web/src/modules/viewer/animations/PhotoViewerTransitionPreview.tsx b/apps/web/src/modules/viewer/animations/PhotoViewerTransitionPreview.tsx index 3921b439..a39a0fe4 100644 --- a/apps/web/src/modules/viewer/animations/PhotoViewerTransitionPreview.tsx +++ b/apps/web/src/modules/viewer/animations/PhotoViewerTransitionPreview.tsx @@ -1,40 +1,115 @@ import { Thumbhash } from '@afilmory/ui' -import { Spring } from '@afilmory/utils' -import { m } from 'motion/react' +import { animate, m, useMotionValue } from 'motion/react' +import { useEffect, useRef } from 'react' import type { PhotoViewerTransition } from './types' interface PhotoViewerTransitionPreviewProps { transition: PhotoViewerTransition + onReady?: () => void onComplete: () => void } -export const PhotoViewerTransitionPreview = ({ transition, onComplete }: PhotoViewerTransitionPreviewProps) => { - const baseTransition = Spring.snappy(0.5) +export const PhotoViewerTransitionPreview = ({ + transition, + onReady, + onComplete, +}: PhotoViewerTransitionPreviewProps) => { + const baseTransition = { + duration: 0.42, + ease: [0.22, 1, 0.36, 1] as const, + } + const entryFadeOutTransition = { + duration: 0.22, + ease: [0.32, 0.72, 0, 1] as const, + } const thumbHash = typeof transition.thumbHash === 'string' ? transition.thumbHash : null + const x = useMotionValue(transition.from.left) + const y = useMotionValue(transition.from.top) + const width = useMotionValue(transition.from.width) + const height = useMotionValue(transition.from.height) + const borderRadius = useMotionValue(transition.from.borderRadius) + const rotate = useMotionValue(transition.from.rotate) + const opacity = useMotionValue(1) + const hasReadyRef = useRef(false) + const hasCompletedRef = useRef(false) + + useEffect(() => { + opacity.set(1) + hasReadyRef.current = false + hasCompletedRef.current = false + + const complete = () => { + if (hasCompletedRef.current) return + hasCompletedRef.current = true + onComplete() + } + + const ready = () => { + if (hasReadyRef.current) return + hasReadyRef.current = true + + if (transition.variant === 'entry') { + onReady?.() + const fadeAnimation = animate(opacity, 0, { + ...entryFadeOutTransition, + onComplete: complete, + }) + animations.push(fadeAnimation) + return + } + + complete() + } + + const animations = [ + animate(x, transition.to.left, baseTransition), + animate(y, transition.to.top, baseTransition), + animate(width, transition.to.width, { + ...baseTransition, + onComplete: ready, + }), + animate(height, transition.to.height, baseTransition), + animate(borderRadius, transition.to.borderRadius, baseTransition), + animate(rotate, transition.to.rotate, baseTransition), + ] + + return () => { + animations.forEach((animation) => animation.stop()) + } + }, [ + borderRadius, + height, + onReady, + onComplete, + opacity, + rotate, + transition.to.borderRadius, + transition.to.height, + transition.to.left, + transition.to.rotate, + transition.to.top, + transition.to.width, + transition.variant, + width, + x, + y, + ]) return (
{thumbHash && ( diff --git a/apps/web/src/modules/viewer/animations/types.ts b/apps/web/src/modules/viewer/animations/types.ts index 498bdc12..4fa6faf4 100644 --- a/apps/web/src/modules/viewer/animations/types.ts +++ b/apps/web/src/modules/viewer/animations/types.ts @@ -4,6 +4,7 @@ export interface AnimationFrameRect { width: number height: number borderRadius: number + rotate: number } export type PhotoViewerTransitionVariant = 'entry' | 'exit' diff --git a/apps/web/src/modules/viewer/animations/usePhotoViewerTransitions.ts b/apps/web/src/modules/viewer/animations/usePhotoViewerTransitions.ts index c3d6e8da..88d7b3ad 100644 --- a/apps/web/src/modules/viewer/animations/usePhotoViewerTransitions.ts +++ b/apps/web/src/modules/viewer/animations/usePhotoViewerTransitions.ts @@ -7,6 +7,7 @@ import type { AnimationFrameRect, PhotoViewerTransition, PhotoViewerTransitionSt import { computeViewerImageFrame, escapeAttributeValue, getBorderRadius } from './utils' interface UsePhotoViewerTransitionsParams { + exitOverrideFrame?: AnimationFrameRect | null isOpen: boolean triggerElement: HTMLElement | null currentPhoto: PhotoManifest | undefined @@ -24,11 +25,13 @@ interface UsePhotoViewerTransitionsResult { shouldRenderBackdrop: boolean thumbHash: string | null shouldRenderThumbhash: boolean - handleEntryAnimationComplete: () => void + handleEntryTransitionReady: () => void + handleEntryTransitionComplete: () => void handleExitAnimationComplete: () => void } export const usePhotoViewerTransitions = ({ + exitOverrideFrame = null, isOpen, triggerElement, currentPhoto, @@ -157,16 +160,16 @@ export const usePhotoViewerTransitions = ({ triggerEl instanceof HTMLImageElement && triggerEl.parentElement ? triggerEl.parentElement : triggerEl, ) - setIsViewerContentVisible(true) viewerImageFrameRef.current = { left: targetFrame.left, top: targetFrame.top, width: targetFrame.width, height: targetFrame.height, borderRadius: targetFrame.borderRadius, + rotate: targetFrame.rotate, } - const frameForAnimation = viewerImageFrameRef.current + const frameForAnimation = viewerImageFrameRef.current ?? targetFrame const transitionState: PhotoViewerTransitionState = { photoId: currentPhoto.id, @@ -178,6 +181,7 @@ export const usePhotoViewerTransitions = ({ width: fromRect.width, height: fromRect.height, borderRadius: triggerBorderRadius, + rotate: 0, }, to: { left: frameForAnimation.left, @@ -185,6 +189,7 @@ export const usePhotoViewerTransitions = ({ width: frameForAnimation.width, height: frameForAnimation.height, borderRadius: frameForAnimation.borderRadius, + rotate: frameForAnimation.rotate, }, } @@ -234,13 +239,7 @@ export const usePhotoViewerTransitions = ({ const viewportRect = viewerBoundsRef.current ?? containerRef.current?.getBoundingClientRect() ?? null const computedFrame = computeViewerImageFrame(currentPhoto, viewportRect, isMobile) - const viewerFrame = viewerImageFrameRef.current ?? { - left: computedFrame.left, - top: computedFrame.top, - width: computedFrame.width, - height: computedFrame.height, - borderRadius: computedFrame.borderRadius, - } + const viewerFrame = exitOverrideFrame ?? viewerImageFrameRef.current ?? computedFrame if (!viewerFrame.width || !viewerFrame.height) { wasOpenRef.current = false @@ -279,6 +278,7 @@ export const usePhotoViewerTransitions = ({ width: viewerFrame.width, height: viewerFrame.height, borderRadius: viewerFrame.borderRadius, + rotate: viewerFrame.rotate, }, to: { left: targetRect.left, @@ -286,6 +286,7 @@ export const usePhotoViewerTransitions = ({ width: targetRect.width, height: targetRect.height, borderRadius, + rotate: 0, }, } @@ -296,6 +297,7 @@ export const usePhotoViewerTransitions = ({ isOpen, currentPhoto, currentBlobSrc, + exitOverrideFrame, isMobile, resolveTriggerElement, restoreTriggerElementVisibility, @@ -320,8 +322,11 @@ export const usePhotoViewerTransitions = ({ } }, [isOpen]) - const handleEntryAnimationComplete = useCallback(() => { + const handleEntryTransitionReady = useCallback(() => { setIsViewerContentVisible(true) + }, []) + + const handleEntryTransitionComplete = useCallback(() => { setEntryTransition(null) }, []) @@ -346,7 +351,8 @@ export const usePhotoViewerTransitions = ({ shouldRenderBackdrop, thumbHash, shouldRenderThumbhash, - handleEntryAnimationComplete, + handleEntryTransitionReady, + handleEntryTransitionComplete, handleExitAnimationComplete, } } diff --git a/apps/web/src/modules/viewer/animations/utils.ts b/apps/web/src/modules/viewer/animations/utils.ts index 39654630..9209ab98 100644 --- a/apps/web/src/modules/viewer/animations/utils.ts +++ b/apps/web/src/modules/viewer/animations/utils.ts @@ -78,5 +78,39 @@ export const computeViewerImageFrame = ( width: displayWidth, height: displayHeight, borderRadius: 0, + rotate: 0, + } +} + +interface ViewerFrameTransformSnapshot { + borderRadius: number + rotate: number + scale: number + translateX: number + translateY: number +} + +interface ViewportRectLike { + height: number + left: number + top: number + width: number +} + +export const projectViewerImageFrame = ( + frame: AnimationFrameRect, + viewportRect: ViewportRectLike, + snapshot: ViewerFrameTransformSnapshot, +): AnimationFrameRect => { + const originX = viewportRect.left + viewportRect.width * 0.5 + const originY = viewportRect.top + viewportRect.height * 0.18 + + return { + left: originX + (frame.left - originX) * snapshot.scale + snapshot.translateX, + top: originY + (frame.top - originY) * snapshot.scale + snapshot.translateY, + width: frame.width * snapshot.scale, + height: frame.height * snapshot.scale, + borderRadius: snapshot.borderRadius, + rotate: snapshot.rotate, } } diff --git a/apps/web/src/modules/viewer/types.ts b/apps/web/src/modules/viewer/types.ts index 08a4f806..49fc296c 100644 --- a/apps/web/src/modules/viewer/types.ts +++ b/apps/web/src/modules/viewer/types.ts @@ -54,6 +54,8 @@ export interface DOMImageViewerProps { onZoomChange?: (isZoomed: boolean, scale: number) => any minZoom: number maxZoom: number + enableZoom?: boolean + enablePan?: boolean src: string alt: string highResLoaded: boolean diff --git a/apps/web/src/modules/viewer/usePhotoViewerMobileInteractions.ts b/apps/web/src/modules/viewer/usePhotoViewerMobileInteractions.ts new file mode 100644 index 00000000..67a28eef --- /dev/null +++ b/apps/web/src/modules/viewer/usePhotoViewerMobileInteractions.ts @@ -0,0 +1,358 @@ +import { Spring } from '@afilmory/utils' +import { useDrag } from '@use-gesture/react' +import { animate, useMotionValue, useTransform } from 'motion/react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { useViewport } from '~/hooks/useViewport' + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max) +const easeOutCubic = (value: number) => 1 - Math.pow(1 - value, 3) +const easeOutQuad = (value: number) => 1 - Math.pow(1 - value, 2) + +interface MobilePhotoViewerInteractionsOptions { + enabled: boolean + isImageZoomed: boolean + onDismiss: (snapshot: MobilePhotoViewerDismissSnapshot) => void +} + +export interface MobilePhotoViewerDismissSnapshot { + borderRadius: number + rotate: number + scale: number + translateX: number + translateY: number +} + +export const usePhotoViewerMobileInteractions = ({ + enabled, + isImageZoomed, + onDismiss, +}: MobilePhotoViewerInteractionsOptions) => { + const viewport = useViewport((value) => ({ width: value.w, height: value.h })) + const viewportWidth = viewport.width || (typeof window !== 'undefined' ? window.innerWidth : 390) + const viewportHeight = viewport.height || (typeof window !== 'undefined' ? window.innerHeight : 844) + + const inspectorRevealDistance = useMemo(() => clamp(viewportHeight * 0.34, 220, 320), [viewportHeight]) + const dismissThreshold = useMemo(() => clamp(viewportHeight * 0.18, 120, 180), [viewportHeight]) + const dismissTravel = useMemo(() => viewportHeight + 160, [viewportHeight]) + + const inspectorProgress = useMotionValue(0) + const dismissX = useMotionValue(0) + const dismissY = useMotionValue(0) + const [isInspectorVisible, setIsInspectorVisible] = useState(false) + const [isVerticalGestureActive, setIsVerticalGestureActive] = useState(false) + + const animationControlsRef = useRef[]>([]) + const isClosingRef = useRef(false) + + const registerAnimation = useCallback((animation: ReturnType) => { + animationControlsRef.current.push(animation) + return animation + }, []) + + const stopAnimations = useCallback(() => { + animationControlsRef.current.forEach((animation) => animation.stop()) + animationControlsRef.current = [] + }, []) + + const reset = useCallback(() => { + stopAnimations() + isClosingRef.current = false + setIsInspectorVisible(false) + setIsVerticalGestureActive(false) + inspectorProgress.set(0) + dismissX.set(0) + dismissY.set(0) + }, [dismissX, dismissY, inspectorProgress, stopAnimations]) + + useEffect(() => { + const unsubscribe = inspectorProgress.on('change', (latest) => { + setIsInspectorVisible(clamp(latest, 0, 1) > 0.02) + }) + + return () => { + unsubscribe() + } + }, [inspectorProgress]) + + useEffect(() => { + if (!enabled) { + reset() + } + }, [enabled, reset]) + + const springValue = useCallback( + (value: typeof inspectorProgress | typeof dismissX | typeof dismissY, to: number, velocity = 0) => { + return registerAnimation( + animate(value, to, { + ...Spring.presets.smooth, + velocity, + }), + ) + }, + [registerAnimation], + ) + + const getDismissPresentationSnapshot = useCallback( + (translateX = dismissX.get(), translateY = dismissY.get()): MobilePhotoViewerDismissSnapshot => { + const dismissRatio = clamp(translateY / Math.max(dismissTravel, 1), 0, 1) + const dismissVisual = easeOutQuad(dismissRatio) + + return { + translateX, + translateY, + scale: clamp(1 - dismissVisual * 0.13, 0.8, 1), + rotate: (translateX / Math.max(viewportWidth, 1)) * (4.5 + dismissVisual * 2.5), + borderRadius: dismissVisual * 22, + } + }, + [dismissTravel, dismissX, dismissY, viewportWidth], + ) + + const settleInspector = useCallback( + (open: boolean, velocity = 0) => { + stopAnimations() + if (isClosingRef.current) return + const clampedVelocity = clamp(velocity, -2.2, 2.2) + const settleVelocity = open ? Math.max(clampedVelocity, 0) : Math.min(clampedVelocity, 0) + + registerAnimation( + animate(inspectorProgress, open ? 1 : 0, { + ...Spring.smooth(open ? 0.32 : 0.28), + velocity: settleVelocity, + }), + ) + registerAnimation(animate(dismissX, 0, Spring.smooth(0.26))) + registerAnimation(animate(dismissY, 0, Spring.smooth(open ? 0.28 : 0.24))) + }, + [dismissX, dismissY, inspectorProgress, registerAnimation, stopAnimations], + ) + + const dismissWithThrow = useCallback( + (velocityX: number, velocityY: number) => { + if (isClosingRef.current) return + + isClosingRef.current = true + setIsInspectorVisible(false) + setIsVerticalGestureActive(false) + stopAnimations() + inspectorProgress.set(0) + + const clampedVelocityX = clamp(velocityX, -2.2, 2.2) + const clampedVelocityY = clamp(velocityY, 0.72, 2.8) + const currentX = dismissX.get() + const currentY = dismissY.get() + const throwDistanceY = clamp(viewportHeight * (0.06 + clampedVelocityY * 0.045), 56, 168) + const throwDistanceX = clamp(clampedVelocityX * viewportWidth * 0.12, -viewportWidth * 0.16, viewportWidth * 0.16) + const targetX = clamp(currentX + throwDistanceX, -viewportWidth * 0.28, viewportWidth * 0.28) + const targetY = clamp(currentY + throwDistanceY, currentY + 40, viewportHeight * 0.42) + + registerAnimation( + animate(dismissX, targetX, { + ...Spring.snappy(0.18, 0.06), + velocity: clampedVelocityX * viewportWidth * 0.14, + }), + ) + + registerAnimation( + animate(dismissY, targetY, { + ...Spring.snappy(0.18, 0.04), + velocity: Math.max(clampedVelocityY * viewportHeight * 0.08, 160), + onComplete: () => { + onDismiss(getDismissPresentationSnapshot(targetX, targetY)) + }, + }), + ) + }, + [ + dismissX, + dismissY, + getDismissPresentationSnapshot, + inspectorProgress, + onDismiss, + registerAnimation, + stopAnimations, + viewportHeight, + viewportWidth, + ], + ) + + const openInspector = useCallback(() => { + settleInspector(true) + }, [settleInspector]) + + const closeInspector = useCallback(() => { + settleInspector(false) + }, [settleInspector]) + + const toggleInspector = useCallback(() => { + settleInspector(inspectorProgress.get() <= 0.5) + }, [inspectorProgress, settleInspector]) + + const bindStage = useDrag( + ({ active, axis, event, first, last, movement: [mx, my], velocity: [vx, vy], direction: [dx, dy], memo }) => { + if (!enabled || isImageZoomed || isClosingRef.current) { + if (last) { + setIsVerticalGestureActive(false) + } + return memo + } + + const start = + memo ?? + ({ + inspectorPixels: inspectorProgress.get() * inspectorRevealDistance, + startedWithInspectorOpen: inspectorProgress.get() > 0.02, + ignore: false, + } as const) + + if (first && event.target instanceof HTMLElement) { + const isInteractiveTarget = Boolean( + event.target.closest('button, a, [role="button"], [data-viewer-interactive]'), + ) + if (isInteractiveTarget) { + return { + ...start, + ignore: true, + } + } + } + + if (start.ignore) { + if (last) { + setIsVerticalGestureActive(false) + } + return start + } + + if (axis && axis !== 'y') { + if (last) { + setIsVerticalGestureActive(false) + } + return start + } + + if (active) { + stopAnimations() + setIsVerticalGestureActive(true) + + const nextInspectorPixels = start.inspectorPixels - my + const startedWithInspectorOpen = start.startedWithInspectorOpen || start.inspectorPixels > 0 + + if (startedWithInspectorOpen) { + inspectorProgress.set(clamp(nextInspectorPixels / inspectorRevealDistance, 0, 1)) + dismissX.set(0) + dismissY.set(0) + } else if (nextInspectorPixels > 0) { + inspectorProgress.set(clamp(nextInspectorPixels / inspectorRevealDistance, 0, 1)) + dismissX.set(0) + dismissY.set(0) + } else { + const nextDismissY = clamp(-nextInspectorPixels, 0, dismissTravel) + const dismissRatio = nextDismissY / Math.max(dismissTravel, 1) + + inspectorProgress.set(0) + dismissY.set(nextDismissY) + dismissX.set(clamp(mx * (0.18 + dismissRatio * 0.12), -viewportWidth * 0.24, viewportWidth * 0.24)) + } + } + + if (last) { + setIsVerticalGestureActive(false) + const startedWithInspectorOpen = + start.startedWithInspectorOpen || start.inspectorPixels > 0 || inspectorProgress.get() > 0.02 + const inspectorReleaseVelocity = -dy * vy + + if (startedWithInspectorOpen) { + springValue(dismissX, 0) + springValue(dismissY, 0) + + const currentProgress = inspectorProgress.get() + const shouldOpen = currentProgress > 0.42 || (dy < 0 && vy > 0.2) + settleInspector(shouldOpen, inspectorReleaseVelocity) + return start + } + + const dismissDistance = dismissY.get() + + if (dismissDistance > dismissThreshold || (dy > 0 && vy > 0.65 && my > 36)) { + dismissWithThrow(vx * (dx === 0 ? 1 : dx), Math.max(vy, 0.72)) + return start + } + + springValue(dismissX, 0) + springValue(dismissY, 0) + + const currentProgress = inspectorProgress.get() + const shouldOpen = currentProgress > 0.42 || (dy < 0 && vy > 0.2) + settleInspector(shouldOpen, inspectorReleaseVelocity) + } + + return start + }, + { + axis: 'lock', + filterTaps: true, + threshold: 10, + pointer: { touch: true, capture: false }, + rubberband: 0.12, + }, + ) + + const dismissProgress = useTransform(dismissY, [0, dismissTravel], [0, 1]) + const inspectorClampedProgress = useTransform(() => clamp(inspectorProgress.get(), 0, 1)) + const inspectorVisualProgress = useTransform(() => easeOutCubic(inspectorClampedProgress.get())) + const dismissVisualProgress = useTransform(() => easeOutQuad(dismissProgress.get())) + const viewerScale = useTransform(() => + clamp(1 - inspectorVisualProgress.get() * 0.022 - dismissVisualProgress.get() * 0.13, 0.8, 1), + ) + const viewerRotate = useTransform( + () => (dismissX.get() / Math.max(viewportWidth, 1)) * (4.5 + dismissVisualProgress.get() * 2.5), + ) + const viewerLiftY = useTransform( + () => dismissY.get() - inspectorVisualProgress.get() * 18 - dismissVisualProgress.get() * 8, + ) + const viewerBorderRadius = useTransform(() => inspectorVisualProgress.get() * 14 + dismissVisualProgress.get() * 22) + const backdropOpacity = useTransform(() => + clamp(1 - dismissVisualProgress.get() * 0.84 - inspectorVisualProgress.get() * 0.08, 0.08, 1), + ) + const chromeOpacity = useTransform(() => + clamp(1 - inspectorVisualProgress.get() * 0.92 - dismissVisualProgress.get() * 0.52, 0, 1), + ) + const chromeY = useTransform( + () => dismissY.get() * 0.08 - inspectorVisualProgress.get() * 12 - dismissVisualProgress.get() * 6, + ) + const thumbnailsOpacity = useTransform(() => { + return clamp(1 - inspectorVisualProgress.get() * 1.05 - dismissVisualProgress.get() * 0.58, 0, 1) + }) + const thumbnailsY = useTransform(() => inspectorVisualProgress.get() * 18 + dismissVisualProgress.get() * 10) + const stageHintOpacity = useTransform(() => + clamp(0.42 - inspectorVisualProgress.get() * 0.66 - dismissVisualProgress.get() * 0.54, 0, 0.42), + ) + const stageHintY = useTransform(() => inspectorVisualProgress.get() * 10 + dismissVisualProgress.get() * 12) + + return { + bindStage, + closeInspector, + dismissX, + dismissY, + inspectorProgress: inspectorClampedProgress, + isInspectorVisible, + isVerticalGestureActive, + openInspector, + reset, + settleInspector, + stageHintOpacity, + stageHintY, + thumbnailsOpacity, + thumbnailsY, + toggleInspector, + viewerBorderRadius, + viewerLiftY, + viewerRotate, + viewerScale, + backdropOpacity, + chromeOpacity, + chromeY, + } +} diff --git a/packages/webgl-viewer/src/WebGLImageViewer.tsx b/packages/webgl-viewer/src/WebGLImageViewer.tsx index e499b891..807b69e1 100644 --- a/packages/webgl-viewer/src/WebGLImageViewer.tsx +++ b/packages/webgl-viewer/src/WebGLImageViewer.tsx @@ -53,61 +53,87 @@ export const WebGLImageViewer = ({ const viewerRef = useRef(null) const [tileOutlineEnabled, setTileOutlineEnabled] = useState(false) - const setDebugInfo = useRef((() => {}) as (debugInfo: any) => void) + const setDebugInfoRef = useRef((() => {}) as (debugInfo: any) => void) + const debugEnabled = Boolean(debug) - const config: Required = useMemo( + const mergedWheel = useMemo( () => ({ - src, - className, - width: width || 0, - height: height || 0, - initialScale, - minScale, - maxScale, - wheel: { - ...defaultWheelConfig, - ...wheel, - }, - pinch: { ...defaultPinchConfig, ...pinch }, - doubleClick: { ...defaultDoubleClickConfig, ...doubleClick }, - panning: { ...defaultPanningConfig, ...panning }, - limitToBounds, - centerOnInit, - smooth, - alignmentAnimation: { - ...defaultAlignmentAnimation, - ...alignmentAnimation, - }, - velocityAnimation: { ...defaultVelocityAnimation, ...velocityAnimation }, - onZoomChange: onZoomChange || (() => {}), - onImageCopied: onImageCopied || (() => {}), - onLoadingStateChange: onLoadingStateChange || (() => {}), - debug: debug || false, + ...defaultWheelConfig, + ...wheel, }), - [ - src, - className, - width, - height, - initialScale, - minScale, - maxScale, - wheel, - pinch, - doubleClick, - panning, - limitToBounds, - centerOnInit, - smooth, - alignmentAnimation, - velocityAnimation, - onZoomChange, - onImageCopied, - onLoadingStateChange, - debug, - ], + [wheel], ) + const mergedPinch = useMemo( + () => ({ + ...defaultPinchConfig, + ...pinch, + }), + [pinch], + ) + + const mergedDoubleClick = useMemo( + () => ({ + ...defaultDoubleClickConfig, + ...doubleClick, + }), + [doubleClick], + ) + + const mergedPanning = useMemo( + () => ({ + ...defaultPanningConfig, + ...panning, + }), + [panning], + ) + + const mergedAlignmentAnimation = useMemo( + () => ({ + ...defaultAlignmentAnimation, + ...alignmentAnimation, + }), + [alignmentAnimation], + ) + + const mergedVelocityAnimation = useMemo( + () => ({ + ...defaultVelocityAnimation, + ...velocityAnimation, + }), + [velocityAnimation], + ) + + const callbacksRef = useRef< + Pick, 'onZoomChange' | 'onImageCopied' | 'onLoadingStateChange'> + >({ + onZoomChange: onZoomChange || (() => {}), + onImageCopied: onImageCopied || (() => {}), + onLoadingStateChange: onLoadingStateChange || (() => {}), + }) + + callbacksRef.current = { + onZoomChange: onZoomChange || (() => {}), + onImageCopied: onImageCopied || (() => {}), + onLoadingStateChange: onLoadingStateChange || (() => {}), + } + + const interactionConfigRef = useRef< + Pick, 'wheel' | 'pinch' | 'doubleClick' | 'panning'> + >({ + wheel: mergedWheel, + pinch: mergedPinch, + doubleClick: mergedDoubleClick, + panning: mergedPanning, + }) + + interactionConfigRef.current = { + wheel: mergedWheel, + pinch: mergedPinch, + doubleClick: mergedDoubleClick, + panning: mergedPanning, + } + useImperativeHandle(ref, () => ({ zoomIn: (animated?: boolean) => viewerRef.current?.zoomIn(animated), zoomOut: (animated?: boolean) => viewerRef.current?.zoomOut(animated), @@ -120,14 +146,35 @@ export const WebGLImageViewer = ({ const webGLImageViewerEngine = new WebGLImageViewerEngine( canvasRef.current, - config, - debug ? setDebugInfo : undefined, + { + src, + className: '', + width: width || 0, + height: height || 0, + initialScale, + minScale, + maxScale, + wheel: interactionConfigRef.current.wheel, + pinch: interactionConfigRef.current.pinch, + doubleClick: interactionConfigRef.current.doubleClick, + panning: interactionConfigRef.current.panning, + limitToBounds, + centerOnInit, + smooth, + alignmentAnimation: mergedAlignmentAnimation, + velocityAnimation: mergedVelocityAnimation, + onZoomChange: callbacksRef.current.onZoomChange, + onImageCopied: callbacksRef.current.onImageCopied, + onLoadingStateChange: callbacksRef.current.onLoadingStateChange, + debug: debugEnabled, + }, + debugEnabled ? setDebugInfoRef : undefined, ) try { // 如果提供了尺寸,传递给loadImage进行优化 - const preknownWidth = config.width > 0 ? config.width : undefined - const preknownHeight = config.height > 0 ? config.height : undefined + const preknownWidth = width && width > 0 ? width : undefined + const preknownHeight = height && height > 0 ? height : undefined webGLImageViewerEngine.loadImage(src, preknownWidth, preknownHeight).catch(console.error) viewerRef.current = webGLImageViewerEngine setTileOutlineEnabled(webGLImageViewerEngine.isTileOutlineEnabled()) @@ -139,7 +186,28 @@ export const WebGLImageViewer = ({ webGLImageViewerEngine?.destroy() viewerRef.current = null } - }, [src, config, debug]) + }, [ + src, + width, + height, + initialScale, + minScale, + maxScale, + limitToBounds, + centerOnInit, + smooth, + mergedAlignmentAnimation, + mergedVelocityAnimation, + debugEnabled, + ]) + + useEffect(() => { + viewerRef.current?.updateCallbacks(callbacksRef.current) + }, [onZoomChange, onImageCopied, onLoadingStateChange]) + + useEffect(() => { + viewerRef.current?.updateInteractionConfig(interactionConfigRef.current) + }, [mergedWheel, mergedPinch, mergedDoubleClick, mergedPanning]) const handleOutlineToggle = useCallback( (enabled: boolean) => { @@ -181,7 +249,7 @@ export const WebGLImageViewer = ({ onToggleOutline={handleOutlineToggle} ref={(e) => { if (e) { - setDebugInfo.current = e.updateDebugInfo + setDebugInfoRef.current = e.updateDebugInfo } }} /> diff --git a/packages/webgl-viewer/src/WebGLImageViewerEngine.ts b/packages/webgl-viewer/src/WebGLImageViewerEngine.ts index 4e2e24aa..9d371f56 100644 --- a/packages/webgl-viewer/src/WebGLImageViewerEngine.ts +++ b/packages/webgl-viewer/src/WebGLImageViewerEngine.ts @@ -1008,6 +1008,36 @@ export class WebGLImageViewerEngine extends ImageViewerEngineBase { return this.scale } + public updateCallbacks({ + onZoomChange, + onImageCopied, + onLoadingStateChange, + }: Pick, 'onZoomChange' | 'onImageCopied' | 'onLoadingStateChange'>) { + this.onZoomChange = onZoomChange + this.onImageCopied = onImageCopied + this.onLoadingStateChange = onLoadingStateChange + } + + public updateInteractionConfig({ + wheel, + pinch, + doubleClick, + panning, + }: Pick, 'wheel' | 'pinch' | 'doubleClick' | 'panning'>) { + this.config.wheel = wheel + this.config.pinch = pinch + this.config.doubleClick = doubleClick + this.config.panning = panning + + if (panning.disabled) { + this.isDragging = false + } + + if (pinch.disabled) { + this.lastTouchDistance = 0 + } + } + public setTileOutlineEnabled(enabled: boolean) { this.tileOutlineEnabled = enabled this.render() @@ -1218,15 +1248,22 @@ export class WebGLImageViewerEngine extends ImageViewerEngineBase { } private handleTouchStart(e: TouchEvent) { + const canHandleSingleTouch = + e.touches.length === 1 && (!this.config.panning.disabled || !this.config.doubleClick.disabled) + const canHandlePinch = e.touches.length === 2 && !this.config.pinch.disabled + + if (!canHandleSingleTouch && !canHandlePinch) { + return + } + e.preventDefault() if (this.isAnimating) { this.isAnimating = false this.animationStartLOD = -1 - return } - if (e.touches.length === 1 && !this.config.panning.disabled) { + if (e.touches.length === 1) { const touch = e.touches[0] const now = Date.now() @@ -1242,9 +1279,11 @@ export class WebGLImageViewerEngine extends ImageViewerEngineBase { return } - this.isDragging = true - this.lastMouseX = touch.clientX - this.lastMouseY = touch.clientY + if (!this.config.panning.disabled) { + this.isDragging = true + this.lastMouseX = touch.clientX + this.lastMouseY = touch.clientY + } this.lastTouchTime = now this.lastTouchX = touch.clientX @@ -1260,9 +1299,9 @@ export class WebGLImageViewerEngine extends ImageViewerEngineBase { } private handleTouchMove(e: TouchEvent) { - e.preventDefault() - if (e.touches.length === 1 && this.isDragging && !this.config.panning.disabled) { + e.preventDefault() + const deltaX = e.touches[0].clientX - this.lastMouseX const deltaY = e.touches[0].clientY - this.lastMouseY @@ -1275,6 +1314,8 @@ export class WebGLImageViewerEngine extends ImageViewerEngineBase { this.constrainImagePosition() this.render() } else if (e.touches.length === 2 && !this.config.pinch.disabled) { + e.preventDefault() + const touch1 = e.touches[0] const touch2 = e.touches[1] const distance = Math.sqrt(