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 = ({ 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(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 (
setInlineEditMode(false)} spellCheck={false} />
setInlineEditMode(false)}>{_('Cancel')} {_('Save')}
); } const isEditable = item.note || item.type === 'bookmark'; return (
  • { if (e.key === 'Enter' || e.key === ' ') { handleClickItem(e); } else { e.stopPropagation(); } }} >
    {item.note && (
    )}
    {item.note && (
    )}
    {text || ''}
    {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
    e.stopPropagation()} >
    {item.page ? _('p {{page}}' + ' · ', { page: item.page }) : ''} {createdAtLabel}
    {isEditable && ( )}
  • ); }; // 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);