Skip to content

Commit 2b002f1

Browse files
authored
Merge pull request #6 from Retsomm/dev
feat(mobile): 新增閱讀頁劃線註記功能,修正選字手勢與標記顯示問題
2 parents f2fd67f + 255831b commit 2b002f1

9 files changed

Lines changed: 833 additions & 20 deletions

File tree

RN_SETUP_GUIDE.md

Lines changed: 43 additions & 1 deletion
Large diffs are not rendered by default.

mobile/app/reader/[id].tsx

Lines changed: 191 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,26 @@
1+
import * as Clipboard from 'expo-clipboard';
12
import { useLocalSearchParams, router } from 'expo-router';
23
import { useCallback, useEffect, useRef, useState } from 'react';
34
import { ActivityIndicator, Linking, Pressable, Text, View } from 'react-native';
45
import { SafeAreaView } from 'react-native-safe-area-context';
56
import { WebView } from 'react-native-webview';
6-
import { IconBack, IconBookmarkFill, IconBookmarkOutline, IconChapters, IconSettings } from '../../components/icons';
7+
import { IconBack, IconBookmarkFill, IconBookmarkOutline, IconChapters, IconNotes, IconSettings } from '../../components/icons';
78
import ListPanel from '../../components/ListPanel';
9+
import SelectionBar from '../../components/SelectionBar';
810
import SettingsPanel from '../../components/SettingsPanel';
911
import {
12+
type Annotation,
1013
type BookRecord,
1114
type BookSettings,
1215
type Bookmark,
1316
generateId,
1417
getBookBase64,
1518
listBooks,
19+
loadAnnotations,
1620
loadBookSettings,
1721
loadBookmarks,
1822
loadReadingCfi,
23+
saveAnnotations,
1924
saveBookSettings,
2025
saveBookmarks,
2126
saveReadingCfi,
@@ -40,6 +45,10 @@ const ReaderScreen = () => {
4045
const [settingsVisible, setSettingsVisible] = useState(false);
4146
const [typography, setTypography] = useState<BookSettings>(DEFAULT_TYPOGRAPHY);
4247
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
48+
const [annotations, setAnnotations] = useState<Annotation[]>([]);
49+
const [selection, setSelection] = useState<{ cfi: string; text: string } | null>(null);
50+
const [editingAnnotationId, setEditingAnnotationId] = useState<string | null>(null);
51+
const [annotationMode, setAnnotationMode] = useState(false);
4352
const [toc, setToc] = useState<TocItem[]>([]);
4453
const [currentCfi, setCurrentCfi] = useState('');
4554
const [currentHref, setCurrentHref] = useState('');
@@ -93,6 +102,11 @@ const ReaderScreen = () => {
93102
setCurrentChapterTitle('');
94103
setPageInfo(null);
95104
setListPanelTab(null);
105+
setAnnotations([]);
106+
setSelection(null);
107+
setEditingAnnotationId(null);
108+
setAnnotationMode(false);
109+
webviewRef.current?.postMessage(JSON.stringify({ type: 'clearSelection' }));
96110
// eslint-disable-next-line react-hooks/exhaustive-deps
97111
}, [id]);
98112

@@ -116,8 +130,20 @@ const ReaderScreen = () => {
116130
return;
117131
}
118132
try {
119-
const [base64, cfi] = await Promise.all([getBookBase64(found), loadReadingCfi(id)]);
120-
webviewRef.current?.postMessage(JSON.stringify({ type: 'load', base64, cfi }));
133+
const [base64, cfi, savedAnnotations] = await Promise.all([
134+
getBookBase64(found),
135+
loadReadingCfi(id),
136+
loadAnnotations(id),
137+
]);
138+
setAnnotations(savedAnnotations);
139+
webviewRef.current?.postMessage(
140+
JSON.stringify({
141+
type: 'load',
142+
base64,
143+
cfi,
144+
annotations: savedAnnotations.map((a) => ({ id: a.id, cfi: a.cfi, color: a.color })),
145+
})
146+
);
121147
} catch (err) {
122148
setErrorMessage(err instanceof Error ? err.message : String(err));
123149
setLoading(false);
@@ -174,6 +200,27 @@ const ReaderScreen = () => {
174200
chapterTextResolverRef.current = null;
175201
return;
176202
}
203+
if (msg.type === 'textSelected') {
204+
if (__DEV__) console.log('[reader] textSelected 收到', msg.text.slice(0, 20));
205+
setEditingAnnotationId(null);
206+
setSelection({ cfi: msg.cfi, text: msg.text });
207+
return;
208+
}
209+
if (msg.type === 'selectionCleared') {
210+
console.log('[reader] selectionCleared 收到');
211+
setSelection(null);
212+
return;
213+
}
214+
if (msg.type === 'annotationTapped') {
215+
console.log('[reader] annotationTapped 收到', msg.id);
216+
setSelection(null);
217+
setEditingAnnotationId(msg.id);
218+
return;
219+
}
220+
if (msg.type === 'debug') {
221+
console.log('[reader-web debug]', msg.message);
222+
return;
223+
}
177224
if (msg.type === 'bookLanguageDetected') {
178225
// 比照網頁版 Reader.tsx:baseScript 永遠反映書本原始語言;只有在使用者這本書
179226
// 從沒存過排版偏好時,才自動把顯示腳本切成跟書本原始語言一致(例如簡體書預設
@@ -301,19 +348,115 @@ const ReaderScreen = () => {
301348
setListPanelTab(null);
302349
};
303350

351+
// WebView 端只負責「畫出目前這份清單長什麼樣子」,annotations 陣列本身以 RN 端/
352+
// AsyncStorage 為唯一資料來源;每次新增/改色/刪除都整批送一次目前完整清單,
353+
// 讓 reader-web 的 applyAnnotations() 自己比對差異決定要新增/移除哪些標記。
354+
const syncAnnotationsToWebView = (next: Annotation[]) => {
355+
console.log('[reader] syncAnnotationsToWebView 送出', next.length, '筆');
356+
webviewRef.current?.postMessage(
357+
JSON.stringify({ type: 'setAnnotations', annotations: next.map((a) => ({ id: a.id, cfi: a.cfi, color: a.color })) })
358+
);
359+
};
360+
361+
const handleCreateAnnotation = (color: string) => {
362+
if (!id || !selection) {
363+
console.log('[reader] handleCreateAnnotation 略過:id 或 selection 為空', { hasId: Boolean(id), hasSelection: Boolean(selection) });
364+
return;
365+
}
366+
if (__DEV__) console.log('[reader] handleCreateAnnotation', color, selection.text.slice(0, 20));
367+
const ann: Annotation = {
368+
id: generateId(),
369+
cfi: selection.cfi,
370+
text: selection.text,
371+
color,
372+
chapter: currentChapterTitle,
373+
createdAt: Date.now(),
374+
};
375+
const next = [...annotations, ann];
376+
setAnnotations(next);
377+
saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err));
378+
syncAnnotationsToWebView(next);
379+
setSelection(null);
380+
webviewRef.current?.postMessage(JSON.stringify({ type: 'clearSelection' }));
381+
};
382+
383+
const handleChangeAnnotationColor = (annotationId: string, color: string) => {
384+
if (!id) return;
385+
const next = annotations.map((a) => (a.id === annotationId ? { ...a, color } : a));
386+
setAnnotations(next);
387+
saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err));
388+
syncAnnotationsToWebView(next);
389+
};
390+
391+
const handleDeleteAnnotation = (annotationId: string) => {
392+
if (!id) return;
393+
const next = annotations.filter((a) => a.id !== annotationId);
394+
setAnnotations(next);
395+
saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err));
396+
syncAnnotationsToWebView(next);
397+
setEditingAnnotationId(null);
398+
};
399+
400+
const handleUpdateAnnotationNote = (annotationId: string, note: string) => {
401+
if (!id) return;
402+
const next = annotations.map((a) => (a.id === annotationId ? { ...a, note: note.trim() || undefined } : a));
403+
setAnnotations(next);
404+
saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err));
405+
};
406+
407+
const handleNavigateToAnnotation = (cfi: string) => {
408+
webviewRef.current?.postMessage(JSON.stringify({ type: 'goto', target: cfi }));
409+
setListPanelTab(null);
410+
};
411+
412+
const handleCopySelection = () => {
413+
if (!selection) return;
414+
Clipboard.setStringAsync(selection.text);
415+
};
416+
417+
const handleSearchSelection = () => {
418+
if (!selection) return;
419+
Linking.openURL(`https://www.google.com/search?q=${encodeURIComponent(selection.text)}`);
420+
};
421+
422+
// 清掉 RN 端的選取狀態同時,也要通知 WebView 清掉內容 iframe 裡實際的原生選取範圍
423+
// (瀏覽器藍色反白),否則只清 RN state 只會讓底部操作列消失,畫面上的選取反白仍留著。
424+
const clearSelectionState = () => {
425+
setSelection(null);
426+
setEditingAnnotationId(null);
427+
webviewRef.current?.postMessage(JSON.stringify({ type: 'clearSelection' }));
428+
};
429+
304430
// 設定面板/清單面板共用同一個畫面區域,一次只會顯示其中一個:開啟其中一個時
305431
// 要順便關掉另一個,否則兩個 overlay 疊在一起,關掉上層那個又會露出底下還開著的另一個。
306432
// 兩顆按鈕都是開關型:再按一次目前開著的那顆就直接關閉,不需要額外的關閉鈕/麵包屑列。
307433
const toggleSettings = () => {
308434
setListPanelTab(null);
435+
clearSelectionState();
309436
setSettingsVisible((prev) => !prev);
310437
};
311438

