diff --git a/apps/readest-app/src/app/reader/components/BookmarkToggler.tsx b/apps/readest-app/src/app/reader/components/BookmarkToggler.tsx index f0fbda26..b2c77770 100644 --- a/apps/readest-app/src/app/reader/components/BookmarkToggler.tsx +++ b/apps/readest-app/src/app/reader/components/BookmarkToggler.tsx @@ -5,6 +5,7 @@ import * as CFI from 'foliate-js/epubcfi.js'; import { useReaderStore } from '@/store/readerStore'; import { useEnv } from '@/context/EnvContext'; import { BookNote } from '@/types/book'; +import { uniqueId } from '@/utils/misc'; interface BookmarkTogglerProps { bookKey: string; @@ -28,6 +29,7 @@ const BookmarkToggler: React.FC = ({ bookKey }) => { const text = range?.startContainer.textContent?.slice(0, 128) || ''; const truncatedText = text.length === 128 ? text + '...' : text; const bookmark: BookNote = { + id: uniqueId(), type: 'bookmark', cfi, href, @@ -46,7 +48,11 @@ const BookmarkToggler: React.FC = ({ bookKey }) => { const end = CFI.collapse(cfi, true); const updatedConfig = updateBooknotes( bookKey, - bookmarks.filter((item) => CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) > 0), + bookmarks.filter( + (item) => + item.type !== 'bookmark' || + CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) > 0, + ), ); if (updatedConfig) { saveConfig(envConfig, bookKey, updatedConfig, settings); diff --git a/apps/readest-app/src/app/reader/components/HeaderBar.tsx b/apps/readest-app/src/app/reader/components/HeaderBar.tsx index d65b4779..367887f7 100644 --- a/apps/readest-app/src/app/reader/components/HeaderBar.tsx +++ b/apps/readest-app/src/app/reader/components/HeaderBar.tsx @@ -7,6 +7,7 @@ import WindowButtons from '@/components/WindowButtons'; import Dropdown from '@/components/Dropdown'; import SidebarToggler from './SidebarToggler'; import BookmarkToggler from './BookmarkToggler'; +import NotebookToggler from './NotebookToggler'; import ViewMenu from './ViewMenu'; interface HeaderBarProps { @@ -57,6 +58,7 @@ const HeaderBar: React.FC = ({
+ = ({ bookKey }) => { + const { sideBarBookKey, isNotebookVisible, setSideBarBookKey, toggleNotebook } = useReaderStore(); + const handleToggleSidebar = () => { + if (sideBarBookKey === bookKey) { + toggleNotebook(); + } else { + setSideBarBookKey(bookKey); + if (!isNotebookVisible) toggleNotebook(); + } + }; + return ( + + ); +}; + +export default NotebookToggler; diff --git a/apps/readest-app/src/app/reader/components/ReaderContent.tsx b/apps/readest-app/src/app/reader/components/ReaderContent.tsx index 0fe1cb21..f8e3c764 100644 --- a/apps/readest-app/src/app/reader/components/ReaderContent.tsx +++ b/apps/readest-app/src/app/reader/components/ReaderContent.tsx @@ -12,6 +12,7 @@ import SideBar from './sidebar/SideBar'; import useBooks from '../hooks/useBooks'; import BookGrid from './BookGrid'; import useBookShortcuts from '../hooks/useBookShortcuts'; +import Notebook from './notebook/Notebook'; const ReaderContent: React.FC<{ settings: SystemSettings }> = ({ settings }) => { const router = useRouter(); @@ -76,8 +77,11 @@ const ReaderContent: React.FC<{ settings: SystemSettings }> = ({ settings }) => onGoToLibrary={handleCloseBooks} onOpenSplitView={openSplitView} /> - +
); }; diff --git a/apps/readest-app/src/app/reader/components/SidebarToggler.tsx b/apps/readest-app/src/app/reader/components/SidebarToggler.tsx index c157d270..54cfa2b5 100644 --- a/apps/readest-app/src/app/reader/components/SidebarToggler.tsx +++ b/apps/readest-app/src/app/reader/components/SidebarToggler.tsx @@ -8,7 +8,7 @@ interface SidebarTogglerProps { } const SidebarToggler: React.FC = ({ bookKey }) => { - const { isSideBarVisible, setSideBarBookKey, sideBarBookKey, toggleSideBar } = useReaderStore(); + const { sideBarBookKey, isSideBarVisible, setSideBarBookKey, toggleSideBar } = useReaderStore(); const handleToggleSidebar = () => { if (sideBarBookKey === bookKey) { toggleSideBar(); diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index cd4dde35..a6739c91 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -11,22 +11,17 @@ import { useEnv } from '@/context/EnvContext'; import { BookNote, HighlightColor, HighlightStyle } from '@/types/book'; import { useReaderStore } from '@/store/readerStore'; import { useFoliateEvents } from '../../hooks/useFoliateEvents'; -import { getPopupPosition, getPosition, Position } from '@/utils/sel'; +import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel'; import Toast from '@/components/Toast'; import useOutsideClick from '@/hooks/useOutsideClick'; import AnnotationPopup from './AnnotationPopup'; - -interface TextSelection { - annotated?: boolean; - text: string; - range: Range; - index: number; -} +import { uniqueId } from '@/utils/misc'; const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const { envConfig } = useEnv(); const { settings, saveConfig, getProgress, updateBooknotes } = useReaderStore(); const { getConfig, getView, getViewsById } = useReaderStore(); + const { notebookNewAnnotation, setNotebookVisible, setNotebookNewAnnotation } = useReaderStore(); const globalReadSettings = settings.globalReadSettings; const config = getConfig(bookKey)!; const progress = getProgress(bookKey)!; @@ -55,8 +50,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const { doc, index } = detail; const handlePointerup = () => { const sel = doc.getSelection(); - if (sel && sel.toString().trim().length > 0) { - setSelection({ text: sel.toString(), range: sel.getRangeAt(0), index }); + if (sel && sel.toString().trim().length > 0 && sel.rangeCount > 0) { + setSelection({ key: bookKey, text: sel.toString(), range: sel.getRangeAt(0), index }); } }; detail.doc?.addEventListener('pointerup', handlePointerup); @@ -84,10 +79,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const annotations = booknotes.filter((booknote) => booknote.type === 'annotation'); const annotation = annotations.find((annotation) => annotation.cfi === cfi); if (!annotation) return; - const selection = { annotated: true, text: annotation.text, range, index }; + const selection = { key: bookKey, annotated: true, text: annotation.text ?? '', range, index }; setSelectedStyle(annotation.style!); setSelectedColor(annotation.color!); - setSelection(selection as TextSelection); + setSelection(selection); }; useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation }, [config]); @@ -117,9 +112,39 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { }, [toastMessage]); const handleCopy = () => { + if (!selection || !selection.text) return; setShowPopup(false); - setToastMessage('Copied to clipboard'); + setToastMessage('Copied to notebook'); + + const { booknotes: annotations = [] } = config; if (selection) navigator.clipboard.writeText(selection.text); + const { tocHref: href } = progress; + const cfi = view?.getCFI(selection.index, selection.range); + if (!cfi) return; + const annotation: BookNote = { + id: uniqueId(), + type: 'excerpt', + cfi, + href, + text: selection.text, + note: '', + created: Date.now(), + }; + + const existingIndex = annotations.findIndex( + (annotation) => annotation.cfi === cfi && annotation.type === 'excerpt', + ); + if (existingIndex !== -1) { + annotations[existingIndex] = annotation; + } else { + annotations.push(annotation); + } + const updatedConfig = updateBooknotes(bookKey, annotations); + if (updatedConfig) { + saveConfig(envConfig, bookKey, updatedConfig, settings); + } + setHighlightOptionsVisible(false); + setNotebookVisible(true); }; const handleHighlight = (update = false) => { @@ -131,11 +156,17 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { if (!cfi) return; const style = globalReadSettings.highlightStyle; const color = globalReadSettings.highlightStyles[style]; - const text = selection.text; - const type = 'annotation'; - const created = Date.now(); - const note = ''; - const annotation: BookNote = { type, cfi, href, style, color, text, note, created }; + const annotation: BookNote = { + id: uniqueId(), + type: 'annotation', + cfi, + href, + style, + color, + text: selection.text, + note: '', + created: Date.now(), + }; const existingIndex = annotations.findIndex( (annotation) => annotation.cfi === cfi && annotation.type === 'annotation', ); @@ -155,15 +186,20 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { setSelection({ ...selection, annotated: true }); } - const dedupedAnnotations = Array.from( - new Map(annotations.map((item) => [`${item.type}-${item.cfi}`, item])).values(), - ); - const updatedConfig = updateBooknotes(bookKey, dedupedAnnotations); + const updatedConfig = updateBooknotes(bookKey, annotations); if (updatedConfig) { saveConfig(envConfig, bookKey, updatedConfig, settings); } }; - const handleAnnotate = () => {}; + const handleAnnotate = () => { + if (!selection || !selection.text) return; + const { tocHref: href } = progress; + selection.href = href; + setHighlightOptionsVisible(false); + setNotebookVisible(true); + setNotebookNewAnnotation(selection); + handleHighlight(true); + }; const handleSearch = () => {}; const handleDictionary = () => {}; @@ -181,7 +217,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { ]; return ( -
+
{showPopup && trianglePosition && popupPosition && ( void; +}> = ({ isPinned, handleTogglePin }) => ( +
+
+
Notebook
+
+
+ + +
+
+); + +export default NotebookHeader; diff --git a/apps/readest-app/src/app/reader/components/notebook/NoteEditor.tsx b/apps/readest-app/src/app/reader/components/notebook/NoteEditor.tsx new file mode 100644 index 00000000..4754b309 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/notebook/NoteEditor.tsx @@ -0,0 +1,95 @@ +import clsx from 'clsx'; +import React, { useEffect, useRef } from 'react'; +import { useReaderStore } from '@/store/readerStore'; +import { TextSelection } from '@/utils/sel'; +import { BookNote } from '@/types/book'; + +interface NoteEditorProps { + onSave: (selction: TextSelection, note: string) => void; + onEdit: (annotation: BookNote) => void; +} + +const NoteEditor: React.FC = ({ onSave, onEdit }) => { + const { notebookNewAnnotation, notebookEditAnnotation } = useReaderStore(); + const editorRef = useRef(null); + const [note, setNote] = React.useState(''); + + useEffect(() => { + if (editorRef.current) { + editorRef.current.focus(); + } + }, []); + + useEffect(() => { + if (notebookEditAnnotation) { + setNote(notebookEditAnnotation.note); + } + }, [notebookEditAnnotation]); + + const adjustHeight = () => { + if (editorRef.current) { + editorRef.current.style.height = 'auto'; + editorRef.current.style.height = `${editorRef.current.scrollHeight}px`; + } + }; + + const handleInput = (e: React.FormEvent) => { + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + }; + + const handleChange = (e: React.ChangeEvent) => { + adjustHeight(); + setNote(e.currentTarget.value); + }; + + const handleSaveNote = () => { + if (editorRef.current && notebookNewAnnotation) { + onSave(notebookNewAnnotation, editorRef.current.value); + } else if (editorRef.current && notebookEditAnnotation) { + notebookEditAnnotation.note = editorRef.current.value; + onEdit(notebookEditAnnotation); + } + }; + + return ( +
+
+
+ +
+ +
+
+
+
+ {notebookNewAnnotation?.text || notebookEditAnnotation?.text} +
+
+
+ ); +}; + +export default NoteEditor; diff --git a/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx b/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx new file mode 100644 index 00000000..2a9a12eb --- /dev/null +++ b/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx @@ -0,0 +1,206 @@ +import clsx from 'clsx'; +import React, { useEffect } from 'react'; + +import { useReaderStore } from '@/store/readerStore'; +import { useEnv } from '@/context/EnvContext'; +import useDragBar from '../../hooks/useDragBar'; +import NotebookHeader from './Header'; +import NoteEditor from './NoteEditor'; +import { TextSelection } from '@/utils/sel'; +import { BookNote } from '@/types/book'; +import { uniqueId } from '@/utils/misc'; +import BooknoteItem from '../sidebar/BooknoteItem'; + +const MIN_NOTEBOOK_WIDTH = 0.05; +const MAX_NOTEBOOK_WIDTH = 0.45; + +const Notebook: React.FC<{ width: string; isPinned: boolean }> = ({ width, isPinned }) => { + const { envConfig } = useEnv(); + const { settings, sideBarBookKey, notebookWidth } = useReaderStore(); + const { isNotebookVisible, isNotebookPinned } = useReaderStore(); + const { notebookNewAnnotation, notebookEditAnnotation } = useReaderStore(); + const { setNotebookWidth, setNotebookPin, toggleNotebookPin } = useReaderStore(); + const { getConfig, saveConfig, getView, setNotebookVisible, updateBooknotes } = useReaderStore(); + const { setNotebookNewAnnotation, setNotebookEditAnnotation } = useReaderStore(); + + const handleNotebookResize = (newWidth: string) => { + setNotebookWidth(newWidth); + settings.globalReadSettings.notebookWidth = newWidth; + }; + + const handleTogglePin = () => { + toggleNotebookPin(); + settings.globalReadSettings.isNotebookPinned = !isNotebookPinned; + }; + + const handleClickOverlay = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setNotebookVisible(false); + }; + + const handleDragMove = (e: MouseEvent) => { + const widthFraction = 1 - e.clientX / window.innerWidth; + const newWidth = Math.max(MIN_NOTEBOOK_WIDTH, Math.min(MAX_NOTEBOOK_WIDTH, widthFraction)); + handleNotebookResize(`${Math.round(newWidth * 10000) / 100}%`); + }; + + const handleSaveNote = (selection: TextSelection, note: string) => { + if (!sideBarBookKey) return; + const view = getView(sideBarBookKey); + const config = getConfig(sideBarBookKey)!; + + const cfi = view?.getCFI(selection.index, selection.range); + if (!cfi) return; + + const { booknotes: annotations = [] } = config; + const annotation: BookNote = { + id: uniqueId(), + type: 'annotation', + cfi, + note, + href: selection.href || '', + text: selection.text, + created: Date.now(), + }; + annotations.push(annotation); + const updatedConfig = updateBooknotes(sideBarBookKey, annotations); + if (updatedConfig) { + saveConfig(envConfig, sideBarBookKey, updatedConfig, settings); + } + setNotebookNewAnnotation(null); + }; + + const handleEditNote = (annotation: BookNote) => { + if (!sideBarBookKey) return; + const config = getConfig(sideBarBookKey)!; + const { booknotes: annotations = [] } = config; + const existingIndex = annotations.findIndex((item) => item.id === annotation.id); + if (existingIndex === -1) return; + annotation.modified = Date.now(); + annotations[existingIndex] = annotation; + const updatedConfig = updateBooknotes(sideBarBookKey, annotations); + if (updatedConfig) { + saveConfig(envConfig, sideBarBookKey, updatedConfig, settings); + } + setNotebookEditAnnotation(null); + }; + + useEffect(() => { + setNotebookWidth(width); + setNotebookPin(isPinned); + setNotebookVisible(isPinned); + }, []); + + const { handleMouseDown } = useDragBar(handleDragMove); + const deleteNote = (note: BookNote) => { + if (!sideBarBookKey) return; + const config = getConfig(sideBarBookKey); + if (!config) return; + const { booknotes = [] } = config; + const updatedNotes = booknotes.filter((item) => item.id !== note.id); + const updatedConfig = updateBooknotes(sideBarBookKey, updatedNotes); + if (updatedConfig) { + saveConfig(envConfig, sideBarBookKey, updatedConfig, settings); + } + }; + + if (!sideBarBookKey) return null; + + const config = getConfig(sideBarBookKey); + const { booknotes: allNotes = [] } = config || {}; + const annotationNotes = allNotes + .filter((note) => note.type === 'annotation' && note.note) + .sort((a, b) => b.created - a.created); + const excerptNotes = allNotes + .filter((note) => note.type === 'excerpt') + .sort((a, b) => a.created - b.created); + + return isNotebookVisible ? ( + <> + {!isPinned && ( +
+ )} +
+
+ +
+
{excerptNotes.length > 0 &&

Excerpts

}
+
    + {excerptNotes.map((item, index) => ( +
  • +
    +
    +

    {item.text || `Excerpt ${index + 1}`}

    +
    +
    e.stopPropagation()} + > +

    {item.text}

    +
    + +
    +
    +
    +
  • + ))} +
+
+ {(notebookNewAnnotation || annotationNotes.length > 0) && ( +

Notes

+ )} +
+ {(notebookNewAnnotation || notebookEditAnnotation) && ( + + )} +
    + {annotationNotes.map((item, index) => ( + + ))} +
+
+
+ + ) : null; +}; + +export default Notebook; diff --git a/apps/readest-app/src/app/reader/components/sidebar/BooknoteItem.tsx b/apps/readest-app/src/app/reader/components/sidebar/BooknoteItem.tsx new file mode 100644 index 00000000..0471600e --- /dev/null +++ b/apps/readest-app/src/app/reader/components/sidebar/BooknoteItem.tsx @@ -0,0 +1,130 @@ +import clsx from 'clsx'; +import React, { useEffect, useState } from 'react'; + +import * as CFI from 'foliate-js/epubcfi.js'; +import { useEnv } from '@/context/EnvContext'; +import { BookNote } from '@/types/book'; +import { useReaderStore } from '@/store/readerStore'; + +interface BooknoteItemProps { + bookKey: string; + item: BookNote; + editable?: boolean; +} + +const BooknoteItem: React.FC = ({ bookKey, item, editable = false }) => { + const { envConfig } = useEnv(); + const { settings, getProgress, getView } = useReaderStore(); + const { getConfig, saveConfig, updateBooknotes, setNotebookEditAnnotation } = useReaderStore(); + const [isCurrent, setIsCurrent] = useState(false); + + const { text, cfi } = item; + const progress = getProgress(bookKey); + + useEffect(() => { + if (!progress) return; + const { location } = progress; + const start = CFI.collapse(location); + const end = CFI.collapse(location, true); + setIsCurrent(CFI.compare(start, cfi) * CFI.compare(end, cfi) <= 0); + }, [progress]); + + const handleClickItem = (event: React.MouseEvent) => { + event.preventDefault(); + getView(bookKey)?.goTo(cfi); + }; + + const deleteNote = (note: BookNote) => { + if (!bookKey) return; + const config = getConfig(bookKey); + if (!config) return; + const { booknotes = [] } = config; + const updatedNotes = booknotes.filter((item) => item.id !== note.id); + const updatedConfig = updateBooknotes(bookKey, updatedNotes); + if (updatedConfig) { + saveConfig(envConfig, bookKey, updatedConfig, settings); + } + }; + + const editNote = (note: BookNote) => { + setNotebookEditAnnotation(note); + }; + + return ( +
  • +
    + {item.note && {item.note}} +
    + {item.note && ( +
    + )} +
    + + {text || ''} + +
    +
    +
    + {editable && ( +
    e.stopPropagation()} + > +
    + + +
    +
    + )} +
  • + ); +}; + +export default BooknoteItem; diff --git a/apps/readest-app/src/app/reader/components/sidebar/BooknoteView.tsx b/apps/readest-app/src/app/reader/components/sidebar/BooknoteView.tsx index 43ae5058..02e79e4e 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/BooknoteView.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/BooknoteView.tsx @@ -1,11 +1,11 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import * as CFI from 'foliate-js/epubcfi.js'; import { useReaderStore } from '@/store/readerStore'; import { findParentPath } from '@/utils/toc'; import { TOCItem } from '@/libs/document'; import { BookNote, BookNoteType } from '@/types/book'; -import clsx from 'clsx'; +import BooknoteItem from './BooknoteItem'; interface BooknoteGroup { id: number; @@ -14,59 +14,6 @@ interface BooknoteGroup { booknotes: BookNote[]; } -interface BooknoteItemProps { - bookKey: string; - item: BookNote; -} - -const BooknoteItem: React.FC = ({ bookKey, item }) => { - const { getProgress, getView } = useReaderStore(); - const [isCurrentBookmark, setIsCurrentBookmark] = useState(false); - - const { text, cfi } = item; - const progress = getProgress(bookKey)!; - - useEffect(() => { - const { location } = progress; - const start = CFI.collapse(location); - const end = CFI.collapse(location, true); - setIsCurrentBookmark(CFI.compare(start, cfi) * CFI.compare(end, cfi) <= 0); - }, [progress]); - - const handleClickItem = (event: React.MouseEvent) => { - event.preventDefault(); - getView(bookKey)?.goTo(cfi); - }; - - return ( -
  • -
    - - {text || ''} - -
    -
  • - ); -}; - const BooknoteView: React.FC<{ type: BookNoteType; bookKey: string; diff --git a/apps/readest-app/src/app/reader/components/sidebar/Header.tsx b/apps/readest-app/src/app/reader/components/sidebar/Header.tsx index 3cad85a7..ea9771da 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/Header.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/Header.tsx @@ -10,8 +10,8 @@ const SidebarHeader: React.FC<{ isPinned: boolean; onGoToLibrary: () => void; onOpenSplitView: () => void; - handleSideBarTogglePin: () => void; -}> = ({ isPinned, onGoToLibrary, onOpenSplitView, handleSideBarTogglePin }) => ( + handleTogglePin: () => void; +}> = ({ isPinned, onGoToLibrary, onOpenSplitView, handleTogglePin }) => (