From 6e706ac8f7dce174c204d9405772d590dd965615 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 3 Sep 2025 17:06:35 +0800 Subject: [PATCH] feat: add inline editor for bookmarks in the sidebar, closes #1909 (#1954) --- .../reader/components/notebook/NoteEditor.tsx | 136 ++++++++--------- .../components/sidebar/BooknoteItem.tsx | 112 +++++++++----- .../readest-app/src/components/TextButton.tsx | 58 +++++++ .../readest-app/src/components/TextEditor.tsx | 142 ++++++++++++++++++ apps/readest-app/src/styles/globals.css | 6 +- 5 files changed, 335 insertions(+), 119 deletions(-) create mode 100644 apps/readest-app/src/components/TextButton.tsx create mode 100644 apps/readest-app/src/components/TextEditor.tsx diff --git a/apps/readest-app/src/app/reader/components/notebook/NoteEditor.tsx b/apps/readest-app/src/app/reader/components/notebook/NoteEditor.tsx index a6579d1c..9be90174 100644 --- a/apps/readest-app/src/app/reader/components/notebook/NoteEditor.tsx +++ b/apps/readest-app/src/app/reader/components/notebook/NoteEditor.tsx @@ -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 = ({ onSave, onEdit }) => { saveNotebookAnnotationDraft, getNotebookAnnotationDraft, } = useNotebookStore(); - const editorRef = useRef(null); - const [note, setNote] = React.useState(''); + + const editorRef = useRef(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) => { - 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 (
-
-
- -
+
+
+
= ({ onSave, onEdit }) => { {getAnnotationText()}
-
- + +
+ {_('Cancel')} + + {_('Save')} +
); diff --git a/apps/readest-app/src/app/reader/components/sidebar/BooknoteItem.tsx b/apps/readest-app/src/app/reader/components/sidebar/BooknoteItem.tsx index ff813f98..a9f30e13 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/BooknoteItem.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/BooknoteItem.tsx @@ -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 = ({ 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(null); + const editorDraftRef = useRef(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 = ({ 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 ( +
+
+ (editorDraftRef.current = value)} + onSave={handleSaveBookmark} + onEscape={() => setInlineEditMode(false)} + autoFocus={true} + spellCheck={false} + /> +
+
+ setInlineEditMode(false)}>{_('Cancel')} + + {_('Save')} + +
+
+ ); + } + return (
  • = ({ bookKey, item }) => {
  • = ({ bookKey, item }) => {
    - {item.note && ( -
    -
    - {_('Edit')} -
    -
    + {_('Edit')} + )} -
    -
    - {_('Delete')} -
    -
    + {_('Delete')} +
    diff --git a/apps/readest-app/src/components/TextButton.tsx b/apps/readest-app/src/components/TextButton.tsx new file mode 100644 index 00000000..f453850e --- /dev/null +++ b/apps/readest-app/src/components/TextButton.tsx @@ -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 = ({ + 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 ( + + ); +}; + +export default TextButton; diff --git a/apps/readest-app/src/components/TextEditor.tsx b/apps/readest-app/src/components/TextEditor.tsx new file mode 100644 index 00000000..afc57fde --- /dev/null +++ b/apps/readest-app/src/components/TextEditor.tsx @@ -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( + ( + { + value, + onChange, + onBlur, + onSave, + onEscape, + placeholder, + className, + autoFocus = false, + spellCheck = false, + disabled = false, + maxRows, + minRows = 1, + }, + ref, + ) => { + const editorRef = useRef(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) => { + adjustHeight(); + onChange(e.target.value); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape' && onEscape) { + onEscape(); + return; + } + + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && onSave) { + e.preventDefault(); + onSave(); + return; + } + }; + + return ( +