1+ import * as Clipboard from 'expo-clipboard' ;
12import { useLocalSearchParams , router } from 'expo-router' ;
23import { useCallback , useEffect , useRef , useState } from 'react' ;
34import { ActivityIndicator , Linking , Pressable , Text , View } from 'react-native' ;
45import { SafeAreaView } from 'react-native-safe-area-context' ;
56import { 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' ;
78import ListPanel from '../../components/ListPanel' ;
9+ import SelectionBar from '../../components/SelectionBar' ;
810import SettingsPanel from '../../components/SettingsPanel' ;
911import {
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