diff --git a/apps/readest-app/src/app/reader/components/annotator/AnnotationNotes.tsx b/apps/readest-app/src/app/reader/components/annotator/AnnotationNotes.tsx new file mode 100644 index 00000000..c2f077da --- /dev/null +++ b/apps/readest-app/src/app/reader/components/annotator/AnnotationNotes.tsx @@ -0,0 +1,100 @@ +import clsx from 'clsx'; +import React from 'react'; +import { BookNote } from '@/types/book'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { useReaderStore } from '@/store/readerStore'; +import { useSidebarStore } from '@/store/sidebarStore'; +import { useResponsiveSize } from '@/hooks/useResponsiveSize'; + +interface AnnotationNotesProps { + bookKey: string; + isVertical: boolean; + notes: BookNote[]; + triangleDir: 'up' | 'down' | 'left' | 'right'; + popupWidth: number; + popupHeight: number; +} + +const AnnotationNotes: React.FC = ({ + bookKey, + isVertical, + notes, + triangleDir, + popupWidth, + popupHeight, +}) => { + const { getConfig, setConfig } = useBookDataStore(); + const { setHoveredBookKey } = useReaderStore(); + const { setSideBarVisible } = useSidebarStore(); + const config = getConfig(bookKey); + const maxSize = useResponsiveSize(300); + + const handleShowAnnotation = (note: BookNote) => { + if (!note.id) return; + + setHoveredBookKey(''); + setSideBarVisible(true); + if (config?.viewSettings) { + setConfig(bookKey, { + viewSettings: { ...config.viewSettings, sideBarTab: 'annotations' }, + }); + } + }; + + return ( +
+
+ {notes.map((note, index) => ( +
handleShowAnnotation?.(note)} + className='cursor-pointer rounded-lg bg-gray-600 p-4 shadow-md transition-colors' + > + {note.note && ( +
+ {note.note} +
+ )} +
+ ))} +
+
+ ); +}; + +export default AnnotationNotes; diff --git a/apps/readest-app/src/app/reader/components/annotator/AnnotationPopup.tsx b/apps/readest-app/src/app/reader/components/annotator/AnnotationPopup.tsx index 29f0486b..5aead94c 100644 --- a/apps/readest-app/src/app/reader/components/annotator/AnnotationPopup.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/AnnotationPopup.tsx @@ -1,13 +1,14 @@ import clsx from 'clsx'; import React from 'react'; +import { Position } from '@/utils/sel'; +import { BookNote, HighlightColor, HighlightStyle } from '@/types/book'; import Popup from '@/components/Popup'; import PopupButton from './PopupButton'; +import AnnotationNotes from './AnnotationNotes'; import HighlightOptions from './HighlightOptions'; -import { Position } from '@/utils/sel'; -import { HighlightColor, HighlightStyle } from '@/types/book'; -import { useResponsiveSize } from '@/hooks/useResponsiveSize'; interface AnnotationPopupProps { + bookKey: string; dir: 'ltr' | 'rtl'; isVertical: boolean; buttons: Array<{ @@ -17,6 +18,7 @@ interface AnnotationPopupProps { disabled?: boolean; visible?: boolean; }>; + notes: BookNote[]; position: Position; trianglePosition: Position; highlightOptionsVisible: boolean; @@ -28,13 +30,12 @@ interface AnnotationPopupProps { onDismiss?: () => void; } -const OPTIONS_HEIGHT_PIX = 28; -const OPTIONS_PADDING_PIX = 16; - const AnnotationPopup: React.FC = ({ + bookKey, dir, isVertical, buttons, + notes, position, trianglePosition, highlightOptionsVisible, @@ -45,8 +46,6 @@ const AnnotationPopup: React.FC = ({ onHighlight, onDismiss, }) => { - const highlightOptionsHeightPx = useResponsiveSize(OPTIONS_HEIGHT_PIX); - const highlightOptionsPaddingPx = useResponsiveSize(OPTIONS_PADDING_PIX); return (
= ({ triangleClassName='text-gray-600' onDismiss={onDismiss} > -
- {buttons.map((button, index) => { - if (button.visible === false) return null; - return ( - +
+ {buttons.map((button, index) => { + if (button.visible === false) return null; + return ( + + ); + })} +
+ {notes.length > 0 ? ( + + ) : ( + highlightOptionsVisible && ( + - ); - })} + ) + )}
- {highlightOptionsVisible && ( - - )}
); }; diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index c4b59929..e9258828 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -14,6 +14,7 @@ import * as CFI from 'foliate-js/epubcfi.js'; import { Overlayer } from 'foliate-js/overlayer.js'; import { useEnv } from '@/context/EnvContext'; import { BookNote, BooknoteGroup, HighlightColor, HighlightStyle } from '@/types/book'; +import { NOTE_PREFIX } from '@/types/view'; import { getOSPlatform, uniqueId } from '@/utils/misc'; import { useBookDataStore } from '@/store/bookDataStore'; import { useSettingsStore } from '@/store/settingsStore'; @@ -69,6 +70,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const [translatorPopupPosition, setTranslatorPopupPosition] = useState(); const [proofreadPopupPosition, setProofreadPopupPosition] = useState(); const [highlightOptionsVisible, setHighlightOptionsVisible] = useState(false); + const [showAnnotationNotes, setShowAnnotationNotes] = useState(false); + const [annotationNotes, setAnnotationNotes] = useState([]); const [selectedStyle, setSelectedStyle] = useState( settings.globalReadSettings.highlightStyle, @@ -78,6 +81,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { ); const popupPadding = useResponsiveSize(10); + const trianglePadding = popupPadding * 2 + 6; const maxWidth = window.innerWidth - 2 * popupPadding; const maxHeight = window.innerHeight - 2 * popupPadding; const dictPopupWidth = Math.min(480, maxWidth); @@ -96,7 +100,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const gridFrame = document.querySelector(`#gridcell-${bookKey}`); if (!gridFrame) return; const rect = gridFrame.getBoundingClientRect(); - const triangPos = getPosition(selection.range, rect, popupPadding, viewSettings.vertical); + const triangPos = getPosition(selection, rect, trianglePadding, viewSettings.vertical); const annotPopupPos = getPopupPosition( triangPos, rect, @@ -135,21 +139,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { setTranslatorPopupPosition(transPopupPos); setProofreadPopupPosition(proofreadPopupPos); setTrianglePosition(triangPos); - }, [ - selection, - bookKey, - osPlatform, - popupPadding, - viewSettings.vertical, - annotPopupHeight, - annotPopupWidth, - dictPopupWidth, - dictPopupHeight, - transPopupWidth, - transPopupHeight, - proofreadPopupWidth, - proofreadPopupHeight, - ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selection, bookKey, viewSettings.vertical]); useEffect(() => { setSelectedStyle(settings.globalReadSettings.highlightStyle); @@ -275,23 +266,40 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const onShowAnnotation = (event: Event) => { const detail = (event as CustomEvent).detail; - const { value: cfi, index, range } = detail; + const { value, index, range } = detail; const { booknotes = [] } = getConfig(bookKey)!; + const isNote = value.startsWith(NOTE_PREFIX); + const cfi = isNote ? value.replace(NOTE_PREFIX, '') : value; const annotations = booknotes.filter( - (booknote) => booknote.type === 'annotation' && !booknote.deletedAt, + (booknote) => booknote.type === 'annotation' && !booknote.deletedAt && booknote.cfi === cfi, + ); + const annotation = annotations.find( + (annotation) => (!isNote && annotation.style) || (isNote && annotation.note), ); - const annotation = annotations.find((annotation) => annotation.cfi === cfi); if (!annotation) return; + + const { style, color, text, note } = annotation; const selection = { key: bookKey, annotated: true, - text: annotation.text ?? '', - cfi: view?.getCFI(index, range), + text: text ?? '', + note: note ?? '', + rect: isNote ? detail.rect : undefined, + cfi, range, index, }; - setSelectedStyle(annotation.style!); - setSelectedColor(annotation.color!); + if (isNote) { + setShowAnnotationNotes(true); + setHighlightOptionsVisible(false); + } else { + setShowAnnotationNotes(false); + setAnnotationNotes([]); + if (style && color) { + setSelectedStyle(style); + setSelectedColor(color); + } + } setSelection(selection); handleUpToPopup(); }; @@ -353,7 +361,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const gridFrame = document.querySelector(`#gridcell-${bookKey}`); if (!gridFrame) return; const rect = gridFrame.getBoundingClientRect(); - const triangPos = getPosition(selection.range, rect, popupPadding, viewSettings.vertical); + const triangPos = getPosition(selection, rect, trianglePadding, viewSettings.vertical); const annotPopupPos = getPopupPosition( triangPos, rect, @@ -411,14 +419,36 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { CFI.compare(item.cfi, start) >= 0 && CFI.compare(item.cfi, end) <= 0, ); + const notes = booknotes.filter( + (item) => + !item.deletedAt && + item.type === 'annotation' && + item.note && + item.note.trim().length > 0 && + CFI.compare(item.cfi, start) >= 0 && + CFI.compare(item.cfi, end) <= 0, + ); try { Promise.all(annotations.map((annotation) => view?.addAnnotation(annotation))); + Promise.all( + notes.map((note) => view?.addAnnotation({ ...note, value: `${NOTE_PREFIX}${note.cfi}` })), + ); } catch (e) { console.warn(e); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [progress]); + useEffect(() => { + if (!config.booknotes || !selection?.cfi || !showAnnotationNotes) return; + const annotations = config.booknotes.filter( + (booknote) => + booknote.type === 'annotation' && !booknote.deletedAt && booknote.cfi === selection.cfi, + ); + const notes = annotations.filter((item) => item.note && item.note.trim().length > 0); + setAnnotationNotes(notes); + }, [selection?.cfi, showAnnotationNotes, config.booknotes]); + const handleShowAnnotPopup = () => { if (!appService?.isMobile) { containerRef.current?.focus(); @@ -500,7 +530,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { }; const existingIndex = annotations.findIndex( (annotation) => - annotation.cfi === cfi && annotation.type === 'annotation' && !annotation.deletedAt, + annotation.cfi === cfi && + annotation.type === 'annotation' && + annotation.style && + !annotation.deletedAt, ); const views = getViewsById(bookKey.split('-')[0]!); if (existingIndex !== -1) { @@ -779,9 +812,11 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { )} {showAnnotPopup && trianglePosition && annotPopupPosition && ( void; } +const OPTIONS_HEIGHT_PIX = 28; +const OPTIONS_PADDING_PIX = 16; + const HighlightOptions: React.FC = ({ - style, isVertical, + popupWidth, + popupHeight, + triangleDir, selectedStyle: _selectedStyle, selectedColor: _selectedColor, onHandleHighlight, @@ -34,6 +41,8 @@ const HighlightOptions: React.FC = ({ const size16 = useResponsiveSize(16); const size18 = useResponsiveSize(18); const size28 = useResponsiveSize(28); + const highlightOptionsHeightPx = useResponsiveSize(OPTIONS_HEIGHT_PIX); + const highlightOptionsPaddingPx = useResponsiveSize(OPTIONS_PADDING_PIX); const handleSelectStyle = (style: HighlightStyle) => { const newGlobalReadSettings = { ...globalReadSettings, highlightStyle: style }; @@ -58,7 +67,23 @@ const HighlightOptions: React.FC = ({ 'highlight-options absolute flex items-center justify-between', isVertical ? 'flex-col' : 'flex-row', )} - style={style} + style={{ + width: `${popupWidth}px`, + height: `${popupHeight}px`, + ...(isVertical + ? { + left: `${ + (highlightOptionsHeightPx + highlightOptionsPaddingPx) * + (triangleDir === 'left' ? -1 : 1) + }px`, + } + : { + top: `${ + (highlightOptionsHeightPx + highlightOptionsPaddingPx) * + (triangleDir === 'up' ? -1 : 1) + }px`, + }), + }} >
{ createdAt: Date.now(), updatedAt: Date.now(), }; + view?.addAnnotation({ ...annotation, value: `${NOTE_PREFIX}${annotation.cfi}` }); annotations.push(annotation); const updatedConfig = updateBooknotes(sideBarBookKey, annotations); if (updatedConfig) { @@ -128,6 +130,7 @@ const Notebook: React.FC = ({}) => { const handleEditNote = (note: BookNote, isDelete: boolean) => { if (!sideBarBookKey) return; + const view = getView(sideBarBookKey); const config = getConfig(sideBarBookKey)!; const { booknotes: annotations = [] } = config; const existingIndex = annotations.findIndex((item) => item.id === note.id); @@ -138,6 +141,7 @@ const Notebook: React.FC = ({}) => { note.updatedAt = Date.now(); } annotations[existingIndex] = note; + view?.addAnnotation({ ...note, value: `${NOTE_PREFIX}${note.cfi}` }, true); const updatedConfig = updateBooknotes(sideBarBookKey, annotations); if (updatedConfig) { saveConfig(envConfig, sideBarBookKey, updatedConfig, settings); 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 47cef0e1..838ce066 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/BooknoteItem.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/BooknoteItem.tsx @@ -12,6 +12,7 @@ import { useBookDataStore } from '@/store/bookDataStore'; import { useTranslation } from '@/hooks/useTranslation'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; import { eventDispatcher } from '@/utils/event'; +import { NOTE_PREFIX } from '@/types/view'; import useScrollToItem from '../../hooks/useScrollToItem'; import TextButton from '@/components/TextButton'; import TextEditor, { TextEditorRef } from '@/components/TextEditor'; @@ -57,7 +58,9 @@ const BooknoteItem: React.FC = ({ bookKey, item }) => { if (item.id === note.id) { item.deletedAt = Date.now(); const views = getViewsById(bookKey.split('-')[0]!); - views.forEach((view) => view?.addAnnotation(item, true)); + views.forEach((view) => + view?.addAnnotation({ ...item, value: `${NOTE_PREFIX}${item.cfi}` }, true), + ); } }); const updatedConfig = updateBooknotes(bookKey, booknotes); diff --git a/apps/readest-app/src/components/Popup.tsx b/apps/readest-app/src/components/Popup.tsx index d07feced..9f16abb2 100644 --- a/apps/readest-app/src/components/Popup.tsx +++ b/apps/readest-app/src/components/Popup.tsx @@ -105,7 +105,7 @@ const Popup = ({ {children}
Promise; close: () => void; @@ -15,7 +17,10 @@ export interface FoliateView extends HTMLElement { goRight: () => void; getCFI: (index: number, range: Range) => string; resolveCFI: (cfi: string) => { index: number; anchor: (doc: Document) => Range }; - addAnnotation: (note: BookNote, remove?: boolean) => { index: number; label: string }; + addAnnotation: ( + note: BookNote & { value?: string }, + remove?: boolean, + ) => { index: number; label: string }; search: (config: BookSearchConfig) => AsyncGenerator; clearSearch: () => void; select: (target: string | number | { fraction: number }) => void; diff --git a/apps/readest-app/src/utils/sel.ts b/apps/readest-app/src/utils/sel.ts index 205d9d8e..7b241503 100644 --- a/apps/readest-app/src/utils/sel.ts +++ b/apps/readest-app/src/utils/sel.ts @@ -30,6 +30,7 @@ export interface TextSelection { cfi?: string; href?: string; annotated?: boolean; + rect?: Rect; } const frameRect = (frame: Frame, rect?: Rect, sx = 1, sy = 1) => { @@ -68,17 +69,21 @@ const getIframeElement = (nodeElement: Range | Element): HTMLIFrameElement | nul const constrainPointWithinRect = (point: Point, rect: Rect, padding: number) => { return { - x: Math.max(padding, Math.min(point.x, rect.right - padding)), - y: Math.max(padding, Math.min(point.y, rect.bottom - padding)), + x: Math.max(padding, Math.min(point.x, rect.right - rect.left - padding)), + y: Math.max(padding, Math.min(point.y, rect.bottom - rect.top - padding)), }; }; export const getPosition = ( - target: Range | Element, + targetElement: Range | Element | TextSelection, rect: Rect, paddingPx: number, isVertical: boolean = false, ) => { + const { range: target, rect: targetRect } = + targetElement && 'range' in targetElement + ? targetElement + : { range: targetElement, rect: undefined }; const frameElement = getIframeElement(target); const transform = frameElement ? getComputedStyle(frameElement).transform : ''; const match = transform.match(/matrix\((.+)\)/); @@ -103,8 +108,12 @@ export const getPosition = ( left: rect.left + padding.left, }; }); - const first = frameRect(frame, rects[0], sx, sy); - const last = frameRect(frame, rects.at(-1), sx, sy); + const first = targetRect + ? frameRect(frame, targetRect, sx, sy) + : frameRect(frame, rects[0], sx, sy); + const last = targetRect + ? frameRect(frame, targetRect, sx, sy) + : frameRect(frame, rects.at(-1), sx, sy); if (isVertical) { const leftSpace = first.left - rect.left; @@ -126,11 +135,19 @@ export const getPosition = ( } const start = { - point: { x: (first.left + first.right) / 2 - rect.left, y: first.top - rect.top - 12 }, + point: constrainPointWithinRect( + { x: (first.left + first.right) / 2 - rect.left, y: first.top - rect.top - 12 }, + rect, + paddingPx, + ), dir: 'up', } as Position; const end = { - point: { x: (last.left + last.right) / 2 - rect.left, y: last.bottom - rect.top + 6 }, + point: constrainPointWithinRect( + { x: (last.left + last.right) / 2 - rect.left, y: last.bottom - rect.top + 6 }, + rect, + paddingPx, + ), dir: 'down', } as Position; const startInView = pointIsInView(start.point); diff --git a/apps/readest-app/src/utils/style.ts b/apps/readest-app/src/utils/style.ts index 97f0891d..0b5c87c7 100644 --- a/apps/readest-app/src/utils/style.ts +++ b/apps/readest-app/src/utils/style.ts @@ -386,7 +386,7 @@ const getLayoutStyles = ( } img.has-text-siblings { height: 1em; - vertical-align: middle; + vertical-align: baseline; } :is(div) > img.has-text-siblings[style*="object-fit"] { display: block; diff --git a/packages/foliate-js b/packages/foliate-js index c80ead5f..873b5459 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit c80ead5f347385b09cb3c776b2cd26fc7cea8cda +Subproject commit 873b545933d911047a472d388fa8aab6c545235c