forked from akai/readest
This commit is contained in:
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user