Add note taking in notebook

This commit is contained in:
chrox
2024-11-10 18:51:52 +01:00
parent a9fd1a00e6
commit bd908f0929
23 changed files with 659 additions and 123 deletions
@@ -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<BookmarkTogglerProps> = ({ 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<BookmarkTogglerProps> = ({ 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);
@@ -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<HeaderBarProps> = ({
</div>
<div className='flex h-full items-center space-x-2'>
<NotebookToggler bookKey={bookKey} />
<Dropdown
className='dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
@@ -0,0 +1,31 @@
import React from 'react';
import { TbLayoutSidebarRight, TbLayoutSidebarRightFilled } from 'react-icons/tb';
import { useReaderStore } from '@/store/readerStore';
interface NotebookTogglerProps {
bookKey: string;
}
const NotebookToggler: React.FC<NotebookTogglerProps> = ({ bookKey }) => {
const { sideBarBookKey, isNotebookVisible, setSideBarBookKey, toggleNotebook } = useReaderStore();
const handleToggleSidebar = () => {
if (sideBarBookKey === bookKey) {
toggleNotebook();
} else {
setSideBarBookKey(bookKey);
if (!isNotebookVisible) toggleNotebook();
}
};
return (
<button onClick={handleToggleSidebar} className='p-2'>
{sideBarBookKey == bookKey && isNotebookVisible ? (
<TbLayoutSidebarRightFilled size={20} />
) : (
<TbLayoutSidebarRight size={20} />
)}
</button>
);
};
export default NotebookToggler;
@@ -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}
/>
<BookGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
<Notebook
width={settings.globalReadSettings.notebookWidth}
isPinned={settings.globalReadSettings.isNotebookPinned}
/>
</div>
);
};
@@ -8,7 +8,7 @@ interface SidebarTogglerProps {
}
const SidebarToggler: React.FC<SidebarTogglerProps> = ({ bookKey }) => {
const { isSideBarVisible, setSideBarBookKey, sideBarBookKey, toggleSideBar } = useReaderStore();
const { sideBarBookKey, isSideBarVisible, setSideBarBookKey, toggleSideBar } = useReaderStore();
const handleToggleSidebar = () => {
if (sideBarBookKey === bookKey) {
toggleSideBar();
@@ -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 (
<div ref={popupRef}>
<div ref={notebookNewAnnotation ? null : popupRef}>
{showPopup && trianglePosition && popupPosition && (
<AnnotationPopup
buttons={buttons}
@@ -0,0 +1,28 @@
import React from 'react';
import { FiSearch } from 'react-icons/fi';
import { MdOutlinePushPin, MdPushPin } from 'react-icons/md';
const NotebookHeader: React.FC<{
isPinned: boolean;
handleTogglePin: () => void;
}> = ({ isPinned, handleTogglePin }) => (
<div className='notebook-header relative flex h-11 items-center px-3'>
<div className='absolute inset-0 flex items-center justify-center'>
<div className='notebook-title text-sm font-medium'>Notebook</div>
</div>
<div className='z-10 flex items-center space-x-3'>
<button
onClick={handleTogglePin}
className={`${isPinned ? 'bg-gray-300' : 'bg-base-300'} btn btn-ghost btn-circle h-6 min-h-6 w-6`}
>
{isPinned ? <MdPushPin size={14} /> : <MdOutlinePushPin size={14} />}
</button>
<button className='btn btn-ghost left-0 h-8 min-h-8 w-8 p-0'>
<FiSearch size={18} />
</button>
</div>
</div>
);
export default NotebookHeader;
@@ -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<NoteEditorProps> = ({ onSave, onEdit }) => {
const { notebookNewAnnotation, notebookEditAnnotation } = useReaderStore();
const editorRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
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 (
<div className='note-editor-container mt-2 rounded-md bg-white p-2'>
<div className='flex w-full justify-between space-x-2'>
<div className='settings-content relative w-full'>
<textarea
className={clsx(
'note-editor textarea textarea-ghost min-h-[1em] resize-none !outline-none',
'inset-0 w-full rounded-none border-0 bg-transparent p-0 leading-normal',
'text-base',
)}
ref={editorRef}
value={note}
rows={1}
spellCheck={false}
onChange={handleChange}
onKeyDown={handleInput}
placeholder='Add your notes here...'
></textarea>
</div>
<button
className={clsx(
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-[1.5em] min-h-[1.5em] items-end p-0',
editorRef.current && editorRef.current.value ? '' : 'btn-disabled !bg-opacity-0',
)}
onClick={handleSaveNote}
>
<div className='align-bottom text-blue-400'>Save</div>
</button>
</div>
<div className='flex items-start pt-2'>
<div className='mr-2 min-h-full self-stretch border-l-2 border-gray-300'></div>
<div className='note-citation settings-content line-clamp-3 text-sm'>
{notebookNewAnnotation?.text || notebookEditAnnotation?.text}
</div>
</div>
</div>
);
};
export default NoteEditor;
@@ -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 && (
<div className='overlay fixed inset-0 z-10 bg-black/20' onClick={handleClickOverlay} />
)}
<div
className={clsx(
'notebook-container bg-base-200 right-0 z-20 h-full min-w-60 select-none',
'rounded-window-top-right rounded-window-bottom-right',
!isPinned && 'shadow-2xl',
)}
style={{
width: `${notebookWidth}`,
maxWidth: `${MAX_NOTEBOOK_WIDTH * 100}%`,
position: isPinned ? 'relative' : 'absolute',
}}
>
<div
className='drag-bar absolute left-0 top-0 h-full w-0.5 cursor-col-resize'
onMouseDown={handleMouseDown}
/>
<NotebookHeader isPinned={isPinned} handleTogglePin={handleTogglePin} />
<div className='max-h-[calc(100vh-44px)] overflow-y-auto px-3'>
<div>{excerptNotes.length > 0 && <p className='pt-1 text-sm'>Excerpts</p>}</div>
<ul className=''>
{excerptNotes.map((item, index) => (
<li key={`${index}-${item.id}`} className='my-2'>
<div
tabIndex={0}
className='collapse-arrow border-base-300 collapse border bg-white'
>
<div
className='collapse-title h-9 min-h-9 p-2 pr-8 text-sm font-medium'
style={
{
'--top-override': '1.2rem',
'--end-override': '0.7rem',
} as React.CSSProperties
}
>
<p className='line-clamp-1'>{item.text || `Excerpt ${index + 1}`}</p>
</div>
<div
className='collapse-content select-text px-3 pb-0 text-xs'
onClick={(e) => e.stopPropagation()}
>
<p className='hyphens-auto text-justify'>{item.text}</p>
<div className='flex justify-end'>
<button
className={clsx(
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-[1em] min-h-[1em] items-end p-0',
)}
onClick={deleteNote.bind(null, item)}
>
<div className='align-bottom text-xs text-red-400'>Delete</div>
</button>
</div>
</div>
</div>
</li>
))}
</ul>
<div>
{(notebookNewAnnotation || annotationNotes.length > 0) && (
<p className='pt-1 text-sm'>Notes</p>
)}
</div>
{(notebookNewAnnotation || notebookEditAnnotation) && (
<NoteEditor onSave={handleSaveNote} onEdit={handleEditNote} />
)}
<ul className=''>
{annotationNotes.map((item, index) => (
<BooknoteItem
key={`${index}-${item.cfi}`}
bookKey={sideBarBookKey}
item={item}
editable={true}
/>
))}
</ul>
</div>
</div>
</>
) : null;
};
export default Notebook;
@@ -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<BooknoteItemProps> = ({ 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 (
<li
className={clsx(
'border-base-300 my-2 cursor-pointer rounded-lg p-2 text-sm',
editable && 'collapse-arrow collapse',
isCurrent ? 'bg-base-300 hover:bg-gray-300/70' : 'hover:bg-base-300 bg-white',
)}
tabIndex={0}
onClick={handleClickItem}
>
<div
className={clsx('collapse-title min-h-4 p-0', editable && 'pr-4')}
style={
{
'--top-override': '0.7rem',
'--end-override': '0.3rem',
} as React.CSSProperties
}
>
{item.note && <span className='line-clamp-3 font-normal'>{item.note}</span>}
<div className='flex items-start'>
{item.note && (
<div className='my-1 mr-2 min-h-full self-stretch border-l-2 border-gray-300'></div>
)}
<div className='line-clamp-3'>
<span
className={clsx(
'inline',
item.note && 'text-xs text-gray-500',
(item.style === 'underline' || item.style === 'squiggly') &&
'underline decoration-2',
item.style === 'highlight' && `bg-${item.color}-100`,
item.style === 'underline' && `decoration-${item.color}-400`,
item.style === 'squiggly' && `decoration-wavy decoration-${item.color}-400`,
)}
>
{text || ''}
</span>
</div>
</div>
</div>
{editable && (
<div
className={clsx('collapse-content invisible !p-0 text-xs')}
style={
{
'--bottom-override': 0,
} as React.CSSProperties
}
onClick={(e) => e.stopPropagation()}
>
<div className='flex justify-end space-x-3'>
<button
className={clsx(
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-4 min-h-4 items-end p-0',
)}
onClick={editNote.bind(null, item)}
>
<div className='align-bottom text-xs text-blue-400'>Edit</div>
</button>
<button
className={clsx(
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-4 min-h-4 items-end p-0',
)}
onClick={deleteNote.bind(null, item)}
>
<div className='align-bottom text-xs text-red-400'>Delete</div>
</button>
</div>
</div>
)}
</li>
);
};
export default BooknoteItem;
@@ -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<BooknoteItemProps> = ({ 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 (
<li
className={clsx(
'my-2 cursor-pointer rounded-lg p-2 text-sm',
isCurrentBookmark ? 'bg-base-300 hover:bg-gray-300' : 'hover:bg-base-300 bg-white',
)}
onClick={handleClickItem}
>
<div className='line-clamp-3 overflow-hidden'>
<span
className={clsx(
'inline',
(item.style === 'underline' || item.style === 'squiggly') && 'underline decoration-2',
item.type === 'annotation' && item.style === 'highlight' && `bg-${item.color}-100`,
item.type === 'annotation' &&
item.style === 'underline' &&
`decoration-${item.color}-400`,
item.type === 'annotation' &&
item.style === 'squiggly' &&
`decoration-wavy decoration-${item.color}-400`,
)}
>
{text || ''}
</span>
</div>
</li>
);
};
const BooknoteView: React.FC<{
type: BookNoteType;
bookKey: string;
@@ -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 }) => (
<div className='sidebar-header flex h-11 items-center justify-between pl-1.5 pr-3'>
<div className='flex items-center'>
<button className='btn btn-ghost h-8 min-h-8 w-8 p-0' onClick={onGoToLibrary}>
@@ -30,7 +30,7 @@ const SidebarHeader: React.FC<{
<BookMenu openSplitView={onOpenSplitView} />
</Dropdown>
<button
onClick={handleSideBarTogglePin}
onClick={handleTogglePin}
className={`${isPinned ? 'bg-gray-300' : 'bg-base-300'} btn btn-ghost btn-circle right-0 h-6 min-h-6 w-6`}
>
{isPinned ? <MdPushPin size={14} /> : <MdOutlinePushPin size={14} />}
@@ -9,7 +9,7 @@ import BookCard from './BookCard';
import useSidebar from '../../hooks/useSidebar';
import useDragBar from '../../hooks/useDragBar';
const MIN_SIDEBAR_WIDTH = 0.15;
const MIN_SIDEBAR_WIDTH = 0.05;
const MAX_SIDEBAR_WIDTH = 0.45;
const SideBar: React.FC<{
@@ -25,11 +25,17 @@ const SideBar: React.FC<{
const {
sideBarWidth,
isSideBarVisible,
setSideBarVisible,
handleSideBarResize,
handleSideBarTogglePin,
setSideBarVisibility,
} = useSidebar(width, isPinned);
const { handleMouseDown } = useDragBar(handleSideBarResize, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH);
const handleDragMove = (e: MouseEvent) => {
const widthFraction = e.clientX / window.innerWidth;
const newWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, widthFraction));
handleSideBarResize(`${Math.round(newWidth * 10000) / 100}%`);
};
const { handleMouseDown } = useDragBar(handleDragMove);
useEffect(() => {
if (!sideBarBookKey) return;
@@ -38,7 +44,7 @@ const SideBar: React.FC<{
}, [sideBarBookKey]);
const handleClickOverlay = () => {
setSideBarVisibility(false);
setSideBarVisible(false);
};
if (!sideBarBookKey) return null;
@@ -67,7 +73,7 @@ const SideBar: React.FC<{
isPinned={isPinned}
onGoToLibrary={onGoToLibrary}
onOpenSplitView={onOpenSplitView}
handleSideBarTogglePin={handleSideBarTogglePin}
handleTogglePin={handleSideBarTogglePin}
/>
<div className='border-b px-3'>
<BookCard cover={book.coverImageUrl!} title={book.title} author={book.author} />
@@ -2,8 +2,7 @@ import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
const uniqueId = () => Math.random().toString(36).substring(2, 9);
import { uniqueId } from '@/utils/misc';
const useBooks = () => {
const { envConfig } = useEnv();
@@ -1,30 +1,26 @@
import { useCallback } from 'react';
const useDragBar = (
handleSideBarResize: (width: string) => void,
minWidth: number,
maxWidth: number,
) => {
const useDragBar = (handleDragMove: (e: MouseEvent) => void) => {
const handleMouseMove = useCallback(
(e: MouseEvent) => {
const newWidthPx = e.clientX;
const width = `${Math.round((newWidthPx / window.innerWidth) * 10000) / 100}%`;
const minWidthPx = minWidth * window.innerWidth;
const maxWidthPx = maxWidth * window.innerWidth;
if (newWidthPx >= minWidthPx && newWidthPx <= maxWidthPx) {
handleSideBarResize(width);
}
handleDragMove(e);
},
[handleSideBarResize, minWidth, maxWidth],
[handleDragMove],
);
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', () => {
document.removeEventListener('mousemove', handleMouseMove);
});
};
const handleMouseUp = useCallback(() => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}, [handleMouseMove]);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[handleMouseMove, handleMouseUp],
);
return { handleMouseDown };
};
@@ -5,19 +5,19 @@ const useSidebar = (initialWidth: string, isPinned: boolean) => {
const { settings } = useReaderStore();
const {
sideBarWidth,
setSideBarWidth,
isSideBarVisible,
setSideBarVisibility,
toggleSideBar,
isSideBarPinned,
setSideBarWidth,
setSideBarVisible,
setSideBarPin,
toggleSideBar,
toggleSideBarPin,
} = useReaderStore();
useEffect(() => {
setSideBarWidth(initialWidth);
setSideBarPin(isPinned);
setSideBarVisibility(isPinned);
setSideBarVisible(isPinned);
}, []);
const handleSideBarResize = (newWidth: string) => {
@@ -28,7 +28,7 @@ const useSidebar = (initialWidth: string, isPinned: boolean) => {
const handleSideBarTogglePin = () => {
toggleSideBarPin();
settings.globalReadSettings.isSideBarPinned = !isSideBarPinned;
if (isSideBarPinned && isSideBarVisible) setSideBarVisibility(false);
if (isSideBarPinned && isSideBarVisible) setSideBarVisible(false);
};
return {
@@ -37,7 +37,7 @@ const useSidebar = (initialWidth: string, isPinned: boolean) => {
isSideBarVisible,
handleSideBarResize,
handleSideBarTogglePin,
setSideBarVisibility,
setSideBarVisible,
toggleSideBar,
};
};
@@ -7,6 +7,8 @@ export const CLOUD_BOOKS_SUBDIR = 'Readest/Books';
export const DEFAULT_READSETTINGS: ReadSettings = {
sideBarWidth: '25%',
isSideBarPinned: true,
notebookWidth: '25%',
isNotebookPinned: false,
autohideCursor: true,
highlightStyle: 'highlight',
+35 -4
View File
@@ -14,6 +14,7 @@ import { SystemSettings } from '@/types/settings';
import { FoliateView } from '@/app/reader/components/FoliateViewer';
import { BookDoc, DocumentLoader, TOCItem } from '@/libs/document';
import { updateTocID } from '@/utils/toc';
import { TextSelection } from '@/utils/sel';
export interface BookData {
/* Persistent data shared with different views of the same book */
@@ -58,9 +59,22 @@ interface ReaderStore {
setSideBarWidth: (width: string) => void;
toggleSideBar: () => void;
toggleSideBarPin: () => void;
setSideBarVisibility: (visible: boolean) => void;
setSideBarVisible: (visible: boolean) => void;
setSideBarPin: (pinned: boolean) => void;
notebookWidth: string;
isNotebookVisible: boolean;
isNotebookPinned: boolean;
notebookNewAnnotation: TextSelection | null;
notebookEditAnnotation: BookNote | null;
setNotebookWidth: (width: string) => void;
toggleNotebook: () => void;
toggleNotebookPin: () => void;
setNotebookVisible: (visible: boolean) => void;
setNotebookPin: (pinned: boolean) => void;
setNotebookNewAnnotation: (selection: TextSelection | null) => void;
setNotebookEditAnnotation: (note: BookNote | null) => void;
setBookmarkRibbonVisibility: (key: string, visible: boolean) => void;
isFontLayoutSettingsDialogOpen: boolean;
@@ -120,9 +134,23 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
setSideBarWidth: (width: string) => set({ sideBarWidth: width }),
toggleSideBar: () => set((state) => ({ isSideBarVisible: !state.isSideBarVisible })),
toggleSideBarPin: () => set((state) => ({ isSideBarPinned: !state.isSideBarPinned })),
setSideBarVisibility: (visible: boolean) => set({ isSideBarVisible: visible }),
setSideBarVisible: (visible: boolean) => set({ isSideBarVisible: visible }),
setSideBarPin: (pinned: boolean) => set({ isSideBarPinned: pinned }),
notebookWidth: '',
isNotebookVisible: false,
isNotebookPinned: false,
notebookNewAnnotation: null,
notebookEditAnnotation: null,
setNotebookWidth: (width: string) => set({ notebookWidth: width }),
toggleNotebook: () => set((state) => ({ isNotebookVisible: !state.isNotebookVisible })),
toggleNotebookPin: () => set((state) => ({ isNotebookPinned: !state.isNotebookPinned })),
setNotebookVisible: (visible: boolean) => set({ isNotebookVisible: visible }),
setNotebookPin: (pinned: boolean) => set({ isNotebookPinned: pinned }),
setNotebookNewAnnotation: (selection: TextSelection | null) =>
set({ notebookNewAnnotation: selection }),
setNotebookEditAnnotation: (note: BookNote | null) => set({ notebookEditAnnotation: note }),
isFontLayoutSettingsDialogOpen: false,
isFontLayoutSettingsGlobal: true,
setFontLayoutSettingsDialogOpen: (open: boolean) => set({ isFontLayoutSettingsDialogOpen: open }),
@@ -389,10 +417,13 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
const id = key.split('-')[0]!;
const book = state.booksData[id];
if (!book) return state;
const dedupedBooknotes = Array.from(
new Map(booknotes.map((item) => [`${item.id}-${item.type}-${item.cfi}`, item])).values(),
);
updatedConfig = {
...book.config,
lastUpdated: Date.now(),
booknotes,
booknotes: dedupedBooknotes,
};
return {
booksData: {
@@ -402,7 +433,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
config: {
...book.config,
lastUpdated: Date.now(),
booknotes,
booknotes: dedupedBooknotes,
},
},
},
+4
View File
@@ -157,3 +157,7 @@ foliate-view {
@apply rounded-b-2xl;
}
.collapse-arrow > .collapse-title:after {
top: var(--top-override, default-value);
inset-inline-end: var(--end-override, default-value);
}
+2 -1
View File
@@ -1,5 +1,5 @@
export type BookFormat = 'EPUB' | 'PDF' | 'MOBI' | 'CBZ' | 'FB2' | 'FBZ';
export type BookNoteType = 'bookmark' | 'highlight' | 'annotation';
export type BookNoteType = 'bookmark' | 'annotation' | 'excerpt';
export type HighlightStyle = 'highlight' | 'underline' | 'squiggly';
export type HighlightColor = 'red' | 'yellow' | 'green' | 'blue' | 'violet';
@@ -22,6 +22,7 @@ export interface PageInfo {
}
export interface BookNote {
id: string;
type: BookNoteType;
cfi: string;
href: string;
+2
View File
@@ -5,6 +5,8 @@ export type ThemeType = 'light' | 'dark' | 'auto';
export interface ReadSettings {
sideBarWidth: string;
isSideBarPinned: boolean;
notebookWidth: string;
isNotebookPinned: boolean;
autohideCursor: boolean;
highlightStyle: HighlightStyle;
+1
View File
@@ -0,0 +1 @@
export const uniqueId = () => Math.random().toString(36).substring(2, 9);
+9
View File
@@ -22,6 +22,15 @@ export interface Position {
dir?: PositionDir;
}
export interface TextSelection {
key: string;
text: string;
range: Range;
index: number;
href?: string;
annotated?: boolean;
}
const frameRect = (frame: Frame, rect: Rect, sx = 1, sy = 1) => {
const left = sx * rect.left + frame.left;
const right = sx * rect.right + frame.left;