From cc77ce3beb8db90c030b35638bf7bbe371279b56 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 13 Aug 2026 15:55:25 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(reader):=20=E5=9B=BE=E7=89=87=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E5=85=A8=E5=B1=8F=E6=9F=A5=E7=9C=8B=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8F=8C=E6=8C=87=E7=BC=A9=E6=94=BE=EF=BC=88=E7=B1=BB?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E8=AF=BB=E4=B9=A6=E4=BD=93=E9=AA=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WebView 捕获图片 tap(img/picture/svg/canvas),阻止冒泡到翻页判定 - 优先级:图片点击 > 翻页,通过 bridge imageTap 消息通知 RN - 新增 ImageViewerModal:全屏查看、双指缩放 1x-4x、拖动识别、点按关闭 - 打开新图片时重置缩放级别 - reader.template.html 与编译产物 reader.html 同步 --- packages/app-expo/assets/reader/reader.html | 11 + .../assets/reader/reader.template.html | 11 + .../components/reader/ImageViewerModal.tsx | 90 ++++ .../app-expo/src/hooks/use-reader-bridge.ts | 418 +++++++++--------- .../app-expo/src/screens/ReaderScreen.tsx | 33 +- 5 files changed, 346 insertions(+), 217 deletions(-) create mode 100644 packages/app-expo/src/components/reader/ImageViewerModal.tsx diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index ee88b5dfd..dc826336e 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -2528,6 +2528,17 @@ } const target = e.target || state.target; + const imageTarget = target && target.closest ? target.closest('img, picture, svg, image, canvas') : null; + if (imageTarget) { + e.preventDefault(); + e.stopPropagation(); + const image = imageTarget.matches && imageTarget.matches('img') + ? imageTarget + : imageTarget.querySelector && imageTarget.querySelector('img'); + const src = image && (image.currentSrc || image.src); + if (src) postToRN('imageTap', { src, alt: image.alt || '' }); + return; + } const interactiveTarget = target && target.closest ? target.closest('a[href], audio, video, button, input, select, textarea') : null; diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html index 37b71bc77..fc21ec8d3 100644 --- a/packages/app-expo/assets/reader/reader.template.html +++ b/packages/app-expo/assets/reader/reader.template.html @@ -2528,6 +2528,17 @@ } const target = e.target || state.target; + const imageTarget = target && target.closest ? target.closest('img, picture, svg, image, canvas') : null; + if (imageTarget) { + e.preventDefault(); + e.stopPropagation(); + const image = imageTarget.matches && imageTarget.matches('img') + ? imageTarget + : imageTarget.querySelector && imageTarget.querySelector('img'); + const src = image && (image.currentSrc || image.src); + if (src) postToRN('imageTap', { src, alt: image.alt || '' }); + return; + } const interactiveTarget = target && target.closest ? target.closest('a[href], audio, video, button, input, select, textarea') : null; diff --git a/packages/app-expo/src/components/reader/ImageViewerModal.tsx b/packages/app-expo/src/components/reader/ImageViewerModal.tsx new file mode 100644 index 000000000..ca5d8d06b --- /dev/null +++ b/packages/app-expo/src/components/reader/ImageViewerModal.tsx @@ -0,0 +1,90 @@ +import { useRef, useState } from "react"; +import { + Modal, + PanResponder, + Pressable, + StyleSheet, + View, + Image, + useWindowDimensions, +} from "react-native"; + +interface Props { + source: string | null; + onClose: () => void; +} + +export function ImageViewerModal({ source, onClose }: Props) { + const { width, height } = useWindowDimensions(); + const [scale, setScale] = useState(1); + const scaleRef = useRef(1); + const startDistance = useRef(0); + const startScale = useRef(1); + const moved = useRef(false); + // Reset zoom when a new image opens (or the viewer closes) + const lastSource = useRef(null); + if (source !== lastSource.current) { + lastSource.current = source; + scaleRef.current = 1; + setScale(1); + } + const responder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: () => true, + onPanResponderGrant: (event) => { + moved.current = false; + const touches = event.nativeEvent.touches; + if (touches.length >= 2) { + startDistance.current = distance(touches[0], touches[1]); + startScale.current = scaleRef.current; + } + }, + onPanResponderMove: (event) => { + const touches = event.nativeEvent.touches; + if (touches.length >= 2 && startDistance.current > 0) { + moved.current = true; + const next = Math.min( + 4, + Math.max( + 1, + (startScale.current * distance(touches[0], touches[1])) / startDistance.current, + ), + ); + scaleRef.current = next; + setScale(next); + } else if (touches.length === 1) { + moved.current = Math.abs(event.nativeEvent.dx) > 8 || Math.abs(event.nativeEvent.dy) > 8; + } + }, + onPanResponderRelease: () => { + startDistance.current = 0; + if (!moved.current && scaleRef.current <= 1.05) onClose(); + }, + }), + ).current; + + return ( + + + + {source ? ( + + ) : null} + + + ); +} + +function distance(a: { pageX: number; pageY: number }, b: { pageX: number; pageY: number }) { + return Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY); +} + +const styles = StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: "#000", alignItems: "center", justifyContent: "center" }, + closeArea: { ...StyleSheet.absoluteFillObject }, +}); diff --git a/packages/app-expo/src/hooks/use-reader-bridge.ts b/packages/app-expo/src/hooks/use-reader-bridge.ts index 45e5a6a47..51c68fa9f 100644 --- a/packages/app-expo/src/hooks/use-reader-bridge.ts +++ b/packages/app-expo/src/hooks/use-reader-bridge.ts @@ -62,6 +62,7 @@ export interface ReaderBridgeCallbacks { onSelection?: (detail: SelectionEvent) => void; onSelectionCleared?: () => void; onTap?: () => void; + onImageTap?: (detail: { src: string; alt?: string }) => void; onSearchResult?: (index: number, count: number) => void; onSearchComplete?: (count: number) => void; onError?: (message: string) => void; @@ -692,232 +693,233 @@ export function useReaderBridge(callbacks: ReaderBridgeCallbacks) { // ─── Handle messages from WebView ─── - const handleMessage = useCallback( - (event: { nativeEvent: { data: string } }) => { - try { - const msg = JSON.parse(event.nativeEvent.data); - const cb = callbacksRef.current; - - switch (msg.type) { - case "ready": - cb.onReady?.(); - break; - case "loaded": - cb.onLoaded?.(); - break; - case "relocate": - cb.onRelocate?.(msg); - break; - case "bookTextMetrics": - cb.onBookTextMetrics?.({ - totalCharacters: Number(msg.totalCharacters) || 0, + const handleMessage = useCallback((event: { nativeEvent: { data: string } }) => { + try { + const msg = JSON.parse(event.nativeEvent.data); + const cb = callbacksRef.current; + + switch (msg.type) { + case "ready": + cb.onReady?.(); + break; + case "loaded": + cb.onLoaded?.(); + break; + case "relocate": + cb.onRelocate?.(msg); + break; + case "bookTextMetrics": + cb.onBookTextMetrics?.({ + totalCharacters: Number(msg.totalCharacters) || 0, + }); + break; + case "toc": + cb.onTocReady?.(msg.items || []); + break; + case "selection": + cb.onSelection?.(msg); + break; + case "selectionCleared": + cb.onSelectionCleared?.(); + break; + case "tap": + cb.onTap?.(); + break; + case "imageTap": + if (typeof msg.src === "string" && msg.src) + cb.onImageTap?.({ src: msg.src, alt: msg.alt }); + break; + case "searchResult": + cb.onSearchResult?.(msg.index || 0, msg.count || 0); + break; + case "searchComplete": + cb.onSearchComplete?.(msg.count || 0); + break; + case "error": + console.error("[ReaderBridge] Error from WebView:", msg.message); + cb.onError?.(msg.message || "Unknown error"); + break; + case "foliate-loaded": + break; + case "show-annotation": + if (msg.value && msg.position) { + cb.onShowAnnotation?.({ + value: msg.value, + range: msg.range, + position: msg.position, }); - break; - case "toc": - cb.onTocReady?.(msg.items || []); - break; - case "selection": - cb.onSelection?.(msg); - break; - case "selectionCleared": - cb.onSelectionCleared?.(); - break; - case "tap": - cb.onTap?.(); - break; - case "searchResult": - cb.onSearchResult?.(msg.index || 0, msg.count || 0); - break; - case "searchComplete": - cb.onSearchComplete?.(msg.count || 0); - break; - case "error": - console.error("[ReaderBridge] Error from WebView:", msg.message); - cb.onError?.(msg.message || "Unknown error"); - break; - case "foliate-loaded": - break; - case "show-annotation": - if (msg.value && msg.position) { - cb.onShowAnnotation?.({ - value: msg.value, - range: msg.range, - position: msg.position, - }); - } - break; - case "note-tooltip": - if (msg.cfi && msg.note && msg.position) { - cb.onNoteTooltip?.({ - cfi: msg.cfi, - note: msg.note, - position: msg.position, - }); - } - break; - case "pageSnippet": - cb.onPageSnippet?.(msg.textSnippet || ""); - break; - case "bookmarkSnippet": - cb.onBookmarkSnippet?.(msg.textSnippet || ""); - break; - case "toggleBookmark": - cb.onToggleBookmark?.(); - break; - case "bookmarkPull": - cb.onBookmarkPull?.({ - offset: typeof msg.offset === "number" ? msg.offset : 0, - armed: !!msg.armed, - active: !!msg.active, + } + break; + case "note-tooltip": + if (msg.cfi && msg.note && msg.position) { + cb.onNoteTooltip?.({ + cfi: msg.cfi, + note: msg.note, + position: msg.position, }); - break; - case "visibleText": - console.log( - "[ReaderBridge] received visibleText:", - JSON.stringify({ - textLength: msg.text?.length || 0, - error: msg.error || "none", - debug: msg.debug || null, - }), - ); - if (pendingVisibleTextResolveRef.current) { - pendingVisibleTextResolveRef.current(msg.text || ""); - pendingVisibleTextResolveRef.current = null; + } + break; + case "pageSnippet": + cb.onPageSnippet?.(msg.textSnippet || ""); + break; + case "bookmarkSnippet": + cb.onBookmarkSnippet?.(msg.textSnippet || ""); + break; + case "toggleBookmark": + cb.onToggleBookmark?.(); + break; + case "bookmarkPull": + cb.onBookmarkPull?.({ + offset: typeof msg.offset === "number" ? msg.offset : 0, + armed: !!msg.armed, + active: !!msg.active, + }); + break; + case "visibleText": + console.log( + "[ReaderBridge] received visibleText:", + JSON.stringify({ + textLength: msg.text?.length || 0, + error: msg.error || "none", + debug: msg.debug || null, + }), + ); + if (pendingVisibleTextResolveRef.current) { + pendingVisibleTextResolveRef.current(msg.text || ""); + pendingVisibleTextResolveRef.current = null; + } + break; + case "visibleTTSSegments": + { + if (msg.debug) { + console.log("[ReaderBridge] visibleTTSSegments debug:", JSON.stringify(msg.debug)); } - break; - case "visibleTTSSegments": - { - if (msg.debug) { - console.log("[ReaderBridge] visibleTTSSegments debug:", JSON.stringify(msg.debug)); + const requestId = typeof msg.requestId === "string" ? msg.requestId : null; + const pendingResolve = requestId + ? pendingVisibleTTSSegmentsResolveRef.current.get(requestId) + : pendingVisibleTTSSegmentsResolveRef.current.values().next().value; + if (pendingResolve) { + if (msg.error) { + console.warn("[ReaderBridge] visibleTTSSegments error:", msg.error); } - const requestId = typeof msg.requestId === "string" ? msg.requestId : null; - const pendingResolve = requestId - ? pendingVisibleTTSSegmentsResolveRef.current.get(requestId) - : pendingVisibleTTSSegmentsResolveRef.current.values().next().value; - if (pendingResolve) { - if (msg.error) { - console.warn("[ReaderBridge] visibleTTSSegments error:", msg.error); - } - pendingResolve(msg.segments || []); - if (requestId) { - pendingVisibleTTSSegmentsResolveRef.current.delete(requestId); - } else { - pendingVisibleTTSSegmentsResolveRef.current.clear(); - } + pendingResolve(msg.segments || []); + if (requestId) { + pendingVisibleTTSSegmentsResolveRef.current.delete(requestId); + } else { + pendingVisibleTTSSegmentsResolveRef.current.clear(); } } - break; - case "ttsSegmentContext": - { - const requestId = typeof msg.requestId === "string" ? msg.requestId : null; - const pendingResolve = requestId - ? pendingTTSContextResolveRef.current.get(requestId) - : pendingTTSContextResolveRef.current.values().next().value; - if (pendingResolve) { - if (msg.error) { - console.warn("[ReaderBridge] ttsSegmentContext error:", msg.error); - } - pendingResolve({ - before: msg.before || [], - after: msg.after || [], - }); - if (requestId) { - pendingTTSContextResolveRef.current.delete(requestId); - } else { - pendingTTSContextResolveRef.current.clear(); - } + } + break; + case "ttsSegmentContext": + { + const requestId = typeof msg.requestId === "string" ? msg.requestId : null; + const pendingResolve = requestId + ? pendingTTSContextResolveRef.current.get(requestId) + : pendingTTSContextResolveRef.current.values().next().value; + if (pendingResolve) { + if (msg.error) { + console.warn("[ReaderBridge] ttsSegmentContext error:", msg.error); + } + pendingResolve({ + before: msg.before || [], + after: msg.after || [], + }); + if (requestId) { + pendingTTSContextResolveRef.current.delete(requestId); + } else { + pendingTTSContextResolveRef.current.clear(); } } - break; - case "hrefTTSSegments": - { - const requestId = typeof msg.requestId === "string" ? msg.requestId : null; - const pendingResolve = requestId - ? pendingHrefTTSSegmentsResolveRef.current.get(requestId) - : pendingHrefTTSSegmentsResolveRef.current.values().next().value; - if (pendingResolve) { - if (msg.error) { - console.warn("[ReaderBridge] hrefTTSSegments error:", msg.error); - } - pendingResolve(msg.segments || []); - if (requestId) { - pendingHrefTTSSegmentsResolveRef.current.delete(requestId); - } else { - pendingHrefTTSSegmentsResolveRef.current.clear(); - } + } + break; + case "hrefTTSSegments": + { + const requestId = typeof msg.requestId === "string" ? msg.requestId : null; + const pendingResolve = requestId + ? pendingHrefTTSSegmentsResolveRef.current.get(requestId) + : pendingHrefTTSSegmentsResolveRef.current.values().next().value; + if (pendingResolve) { + if (msg.error) { + console.warn("[ReaderBridge] hrefTTSSegments error:", msg.error); + } + pendingResolve(msg.segments || []); + if (requestId) { + pendingHrefTTSSegmentsResolveRef.current.delete(requestId); + } else { + pendingHrefTTSSegmentsResolveRef.current.clear(); } } - break; - case "sectionTTSSegments": - { - const requestId = typeof msg.requestId === "string" ? msg.requestId : null; - const pendingResolve = requestId - ? pendingSectionTTSSegmentsResolveRef.current.get(requestId) - : pendingSectionTTSSegmentsResolveRef.current.values().next().value; - if (pendingResolve) { - if (msg.error) { - console.warn("[ReaderBridge] sectionTTSSegments error:", msg.error); - } - pendingResolve(msg.segments || []); - if (requestId) { - pendingSectionTTSSegmentsResolveRef.current.delete(requestId); - } else { - pendingSectionTTSSegmentsResolveRef.current.clear(); - } + } + break; + case "sectionTTSSegments": + { + const requestId = typeof msg.requestId === "string" ? msg.requestId : null; + const pendingResolve = requestId + ? pendingSectionTTSSegmentsResolveRef.current.get(requestId) + : pendingSectionTTSSegmentsResolveRef.current.values().next().value; + if (pendingResolve) { + if (msg.error) { + console.warn("[ReaderBridge] sectionTTSSegments error:", msg.error); + } + pendingResolve(msg.segments || []); + if (requestId) { + pendingSectionTTSSegmentsResolveRef.current.delete(requestId); + } else { + pendingSectionTTSSegmentsResolveRef.current.clear(); } } - break; - case "chapterParagraphs": - console.log( - "[ChapterTranslation] Received chapterParagraphs:", - JSON.stringify({ - count: msg.paragraphs?.length || 0, - error: msg.error || "none", - }), + } + break; + case "chapterParagraphs": + console.log( + "[ChapterTranslation] Received chapterParagraphs:", + JSON.stringify({ + count: msg.paragraphs?.length || 0, + error: msg.error || "none", + }), + ); + if (pendingChapterParagraphsResolveRef.current) { + if (msg.error) { + console.warn("[ChapterTranslation] WebView error:", msg.error); + } + pendingChapterParagraphsResolveRef.current(msg.paragraphs || []); + pendingChapterParagraphsResolveRef.current = null; + } else { + console.warn( + "[ChapterTranslation] No pending resolve for chapterParagraphs (timed out?)", ); - if (pendingChapterParagraphsResolveRef.current) { + } + break; + case "chapterTranslationsInjected": + { + const requestId = typeof msg.requestId === "string" ? msg.requestId : null; + const pendingResolve = requestId + ? pendingChapterTranslationInjectionResolveRef.current.get(requestId) + : pendingChapterTranslationInjectionResolveRef.current.values().next().value; + if (pendingResolve) { if (msg.error) { - console.warn("[ChapterTranslation] WebView error:", msg.error); + console.warn("[ChapterTranslation] WebView injection error:", msg.error); } - pendingChapterParagraphsResolveRef.current(msg.paragraphs || []); - pendingChapterParagraphsResolveRef.current = null; - } else { - console.warn( - "[ChapterTranslation] No pending resolve for chapterParagraphs (timed out?)", - ); - } - break; - case "chapterTranslationsInjected": - { - const requestId = typeof msg.requestId === "string" ? msg.requestId : null; - const pendingResolve = requestId - ? pendingChapterTranslationInjectionResolveRef.current.get(requestId) - : pendingChapterTranslationInjectionResolveRef.current.values().next().value; - if (pendingResolve) { - if (msg.error) { - console.warn("[ChapterTranslation] WebView injection error:", msg.error); - } - pendingResolve(); - if (requestId) { - pendingChapterTranslationInjectionResolveRef.current.delete(requestId); - } else { - pendingChapterTranslationInjectionResolveRef.current.clear(); - } + pendingResolve(); + if (requestId) { + pendingChapterTranslationInjectionResolveRef.current.delete(requestId); + } else { + pendingChapterTranslationInjectionResolveRef.current.clear(); } } - break; - case "debug": - console.log("[WebView]", msg.message); - break; - default: - break; - } - } catch (err) { - console.error("[ReaderBridge] Parse error:", err); + } + break; + case "debug": + console.log("[WebView]", msg.message); + break; + default: + break; } - }, - [], - ); + } catch (err) { + console.error("[ReaderBridge] Parse error:", err); + } + }, []); return useMemo( () => ({ diff --git a/packages/app-expo/src/screens/ReaderScreen.tsx b/packages/app-expo/src/screens/ReaderScreen.tsx index 3eb75832a..07001de85 100644 --- a/packages/app-expo/src/screens/ReaderScreen.tsx +++ b/packages/app-expo/src/screens/ReaderScreen.tsx @@ -3,6 +3,7 @@ import { BookmarkRibbon } from "@/components/reader/BookmarkRibbon"; import { ChapterTranslationSheet } from "@/components/reader/ChapterTranslationSheet"; import { ReadingProgressSlider } from "@/components/reader/ReadingProgressSlider"; import { SelectionPopover } from "@/components/reader/SelectionPopover"; +import { ImageViewerModal } from "@/components/reader/ImageViewerModal"; import { TTSPage } from "@/components/reader/TTSPage"; import { TranslationPanel } from "@/components/reader/TranslationPanel"; import { @@ -149,11 +150,7 @@ const NOTE_TOOLTIP_TOP_THRESHOLD = 180; import { useRubyStore } from "@readany/core/stores/ruby-store"; import { ReaderSettingsPanel } from "./reader/ReaderSettingsPanel"; import { ReaderTOCPanel } from "./reader/ReaderTOCPanel"; -import { - CONTROLS_TIMEOUT, - SCREEN_HEIGHT, - SCREEN_WIDTH, -} from "./reader/reader-constants"; +import { CONTROLS_TIMEOUT, SCREEN_HEIGHT, SCREEN_WIDTH } from "./reader/reader-constants"; import { BatteryIcon, ListIcon, SettingsIcon } from "./reader/reader-icons"; import { makeStyles, noteTooltipMdStyles } from "./reader/reader-styles"; import { useReaderBookmark } from "./reader/useReaderBookmark"; @@ -241,6 +238,7 @@ export function ReaderScreen({ route, navigation }: Props) { const [readerHtmlUri, setReaderHtmlUri] = useState(null); const [currentCfi, setCurrentCfi] = useState(""); const [selection, setSelection] = useState(null); + const [imageViewerSource, setImageViewerSource] = useState(null); const [fontServerUrl, setFontServerUrl] = useState(null); const [noteViewHighlight, setNoteViewHighlight] = useState<{ id: string; @@ -812,6 +810,7 @@ export function ReaderScreen({ route, navigation }: Props) { } toggleControls(); }, + onImageTap: ({ src }) => setImageViewerSource(src), onToggleBookmark: () => { handleToggleBookmark(); }, @@ -907,10 +906,25 @@ export function ReaderScreen({ route, navigation }: Props) { appActive, // 维护约定:任何新增遮盖正文/输入态/导航跳转,必须在此追加判定。 [ - readSettings.volumeButtonsPageTurn, webViewReady, loading, error, isReimporting, - showSearch, showTOC, showSettings, showNotebook, showTTS, - showTranslation, showChapterTranslation, chapterTranslation.state.status, - selection, noteViewHighlight, noteTooltip, ttsPlayState, isFocused, appActive, + readSettings.volumeButtonsPageTurn, + webViewReady, + loading, + error, + isReimporting, + showSearch, + showTOC, + showSettings, + showNotebook, + showTTS, + showTranslation, + showChapterTranslation, + chapterTranslation.state.status, + selection, + noteViewHighlight, + noteTooltip, + ttsPlayState, + isFocused, + appActive, ], ); @@ -2028,6 +2042,7 @@ export function ReaderScreen({ route, navigation }: Props) { /> {/* ─── Notebook Panel ─── */} + setImageViewerSource(null)} /> Date: Thu, 13 Aug 2026 17:37:52 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(reader):=20=E5=9B=BE=E7=89=87=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=E5=99=A8=E5=A2=9E=E5=BC=BA=E2=80=94=E2=80=94=E6=8B=96?= =?UTF-8?q?=E5=8A=A8=E5=B9=B3=E7=A7=BB=E3=80=81=E5=8F=8C=E5=87=BB=E6=94=BE?= =?UTF-8?q?=E5=A4=A7=E3=80=81=E6=89=8B=E5=8A=BF=E7=AB=9E=E4=BA=89=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除覆盖图片的 Pressable,统一 PanResponder 处理手势 - 放大后单指拖动平移查看细节 - 双击 1x ↔ 2x 切换,单击关闭延迟 280ms 避免抢占双击 - 拖动/缩放后不误关闭,切换图片重置缩放和平移 --- .../components/reader/ImageViewerModal.tsx | 58 ++++++++++++++----- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/packages/app-expo/src/components/reader/ImageViewerModal.tsx b/packages/app-expo/src/components/reader/ImageViewerModal.tsx index ca5d8d06b..0c4553ab4 100644 --- a/packages/app-expo/src/components/reader/ImageViewerModal.tsx +++ b/packages/app-expo/src/components/reader/ImageViewerModal.tsx @@ -1,13 +1,5 @@ import { useRef, useState } from "react"; -import { - Modal, - PanResponder, - Pressable, - StyleSheet, - View, - Image, - useWindowDimensions, -} from "react-native"; +import { Image, Modal, PanResponder, StyleSheet, View, useWindowDimensions } from "react-native"; interface Props { source: string | null; @@ -17,23 +9,38 @@ interface Props { export function ImageViewerModal({ source, onClose }: Props) { const { width, height } = useWindowDimensions(); const [scale, setScale] = useState(1); + const [offset, setOffset] = useState({ x: 0, y: 0 }); const scaleRef = useRef(1); + const offsetRef = useRef({ x: 0, y: 0 }); + const startOffset = useRef({ x: 0, y: 0 }); const startDistance = useRef(0); const startScale = useRef(1); const moved = useRef(false); + const lastTap = useRef(0); // Reset zoom when a new image opens (or the viewer closes) const lastSource = useRef(null); if (source !== lastSource.current) { lastSource.current = source; scaleRef.current = 1; + offsetRef.current = { x: 0, y: 0 }; setScale(1); + setOffset({ x: 0, y: 0 }); } + const updateScale = (next: number) => { + scaleRef.current = next; + setScale(next); + if (next <= 1) { + offsetRef.current = { x: 0, y: 0 }; + setOffset({ x: 0, y: 0 }); + } + }; const responder = useRef( PanResponder.create({ onStartShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true, onPanResponderGrant: (event) => { moved.current = false; + startOffset.current = offsetRef.current; const touches = event.nativeEvent.touches; if (touches.length >= 2) { startDistance.current = distance(touches[0], touches[1]); @@ -51,15 +58,34 @@ export function ImageViewerModal({ source, onClose }: Props) { (startScale.current * distance(touches[0], touches[1])) / startDistance.current, ), ); - scaleRef.current = next; - setScale(next); + updateScale(next); + } else if (touches.length === 1 && scaleRef.current > 1) { + moved.current = Math.abs(event.nativeEvent.dx) > 8 || Math.abs(event.nativeEvent.dy) > 8; + const next = { + x: startOffset.current.x + event.nativeEvent.dx, + y: startOffset.current.y + event.nativeEvent.dy, + }; + offsetRef.current = next; + setOffset(next); } else if (touches.length === 1) { moved.current = Math.abs(event.nativeEvent.dx) > 8 || Math.abs(event.nativeEvent.dy) > 8; } }, onPanResponderRelease: () => { startDistance.current = 0; - if (!moved.current && scaleRef.current <= 1.05) onClose(); + if (moved.current) return; + const now = Date.now(); + if (now - lastTap.current < 280) { + updateScale(scaleRef.current < 2 ? 2 : 1); + lastTap.current = 0; + return; + } + lastTap.current = now; + if (scaleRef.current <= 1.05) { + setTimeout(() => { + if (lastTap.current === now && !moved.current) onClose(); + }, 280); + } }, }), ).current; @@ -67,12 +93,15 @@ export function ImageViewerModal({ source, onClose }: Props) { return ( - {source ? ( ) : null} @@ -86,5 +115,4 @@ function distance(a: { pageX: number; pageY: number }, b: { pageX: number; pageY const styles = StyleSheet.create({ backdrop: { flex: 1, backgroundColor: "#000", alignItems: "center", justifyContent: "center" }, - closeArea: { ...StyleSheet.absoluteFillObject }, });