import React, { useEffect, useRef, useState } from 'react'; import { useLocale } from '../../../os/locale'; import { dimens } from '../res/dimens'; import { IcBookCheck, IcBookPlus, IcCheck, IcClock, IcDisc, IcExpand, IcList, IcMessageSquare, IcReaderProgressMark, IcMoreVertical, IcNavBack, IcNavForward, IcShare, IcSun, IcUser, WechatReadingBookshelfIcon, WechatReadingDownloadIcon, WechatReadingFolderIcon, } from '../res/icons'; import { getWechatReadingBookById, useWechatReadingStore } from '../state'; import { useWechatReadingStrings } from '../hooks/useWechatReadingStrings'; import { formatWechatReadingCount, formatWechatReadingWords } from '../utils/localization'; import type { BookChapter } from '../data/types'; import type { TransitionId } from '../navigation.declaration'; import { useAppNavigate } from '../navigation'; import { useReaderTheme } from '../hooks/useReaderTheme'; import { resolveReaderTheme } from '../utils/readerTheme'; import type { ReaderPageBlock } from '../utils/readerPagination'; import { FONT_SIZE_STOPS, LINE_HEIGHT_VALUES, MARGIN_PX_VALUES, normalizeLineHeightIndex, normalizeMarginIndex, } from '../constants'; function getSteppedIndex(clientX: number, rect: DOMRect, stopCount: number): number { const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); return Math.round(pct * (stopCount - 1)); } function getPercentFromClientX(clientX: number, rect: DOMRect): number { return Math.max(0, Math.min(100, Math.round(((clientX - rect.left) / rect.width) * 100))); } function isPrimaryPointer(e: React.PointerEvent): boolean { return e.pointerType !== 'mouse' || e.button === 0; } function bindPointerSlider( pointerIdRef: React.MutableRefObject, onUpdate: (clientX: number, rect: DOMRect) => void, ): React.HTMLAttributes { const updateFromEvent = (e: React.PointerEvent) => { onUpdate(e.clientX, e.currentTarget.getBoundingClientRect()); }; const releasePointer = (e: React.PointerEvent) => { if (pointerIdRef.current !== e.pointerId) return; pointerIdRef.current = null; try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { // Ignore browsers that reject release after cancellation. } }; return { onPointerDown: e => { if (!isPrimaryPointer(e)) return; pointerIdRef.current = e.pointerId; try { e.currentTarget.setPointerCapture(e.pointerId); } catch { // Ignore browsers that do not support pointer capture here. } updateFromEvent(e); }, onPointerMove: e => { if (pointerIdRef.current !== e.pointerId) return; updateFromEvent(e); }, onPointerUp: releasePointer, onPointerCancel: releasePointer, onLostPointerCapture: e => { if (pointerIdRef.current === e.pointerId) { pointerIdRef.current = null; } }, }; } function getSteppedLeft(index: number, maxIndex: number, thumbSize = 48, inset = 0): string { const clamped = Math.max(0, Math.min(maxIndex, index)); const pct = (clamped / maxIndex) * 100; return `calc(${inset}px + ${pct}% - ${(pct * (thumbSize + inset * 2)) / 100}px)`; } function getSteppedFillWidth(index: number, maxIndex: number, thumbSize = 48, inset = 0): string { const clamped = Math.max(0, Math.min(maxIndex, index)); const pct = (clamped / maxIndex) * 100; return `calc(${inset + thumbSize}px + ${pct}% - ${(pct * (thumbSize + inset * 2)) / 100}px)`; } function getNearestFontSizeIndex(value: number): number { let bestIndex = 0; let bestDiff = Infinity; for (let i = 0; i < FONT_SIZE_STOPS.length; i++) { const diff = Math.abs(FONT_SIZE_STOPS[i] - value); if (diff < bestDiff) { bestDiff = diff; bestIndex = i; } } return bestIndex; } export const LocalizedReaderMenu: React.FC<{ visible: boolean; onBack: () => void; isInShelf: boolean; toggleShelfBinding: React.ButtonHTMLAttributes; bindTap: any; bookId: string; activeTool: string | null; hasToc?: boolean; }> = ({ visible, onBack, isInShelf, toggleShelfBinding, bindTap, bookId, activeTool, hasToc }) => { const { go } = useAppNavigate(); const rt = useReaderTheme(); const readerPrefs = useWechatReadingStore(s => s.readerPrefs); const updateReaderPrefs = useWechatReadingStore(s => s.updateReaderPrefs); const updateProgress = useWechatReadingStore(s => s.updateProgress); const bookProgress = useWechatReadingStore(s => s.bookProgress); const s = useWechatReadingStrings(); const locale = useLocale(); const book = getWechatReadingBookById(bookId); const currentProgress = bookProgress[bookId]; const [localProgress, setLocalProgress] = useState(0); const [localBrightness, setLocalBrightness] = useState(60); const [showPageTurnPanel, setShowPageTurnPanel] = useState(false); const settings = useWechatReadingStore(s => s.settings); const updateSettings = useWechatReadingStore(s => s.updateSettings); // Reset page turn panel when tool changes useEffect(() => { if (activeTool !== 'typography') setShowPageTurnPanel(false); }, [activeTool]); const progressPointerIdRef = useRef(null); const brightnessPointerIdRef = useRef(null); const fontSizePointerIdRef = useRef(null); const marginPointerIdRef = useRef(null); const lineHeightPointerIdRef = useRef(null); useEffect(() => { if (book && currentProgress) { const pct = Math.min(100, Math.round((currentProgress.charOffset / book.totalWords) * 100)); setLocalProgress(pct); } }, [book, currentProgress]); const handleProgressChange = (pct: number) => { setLocalProgress(pct); if (book) { const offset = Math.floor((pct / 100) * book.totalWords); updateProgress(bookId, offset); } }; const handleBrightnessChange = (clientX: number, rect: DOMRect) => { setLocalBrightness(getPercentFromClientX(clientX, rect)); }; const fontSize = readerPrefs.fontSize; const themeColor = readerPrefs.themeColor; const themeBg = readerPrefs.themeBg; const marginIndex = normalizeMarginIndex(readerPrefs.margin); const lineHeightIndex = normalizeLineHeightIndex(readerPrefs.lineHeight); const panelHeight = 'h-[18rem]'; const notesUnit = locale === 'en' ? 'items' : '条'; const listenLabel = locale === 'en' ? 'Listen' : '听'; const bindReaderToolToggle = (openId: TransitionId, tool: string) => bindTap(openId, { params: { bookId }, onTrigger: () => { if (activeTool === tool) { go('reader.tool.close', { bookId }); } else { go(openId, { bookId }); } }, }); const progressSliderBinding = bindPointerSlider(progressPointerIdRef, (clientX, rect) => { handleProgressChange(getPercentFromClientX(clientX, rect)); }); const brightnessSliderBinding = bindPointerSlider(brightnessPointerIdRef, (clientX, rect) => { handleBrightnessChange(clientX, rect); }); const fontSizeSliderBinding = bindPointerSlider(fontSizePointerIdRef, (clientX, rect) => { const index = getSteppedIndex(clientX, rect, FONT_SIZE_STOPS.length); updateReaderPrefs({ fontSize: FONT_SIZE_STOPS[index] }); }); const marginSliderBinding = bindPointerSlider(marginPointerIdRef, (clientX, rect) => { const index = getSteppedIndex(clientX, rect, 7); updateReaderPrefs({ margin: normalizeMarginIndex(index) }); }); const lineHeightSliderBinding = bindPointerSlider(lineHeightPointerIdRef, (clientX, rect) => { const index = getSteppedIndex(clientX, rect, 7); updateReaderPrefs({ lineHeight: normalizeLineHeightIndex(index) }); }); return ( <> {(visible || activeTool) && (
{!activeTool && (
)} {(visible || !!activeTool) && (
{activeTool && (activeTool === 'progress' || activeTool === 'theme' || activeTool === 'typography') && (
{activeTool === 'progress' && (
{localProgress} %
{s.reader_approx_finish}
1 {s.common_minutes_unit}
{s.reader_reading_duration}
0 {notesUnit}
{s.reader_notes}
{'<'} {'>'}
{s.reader_last_read_here}
)} {activeTool === 'theme' && (
{s.reader_theme_color}
{s.reader_theme_background}
)} {activeTool === 'typography' && (
{showPageTurnPanel ? ( <> {/* ── Page Turn Style Sub-panel ── */}
{s.page_turn_title}
{([ { key: '仿真翻页', label: s.page_turn_simulation }, { key: '左右滑动', label: s.page_turn_swipe }, { key: '上下滚动', label: s.page_turn_scroll }, { key: '覆盖翻页', label: s.page_turn_cover }, ] as const).map(opt => { const isSelected = settings.pageTurnStyle === opt.key; return ( ); })}
) : ( <> {/* ── Typography controls (original) ── */}
A A
{fontSize}
{s.reader_typography_margin_small} {s.reader_typography_margin_large}
{s.reader_typography_margin_label}
{s.reader_typography_line_tight} {s.reader_typography_line_loose}
{s.reader_typography_line_label}
)}
)}
)}
A
)}
)} ); }; export const LocalizedDoorLeafView: React.FC<{ book: any }> = ({ book }) => { const s = useWechatReadingStrings(); const locale = useLocale(); const rt = useReaderTheme(); return (
{book.cover ? ( {book.title} ) : (
{book.title}
)}

{book.title}

{book.author}

{s.reading_recommendation_value} {book.recommendedValue}%
{book.masterpiece && (
{s.book_detail_masterpiece}
)} {formatWechatReadingCount(book.totalReviews, locale)} {s.book_detail_person_review} {' >'}
{s.book_detail_recommend} ({formatWechatReadingCount(book.reviewBreakdown?.recommended, locale)})
{s.book_detail_average} ({formatWechatReadingCount(book.reviewBreakdown?.average, locale)})
{s.book_detail_not_recommend} ({book.reviewBreakdown?.notRecommended ?? 0})
{s.book_detail_recommend} ({formatWechatReadingCount(book.reviewBreakdown?.recommended, locale)})
{s.book_detail_average} ({formatWechatReadingCount(book.reviewBreakdown?.average, locale)})
{s.book_detail_not_recommend} ({book.reviewBreakdown?.notRecommended ?? 0})
{s.book_detail_stat_reading}
{formatWechatReadingCount(book.totalReads, locale)}
{book.isMembership ? s.reader_membership_paid : s.reader_free}
{s.book_detail_word_count} ?
{formatWechatReadingWords(book.totalWords, locale)}
{s.reader_swipe_to_start}
); }; export const LocalizedContentView: React.FC<{ blocks: ReaderPageBlock[]; chapterTitle: string; isChapterStart: boolean; pageInfo: string; }> = ({ blocks, chapterTitle, isChapterStart, pageInfo }) => { const rt = useReaderTheme(); const readerPrefs = useWechatReadingStore(s => s.readerPrefs); const fs = readerPrefs.fontSize; const lhMul = LINE_HEIGHT_VALUES[normalizeLineHeightIndex(readerPrefs.lineHeight)]; const mPx = MARGIN_PX_VALUES[normalizeMarginIndex(readerPrefs.margin)]; return (
{!isChapterStart && (
{chapterTitle}
)}
{isChapterStart && (

{chapterTitle}

)} {blocks.map((block) => (

{block.text}

))}
{pageInfo}
); }; export const LocalizedReaderShelfModal: React.FC<{ bindBack: any; bindTap: any; bookId: string; onRemove: () => void; }> = ({ bindBack, bindTap, bookId, onRemove }) => { const s = useWechatReadingStrings(); const rt = useReaderTheme(); const rowBg = rt.surface; return ( <>
); }; export const LocalizedTocPanel: React.FC<{ chapters: BookChapter[]; currentChapterIndex: number; onSelectChapter: (index: number) => void; onClose: () => void; }> = ({ chapters, currentChapterIndex, onSelectChapter, onClose }) => { const scrollRef = React.useRef(null); const rt = useReaderTheme(); React.useEffect(() => { const container = scrollRef.current; if (!container) return; const activeItem = container.querySelector('[data-active="true"]'); if (activeItem) { activeItem.scrollIntoView({ block: 'center' }); } }, []); return ( <>
目录 共{chapters.length}章
{chapters.map((ch) => { const isActive = ch.index === currentChapterIndex; return ( ); })}
); };