11import { useEffect , useLayoutEffect , useRef , useState } from 'react'
2+ import { Star , Trash2 } from 'lucide-react'
23import { getNotebookLaunchLinks } from '../config.js'
34
45function extractToc ( html ) {
@@ -16,7 +17,7 @@ function extractToc(html) {
1617 return toc
1718}
1819
19- function NotebookViewer ( { notebook, meta, loading } ) {
20+ function NotebookViewer ( { notebook, meta, loading, isBookmarked , toggleBookmark , notes , saveNote , deleteNote } ) {
2021 const contentRef = useRef ( null )
2122 const notebookContentRef = useRef ( null )
2223 const revealFrameRef = useRef ( null )
@@ -26,6 +27,7 @@ function NotebookViewer({ notebook, meta, loading }) {
2627 const [ activeHeading , setActiveHeading ] = useState ( null )
2728 const [ visibleNotebookId , setVisibleNotebookId ] = useState ( null )
2829 const [ imagePreview , setImagePreview ] = useState ( null )
30+ const [ noteEditor , setNoteEditor ] = useState ( null )
2931 const lang = meta ?. lang === 'en' ? 'en' : 'zh'
3032
3133 const shouldReduceMotion = ( ) => {
@@ -126,18 +128,49 @@ function NotebookViewer({ notebook, meta, loading }) {
126128 }
127129 } , [ notebook ?. id , notebook ?. html , toc . length ] )
128130
131+ // Inject note icons on h2/h3 headings
129132 useEffect ( ( ) => {
130- if ( ! imagePreview ) return undefined
133+ const content = notebookContentRef . current
134+ if ( ! notebook ?. id || ! content ) return
135+
136+ const headings = content . querySelectorAll ( 'h2, h3' )
137+ headings . forEach ( ( h ) => {
138+ // Avoid duplicate buttons
139+ if ( h . querySelector ( '.section-note-btn' ) ) return
140+
141+ const btn = document . createElement ( 'button' )
142+ btn . className = 'section-note-btn'
143+ btn . setAttribute ( 'aria-label' , lang === 'en' ? 'Add note' : '添加笔记' )
144+ btn . setAttribute ( 'title' , lang === 'en' ? 'Add note' : '添加笔记' )
145+ btn . dataset . sectionId = h . id || ''
146+ const noteKey = `${ notebook . id } ::${ h . id || '' } `
147+ btn . dataset . noteKey = noteKey
148+ btn . dataset . sectionTitle = h . textContent . replace ( / [ # \n \r ] / g, '' ) . trim ( )
149+
150+ // Show existing note indicator
151+ if ( notes [ noteKey ] ) {
152+ btn . classList . add ( 'has-note' )
153+ btn . setAttribute ( 'title' , lang === 'en' ? 'Edit note' : '编辑笔记' )
154+ }
155+
156+ btn . innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>'
157+ h . appendChild ( btn )
158+ } )
159+ } , [ notebook ?. id , notebook ?. html , notes , lang ] )
160+
161+ useEffect ( ( ) => {
162+ if ( ! imagePreview && ! noteEditor ) return undefined
131163
132164 const handleKeyDown = ( event ) => {
133165 if ( event . key === 'Escape' ) {
134- setImagePreview ( null )
166+ if ( imagePreview ) setImagePreview ( null )
167+ if ( noteEditor ) setNoteEditor ( null )
135168 }
136169 }
137170
138171 window . addEventListener ( 'keydown' , handleKeyDown )
139172 return ( ) => window . removeEventListener ( 'keydown' , handleKeyDown )
140- } , [ imagePreview ] )
173+ } , [ imagePreview , noteEditor ] )
141174
142175 // Reset scroll before paint, then fade the new notebook in.
143176 useLayoutEffect ( ( ) => {
@@ -227,6 +260,26 @@ function NotebookViewer({ notebook, meta, loading }) {
227260 }
228261
229262 const handleNotebookClick = ( event ) => {
263+ // Note button click
264+ const noteBtn = event . target . closest ( '.section-note-btn' )
265+ if ( noteBtn && notebookContentRef . current ?. contains ( noteBtn ) ) {
266+ event . preventDefault ( )
267+ event . stopPropagation ( )
268+ const sectionId = noteBtn . dataset . sectionId || ''
269+ const sectionTitle = noteBtn . dataset . sectionTitle || ''
270+ const noteKey = noteBtn . dataset . noteKey || ''
271+ const rect = noteBtn . getBoundingClientRect ( )
272+ setNoteEditor ( {
273+ sectionId,
274+ sectionTitle,
275+ noteKey,
276+ text : notes [ noteKey ] ?. text || '' ,
277+ top : rect . bottom + 6 ,
278+ left : Math . min ( rect . left , window . innerWidth - 340 ) ,
279+ } )
280+ return
281+ }
282+
230283 const image = event . target . closest ( '.output_area img, .rendered_html img' )
231284 if ( image && notebookContentRef . current ?. contains ( image ) ) {
232285 event . preventDefault ( )
@@ -347,6 +400,19 @@ function NotebookViewer({ notebook, meta, loading }) {
347400 < div className = { `viewer-header${ isVisible ? ' visible' : '' } ` } >
348401 < div className = "viewer-part" > { meta ?. part } </ div >
349402 < h1 className = "viewer-title" > { meta ?. title } </ h1 >
403+ { notebook ?. id && (
404+ < button
405+ className = { `bookmark-star ${ isBookmarked ?. ( notebook . id ) ? 'active' : '' } ` }
406+ onClick = { ( ) => {
407+ toggleBookmark ?. ( notebook . id , meta ?. title || '' )
408+ } }
409+ title = { isBookmarked ?. ( notebook . id )
410+ ? ( lang === 'en' ? 'Remove bookmark' : '取消收藏' )
411+ : ( lang === 'en' ? 'Bookmark' : '收藏' ) }
412+ >
413+ < Star className = "w-5 h-5" />
414+ </ button >
415+ ) }
350416 < div className = "viewer-launches" >
351417 { launchLinks . map ( ( link ) => {
352418 const content = (
@@ -432,6 +498,66 @@ function NotebookViewer({ notebook, meta, loading }) {
432498 </ div >
433499 ) }
434500
501+ { noteEditor && (
502+ < div
503+ className = "note-editor-backdrop"
504+ onClick = { ( ) => setNoteEditor ( null ) }
505+ >
506+ < div
507+ className = "note-editor-popup"
508+ style = { { top : noteEditor . top , left : noteEditor . left } }
509+ onClick = { ( e ) => e . stopPropagation ( ) }
510+ >
511+ < div className = "note-editor-header" >
512+ < span className = "note-editor-section" > { noteEditor . sectionTitle } </ span >
513+ < button
514+ className = "note-editor-close"
515+ onClick = { ( ) => setNoteEditor ( null ) }
516+ > ×</ button >
517+ </ div >
518+ < textarea
519+ className = "note-editor-textarea"
520+ value = { noteEditor . text }
521+ onChange = { ( e ) => setNoteEditor ( { ...noteEditor , text : e . target . value } ) }
522+ placeholder = { lang === 'en' ? 'Write your note...' : '写下你的笔记...' }
523+ rows = { 5 }
524+ autoFocus
525+ />
526+ < div className = "note-editor-actions" >
527+ { notes [ noteEditor . noteKey ] && (
528+ < button
529+ className = "note-editor-btn note-editor-delete"
530+ onClick = { ( ) => {
531+ deleteNote ?. ( notebook . id , noteEditor . sectionId )
532+ setNoteEditor ( null )
533+ } }
534+ >
535+ < Trash2 className = "w-3.5 h-3.5" />
536+ < span > { lang === 'en' ? 'Delete' : '删除' } </ span >
537+ </ button >
538+ ) }
539+ < div className = "note-editor-spacer" />
540+ < button
541+ className = "note-editor-btn note-editor-cancel"
542+ onClick = { ( ) => setNoteEditor ( null ) }
543+ >
544+ { lang === 'en' ? 'Cancel' : '取消' }
545+ </ button >
546+ < button
547+ className = "note-editor-btn note-editor-save"
548+ onClick = { ( ) => {
549+ saveNote ?. ( notebook . id , noteEditor . sectionId , noteEditor . sectionTitle , noteEditor . text )
550+ setNoteEditor ( null )
551+ } }
552+ disabled = { ! noteEditor . text . trim ( ) && ! notes [ noteEditor . noteKey ] }
553+ >
554+ { lang === 'en' ? 'Save' : '保存' }
555+ </ button >
556+ </ div >
557+ </ div >
558+ </ div >
559+ ) }
560+
435561 { toc . length > 0 && (
436562 < aside className = { `toc${ isVisible ? ' visible' : '' } ` } >
437563 < div className = "toc-sticky" >
0 commit comments