feat: add inline editor for bookmarks in the sidebar, closes #1909 (#1954)

This commit is contained in:
Huang Xin
2025-09-03 17:06:35 +08:00
committed by GitHub
parent 39b902c927
commit 6e706ac8f7
5 changed files with 335 additions and 119 deletions
@@ -1,5 +1,4 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
@@ -7,6 +6,8 @@ import { TextSelection } from '@/utils/sel';
import { md5Fingerprint } from '@/utils/md5';
import { BookNote } from '@/types/book';
import useShortcuts from '@/hooks/useShortcuts';
import TextEditor, { TextEditorRef } from '@/components/TextEditor';
import TextButton from '@/components/TextButton';
interface NoteEditorProps {
onSave: (selection: TextSelection, note: string) => void;
@@ -23,110 +24,96 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
saveNotebookAnnotationDraft,
getNotebookAnnotationDraft,
} = useNotebookStore();
const editorRef = useRef<HTMLTextAreaElement>(null);
const [note, setNote] = React.useState('');
const editorRef = useRef<TextEditorRef>(null);
const [note, setNote] = useState('');
const separatorWidth = useResponsiveSize(3);
useEffect(() => {
if (editorRef.current) {
editorRef.current.focus();
}
}, [editorRef]);
useEffect(() => {
if (notebookEditAnnotation) {
setNote(notebookEditAnnotation.note);
if (editorRef.current) {
editorRef.current.value = notebookEditAnnotation.note;
editorRef.current.focus();
adjustHeight();
}
const noteText = notebookEditAnnotation.note;
setNote(noteText);
editorRef.current?.setValue(noteText);
editorRef.current?.focus();
} else if (notebookNewAnnotation) {
const noteText = getAnnotationText();
if (noteText) {
const draftNote = getNotebookAnnotationDraft(md5Fingerprint(noteText)) || '';
setNote(draftNote);
if (editorRef.current) {
editorRef.current.value = draftNote;
editorRef.current.focus();
adjustHeight();
}
editorRef.current?.setValue(draftNote);
editorRef.current?.focus();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [notebookNewAnnotation, notebookEditAnnotation]);
const adjustHeight = () => {
if (editorRef.current) {
editorRef.current.style.height = 'auto';
editorRef.current.style.height = `${editorRef.current.scrollHeight}px`;
}
};
const getAnnotationText = () => {
return notebookEditAnnotation?.text || notebookNewAnnotation?.text || '';
};
const handleOnChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
adjustHeight();
setNote(e.currentTarget.value);
const handleNoteChange = (value: string) => {
setNote(value);
};
const handleOnBlur = () => {
if (editorRef.current && editorRef.current.value) {
const handleBlur = () => {
const currentValue = editorRef.current?.getValue();
if (currentValue) {
const noteText = getAnnotationText();
if (noteText) {
saveNotebookAnnotationDraft(md5Fingerprint(noteText), editorRef.current.value);
saveNotebookAnnotationDraft(md5Fingerprint(noteText), currentValue);
}
}
};
const handleSaveNote = () => {
if (editorRef.current && notebookNewAnnotation) {
onSave(notebookNewAnnotation, editorRef.current.value);
} else if (editorRef.current && notebookEditAnnotation) {
notebookEditAnnotation.note = editorRef.current.value;
onEdit(notebookEditAnnotation);
const currentValue = editorRef.current?.getValue();
if (currentValue) {
if (notebookNewAnnotation) {
onSave(notebookNewAnnotation, currentValue);
} else if (notebookEditAnnotation) {
notebookEditAnnotation.note = currentValue;
onEdit(notebookEditAnnotation);
}
}
};
const handleEscape = () => {
if (notebookNewAnnotation) {
setNotebookNewAnnotation(null);
}
if (notebookEditAnnotation) {
setNotebookEditAnnotation(null);
}
};
useShortcuts({
onSaveNote: () => {
if (editorRef.current && editorRef.current.value) {
const currentValue = editorRef.current?.getValue();
if (currentValue) {
handleSaveNote();
}
},
onEscape: () => {
if (notebookNewAnnotation) {
setNotebookNewAnnotation(null);
}
if (notebookEditAnnotation) {
setNotebookEditAnnotation(null);
}
},
onEscape: handleEscape,
});
const canSave = Boolean(note.trim());
return (
<div className='content note-editor-container bg-base-100 mt-2 rounded-md p-2'>
<div className='flex w-full justify-between space-x-2'>
<div className='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',
'content font-size-sm',
)}
dir='auto'
ref={editorRef}
value={note}
rows={1}
spellCheck={false}
onChange={handleOnChange}
onBlur={handleOnBlur}
placeholder={_('Add your notes here...')}
></textarea>
</div>
<div className='flex w-full'>
<TextEditor
ref={editorRef}
value={note}
onChange={handleNoteChange}
onBlur={handleBlur}
onSave={handleSaveNote}
onEscape={handleEscape}
placeholder={_('Add your notes here...')}
autoFocus={true}
spellCheck={false}
/>
</div>
<div className='flex items-center pt-2'>
<div
className='me-2 mt-0.5 min-h-full self-stretch rounded-xl bg-gray-300'
@@ -138,17 +125,12 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
<span className='content font-size-xs text-gray-500'>{getAnnotationText()}</span>
</div>
</div>
<div className='flex justify-end p-2' dir='ltr'>
<button
className={clsx(
'content btn btn-ghost font-size-sm hover:bg-transparent',
'flex h-[1.3em] min-h-[1.3em] items-end p-0',
editorRef.current && editorRef.current.value ? '' : 'btn-disabled !bg-opacity-0',
)}
onClick={handleSaveNote}
>
<div className='font-size-sm pr-1 align-bottom text-blue-500'>{_('Save')}</div>
</button>
<div className='flex justify-end space-x-3 p-2' dir='ltr'>
<TextButton onClick={handleEscape}>{_('Cancel')}</TextButton>
<TextButton onClick={handleSaveNote} disabled={!canSave}>
{_('Save')}
</TextButton>
</div>
</div>
);
@@ -1,6 +1,6 @@
import clsx from 'clsx';
import dayjs from 'dayjs';
import React from 'react';
import React, { useRef, useState } from 'react';
import { marked } from 'marked';
import { useEnv } from '@/context/EnvContext';
@@ -13,6 +13,8 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { eventDispatcher } from '@/utils/event';
import useScrollToItem from '../../hooks/useScrollToItem';
import TextButton from '@/components/TextButton';
import TextEditor, { TextEditorRef } from '@/components/TextEditor';
interface BooknoteItemProps {
bookKey: string;
@@ -26,9 +28,13 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
const { getConfig, saveConfig, updateBooknotes } = useBookDataStore();
const { getProgress, getView, getViewsById } = useReaderStore();
const { setNotebookEditAnnotation, setNotebookVisible } = useNotebookStore();
const separatorWidth = useResponsiveSize(3);
const { text, cfi, note } = item;
const editorRef = useRef<TextEditorRef>(null);
const editorDraftRef = useRef<string>(text || '');
const [inlineEditMode, setInlineEditMode] = useState(false);
const separatorWidth = useResponsiveSize(3);
const progress = getProgress(bookKey);
const { isCurrent, viewRef } = useScrollToItem(cfi, progress);
@@ -65,6 +71,57 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
setNotebookEditAnnotation(note);
};
const editBookmark = () => {
setInlineEditMode(true);
};
const handleSaveBookmark = () => {
setInlineEditMode(false);
const config = getConfig(bookKey);
if (!config || !editorDraftRef.current) return;
const { booknotes: annotations = [] } = config;
const existingIndex = annotations.findIndex((annotation) => item.id === annotation.id);
if (existingIndex === -1) return;
annotations[existingIndex]!.updatedAt = Date.now();
annotations[existingIndex]!.text = editorDraftRef.current;
const updatedConfig = updateBooknotes(bookKey, annotations);
if (updatedConfig) {
saveConfig(envConfig, bookKey, updatedConfig, settings);
}
};
if (inlineEditMode) {
return (
<div
className={clsx(
'border-base-300 content group relative my-2 cursor-pointer rounded-lg p-2',
isCurrent ? 'bg-base-300/85 hover:bg-base-300' : 'hover:bg-base-300/55 bg-base-100',
'transition-all duration-300 ease-in-out',
)}
>
<div className='flex w-full'>
<TextEditor
className='!leading-normal'
ref={editorRef}
value={editorDraftRef.current}
onChange={(value) => (editorDraftRef.current = value)}
onSave={handleSaveBookmark}
onEscape={() => setInlineEditMode(false)}
autoFocus={true}
spellCheck={false}
/>
</div>
<div className='flex justify-end space-x-3 p-2' dir='ltr'>
<TextButton onClick={() => setInlineEditMode(false)}>{_('Cancel')}</TextButton>
<TextButton onClick={handleSaveBookmark} disabled={!editorDraftRef.current}>
{_('Save')}
</TextButton>
</div>
</div>
);
}
return (
<li
ref={viewRef}
@@ -120,7 +177,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
</div>
<div
className={clsx(
'max-h-0 overflow-hidden p-0 text-xs',
'max-h-0 overflow-hidden p-0',
'transition-[max-height] duration-300 ease-in-out',
'group-hover:max-h-8 group-hover:overflow-visible',
)}
@@ -138,46 +195,23 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
</span>
</div>
<div className='flex items-center justify-end space-x-3 p-2' dir='ltr'>
{item.note && (
<div
className={clsx(
'content settings-content cursor-pointer',
'flex items-end p-0 hover:bg-transparent',
)}
onClick={editNote.bind(null, item)}
{(item.note || item.type === 'bookmark') && (
<TextButton
onClick={item.type === 'bookmark' ? editBookmark : editNote.bind(null, item)}
variant='primary'
className='opacity-0 transition duration-300 ease-in-out group-hover:opacity-100'
>
<div
className={clsx(
'align-bottom text-blue-500',
'transition duration-300 ease-in-out',
'content font-size-sm',
'opacity-0 group-hover:opacity-100',
'hover:text-blue-600',
)}
>
{_('Edit')}
</div>
</div>
{_('Edit')}
</TextButton>
)}
<div
className={clsx(
'content settings-content cursor-pointer',
'flex items-end p-0 hover:bg-transparent',
)}
<TextButton
onClick={deleteNote.bind(null, item)}
variant='danger'
className='opacity-0 transition duration-300 ease-in-out group-hover:opacity-100'
>
<div
className={clsx(
'align-bottom text-red-500',
'transition duration-300 ease-in-out',
'content font-size-sm',
'opacity-0 group-hover:opacity-100',
'hover:text-red-600',
)}
>
{_('Delete')}
</div>
</div>
{_('Delete')}
</TextButton>
</div>
</div>
</div>
@@ -0,0 +1,58 @@
import clsx from 'clsx';
import React from 'react';
interface TextButtonProps {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
className?: string;
variant?: 'primary' | 'secondary' | 'danger' | 'success';
size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit' | 'reset';
}
const TextButton: React.FC<TextButtonProps> = ({
children,
onClick,
disabled = false,
className,
variant = 'primary',
size = 'sm',
type = 'button',
}) => {
const variantClasses = {
primary: 'text-blue-500 hover:text-blue-600',
secondary: 'text-gray-500 hover:text-gray-600',
danger: 'text-red-500 hover:text-red-600',
success: 'text-green-500 hover:text-green-600',
};
const sizeClasses = {
sm: 'font-size-sm h-[1.3em] min-h-[1.3em]',
md: 'font-size-md h-[1.5em] min-h-[1.5em]',
lg: 'font-size-lg h-[1.8em] min-h-[1.8em]',
};
return (
<button
type={type}
className={clsx(
'content settings-content btn btn-ghost hover:bg-transparent',
'flex items-end p-0',
sizeClasses[size],
disabled ? 'btn-disabled !bg-opacity-0' : '',
className,
)}
onClick={onClick}
disabled={disabled}
>
<div
className={clsx('align-bottom', sizeClasses[size].split(' ')[0], variantClasses[variant])}
>
{children}
</div>
</button>
);
};
export default TextButton;
@@ -0,0 +1,142 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
interface TextEditorProps {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
onSave?: () => void;
onEscape?: () => void;
placeholder?: string;
className?: string;
autoFocus?: boolean;
spellCheck?: boolean;
disabled?: boolean;
maxRows?: number;
minRows?: number;
}
export interface TextEditorRef {
focus: () => void;
blur: () => void;
getValue: () => string;
setValue: (value: string) => void;
getElement: () => HTMLTextAreaElement | null;
}
const TextEditor = forwardRef<TextEditorRef, TextEditorProps>(
(
{
value,
onChange,
onBlur,
onSave,
onEscape,
placeholder,
className,
autoFocus = false,
spellCheck = false,
disabled = false,
maxRows,
minRows = 1,
},
ref,
) => {
const editorRef = useRef<HTMLTextAreaElement>(null);
useImperativeHandle(ref, () => ({
focus: () => {
editorRef.current?.focus();
},
blur: () => {
editorRef.current?.blur();
},
getValue: () => {
return editorRef.current?.value || '';
},
setValue: (newValue: string) => {
if (editorRef.current) {
editorRef.current.value = newValue;
adjustHeight();
}
},
getElement: () => editorRef.current,
}));
useEffect(() => {
if (autoFocus && editorRef.current) {
editorRef.current.focus();
}
}, [autoFocus]);
useEffect(() => {
if (editorRef.current) {
editorRef.current.value = value;
adjustHeight();
}
}, [value]);
const adjustHeight = () => {
if (editorRef.current) {
editorRef.current.style.height = 'auto';
const scrollHeight = editorRef.current.scrollHeight;
// Calculate max height based on maxRows
let maxHeight = Infinity;
if (maxRows) {
const lineHeight = parseInt(getComputedStyle(editorRef.current).lineHeight);
maxHeight = lineHeight * maxRows;
}
// Calculate min height based on minRows
const lineHeight = parseInt(getComputedStyle(editorRef.current).lineHeight) || 20;
const minHeight = lineHeight * minRows;
const finalHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
editorRef.current.style.height = `${finalHeight}px`;
}
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
adjustHeight();
onChange(e.target.value);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape' && onEscape) {
onEscape();
return;
}
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && onSave) {
e.preventDefault();
onSave();
return;
}
};
return (
<textarea
ref={editorRef}
className={clsx(
'textarea textarea-ghost min-h-[1em] resize-none !outline-none',
'inset-0 w-full rounded-none border-0 bg-transparent p-0',
'content font-size-sm',
className,
)}
dir='auto'
rows={minRows}
spellCheck={spellCheck}
disabled={disabled}
onChange={handleChange}
onBlur={onBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder || ''}
/>
);
},
);
TextEditor.displayName = 'TextEditor';
export default TextEditor;
+3 -3
View File
@@ -260,17 +260,17 @@ foliate-view {
.content.font-size-base {
font-size: 1em !important;
line-height: 1.5em !important;
line-height: 1.5em;
}
.content.font-size-sm {
font-size: 0.875em !important;
line-height: 1.25em !important;
line-height: 1.25em;
}
.content.font-size-xs {
font-size: 0.75em !important;
line-height: 1em !important;
line-height: 1em;
}
.writing-vertical-rl {