Skip to content

Commit e437e12

Browse files
sanbuphyclaude
andcommitted
Add notes and bookmarks feature with localStorage persistence
Users can bookmark notebooks, add notes to sections, and export/import their data. Everything stored in browser localStorage. - useNotesAndBookmarks hook: CRUD for bookmarks and notes - NotesPanel: full-page view showing all bookmarks and notes - Sidebar: filter tabs (全部/已收藏/有笔记) + bookmark indicators - NotebookViewer: star toggle in header, note icons on h2/h3 headings - Export/Import JSON for data backup Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a07dbb4 commit e437e12

6 files changed

Lines changed: 1024 additions & 60 deletions

File tree

web/src/App.jsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ import { flushSync } from 'react-dom'
33
import { Menu } from 'lucide-react'
44
import Sidebar from './components/Sidebar.jsx'
55
import NotebookViewer from './components/NotebookViewer.jsx'
6+
import NotesPanel from './components/NotesPanel.jsx'
67
import Welcome from './components/Welcome.jsx'
78
import GuidedTour from './components/GuidedTour.jsx'
89
import { getCatalog, getNotebook } from './data/notebooks.js'
10+
import useNotesAndBookmarks from './hooks/useNotesAndBookmarks.js'
11+
12+
const NOTES_SENTINEL = '__notes__'
913

1014
const DEFAULT_LANG = 'zh'
1115

@@ -84,6 +88,8 @@ function App() {
8488
const [tourActive, setTourActive] = useState(false)
8589
const [tourStepIndex, setTourStepIndex] = useState(0)
8690

91+
const nbm = useNotesAndBookmarks()
92+
8793
useEffect(() => {
8894
setCatalog(getCatalog(lang))
8995
document.documentElement.lang = lang === 'en' ? 'en' : 'zh-CN'
@@ -94,7 +100,11 @@ function App() {
94100
useEffect(() => {
95101
const syncFromHash = () => {
96102
const nextId = getInitialNotebookId()
97-
setCurrentId(nextId)
103+
if (nextId === NOTES_SENTINEL) return
104+
setCurrentId((prev) => {
105+
if (prev === NOTES_SENTINEL) return prev
106+
return nextId
107+
})
98108
if (nextId && window.location.hash !== `#${nextId}`) {
99109
replaceUrlWithHash(nextId, lang)
100110
}
@@ -129,6 +139,14 @@ function App() {
129139
}
130140
}, [lang])
131141

142+
const handleOpenNotes = useCallback(() => {
143+
flushSync(() => setCurrentId(NOTES_SENTINEL))
144+
replaceUrlWithHash(null, lang)
145+
if (window.innerWidth < 768) {
146+
setSidebarOpen(false)
147+
}
148+
}, [lang])
149+
132150
const currentMeta = catalog.find(n => n.id === currentId)
133151
const notebook = currentId ? getNotebook(currentId, lang) : null
134152
const tourNotebookId = catalog.find(n => n.id === '01-tokenizer-basics')?.id || catalog[0]?.id
@@ -296,16 +314,35 @@ function App() {
296314
onSelect={handleSelect}
297315
onHome={handleHome}
298316
onStartTour={startTour}
317+
onOpenNotes={handleOpenNotes}
318+
bookmarks={nbm.bookmarks}
319+
notes={nbm.notes}
299320
isOpen={sidebarOpen}
300321
onClose={() => setSidebarOpen(false)}
301322
/>
302323

303324
<main className="flex-1 flex flex-col min-h-screen min-w-0">
304-
{currentId ? (
325+
{currentId === NOTES_SENTINEL ? (
326+
<NotesPanel
327+
catalog={catalog}
328+
bookmarks={nbm.bookmarks}
329+
notes={nbm.notes}
330+
getSectionNotes={nbm.getSectionNotes}
331+
exportData={nbm.exportData}
332+
importFile={nbm.importFile}
333+
onSelect={handleSelect}
334+
lang={lang}
335+
/>
336+
) : currentId ? (
305337
<NotebookViewer
306338
notebook={notebook}
307339
meta={currentMeta}
308340
loading={loading}
341+
isBookmarked={nbm.isBookmarked}
342+
toggleBookmark={nbm.toggleBookmark}
343+
notes={nbm.notes}
344+
saveNote={nbm.saveNote}
345+
deleteNote={nbm.deleteNote}
309346
/>
310347
) : (
311348
<Welcome

web/src/components/NotebookViewer.jsx

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
2+
import { Star, Trash2 } from 'lucide-react'
23
import { getNotebookLaunchLinks } from '../config.js'
34

45
function 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+
>&times;</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

Comments
 (0)