Files
readest/apps/readest-app/src/app/reader/components/sidebar/BooknoteItem.tsx
T
Huang Xin ff96c6d3f7 feat(annotations): unify highlights and annotations into one record (#3870, #4511) (#4647)
A highlight and its note are now a single BookNote. Adding a note attaches
it to the highlight at that CFI (or creates one with the current global
style) instead of creating a second record, and a unified record renders as
both a highlight overlay and a note bubble.

- onDrawAnnotation chooses the draw kind from the overlay value prefix
  (cfi -> highlight, NOTE_PREFIX -> bubble) instead of annotation.note, so a
  record with both a style and a note draws both. Fixes notes synced from
  KOReader losing their highlight (#4511).
- handleSaveNote updates the existing annotation at the CFI rather than
  pushing a new record (#3870); re-styling preserves the note.
- unifyAnnotations migration (book config schema v1 -> v2, run in
  deserializeConfig) collapses existing split highlight+note records into one
  survivor and tombstones the redundant record (deletedAt) so the merge syncs
  to the cloud and KOReader.
- Sidebar: a note's quoted highlight text uses the theme foreground so it
  stays legible on the highlight background.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:37:46 +02:00

292 lines
11 KiB
TypeScript

import clsx from 'clsx';
import dayjs from 'dayjs';
import React, { useMemo, useRef, useState } from 'react';
import { MdEdit, MdDelete } from 'react-icons/md';
import { marked } from 'marked';
import { useEnv } from '@/context/EnvContext';
import { BookNote, HighlightColor } from '@/types/book';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useNotebookStore } from '@/store/notebookStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { eventDispatcher } from '@/utils/event';
import { isCfiInLocation } from '@/utils/cfi';
import { removeBookNoteOverlays } from '../../utils/annotatorUtil';
import TextButton from '@/components/TextButton';
import TextEditor, { TextEditorRef } from '@/components/TextEditor';
interface BooknoteItemProps {
bookKey: string;
item: BookNote;
isNearest?: boolean;
onClick?: () => void;
}
const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, isNearest, onClick }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getConfig, saveConfig, updateBooknotes } = useBookDataStore();
const { getProgress, getView, getViewsById } = useReaderStore();
const { setNotebookEditAnnotation, setNotebookVisible } = useNotebookStore();
const globalReadSettings = settings.globalReadSettings;
const customColors = globalReadSettings.customHighlightColors;
const { text, cfi, note } = item;
const editorRef = useRef<TextEditorRef>(null);
const [editorDraft, setEditorDraft] = useState(text || '');
const [inlineEditMode, setInlineEditMode] = useState(false);
const separatorWidth = useResponsiveSize(3);
const size18 = useResponsiveSize(18);
const progress = getProgress(bookKey);
// Active highlight: keep visual "current" state but don't scroll from the
// item itself anymore — the parent (virtualized) BooknoteView handles
// scrolling via virtuosoRef.scrollToIndex, avoiding N getBoundingClientRect
// calls when the list grows large.
const isCurrent = useMemo(
() => isCfiInLocation(cfi, progress?.location) || !!isNearest,
[cfi, progress?.location, isNearest],
);
// marked.parse is heavy when called on every list scroll re-render across
// hundreds of items. Cache by note text — note edits change item.note and
// bust the cache automatically.
const noteHtml = useMemo(() => (note ? marked.parse(note) : ''), [note]);
// dayjs().fromNow() reformats every render; cache per createdAt.
const createdAtLabel = useMemo(() => dayjs(item.createdAt).fromNow(), [item.createdAt]);
const handleClickItem = (event: React.MouseEvent | React.KeyboardEvent) => {
event.preventDefault();
eventDispatcher.dispatch('navigate', { bookKey, cfi });
onClick?.();
getView(bookKey)?.goTo(cfi);
if (note) {
setNotebookVisible(true);
}
};
const deleteNote = (note: BookNote) => {
if (!bookKey) return;
const config = getConfig(bookKey);
if (!config) return;
const { booknotes = [] } = config;
booknotes.forEach((item) => {
if (item.id === note.id) {
item.deletedAt = Date.now();
const views = getViewsById(bookKey.split('-')[0]!);
views.forEach((view) => removeBookNoteOverlays(view, item));
}
});
const updatedConfig = updateBooknotes(bookKey, booknotes);
if (updatedConfig) {
saveConfig(envConfig, bookKey, updatedConfig, settings);
}
};
const editNote = (note: BookNote) => {
setNotebookVisible(true);
setNotebookEditAnnotation(note);
};
const editBookmark = () => {
setEditorDraft(text || '');
setInlineEditMode(true);
};
const handleSaveBookmark = () => {
setInlineEditMode(false);
const config = getConfig(bookKey);
if (!config || !editorDraft) 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 = editorDraft;
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={editorDraft}
onChange={setEditorDraft}
onSave={handleSaveBookmark}
onEscape={() => setInlineEditMode(false)}
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={!editorDraft}>
{_('Save')}
</TextButton>
</div>
</div>
);
}
const isEditable = item.note || item.type === 'bookmark';
return (
<li
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
role='button'
aria-current={isCurrent ? 'page' : undefined}
className={clsx(
'booknote-item border-base-300 content group relative my-2 cursor-pointer rounded-lg p-2',
isCurrent
? 'bg-base-300/85 hover:bg-base-300 focus:bg-base-300'
: 'hover:bg-base-300/55 focus:bg-base-300/55 bg-base-100',
'transition-all duration-300 ease-in-out',
)}
tabIndex={0}
onClick={handleClickItem}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleClickItem(e);
} else {
e.stopPropagation();
}
}}
>
<div
className={clsx('min-h-4 p-0 transition-all duration-300 ease-in-out')}
style={
{
'--top-override': '0.7rem',
'--end-override': '0.3rem',
} as React.CSSProperties
}
>
{item.note && (
<div
className='content prose prose-sm font-size-sm'
dir='auto'
dangerouslySetInnerHTML={{ __html: noteHtml }}
></div>
)}
<div className='flex items-start'>
{item.note && (
<div
className='me-2 mt-2.5 min-h-full self-stretch rounded-xl bg-gray-300'
style={{
minWidth: `${separatorWidth}px`,
}}
></div>
)}
<div className={clsx('content font-size-sm line-clamp-3', item.note && 'mt-2')}>
<span
className={clsx(
'booknote-text inline leading-normal',
item.note && 'content font-size-xs text-base-content',
(item.style === 'underline' || item.style === 'squiggly') &&
'underline decoration-2',
item.style === 'highlight' && 'rounded-[4px] px-[2px] py-[1px]',
item.style === 'squiggly' && 'decoration-wavy',
)}
style={
{
...(item.style === 'highlight'
? {
backgroundColor: `color-mix(in srgb, ${customColors[item.color as HighlightColor] || item.color} calc(var(--overlayer-highlight-opacity, 0.3) * 100%), transparent)`,
}
: {}),
...(item.style === 'underline' || item.style === 'squiggly'
? {
textDecorationColor: `color-mix(in srgb, ${customColors[item.color as HighlightColor] || item.color} 80%, transparent)`,
}
: {}),
} as React.CSSProperties
}
>
{text || ''}
</span>
</div>
</div>
</div>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
className={clsx(
'max-h-0 overflow-hidden p-0',
'transition-[max-height] duration-300 ease-in-out',
'group-focus-within:overflow-visible group-hover:overflow-visible',
isEditable
? 'group-focus-within:max-h-12 group-hover:max-h-12'
: 'group-focus-within:max-h-8 group-hover:max-h-8',
)}
style={
{
'--bottom-override': 0,
} as React.CSSProperties
}
// This is needed to prevent the parent onClick from being triggered
onClick={(e) => e.stopPropagation()}
>
<div
className={clsx(
'flex cursor-default items-center justify-between py-2',
isEditable && 'flex-col',
)}
>
<div className='flex w-full items-center gap-1 truncate'>
<span className='truncate text-sm text-gray-500 sm:text-xs'>
{item.page ? _('p {{page}}' + ' · ', { page: item.page }) : ''}
</span>
<span className='truncate text-sm text-gray-500 sm:text-xs'>{createdAtLabel}</span>
</div>
<div
className={clsx('flex items-center justify-end gap-4', isEditable && 'w-full')}
dir='ltr'
>
<button
onClick={deleteNote.bind(null, item)}
className='btn btn-ghost btn-xs p-0 text-red-500 opacity-0 transition duration-300 ease-in-out hover:bg-transparent group-focus-within:opacity-100 group-hover:opacity-100'
aria-label={_('Delete')}
>
<MdDelete size={size18} />
</button>
{isEditable && (
<button
onClick={item.type === 'bookmark' ? editBookmark : editNote.bind(null, item)}
className='btn btn-ghost btn-xs p-0 text-blue-500 opacity-0 transition duration-300 ease-in-out hover:bg-transparent group-focus-within:opacity-100 group-hover:opacity-100'
aria-label={_('Edit')}
>
<MdEdit size={size18} />
</button>
)}
</div>
</div>
</div>
</li>
);
};
// Memoize: BooknoteView re-renders on every progress tick / config change.
// Without React.memo each tick would re-render every visible note row even
// though their props are unchanged. Default shallow compare is enough since
// `item` and `onClick` are stable references from the parent's useMemo /
// useCallback.
export default React.memo(BooknoteItem);