feat(annotator): show notes popup when annotation bubble icon is clicked, closes #1845 (#2798)

This commit is contained in:
Huang Xin
2025-12-28 00:31:47 +08:00
committed by GitHub
parent 674fed5230
commit 173404eaad
11 changed files with 279 additions and 96 deletions
@@ -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<AnnotationNotesProps> = ({
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 (
<div
className={clsx(
'annotation-notes absolute flex overflow-y-auto rounded-lg text-white shadow-lg',
'flex-col',
)}
style={{
...(isVertical
? {
right: `${triangleDir === 'left' ? `${popupWidth + 16}px` : undefined}`,
left: `${triangleDir === 'right' ? `${popupWidth + 16}px` : undefined}`,
height: `${popupHeight}px`,
maxWidth: `${maxSize}px`,
}
: {
top: triangleDir === 'up' ? undefined : `${popupHeight + 16}px`,
bottom: triangleDir === 'up' ? `${popupHeight + 16}px` : undefined,
width: `${popupWidth}px`,
maxHeight: `${maxSize}px`,
}),
scrollbarWidth: 'thin',
}}
>
<div className={clsx('flex gap-4', isVertical ? 'h-full flex-row' : 'flex-col')}>
{notes.map((note, index) => (
<div
role='none'
key={note.id || index}
onClick={() => handleShowAnnotation?.(note)}
className='cursor-pointer rounded-lg bg-gray-600 p-4 shadow-md transition-colors'
>
{note.note && (
<div
dir='auto'
className={clsx(
'hyphens-auto text-justify font-sans text-sm text-white',
isVertical && 'writing-vertical-rl',
)}
style={
isVertical
? {
fontFeatureSettings: "'vrt2' 1, 'vert' 1",
}
: {}
}
>
{note.note}
</div>
)}
</div>
))}
</div>
</div>
);
};
export default AnnotationNotes;
@@ -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<AnnotationPopupProps> = ({
bookKey,
dir,
isVertical,
buttons,
notes,
position,
trianglePosition,
highlightOptionsVisible,
@@ -45,8 +46,6 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
onHighlight,
onDismiss,
}) => {
const highlightOptionsHeightPx = useResponsiveSize(OPTIONS_HEIGHT_PIX);
const highlightOptionsPaddingPx = useResponsiveSize(OPTIONS_PADDING_PIX);
return (
<div dir={dir}>
<Popup
@@ -59,57 +58,52 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
triangleClassName='text-gray-600'
onDismiss={onDismiss}
>
<div
className={clsx(
'selection-buttons flex h-full w-full items-center justify-between p-2',
isVertical ? 'flex-col overflow-y-auto' : 'flex-row overflow-x-auto',
)}
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{buttons.map((button, index) => {
if (button.visible === false) return null;
return (
<PopupButton
key={index}
showTooltip={!highlightOptionsVisible}
tooltipText={button.tooltipText}
Icon={button.Icon}
onClick={button.onClick}
disabled={button.disabled}
<div className={clsx('flex h-full gap-4', isVertical ? 'flex-row' : 'flex-col')}>
<div
className={clsx(
'selection-buttons flex h-full w-full items-center justify-between p-2',
isVertical ? 'flex-col overflow-y-auto' : 'flex-row overflow-x-auto',
)}
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{buttons.map((button, index) => {
if (button.visible === false) return null;
return (
<PopupButton
key={index}
showTooltip={!highlightOptionsVisible}
tooltipText={button.tooltipText}
Icon={button.Icon}
onClick={button.onClick}
disabled={button.disabled}
/>
);
})}
</div>
{notes.length > 0 ? (
<AnnotationNotes
bookKey={bookKey}
isVertical={isVertical}
notes={notes}
triangleDir={trianglePosition.dir!}
popupWidth={isVertical ? popupHeight : popupWidth}
popupHeight={isVertical ? popupWidth : popupHeight}
/>
) : (
highlightOptionsVisible && (
<HighlightOptions
isVertical={isVertical}
triangleDir={trianglePosition.dir!}
popupWidth={isVertical ? popupHeight : popupWidth}
popupHeight={isVertical ? popupWidth : popupHeight}
selectedStyle={selectedStyle}
selectedColor={selectedColor}
onHandleHighlight={onHighlight}
/>
);
})}
)
)}
</div>
</Popup>
{highlightOptionsVisible && (
<HighlightOptions
isVertical={isVertical}
style={{
width: `${isVertical ? popupHeight : popupWidth}px`,
height: `${isVertical ? popupWidth : popupHeight}px`,
...(isVertical
? {
left: `${
position.point.x +
(highlightOptionsHeightPx + highlightOptionsPaddingPx) *
(trianglePosition.dir === 'left' ? -1 : 1)
}px`,
top: `${position.point.y}px`,
}
: {
left: `${position.point.x}px`,
top: `${
position.point.y +
(highlightOptionsHeightPx + highlightOptionsPaddingPx) *
(trianglePosition.dir === 'up' ? -1 : 1)
}px`,
}),
}}
selectedStyle={selectedStyle}
selectedColor={selectedColor}
onHandleHighlight={onHighlight}
/>
)}
</div>
);
};
@@ -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<Position>();
const [proofreadPopupPosition, setProofreadPopupPosition] = useState<Position>();
const [highlightOptionsVisible, setHighlightOptionsVisible] = useState(false);
const [showAnnotationNotes, setShowAnnotationNotes] = useState(false);
const [annotationNotes, setAnnotationNotes] = useState<BookNote[]>([]);
const [selectedStyle, setSelectedStyle] = useState<HighlightStyle>(
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 && (
<AnnotationPopup
bookKey={bookKey}
dir={viewSettings.rtl ? 'rtl' : 'ltr'}
isVertical={viewSettings.vertical}
buttons={buttons}
notes={annotationNotes}
position={annotPopupPosition}
trianglePosition={trianglePosition}
highlightOptionsVisible={highlightOptionsVisible}
@@ -12,15 +12,22 @@ const colors = ['red', 'violet', 'blue', 'green', 'yellow'] as HighlightColor[];
interface HighlightOptionsProps {
isVertical: boolean;
style: React.CSSProperties;
popupWidth: number;
popupHeight: number;
triangleDir: 'up' | 'down' | 'left' | 'right';
selectedStyle: HighlightStyle;
selectedColor: HighlightColor;
onHandleHighlight: (update: boolean) => void;
}
const OPTIONS_HEIGHT_PIX = 28;
const OPTIONS_PADDING_PIX = 16;
const HighlightOptions: React.FC<HighlightOptionsProps> = ({
style,
isVertical,
popupWidth,
popupHeight,
triangleDir,
selectedStyle: _selectedStyle,
selectedColor: _selectedColor,
onHandleHighlight,
@@ -34,6 +41,8 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
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<HighlightOptionsProps> = ({
'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`,
}),
}}
>
<div
className={clsx('flex gap-2', isVertical ? 'flex-col' : 'flex-row')}
@@ -17,6 +17,7 @@ import { eventDispatcher } from '@/utils/event';
import { getBookDirFromLanguage } from '@/utils/book';
import { Overlay } from '@/components/Overlay';
import { saveSysSettings } from '@/helpers/settings';
import { NOTE_PREFIX } from '@/types/view';
import useShortcuts from '@/hooks/useShortcuts';
import BooknoteItem from '../sidebar/BooknoteItem';
import NotebookHeader from './Header';
@@ -118,6 +119,7 @@ const Notebook: React.FC = ({}) => {
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);
@@ -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<BooknoteItemProps> = ({ 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);
+1 -1
View File
@@ -105,7 +105,7 @@ const Popup = ({
{children}
</div>
<div
className={`triangle text-base-300 absolute ${triangleClassName}`}
className={`triangle text-base-300 absolute z-50 ${triangleClassName}`}
style={{
left:
trianglePosition?.dir === 'left'
+6 -1
View File
@@ -3,6 +3,8 @@ import { BookNote, BookSearchConfig, BookSearchResult } from '@/types/book';
import { TTSGranularity } from '@/services/tts';
import { TTS } from 'foliate-js/tts.js';
export const NOTE_PREFIX = 'foliate-note:';
export interface FoliateView extends HTMLElement {
open: (book: BookDoc) => Promise<void>;
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<BookSearchResult | string, void, void>;
clearSearch: () => void;
select: (target: string | number | { fraction: number }) => void;
+24 -7
View File
@@ -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);
+1 -1
View File
@@ -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;