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..0c4553ab4 --- /dev/null +++ b/packages/app-expo/src/components/reader/ImageViewerModal.tsx @@ -0,0 +1,118 @@ +import { useRef, useState } from "react"; +import { Image, Modal, PanResponder, StyleSheet, View, 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 [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]); + 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, + ), + ); + 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) 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; + + 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" }, +}); 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)} />