1- import { useRef } from 'react'
1+ import { useRef , useState } from 'react'
22import { Download , Upload , Star , StickyNote , ExternalLink , Trash2 } from 'lucide-react'
33
44export default function NotesPanel ( {
5- catalog, bookmarks, notes, getSectionNotes, exportData, importFile, onSelect, lang,
5+ catalog, bookmarks, notes, notebooksWithNotes , getSectionNotes, exportData, importFile, onClearAll , onSelect, lang,
66} ) {
77 const fileInputRef = useRef ( null )
8+ const [ confirmClear , setConfirmClear ] = useState ( false )
89
910 const bookmarkList = Object . entries ( bookmarks ) . sort ( ( a , b ) => b [ 1 ] . addedAt - a [ 1 ] . addedAt )
10- const notebooksWithNotes = new Set ( )
11+
12+ // Collect all notes across all notebooks
1113 const allNotes = [ ]
12- for ( const [ key , note ] of Object . entries ( notes ) ) {
13- const sepIdx = key . indexOf ( '::' )
14- const notebookId = key . slice ( 0 , sepIdx )
15- const sectionId = key . slice ( sepIdx + 2 )
16- notebooksWithNotes . add ( notebookId )
17- allNotes . push ( { notebookId , sectionId , ... note } )
14+ for ( const [ notebookId , noteList ] of Object . entries ( notes ) ) {
15+ if ( ! Array . isArray ( noteList ) ) continue
16+ for ( const n of noteList ) {
17+ if ( ! n || typeof n !== 'object' ) continue
18+ allNotes . push ( { notebookId, ... n } )
19+ }
1820 }
1921 allNotes . sort ( ( a , b ) => b . updatedAt - a . updatedAt )
2022
2123 const findMeta = ( id ) => catalog . find ( ( n ) => n . id === id )
2224
25+ const noteGroups = Object . entries ( notes )
26+ . map ( ( [ notebookId , noteList ] ) => {
27+ const list = Array . isArray ( noteList )
28+ ? noteList
29+ . filter ( ( n ) => n && typeof n === 'object' )
30+ . slice ( )
31+ . sort ( ( a , b ) => ( b . updatedAt || 0 ) - ( a . updatedAt || 0 ) )
32+ : [ ]
33+ const meta = findMeta ( notebookId )
34+ return {
35+ notebookId,
36+ meta,
37+ notes : list ,
38+ latestAt : list [ 0 ] ?. updatedAt || 0 ,
39+ }
40+ } )
41+ . filter ( ( group ) => group . notes . length > 0 )
42+ . sort ( ( a , b ) => b . latestAt - a . latestAt )
43+
44+ const normalizeText = ( text ) => String ( text || '' ) . replace ( / \s + / g, ' ' ) . trim ( )
45+
46+ const findQuoteTarget = ( root , quote ) => {
47+ const quoteText = normalizeText ( quote )
48+ if ( ! quoteText ) return null
49+
50+ const searchText = quoteText . length > 80 ? quoteText . slice ( 0 , 80 ) : quoteText
51+ const candidates = root . querySelectorAll (
52+ '.rendered_html p, .rendered_html li, .rendered_html td, .rendered_html th'
53+ )
54+
55+ for ( const el of candidates ) {
56+ const candidateText = normalizeText ( el . textContent )
57+ if ( candidateText . includes ( searchText ) || quoteText . includes ( candidateText ) ) {
58+ return el
59+ }
60+ }
61+
62+ return null
63+ }
64+
65+ const markJumpTarget = ( el ) => {
66+ el . classList . remove ( 'note-jump-target' )
67+ void el . offsetWidth
68+ el . classList . add ( 'note-jump-target' )
69+ window . setTimeout ( ( ) => {
70+ el . classList . remove ( 'note-jump-target' )
71+ } , 1800 )
72+ }
73+
2374 const handleExport = ( ) => {
2475 const json = exportData ( )
2576 const blob = new Blob ( [ json ] , { type : 'application/json' } )
@@ -45,6 +96,25 @@ export default function NotesPanel({
4596 if ( fileInputRef . current ) fileInputRef . current . value = ''
4697 }
4798
99+ const handleNoteClick = ( notebookId , sectionId , quote = '' ) => {
100+ onSelect ( notebookId )
101+
102+ const tryScroll = ( retries ) => {
103+ const root = document . querySelector ( '.notebook-content' )
104+ const quoteTarget = root ? findQuoteTarget ( root , quote ) : null
105+ const sectionTarget = sectionId ? document . getElementById ( sectionId ) : null
106+ const target = quoteTarget || sectionTarget
107+
108+ if ( target ) {
109+ target . scrollIntoView ( { behavior : 'smooth' , block : 'center' } )
110+ markJumpTarget ( target )
111+ } else if ( retries > 0 ) {
112+ setTimeout ( ( ) => tryScroll ( retries - 1 ) , 100 )
113+ }
114+ }
115+ setTimeout ( ( ) => tryScroll ( 20 ) , 150 )
116+ }
117+
48118 const t = ( zh , en ) => lang === 'en' ? en : zh
49119
50120 return (
@@ -62,6 +132,20 @@ export default function NotesPanel({
62132 < Upload className = "w-3.5 h-3.5" />
63133 < span > { t ( '导入' , 'Import' ) } </ span >
64134 </ button >
135+ { confirmClear ? (
136+ < button onClick = { ( ) => { onClearAll ?. ( ) ; setConfirmClear ( false ) } }
137+ className = "notes-action-btn" style = { { color : 'var(--accent-red, #ef4444)' } }
138+ title = { t ( '确认清空' , 'Confirm clear' ) } >
139+ < Trash2 className = "w-3.5 h-3.5" />
140+ < span > { t ( '确认' , 'Confirm' ) } </ span >
141+ </ button >
142+ ) : (
143+ < button onClick = { ( ) => setConfirmClear ( true ) } className = "notes-action-btn"
144+ title = { t ( '清空所有数据' , 'Clear all data' ) } >
145+ < Trash2 className = "w-3.5 h-3.5" />
146+ < span > { t ( '清空' , 'Clear' ) } </ span >
147+ </ button >
148+ ) }
65149 < input ref = { fileInputRef } type = "file" accept = ".json" onChange = { handleImport }
66150 style = { { display : 'none' } } />
67151 </ div >
@@ -104,29 +188,94 @@ export default function NotesPanel({
104188 < span className = "notes-count" > { allNotes . length } </ span >
105189 </ h2 >
106190 { allNotes . length === 0 ? (
107- < p className = "notes-empty" > { t ( '还没有笔记。阅读时点击任意节标题旁的笔记图标即可添加 。' , 'No notes yet. Click the note icon next to any section heading while reading .' ) } </ p >
191+ < p className = "notes-empty" > { t ( '还没有笔记。阅读时选中文字,在弹出工具栏中选择"笔记"或"高亮"即可添加 。' , 'No notes yet. Select text while reading and choose "Note" or "Highlight" from the popup toolbar .' ) } </ p >
108192 ) : (
109- < div className = "notes-list" >
110- { allNotes . map ( ( n ) => {
111- const meta = findMeta ( n . notebookId )
112- return (
113- < button key = { `${ n . notebookId } ::${ n . sectionId } ` }
114- className = "notes-card notes-card-row"
115- onClick = { ( ) => onSelect ( n . notebookId ) } >
116- < div className = "notes-card-main" >
117- < span className = "notes-card-section" > { n . sectionTitle } </ span >
118- < span className = "notes-card-preview" > { n . text . slice ( 0 , 120 ) } { n . text . length > 120 ? '…' : '' } </ span >
119- </ div >
120- < div className = "notes-card-meta" >
121- < span className = "notes-card-notebook" > { meta ?. title || n . notebookId } </ span >
122- < span className = "notes-card-date" >
123- { new Date ( n . updatedAt ) . toLocaleDateString ( lang === 'en' ? 'en-US' : 'zh-CN' ) }
193+ < div className = "notes-article-list" >
194+ { noteGroups . map ( ( group ) => (
195+ < section key = { group . notebookId } className = "notes-article-group" >
196+ < button
197+ className = "notes-article-header"
198+ onClick = { ( ) => onSelect ( group . notebookId ) }
199+ >
200+ < div className = "notes-article-title-row" >
201+ < span className = "notes-article-num" >
202+ { group . notebookId . match ( / ^ \d + / ) ?. [ 0 ] || '' }
203+ </ span >
204+ < span className = "notes-article-title" >
205+ { group . meta ?. title || group . notebookId }
124206 </ span >
125- < ExternalLink className = "w-3 h-3 notes-card-icon" />
126207 </ div >
208+ < span className = "notes-article-count" >
209+ { group . notes . length } { t ( '条' , 'notes' ) }
210+ </ span >
127211 </ button >
128- )
129- } ) }
212+
213+ < div className = "notes-article-items" >
214+ { group . notes . map ( ( n ) => (
215+ < div key = { n . id }
216+ role = "button"
217+ tabIndex = { 0 }
218+ className = "notes-note-row"
219+ onClick = { ( ) => handleNoteClick ( group . notebookId , n . sectionId ) }
220+ onKeyDown = { ( e ) => {
221+ if ( e . key !== 'Enter' && e . key !== ' ' ) return
222+ e . preventDefault ( )
223+ handleNoteClick ( group . notebookId , n . sectionId )
224+ } } >
225+ < div className = "notes-card-main" >
226+ < div className = "notes-card-meta-row" >
227+ < span className = "notes-card-section-label" > { n . sectionTitle } </ span >
228+ </ div >
229+ { n . quote && (
230+ < span
231+ className = "notes-card-quote notes-card-quote-link"
232+ role = "link"
233+ tabIndex = { 0 }
234+ onClick = { ( e ) => {
235+ e . stopPropagation ( )
236+ handleNoteClick ( group . notebookId , n . sectionId , n . quote )
237+ } }
238+ onKeyDown = { ( e ) => {
239+ if ( e . key !== 'Enter' && e . key !== ' ' ) return
240+ e . preventDefault ( )
241+ e . stopPropagation ( )
242+ handleNoteClick ( group . notebookId , n . sectionId , n . quote )
243+ } }
244+ >
245+ { String ( n . quote ) . slice ( 0 , 150 ) } { String ( n . quote ) . length > 150 ? '…' : '' }
246+ </ span >
247+ ) }
248+ { n . text && (
249+ < span
250+ className = "notes-card-preview notes-card-preview-link"
251+ role = "link"
252+ tabIndex = { 0 }
253+ onClick = { ( e ) => {
254+ e . stopPropagation ( )
255+ handleNoteClick ( group . notebookId , n . sectionId , n . quote )
256+ } }
257+ onKeyDown = { ( e ) => {
258+ if ( e . key !== 'Enter' && e . key !== ' ' ) return
259+ e . preventDefault ( )
260+ e . stopPropagation ( )
261+ handleNoteClick ( group . notebookId , n . sectionId , n . quote )
262+ } }
263+ >
264+ { String ( n . text ) . slice ( 0 , 100 ) } { String ( n . text ) . length > 100 ? '…' : '' }
265+ </ span >
266+ ) }
267+ </ div >
268+ < div className = "notes-card-meta" >
269+ < span className = "notes-card-date" >
270+ { new Date ( n . updatedAt || Date . now ( ) ) . toLocaleDateString ( lang === 'en' ? 'en-US' : 'zh-CN' ) }
271+ </ span >
272+ < ExternalLink className = "w-3 h-3 notes-card-icon" />
273+ </ div >
274+ </ div >
275+ ) ) }
276+ </ div >
277+ </ section >
278+ ) ) }
130279 </ div >
131280 ) }
132281 </ section >
0 commit comments