312439
const toggleListPanel = () => {
313440
setSettingsVisible(false);
441+
clearSelectionState();
314442
setListPanelTab((prev) => (prev ? null : 'bookmarks'));
315443
};
316444

445+
// 劃線模式:使用者實測回報「畫面中間三分之一窄帶長按選字沒有反應」,log 顯示觸控確實有
446+
// 送到內容 iframe,但長按手勢常常在按住/微調時位移滑出那條窄帶、掃進左右兩側的翻頁點擊區
447+
// 而被中斷。開啟這個模式時通知 WebView 把左右兩側的翻頁點擊區關掉 pointer-events,讓整個
448+
// 畫面寬度都能長按選字(代價是這段期間點擊畫面兩側不會翻頁,需使用者自己切回一般模式)。
449+
const toggleAnnotationMode = () => {
450+
setSettingsVisible(false);
451+
setListPanelTab(null);
452+
clearSelectionState();
453+
setAnnotationMode((prev) => {
454+
const next = !prev;
455+
webviewRef.current?.postMessage(JSON.stringify({ type: 'setAnnotationMode', enabled: next }));
456+
return next;
457+
});
458+
};
459+
317460
return (
318461
<SafeAreaView edges={['top', 'bottom']} style={{ flex: 1, backgroundColor: colors.paperBg }}>
319462
<View style={{ flexDirection: 'row', alignItems: 'center', height: 44, paddingHorizontal: 12 }}>
@@ -337,6 +480,19 @@ const ReaderScreen = () => {
337480
>
338481
{isBookmarked ? <IconBookmarkFill color={colors.progressFill} /> : <IconBookmarkOutline color={colors.ink} />}
339482
</Pressable>
483+
<Pressable
484+
onPress={toggleAnnotationMode}
485+
hitSlop={12}
486+
accessibilityRole="button"
487+
accessibilityLabel={annotationMode ? '結束劃線模式' : '進入劃線模式'}
488+
accessibilityState={{ selected: annotationMode }}
489+
style={{
490+
width: 30, height: 30, borderRadius: 8, alignItems: 'center', justifyContent: 'center',
491+
backgroundColor: annotationMode ? colors.paperBg2 : 'transparent',
492+
}}
493+
>
494+
<IconNotes color={annotationMode ? colors.progressFill : colors.ink} />
495+
</Pressable>
340496
<Pressable
341497
onPress={toggleListPanel}
342498
hitSlop={12}
@@ -378,6 +534,17 @@ const ReaderScreen = () => {
378534
overScrollMode="never"
379535
style={{ flex: 1, opacity: loading ? 0 : 1 }}
380536
/>
537+
{annotationMode && (
538+
<View
539+
pointerEvents="none"
540+
style={{
541+
position: 'absolute', top: 0, left: 0, right: 0,
542+
paddingVertical: 6, alignItems: 'center', backgroundColor: colors.progressFill,
543+
}}
544+
>
545+
<Text style={{ fontSize: 11, color: '#fff' }}>劃線模式中:長按文字選取即可標記,點擊畫面翻頁已暫停</Text>
546+
</View>
547+
)}
381548
{loading && !errorMessage ? (
382549
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center', justifyContent: 'center' }}>
383550
<ActivityIndicator size="large" />
@@ -428,6 +595,27 @@ const ReaderScreen = () => {
428595
currentHref={currentHref}
429596
onNavigateChapter={handleNavigateToTarget}
430597
record={record}
598+
annotations={annotations}
599+
onNavigateAnnotation={handleNavigateToAnnotation}
600+
onChangeAnnotationColor={handleChangeAnnotationColor}
601+
onDeleteAnnotation={handleDeleteAnnotation}
602+
onUpdateAnnotationNote={handleUpdateAnnotationNote}
603+
/>
604+
)}
605+
{selection && (
606+
<SelectionBar
607+
mode="selection"
608+
text={selection.text}
609+
onHighlight={handleCreateAnnotation}
610+
onCopy={handleCopySelection}
611+
onSearch={handleSearchSelection}
612+
/>
613+
)}
614+
{editingAnnotationId && (
615+
<SelectionBar
616+
mode="edit"
617+
onChangeColor={(color) => handleChangeAnnotationColor(editingAnnotationId, color)}
618+
onDelete={() => handleDeleteAnnotation(editingAnnotationId)}
431619
/>
432620
)}
433621
{/* 固定高度、一律渲染(即使 pageInfo 還是 null 也只是內容留白):避免 pageInfo 從

0 commit comments

Comments
 (0)