From b52b8ed109771169de3114f2b46dfa9744897cb9 Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Sat, 1 Aug 2026 19:30:54 +0800 Subject: [PATCH 1/3] =?UTF-8?q?refactor(mobile):=20=E9=9D=9C=E6=85=8B?= =?UTF-8?q?=E6=A8=A3=E5=BC=8F=E6=94=B9=E7=94=A8=20StyleSheet.create?= =?UTF-8?q?=EF=BC=8C=E5=8B=95=E6=85=8B=E9=83=A8=E5=88=86=E4=BF=9D=E7=95=99?= =?UTF-8?q?=20inline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依 RN 效能最佳實踐,將不依賴 props/state/theme 的樣式抽到各元件的 StyleSheet.create;隨 theme 顏色、props(如 width)、互動狀態變動的 部分仍用 style={[styles.x, { 動態值 }]} 合併,避免每次 render 產生 新物件、也讓之後套用 React.memo 的清單元件(如 BookCard)能真正生效。 Co-Authored-By: Claude Sonnet 5 --- mobile/app/(tabs)/index.tsx | 71 +++- mobile/app/(tabs)/settings.tsx | 84 +++-- mobile/app/reader/[id].tsx | 222 +++++++++---- mobile/components/BookCard.tsx | 131 +++++--- mobile/components/ListPanel.tsx | 499 +++++++++++++++++++++------- mobile/components/SelectionBar.tsx | 80 +++-- mobile/components/SettingsPanel.tsx | 318 ++++++++++++++---- mobile/components/SortControl.tsx | 47 +-- 8 files changed, 1087 insertions(+), 365 deletions(-) diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index ac1330c..09b0468 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -1,6 +1,6 @@ import { router, useFocusEffect } from 'expo-router'; import { useCallback, useMemo, useRef, useState } from 'react'; -import { Alert, FlatList, Pressable, Text, View, useWindowDimensions } from 'react-native'; +import { Alert, FlatList, Pressable, StyleSheet, Text, View, useWindowDimensions } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { WebView } from 'react-native-webview'; import BookCard from '../../components/BookCard'; @@ -186,24 +186,24 @@ const LibraryScreen = () => { }, [books, sort]); return ( - - - - 書櫃 {books.length} 本 + + + + 書櫃 {books.length} 本 - + 加入書籍 + + 加入書籍 {books.length > 0 && ( - + )} {!loading && books.length === 0 ? ( - + 尚未加入任何書籍 ) : ( @@ -212,8 +212,8 @@ const LibraryScreen = () => { data={shown} keyExtractor={(item) => item.id} numColumns={COLUMNS} - contentContainerStyle={{ padding: H_PADDING, gap: GRID_GAP }} - columnWrapperStyle={{ gap: GRID_GAP }} + contentContainerStyle={styles.listContent} + columnWrapperStyle={styles.columnWrapper} renderItem={({ item }) => ( { // 用 1x1 近乎歸零的尺寸隱藏這個 WebView 時,WKWebView(iOS)有可能不會確實跑內容的 JS // (近似 headless/離屏極小視圖被系統節流),改用較合理的尺寸(100x100)搬到畫面外, // 只用 opacity:0 隱藏,避免這個因素導致整個 extractMeta 流程收不到任何回應。 - style={{ position: 'absolute', width: 100, height: 100, opacity: 0, top: -1000, left: 0 }} + style={styles.hiddenExtractorWebview} pointerEvents="none" /> ); }; +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + height: 44, + paddingHorizontal: 16, + }, + headerTitle: { + fontSize: 20, + fontWeight: '600', + }, + headerCount: { + fontSize: 13, + fontWeight: '400', + }, + addButtonText: { + fontSize: 16, + color: '#2563eb', + }, + sortWrapper: { + paddingHorizontal: 16, + paddingBottom: 12, + }, + emptyState: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + listContent: { + padding: H_PADDING, + gap: GRID_GAP, + }, + columnWrapper: { + gap: GRID_GAP, + }, + hiddenExtractorWebview: { + position: 'absolute', + width: 100, + height: 100, + opacity: 0, + top: -1000, + left: 0, + }, +}); + export default LibraryScreen; diff --git a/mobile/app/(tabs)/settings.tsx b/mobile/app/(tabs)/settings.tsx index 650b49f..91e3606 100644 --- a/mobile/app/(tabs)/settings.tsx +++ b/mobile/app/(tabs)/settings.tsx @@ -1,4 +1,4 @@ -import { Pressable, Text, View } from 'react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useTheme } from '../../lib/theme'; @@ -6,47 +6,69 @@ const SettingsScreen = () => { const { darkMode, toggleDarkMode, colors } = useTheme(); return ( - - - 設定 - - - 深色模式 + + 設定 + + 深色模式 - + ); }; +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + }, + title: { + fontSize: 20, + fontWeight: '600', + paddingHorizontal: 16, + height: 44, + lineHeight: 44, + }, + toggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginHorizontal: 16, + marginTop: 8, + paddingHorizontal: 16, + paddingVertical: 14, + borderRadius: 10, + borderWidth: 1, + }, + toggleLabel: { + fontSize: 15, + }, + switchTrack: { + width: 48, + height: 28, + borderRadius: 14, + padding: 2, + justifyContent: 'center', + }, + switchThumb: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: '#fff', + }, +}); + export default SettingsScreen; diff --git a/mobile/app/reader/[id].tsx b/mobile/app/reader/[id].tsx index a7a9d4a..99542f3 100644 --- a/mobile/app/reader/[id].tsx +++ b/mobile/app/reader/[id].tsx @@ -1,7 +1,7 @@ import * as Clipboard from 'expo-clipboard'; import { useLocalSearchParams, router } from 'expo-router'; import { useRef, useState } from 'react'; -import { ActivityIndicator, Linking, Pressable, Text, View } from 'react-native'; +import { ActivityIndicator, Linking, Pressable, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { WebView } from 'react-native-webview'; import { IconBack, IconBookmarkFill, IconBookmarkOutline, IconChapters, IconNotes, IconPause, IconPlay, IconReset, IconSettings, IconSleepTimer } from '../../components/icons'; @@ -124,8 +124,8 @@ const ReaderScreen = () => { const pageProgress = formatPageProgress(engine.pageInfo); return ( - - + + { > - + {engine.record?.title ?? '閱讀中'} - + { accessibilityRole="button" accessibilityLabel={annotationMode ? '結束劃線模式' : '進入劃線模式'} accessibilityState={{ selected: annotationMode }} - style={{ - width: 30, height: 30, borderRadius: 8, alignItems: 'center', justifyContent: 'center', - backgroundColor: annotationMode ? colors.paperBg2 : 'transparent', - }} + style={[styles.headerIconButton, { backgroundColor: annotationMode ? colors.paperBg2 : 'transparent' }]} > @@ -165,10 +162,7 @@ const ReaderScreen = () => { accessibilityRole="button" accessibilityLabel="書籤/目錄/資訊" accessibilityState={{ selected: listPanelTab !== null }} - style={{ - width: 30, height: 30, borderRadius: 8, alignItems: 'center', justifyContent: 'center', - backgroundColor: listPanelTab !== null ? colors.paperBg2 : 'transparent', - }} + style={[styles.headerIconButton, { backgroundColor: listPanelTab !== null ? colors.paperBg2 : 'transparent' }]} > @@ -178,16 +172,13 @@ const ReaderScreen = () => { accessibilityRole="button" accessibilityLabel="排版與語音設定" accessibilityState={{ selected: settingsVisible }} - style={{ - width: 30, height: 30, borderRadius: 8, alignItems: 'center', justifyContent: 'center', - backgroundColor: settingsVisible ? colors.paperBg2 : 'transparent', - }} + style={[styles.headerIconButton, { backgroundColor: settingsVisible ? colors.paperBg2 : 'transparent' }]} > - + { webviewDebuggingEnabled={__DEV__} bounces={false} overScrollMode="never" - style={{ flex: 1, opacity: engine.loading ? 0 : 1 }} + style={[styles.webview, { opacity: engine.loading ? 0 : 1 }]} /> {annotationMode && ( - 劃線模式中:長按文字選取即可標記,點擊畫面翻頁已暫停 + 劃線模式中:長按文字選取即可標記,點擊畫面翻頁已暫停 )} {engine.loading && !engine.errorMessage ? ( - + ) : null} {engine.errorMessage ? ( - - 載入失敗:{engine.errorMessage} + + 載入失敗:{engine.errorMessage} ) : null} {settingsVisible && ( @@ -288,23 +276,21 @@ const ReaderScreen = () => { 與底部頁碼列之間,屬於一般排版流(不是蓋在 WebView 上的 overlay),才不會遮到 正在閱讀的文字;也因為高度固定不隨播放狀態變動,不會觸發 WebView resize 讓 epub.js 重新分頁(見下面頁碼列註解,同一個理由)。 */} - + @@ -313,14 +299,11 @@ const ReaderScreen = () => { hitSlop={10} accessibilityRole="button" accessibilityLabel={tts.playing ? '暫停朗讀' : '開始朗讀'} - style={{ - width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', - backgroundColor: tts.playing ? colors.progressFill : colors.ink, - }} + style={[styles.playButton, { backgroundColor: tts.playing ? colors.progressFill : colors.ink }]} > {tts.playing ? : } - + { ? `睡眠計時倒數 ${Math.floor(tts.sleepRemaining / 60)}分${tts.sleepRemaining % 60}秒` : `睡眠計時 ${tts.sleepMinutes} 分鐘` } - style={{ - width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center', - backgroundColor: tts.sleepMinutes > 0 ? colors.paperBg2 : 'transparent', - borderWidth: 1, borderColor: colors.borderColor, - }} + style={[ + styles.circleButton34, + { + backgroundColor: tts.sleepMinutes > 0 ? colors.paperBg2 : 'transparent', + borderColor: colors.borderColor, + }, + ]} > 0 ? colors.progressFill : colors.ink2} /> {tts.sleepMinutes > 0 && ( - + {tts.sleepRemaining !== null ? `${String(Math.floor(tts.sleepRemaining / 60)).padStart(2, '0')}:${String(tts.sleepRemaining % 60).padStart(2, '0')}` : `${tts.sleepMinutes}分`} @@ -353,23 +338,21 @@ const ReaderScreen = () => { null 變有值時這塊區域才冒出來,導致 WebView 版面高度跟著變動——epub.js 的分頁是 依照初次拿到的 viewer 尺寸算的,事後才緊縮 WebView 高度容易讓已渲染好的那一頁內容 被裁切,需要等 resize 事件跑完才會重新分頁,中間會有一段畫面被蓋住的空窗期。 */} - + {pageProgress && ( <> - + 第 {pageProgress.page} 頁 - + - + / {pageProgress.total} · {pageProgress.percent}% @@ -380,4 +363,125 @@ const ReaderScreen = () => { ); }; +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + height: 44, + paddingHorizontal: 12, + }, + headerTitle: { + flex: 1, + textAlign: 'center', + fontSize: 16, + }, + headerIcons: { + flexDirection: 'row', + alignItems: 'center', + gap: 16, + }, + headerIconButton: { + width: 30, + height: 30, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + webviewContainer: { + flex: 1, + }, + webview: { + flex: 1, + }, + annotationBanner: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + paddingVertical: 6, + alignItems: 'center', + }, + annotationBannerText: { + fontSize: 11, + color: '#fff', + }, + centerOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + alignItems: 'center', + justifyContent: 'center', + }, + errorOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + alignItems: 'center', + justifyContent: 'center', + padding: 24, + }, + errorText: { + textAlign: 'center', + }, + ttsBar: { + height: 52, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 20, + borderTopWidth: 1, + }, + circleButton34: { + width: 34, + height: 34, + borderRadius: 17, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + }, + playButton: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: 'center', + justifyContent: 'center', + }, + sleepWrapper: { + width: 34, + alignItems: 'center', + }, + sleepCountdownText: { + fontSize: 9, + marginTop: 2, + }, + pageBar: { + height: 28, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 14, + paddingBottom: 8, + }, + pageText: { + fontSize: 10, + letterSpacing: 0.5, + }, + pageProgressTrack: { + flex: 1, + height: 3, + borderRadius: 2, + }, + pageProgressFill: { + height: '100%', + borderRadius: 2, + }, +}); + export default ReaderScreen; diff --git a/mobile/components/BookCard.tsx b/mobile/components/BookCard.tsx index 8258590..20d9757 100644 --- a/mobile/components/BookCard.tsx +++ b/mobile/components/BookCard.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { Image, Pressable, Text, View } from 'react-native'; +import { Image, Pressable, StyleSheet, Text, View } from 'react-native'; import { getCoverUri, type BookRecord } from '../lib/library'; import { coverStyleFor } from '../lib/coverStyles'; import { useTheme } from '../lib/theme'; @@ -29,30 +29,19 @@ const BookCard = ({ record, width, onPress, onDelete }: Props) => { accessibilityRole="button" accessibilityLabel={record.author ? `開啟《${record.title}》,作者 ${record.author}` : `開啟《${record.title}》`} > - + {coverUri ? ( - + ) : ( - + - - + + {record.title} {record.author ? ( - + {record.author} ) : null} @@ -66,41 +55,109 @@ const BookCard = ({ record, width, onPress, onDelete }: Props) => { - ✕ + ✕ - - + + {record.title} {record.author ? ( - + {record.author} ) : null} - - - + + + - {pct === 100 ? '讀畢' : `${pct}%`} + {pct === 100 ? '讀畢' : `${pct}%`} ); }; +const styles = StyleSheet.create({ + cover: { + borderRadius: 6, + overflow: 'hidden', + }, + coverImage: { + width: '100%', + height: '100%', + }, + coverPlaceholder: { + flex: 1, + justifyContent: 'space-between', + padding: 14, + }, + ruleLine: { + width: 20, + height: 2, + marginBottom: 8, + }, + placeholderTitle: { + fontSize: 13, + fontWeight: '600', + lineHeight: 17, + }, + placeholderAuthor: { + fontSize: 9, + letterSpacing: 1, + textTransform: 'uppercase', + opacity: 0.75, + }, + deleteButton: { + position: 'absolute', + top: 6, + right: 6, + width: 26, + height: 26, + borderRadius: 13, + backgroundColor: 'rgba(0,0,0,0.55)', + alignItems: 'center', + justifyContent: 'center', + }, + deleteButtonText: { + color: '#fff', + fontSize: 12, + lineHeight: 14, + }, + infoContainer: { + marginTop: 8, + }, + title: { + fontSize: 13, + fontWeight: '500', + lineHeight: 17, + }, + author: { + fontSize: 11, + marginTop: 3, + }, + progressRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 6, + gap: 6, + }, + progressTrack: { + flex: 1, + height: 4, + borderRadius: 2, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + }, + progressLabel: { + fontSize: 10, + }, +}); + export default BookCard; diff --git a/mobile/components/ListPanel.tsx b/mobile/components/ListPanel.tsx index ee60b9f..36e661e 100644 --- a/mobile/components/ListPanel.tsx +++ b/mobile/components/ListPanel.tsx @@ -1,6 +1,6 @@ import * as Clipboard from 'expo-clipboard'; import { useMemo, useRef, useState } from 'react'; -import { Image, Pressable, ScrollView, Share, Text, TextInput, View } from 'react-native'; +import { Image, Pressable, ScrollView, Share, StyleSheet, Text, TextInput, View } from 'react-native'; import { IconCopy } from './icons'; import { HIGHLIGHT_COLORS } from '../lib/annotationColors'; import { getCoverUri, type Annotation, type BookRecord, type Bookmark } from '../lib/library'; @@ -115,14 +115,12 @@ const ListPanel = ({ onPress={() => onNavigateChapter(item.href)} accessibilityRole="button" accessibilityLabel={`前往章節:${item.label}`} - style={{ - paddingVertical: 10, - paddingLeft: 16 + depth * 14, - paddingRight: 16, - backgroundColor: item.href === activeHref ? colors.paperBg2 : 'transparent', - }} + style={[ + styles.tocItem, + { paddingLeft: 16 + depth * 14, backgroundColor: item.href === activeHref ? colors.paperBg2 : 'transparent' }, + ]} > - + {item.label} @@ -135,8 +133,8 @@ const ListPanel = ({ const coverUri = useMemo(() => (record ? getCoverUri(record) : null), [record?.coverFilename, record?.coverUri]); return ( - - + + {TABS.map((t) => { const active = tab === t.key; return ( @@ -146,46 +144,46 @@ const ListPanel = ({ accessibilityRole="tab" accessibilityLabel={t.label} accessibilityState={{ selected: active }} - style={{ flex: 1, height: 30, borderRadius: 6, alignItems: 'center', justifyContent: 'center', backgroundColor: active ? colors.paperBg : 'transparent' }} + style={[styles.tabButton, { backgroundColor: active ? colors.paperBg : 'transparent' }]} > - {t.label} + {t.label} ); })} {tab === 'bookmarks' && ( - + {bookmarks.length === 0 ? ( - 尚無書籤 + 尚無書籤 ) : ( [...bookmarks].sort((a, b) => a.addedAt - b.addedAt).map((bm) => ( - + onNavigateBookmark(bm)} accessibilityRole="button" accessibilityLabel={`跳至書籤:${bm.label}`}> - {bm.label} + {bm.label} - - {formatDate(bm.addedAt)} + + {formatDate(bm.addedAt)} setPendingDeleteId(pendingDeleteId === bm.id ? null : bm.id)} hitSlop={8} accessibilityRole="button" accessibilityLabel="移除書籤" > - 移除 + 移除 {pendingDeleteId === bm.id && ( - - 確定移除? - setPendingDeleteId(null)} style={{ paddingHorizontal: 8, height: 22, borderRadius: 5, backgroundColor: colors.paperBg2, alignItems: 'center', justifyContent: 'center' }}> - 取消 + + 確定移除? + setPendingDeleteId(null)} style={[styles.smallCancelButton, { backgroundColor: colors.paperBg2 }]}> + 取消 { onDeleteBookmark(bm.id); setPendingDeleteId(null); }} - style={{ paddingHorizontal: 8, height: 22, borderRadius: 5, backgroundColor: '#ef4444', alignItems: 'center', justifyContent: 'center' }} + style={styles.smallDangerButton} > - 移除 + 移除 )} @@ -196,9 +194,9 @@ const ListPanel = ({ )} {tab === 'chapters' && ( - + {toc.length === 0 ? ( - 此書籍無目錄資料 + 此書籍無目錄資料 ) : ( toc.map((item) => renderTocItem(item, 0)) )} @@ -206,118 +204,118 @@ const ListPanel = ({ )} {tab === 'bookinfo' && record && ( - - + + {coverUri ? ( - + ) : cs ? ( - - {record.title} - {record.author ? {record.author} : null} + + {record.title} + {record.author ? {record.author} : null} ) : null} - {record.title} - {record.author ? {record.author} : null} + {record.title} + {record.author ? {record.author} : null} {pct !== null && ( - - 閱讀進度 - {pct}% + + 閱讀進度 + {pct}% - - + + )} - - - 匯入時間 - {formatDate(record.addedAt)} + + + 匯入時間 + {formatDate(record.addedAt)} - - 最後閱讀 - {record.lastOpenedAt ? formatDate(record.lastOpenedAt) : '尚未記錄'} + + 最後閱讀 + {record.lastOpenedAt ? formatDate(record.lastOpenedAt) : '尚未記錄'} Clipboard.setStringAsync(record.title)} accessibilityRole="button" accessibilityLabel="複製書名" - style={{ - flexDirection: 'row', alignItems: 'center', gap: 8, - paddingVertical: 9, paddingHorizontal: 14, borderRadius: 8, - backgroundColor: colors.paperBg2, borderWidth: 1, borderColor: colors.borderColor, - }} + style={[styles.copyButton, { backgroundColor: colors.paperBg2, borderColor: colors.borderColor }]} > - 複製書名 + 複製書名 )} {tab === 'notes' && ( - + {annotations.length > 0 && ( - + - {allAnnSelected ? '取消全選' : '全選'} + {allAnnSelected ? '取消全選' : '全選'} 0 ? colors.progressFill : 'transparent', - opacity: selectedAnnIds.size > 0 ? 1 : 0.5, - }} + style={[ + styles.exportButton, + { + backgroundColor: selectedAnnIds.size > 0 ? colors.progressFill : 'transparent', + opacity: selectedAnnIds.size > 0 ? 1 : 0.5, + }, + ]} > - 0 ? '#fff' : colors.ink3 }}> + 0 ? '#fff' : colors.ink3 }]}> 匯出{selectedAnnIds.size > 0 ? ` (${selectedAnnIds.size})` : ''} )} - + {annotations.length === 0 ? ( - - 尚無註記 - + + 尚無註記 + 在書本內文選取文字,即可劃線、加註感想。 ) : ( annotations.map((a) => ( - - + + toggleSelectAnn(a.id)} hitSlop={8} accessibilityRole="checkbox" accessibilityState={{ checked: selectedAnnIds.has(a.id) }} - style={{ - width: 18, height: 18, borderRadius: 4, marginTop: 3, alignItems: 'center', justifyContent: 'center', - borderWidth: 1.5, borderColor: selectedAnnIds.has(a.id) ? colors.progressFill : colors.borderColor, - backgroundColor: selectedAnnIds.has(a.id) ? colors.progressFill : 'transparent', - }} + style={[ + styles.annotationCheckbox, + { + borderColor: selectedAnnIds.has(a.id) ? colors.progressFill : colors.borderColor, + backgroundColor: selectedAnnIds.has(a.id) ? colors.progressFill : 'transparent', + }, + ]} > - {selectedAnnIds.has(a.id) && ✓} + {selectedAnnIds.has(a.id) && ✓} - onNavigateAnnotation(a.cfi)} accessibilityRole="button" accessibilityLabel="跳至此註記"> + onNavigateAnnotation(a.cfi)} accessibilityRole="button" accessibilityLabel="跳至此註記"> {a.text} - - {a.chapter || ''} - {formatDate(a.createdAt)} + + {a.chapter || ''} + {formatDate(a.createdAt)} @@ -326,7 +324,7 @@ const ListPanel = ({ hitSlop={8} accessibilityRole="button" accessibilityLabel="更換顏色" - style={{ width: 16, height: 16, borderRadius: 8, backgroundColor: a.color, marginTop: 3 }} + style={[styles.colorSwatchButton, { backgroundColor: a.color }]} /> { setColorPickerId(null); setPendingDeleteAnnId(pendingDeleteAnnId === a.id ? null : a.id); }} @@ -334,43 +332,43 @@ const ListPanel = ({ accessibilityRole="button" accessibilityLabel="刪除此註記" > - ✕ + ✕ {colorPickerId === a.id && ( - + {HIGHLIGHT_COLORS.map((c) => ( { onChangeAnnotationColor(a.id, c.value); setColorPickerId(null); }} accessibilityRole="button" accessibilityLabel={`${c.label}色`} - style={{ - width: 22, height: 22, borderRadius: 11, backgroundColor: c.value, - borderWidth: 2, borderColor: a.color === c.value ? colors.ink : 'transparent', - }} + style={[ + styles.colorOption, + { backgroundColor: c.value, borderColor: a.color === c.value ? colors.ink : 'transparent' }, + ]} /> ))} )} {pendingDeleteAnnId === a.id && ( - - 確定刪除? - setPendingDeleteAnnId(null)} style={{ paddingHorizontal: 8, height: 22, borderRadius: 5, backgroundColor: colors.paperBg2, alignItems: 'center', justifyContent: 'center' }}> - 取消 + + 確定刪除? + setPendingDeleteAnnId(null)} style={[styles.smallCancelButton, { backgroundColor: colors.paperBg2 }]}> + 取消 { onDeleteAnnotation(a.id); setSelectedAnnIds((prev) => { const n = new Set(prev); n.delete(a.id); return n; }); setPendingDeleteAnnId(null); }} - style={{ paddingHorizontal: 8, height: 22, borderRadius: 5, backgroundColor: '#ef4444', alignItems: 'center', justifyContent: 'center' }} + style={styles.smallDangerButton} > - 刪除 + 刪除 )} - + {editingNoteId === a.id ? ( - - saveNote(a.id)} style={{ height: 24, paddingHorizontal: 10, borderRadius: 5, backgroundColor: colors.progressFill, alignItems: 'center', justifyContent: 'center' }}> - 儲存 + + saveNote(a.id)} style={[styles.smallActionButton, { backgroundColor: colors.progressFill }]}> + 儲存 - - 取消 + + 取消 {a.note && ( - deleteNote(a.id)} style={{ height: 24, paddingHorizontal: 10, borderRadius: 5, alignItems: 'center', justifyContent: 'center' }}> - 刪除筆記 + deleteNote(a.id)} style={styles.smallActionButton}> + 刪除筆記 )} ) : a.note ? ( startEditNote(a)} accessibilityRole="button" accessibilityLabel="編輯筆記"> - - {a.note} + + {a.note} ) : ( startEditNote(a)} accessibilityRole="button" accessibilityLabel="新增感想"> - + 新增感想 + + 新增感想 )} @@ -425,4 +419,287 @@ const ListPanel = ({ ); }; +const styles = StyleSheet.create({ + root: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 20, + }, + tabsRow: { + flexDirection: 'row', + borderRadius: 8, + padding: 2, + gap: 2, + margin: 12, + marginTop: 20, + }, + tabButton: { + flex: 1, + height: 30, + borderRadius: 6, + alignItems: 'center', + justifyContent: 'center', + }, + tabButtonText: { + fontSize: 13, + }, + scrollContent: { + paddingBottom: 24, + }, + emptyStateText: { + textAlign: 'center', + fontSize: 12, + padding: 32, + }, + bookmarkRow: { + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + gap: 6, + }, + bookmarkLabel: { + fontSize: 13, + }, + bookmarkMetaRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + metaText10: { + fontSize: 10, + }, + metaText12: { + fontSize: 12, + }, + actionText11: { + fontSize: 11, + }, + whiteText11: { + fontSize: 11, + color: '#fff', + }, + confirmDeleteText11: { + fontSize: 11, + color: '#ef4444', + }, + confirmRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + confirmRowIndented: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingLeft: 28, + }, + smallCancelButton: { + paddingHorizontal: 8, + height: 22, + borderRadius: 5, + alignItems: 'center', + justifyContent: 'center', + }, + smallDangerButton: { + paddingHorizontal: 8, + height: 22, + borderRadius: 5, + backgroundColor: '#ef4444', + alignItems: 'center', + justifyContent: 'center', + }, + tocItem: { + paddingVertical: 10, + paddingRight: 16, + }, + tocItemText: { + fontSize: 14, + }, + bookinfoContent: { + padding: 20, + gap: 16, + }, + bookinfoCover: { + aspectRatio: 2 / 3, + width: 140, + borderRadius: 4, + overflow: 'hidden', + alignSelf: 'center', + }, + coverImageFull: { + width: '100%', + height: '100%', + }, + coverPlaceholder: { + flex: 1, + justifyContent: 'flex-end', + padding: 12, + }, + coverPlaceholderTitle: { + fontSize: 14, + fontWeight: '600', + }, + coverPlaceholderAuthor: { + fontSize: 10, + marginTop: 4, + }, + bookTitle: { + fontSize: 16, + fontWeight: '500', + }, + bookAuthor: { + fontSize: 11, + marginTop: 4, + }, + progressLabelRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 6, + }, + bookinfoProgressTrack: { + height: 3, + borderRadius: 2, + }, + bookinfoProgressFill: { + height: '100%', + borderRadius: 2, + }, + metaList: { + gap: 10, + }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + infoValue: { + fontSize: 11, + }, + copyButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingVertical: 9, + paddingHorizontal: 14, + borderRadius: 8, + borderWidth: 1, + }, + copyButtonText: { + fontSize: 13, + }, + flex1: { + flex: 1, + }, + notesHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingBottom: 8, + }, + exportButton: { + height: 26, + paddingHorizontal: 10, + borderRadius: 6, + alignItems: 'center', + justifyContent: 'center', + }, + emptyNotesWrapper: { + padding: 32, + }, + emptyNotesTitle: { + fontSize: 13, + marginBottom: 6, + textAlign: 'center', + }, + emptyNotesSubtitle: { + fontSize: 12, + textAlign: 'center', + lineHeight: 18, + }, + annotationRow: { + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + gap: 8, + }, + annotationInnerRow: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 10, + }, + annotationCheckbox: { + width: 18, + height: 18, + borderRadius: 4, + marginTop: 3, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1.5, + }, + annotationText: { + fontSize: 13, + lineHeight: 20, + borderLeftWidth: 3, + paddingLeft: 10, + }, + annotationMetaRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 6, + }, + colorSwatchButton: { + width: 16, + height: 16, + borderRadius: 8, + marginTop: 3, + }, + colorPickerRow: { + flexDirection: 'row', + gap: 8, + paddingLeft: 28, + }, + colorOption: { + width: 22, + height: 22, + borderRadius: 11, + borderWidth: 2, + }, + notePaddingLeft: { + paddingLeft: 28, + }, + noteInput: { + minHeight: 60, + borderRadius: 7, + borderWidth: 1, + fontSize: 13, + lineHeight: 20, + padding: 8, + textAlignVertical: 'top', + }, + noteButtonRow: { + flexDirection: 'row', + gap: 6, + marginTop: 6, + }, + smallActionButton: { + height: 24, + paddingHorizontal: 10, + borderRadius: 5, + alignItems: 'center', + justifyContent: 'center', + }, + noteDisplayBox: { + borderRadius: 7, + padding: 8, + borderLeftWidth: 3, + }, + noteDisplayText: { + fontSize: 12, + lineHeight: 18, + }, +}); + export default ListPanel; diff --git a/mobile/components/SelectionBar.tsx b/mobile/components/SelectionBar.tsx index 53d9e80..bed623c 100644 --- a/mobile/components/SelectionBar.tsx +++ b/mobile/components/SelectionBar.tsx @@ -1,4 +1,4 @@ -import { Pressable, Text, View } from 'react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; import { IconCopy } from './icons'; import { HIGHLIGHT_COLORS } from '../lib/annotationColors'; import { useTheme } from '../lib/theme'; @@ -27,51 +27,38 @@ type Props = SelectionProps | EditProps; const SelectionBar = (props: Props) => { const { colors } = useTheme(); return ( - + {props.mode === 'selection' && ( - + {props.text} )} - + {HIGHLIGHT_COLORS.map((c) => ( (props.mode === 'edit' ? props.onChangeColor(c.value) : props.onHighlight(c.value))} accessibilityRole="button" accessibilityLabel={`${c.label}色${props.mode === 'edit' ? '' : '標記'}`} - style={{ width: 28, height: 28, borderRadius: 14, backgroundColor: c.value, borderWidth: 2, borderColor: colors.paperBg }} + style={[styles.colorSwatch, { backgroundColor: c.value, borderColor: colors.paperBg }]} /> ))} - + {props.mode === 'selection' ? ( <> - G + G @@ -81,9 +68,9 @@ const SelectionBar = (props: Props) => { onPress={props.onDelete} accessibilityRole="button" accessibilityLabel="刪除此註記" - style={{ width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.paperBg2 }} + style={[styles.circleIconButton, { backgroundColor: colors.paperBg2 }]} > - ✕ + ✕ )} @@ -91,4 +78,49 @@ const SelectionBar = (props: Props) => { ); }; +const styles = StyleSheet.create({ + root: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + borderTopWidth: 1, + padding: 12, + gap: 10, + zIndex: 30, + }, + selectionText: { + fontSize: 13, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + spacer: { + flex: 1, + }, + colorSwatch: { + width: 28, + height: 28, + borderRadius: 14, + borderWidth: 2, + }, + circleIconButton: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + }, + googleButtonText: { + fontSize: 13, + fontWeight: '600', + }, + deleteIconText: { + fontSize: 13, + color: '#ef4444', + }, +}); + export default SelectionBar; diff --git a/mobile/components/SettingsPanel.tsx b/mobile/components/SettingsPanel.tsx index 7a7db51..b4a21e9 100644 --- a/mobile/components/SettingsPanel.tsx +++ b/mobile/components/SettingsPanel.tsx @@ -1,4 +1,4 @@ -import { Pressable, ScrollView, Text, View } from 'react-native'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { IconPause, IconPlay, IconReset } from './icons'; import { FONT_OPTIONS, type ReadingDirection, type Script } from '../lib/readerSettings'; import { useTheme } from '../lib/theme'; @@ -33,8 +33,6 @@ interface Props { ttsSleepRemaining: number | null; } -const SECT_TITLE_STYLE = { fontSize: 11, letterSpacing: 1, textTransform: 'uppercase' as const, marginBottom: 10 }; - const SettingsPanel = ({ fontSize, onFontSizeChange, fontFamily, onFontFamilyChange, script, onScriptChange, readingDirection, onReadingDirectionChange, @@ -45,23 +43,19 @@ const SettingsPanel = ({ }: Props) => { const { colors } = useTheme(); - const segWrap = { flexDirection: 'row' as const, backgroundColor: colors.paperBg2, borderRadius: 8, padding: 2, gap: 2 }; - const segBtn = (active: boolean) => ({ - flex: 1, height: 28, borderRadius: 6, alignItems: 'center' as const, justifyContent: 'center' as const, - backgroundColor: active ? colors.paperBg : 'transparent', - }); - const segLabel = (active: boolean) => ({ fontSize: 12, color: active ? colors.ink : colors.ink3 }); + const segBtn = (active: boolean) => [styles.segBtn, { backgroundColor: active ? colors.paperBg : 'transparent' }]; + const segLabel = (active: boolean) => [styles.segLabel, { color: active ? colors.ink : colors.ink3 }]; const stepper = (value: string, onDec: () => void, onInc: () => void) => ( - - - − + + + − - - {value} + + {value} - - + + + + ); @@ -69,17 +63,17 @@ const SettingsPanel = ({ const voiceLabel = (v: TTSVoice) => v.name.replace(/^(Google|Microsoft|Apple)\s*/i, ''); return ( - - - 排版與語音 + + + 排版與語音 - + {/* 字體 */} - 字體 + 字體 - + {FONT_OPTIONS.map((f) => { const active = fontFamily === f.value; return ( @@ -89,21 +83,17 @@ const SettingsPanel = ({ accessibilityRole="radio" accessibilityLabel={`字體:${f.label}`} accessibilityState={{ selected: active }} - style={{ - flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingVertical: 9, paddingHorizontal: 12, borderRadius: 8, - backgroundColor: active ? colors.paperBg2 : 'transparent', - }} + style={[styles.fontOptionButton, { backgroundColor: active ? colors.paperBg2 : 'transparent' }]} > - {f.label} - {active && } + {f.label} + {active && } ); })} - - + + onScriptChange('tc')} @@ -123,7 +113,7 @@ const SettingsPanel = ({ 簡體 - + onReadingDirectionChange('ltr')} @@ -145,34 +135,34 @@ const SettingsPanel = ({ - - - 字體大小 + + + 字體大小 {stepper(`${fontSize}px`, () => onFontSizeChange(Math.max(12, fontSize - 1)), () => onFontSizeChange(Math.min(32, fontSize + 1)))} - - 行距 + + 行距 {stepper(lineHeight.toFixed(1), () => onLineHeightChange(parseFloat(Math.max(1.0, lineHeight - 0.1).toFixed(1))), () => onLineHeightChange(parseFloat(Math.min(3.0, lineHeight + 0.1).toFixed(1))))} - - 字距 + + 字距 {stepper(`${letterSpacing.toFixed(2)}em`, () => onLetterSpacingChange(parseFloat(Math.max(0, letterSpacing - 0.05).toFixed(2))), () => onLetterSpacingChange(parseFloat(Math.min(0.5, letterSpacing + 0.05).toFixed(2))))} - - 重設預設值 + + 重設預設值 - + {/* 語音朗讀 */} - 語音朗讀 + 語音朗讀 {ttsVoices.length > 0 && ( - + {ttsVoices.map((v) => { const active = ttsSelectedVoice?.identifier === v.identifier; return ( @@ -182,46 +172,45 @@ const SettingsPanel = ({ accessibilityRole="radio" accessibilityLabel={`語音:${voiceLabel(v)}`} accessibilityState={{ selected: active }} - style={{ - paddingVertical: 6, paddingHorizontal: 12, borderRadius: 8, - borderWidth: 1, borderColor: colors.borderColor, - backgroundColor: active ? colors.paperBg2 : 'transparent', - }} + style={[ + styles.voiceButton, + { borderColor: colors.borderColor, backgroundColor: active ? colors.paperBg2 : 'transparent' }, + ]} > - {voiceLabel(v)} + {voiceLabel(v)} ); })} )} - - + + - {ttsPlaying ? '正在朗讀' : ttsPaused ? '已暫停' : '準備朗讀'} - + {ttsPlaying ? '正在朗讀' : ttsPaused ? '已暫停' : '準備朗讀'} + {(ttsSelectedVoice ? voiceLabel(ttsSelectedVoice) : '系統語音')} · {ttsRate.toFixed(1)}× - + {ttsPlaying ? : } @@ -229,22 +218,22 @@ const SettingsPanel = ({ - - 語速 + + 語速 {stepper(`${ttsRate.toFixed(1)}×`, () => onTTSRateChange(parseFloat(Math.max(0.5, ttsRate - 0.1).toFixed(1))), () => onTTSRateChange(parseFloat(Math.min(2, ttsRate + 0.1).toFixed(1))))} - - 睡眠計時 + + 睡眠計時 {ttsSleepRemaining !== null && ( - + {String(Math.floor(ttsSleepRemaining / 60)).padStart(2, '0')}:{String(ttsSleepRemaining % 60).padStart(2, '0')} )} - + {([0, 15, 30, 45, 60] as const).map((m) => ( { const { colors } = useTheme(); return ( - + {(Object.keys(LABELS) as SortKey[]).map((key) => { const active = sort === key; return ( onSortChange(key)} - style={{ - height: 26, - paddingHorizontal: 10, - borderRadius: 6, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: active ? colors.paperBg : 'transparent', - }} + style={[styles.button, { backgroundColor: active ? colors.paperBg : 'transparent' }]} > - + {LABELS[key]} @@ -55,4 +37,25 @@ const SortControl = ({ sort, onSortChange }: Props) => { ); }; +const styles = StyleSheet.create({ + wrap: { + flexDirection: 'row', + alignSelf: 'flex-start', + borderWidth: 1, + borderRadius: 8, + padding: 2, + gap: 2, + }, + button: { + height: 26, + paddingHorizontal: 10, + borderRadius: 6, + alignItems: 'center', + justifyContent: 'center', + }, + label: { + fontSize: 12, + }, +}); + export default SortControl; From a60a4566e38d5a8814d4f99b0074a58ade96ff7f Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Sat, 1 Aug 2026 19:51:19 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(mobile):=20=E4=BF=AE=E6=AD=A3=E6=9B=B8?= =?UTF-8?q?=E7=B1=A4=EF=BC=88=E6=94=B6=E8=97=8F=EF=BC=89=E9=9B=A2=E9=96=8B?= =?UTF-8?q?=E6=9B=B8=E6=9C=AC=E9=87=8D=E9=96=8B=E5=BE=8C=E9=81=BA=E5=A4=B1?= =?UTF-8?q?=E7=9A=84=E5=95=8F=E9=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useBookmarks 原本依賴 setState updater 是否被提前同步執行來讀取新陣列, 在閱讀器頁面頻繁有其他 state 更新時該行為不保證發生,導致存進 AsyncStorage 的是舊值。改成跟 useAnnotations 一致的寫法:直接用當下 state 算出 next 再依序 setBookmarks/saveBookmarks。 Co-Authored-By: Claude Sonnet 5 --- mobile/hooks/reader/useBookmarks.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/mobile/hooks/reader/useBookmarks.ts b/mobile/hooks/reader/useBookmarks.ts index 8eb4522..57c05a5 100644 --- a/mobile/hooks/reader/useBookmarks.ts +++ b/mobile/hooks/reader/useBookmarks.ts @@ -24,21 +24,15 @@ export const useBookmarks = (id: string | undefined, currentCfi: string, current label: currentChapterTitle || '書籤', addedAt: Date.now(), }; - let next: Bookmark[] = []; - setBookmarks((prev) => { - next = toggleBookmarkList(prev, currentCfi, newBookmark); - return next; - }); + const next = toggleBookmarkList(bookmarks, currentCfi, newBookmark); + setBookmarks(next); saveBookmarks(id, next).catch((err) => console.error('[reader] saveBookmarks 失敗', err)); }; const handleDeleteBookmark = (bookmarkId: string) => { if (!id) return; - let next: Bookmark[] = []; - setBookmarks((prev) => { - next = removeBookmarkList(prev, bookmarkId); - return next; - }); + const next = removeBookmarkList(bookmarks, bookmarkId); + setBookmarks(next); saveBookmarks(id, next).catch((err) => console.error('[reader] saveBookmarks 失敗', err)); }; From 2f57cff97a43dc0b32e6557429f780a590d0653c Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Sat, 1 Aug 2026 20:09:03 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(mobile):=20=E4=BF=AE=E6=AD=A3=E9=80=A3?= =?UTF-8?q?=E7=BA=8C=E5=88=AA=E9=99=A4=E6=9B=B8=E7=B1=A4=E6=99=82=E4=BA=92?= =?UTF-8?q?=E7=9B=B8=E8=93=8B=E6=8E=89=E7=9A=84=E7=AB=B6=E6=85=8B=E5=95=8F?= =?UTF-8?q?=E9=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleToggleBookmark/handleDeleteBookmark 原本從 closure 裡的 bookmarks 快照算出 next 再同步存檔,連續呼叫(例如書籤清單連點兩個刪除)會用同一份 舊 snapshot,後者蓋掉前者。改成 functional updater 疊加狀態,saveBookmarks 移到跟著已提交 bookmarks state 變化的 effect 裡執行,同時維持前一輪修的 「不讀取 setState updater 尚未執行的舊值」。 Co-Authored-By: Claude Sonnet 5 --- mobile/hooks/reader/useBookmarks.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/mobile/hooks/reader/useBookmarks.ts b/mobile/hooks/reader/useBookmarks.ts index 57c05a5..903a00c 100644 --- a/mobile/hooks/reader/useBookmarks.ts +++ b/mobile/hooks/reader/useBookmarks.ts @@ -1,21 +1,34 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { type Bookmark, generateId, loadBookmarks, saveBookmarks } from '../../lib/library'; import { isBookmarked, removeBookmarkList, toggleBookmarkList } from '../../lib/reader/calculations'; export const useBookmarks = (id: string | undefined, currentCfi: string, currentChapterTitle: string) => { const [bookmarks, setBookmarks] = useState([]); + const loadedRef = useRef(false); useEffect(() => { if (!id) return; let cancelled = false; + loadedRef.current = false; loadBookmarks(id).then((saved) => { - if (!cancelled) setBookmarks(saved); + if (!cancelled) { + setBookmarks(saved); + loadedRef.current = true; + } }); return () => { cancelled = true; }; }, [id]); + // 存檔改成跟著已提交的 bookmarks state 走,而不是在 handler 裡用當下算出的 + // next 立即存檔:這樣連續呼叫(例如清單裡連點兩個刪除)會依序疊加在最新 state + // 上,不會有兩次呼叫都基於同一份舊 snapshot 而互相蓋掉對方的問題。 + useEffect(() => { + if (!id || !loadedRef.current) return; + saveBookmarks(id, bookmarks).catch((err) => console.error('[reader] saveBookmarks 失敗', err)); + }, [id, bookmarks]); + const handleToggleBookmark = () => { if (!id || !currentCfi) return; const newBookmark: Bookmark = { @@ -24,16 +37,11 @@ export const useBookmarks = (id: string | undefined, currentCfi: string, current label: currentChapterTitle || '書籤', addedAt: Date.now(), }; - const next = toggleBookmarkList(bookmarks, currentCfi, newBookmark); - setBookmarks(next); - saveBookmarks(id, next).catch((err) => console.error('[reader] saveBookmarks 失敗', err)); + setBookmarks((prev) => toggleBookmarkList(prev, currentCfi, newBookmark)); }; const handleDeleteBookmark = (bookmarkId: string) => { - if (!id) return; - const next = removeBookmarkList(bookmarks, bookmarkId); - setBookmarks(next); - saveBookmarks(id, next).catch((err) => console.error('[reader] saveBookmarks 失敗', err)); + setBookmarks((prev) => removeBookmarkList(prev, bookmarkId)); }; return {