forked from akai/readest
Refactoring Annotator
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import PopupButton from './PopupButton';
|
||||
import HighlightOptions from './HighlightOptions';
|
||||
import { Position } from '@/utils/sel';
|
||||
import { HighlightColor, HighlightStyle } from '@/types/book';
|
||||
|
||||
interface AnnotationPopupProps {
|
||||
buttons: Array<{ tooltipText: string; Icon: React.ElementType; onClick: () => void }>;
|
||||
position: Position;
|
||||
trianglePosition: Position;
|
||||
highlightOptionsVisible: boolean;
|
||||
selectedStyle: HighlightStyle;
|
||||
selectedColor: HighlightColor;
|
||||
popupWidth: number;
|
||||
popupHeight: number;
|
||||
onHighlight: (update?: boolean) => void;
|
||||
}
|
||||
|
||||
const highlightOptionsHeightPx = 28;
|
||||
const highlightOptionsPaddingPx = 16;
|
||||
|
||||
const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
|
||||
buttons,
|
||||
position,
|
||||
trianglePosition,
|
||||
highlightOptionsVisible,
|
||||
selectedStyle,
|
||||
selectedColor,
|
||||
popupWidth,
|
||||
popupHeight,
|
||||
onHighlight,
|
||||
}) => {
|
||||
const isPopupAbove = trianglePosition.dir === 'up';
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className='triangle absolute'
|
||||
style={{
|
||||
left: `${trianglePosition.point.x}px`,
|
||||
top: `${trianglePosition.point.y}px`,
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderBottom: isPopupAbove ? 'none' : '6px solid #465563',
|
||||
borderTop: isPopupAbove ? '6px solid #465563' : 'none',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='selection-popup absolute rounded-lg bg-gray-600 px-4 text-white shadow-lg'
|
||||
style={{
|
||||
width: `${popupWidth}px`,
|
||||
height: `${popupHeight}px`,
|
||||
left: `${position.point.x}px`,
|
||||
top: `${position.point.y}px`,
|
||||
}}
|
||||
>
|
||||
<div className='selection-buttons flex h-11 items-center justify-between'>
|
||||
{buttons.map((button, index) => (
|
||||
<PopupButton
|
||||
key={index}
|
||||
tooltipText={button.tooltipText}
|
||||
Icon={button.Icon}
|
||||
onClick={button.onClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{highlightOptionsVisible && (
|
||||
<HighlightOptions
|
||||
style={{
|
||||
width: `${popupWidth}px`,
|
||||
height: `${popupHeight}px`,
|
||||
left: `${position.point.x}px`,
|
||||
top: `${
|
||||
position.point.y +
|
||||
(highlightOptionsHeightPx + highlightOptionsPaddingPx) * (isPopupAbove ? -1 : 1)
|
||||
}px`,
|
||||
}}
|
||||
selectedStyle={selectedStyle}
|
||||
selectedColor={selectedColor}
|
||||
onHandleHighlight={onHighlight}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnotationPopup;
|
||||
@@ -4,17 +4,17 @@ import { FiCopy } from 'react-icons/fi';
|
||||
import { PiHighlighterFill } from 'react-icons/pi';
|
||||
import { FaWikipediaW } from 'react-icons/fa';
|
||||
import { BsPencilSquare } from 'react-icons/bs';
|
||||
import { RiDeleteBinFill } from 'react-icons/ri';
|
||||
import { RiDeleteBinLine } from 'react-icons/ri';
|
||||
|
||||
import { Overlayer } from 'foliate-js/overlayer.js';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { BookNote, HighlightColor, HighlightStyle } from '@/types/book';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useFoliateEvents } from '../../hooks/useFoliateEvents';
|
||||
import { getPosition, Position } from '@/utils/sel';
|
||||
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
|
||||
import Toast from '@/components/Toast';
|
||||
import useOutsideClick from '@/hooks/useOutsideClick';
|
||||
import PopupButton from './PopupButton';
|
||||
import HighlightOptions from './HighlightOptions';
|
||||
import { Overlayer } from 'foliate-js/overlayer.js';
|
||||
import AnnotationPopup from './AnnotationPopup';
|
||||
|
||||
interface TextSelection {
|
||||
annotated?: boolean;
|
||||
@@ -34,7 +34,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
const [selection, setSelection] = useState<TextSelection | null>();
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
const [isPopupAbove, setIsPopupAbove] = useState(false);
|
||||
const [trianglePosition, setTrianglePosition] = useState<Position>();
|
||||
const [popupPosition, setPopupPosition] = useState<Position>();
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
@@ -47,28 +46,23 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
globalReadSettings.highlightStyles[selectedStyle],
|
||||
);
|
||||
|
||||
const popupWidthPx = 240;
|
||||
const popupHeightPx = 44;
|
||||
const popupPaddingPx = 10;
|
||||
const highlightOptionsHeightPx = 28;
|
||||
const highlightOptionsPaddingPx = 16;
|
||||
const popupWidth = 240;
|
||||
const popupHeight = 44;
|
||||
const popupPadding = 10;
|
||||
|
||||
const docLoadHandler = (event: Event) => {
|
||||
const onLoad = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
const { doc, index } = detail;
|
||||
|
||||
const handlePointerup = () => {
|
||||
const sel = doc.getSelection();
|
||||
if (sel && sel.toString().trim().length > 0) {
|
||||
setSelection({ text: sel.toString(), range: sel.getRangeAt(0), index });
|
||||
}
|
||||
};
|
||||
if (detail.doc) {
|
||||
detail.doc.addEventListener('pointerup', handlePointerup);
|
||||
}
|
||||
detail.doc?.addEventListener('pointerup', handlePointerup);
|
||||
};
|
||||
|
||||
const drawAnnotationHandler = (event: Event) => {
|
||||
const onDrawAnnotation = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
const { draw, annotation, doc, range } = detail;
|
||||
const { style, color } = annotation as BookNote;
|
||||
@@ -83,33 +77,20 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const showAnnotationHandler = (event: Event) => {
|
||||
const onShowAnnotation = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
const { value: cfi, index, range } = detail;
|
||||
const { booknotes = [] } = config;
|
||||
const annotations = booknotes.filter((booknote) => booknote.type === 'annotation');
|
||||
const annotation = annotations.find((annotation) => annotation.cfi === cfi);
|
||||
if (!annotation) return;
|
||||
const selection = {
|
||||
annotated: true,
|
||||
text: annotation.text,
|
||||
range,
|
||||
index,
|
||||
};
|
||||
const selection = { annotated: true, text: annotation.text, range, index };
|
||||
setSelectedStyle(annotation.style!);
|
||||
setSelectedColor(annotation.color!);
|
||||
setSelection(selection as TextSelection);
|
||||
};
|
||||
|
||||
useFoliateEvents(
|
||||
view,
|
||||
{
|
||||
onLoad: docLoadHandler,
|
||||
onDrawAnnotation: drawAnnotationHandler,
|
||||
onShowAnnotation: showAnnotationHandler,
|
||||
},
|
||||
[bookState],
|
||||
);
|
||||
useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation }, [bookState]);
|
||||
|
||||
const popupRef = useOutsideClick<HTMLDivElement>(() => {
|
||||
setShowPopup(false);
|
||||
@@ -121,42 +102,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
if (selection && selection.text.trim().length > 0) {
|
||||
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
|
||||
if (!gridFrame) return;
|
||||
const offset = {
|
||||
x: gridFrame.getBoundingClientRect().left,
|
||||
y: gridFrame.getBoundingClientRect().top,
|
||||
};
|
||||
const gridFrameRect = gridFrame.getBoundingClientRect();
|
||||
const position = getPosition(selection.range, offset);
|
||||
|
||||
if (position.dir === 'up') {
|
||||
position.point.y -= 12;
|
||||
setIsPopupAbove(true);
|
||||
} else {
|
||||
position.point.y += 0;
|
||||
setIsPopupAbove(false);
|
||||
}
|
||||
|
||||
const popupPoint = {
|
||||
x: position.point.x - popupWidthPx / 2,
|
||||
y: position.dir === 'up' ? position.point.y - popupHeightPx : position.point.y + 6,
|
||||
};
|
||||
|
||||
if (popupPoint.x + popupWidthPx > gridFrameRect.right - offset.x - popupPaddingPx) {
|
||||
popupPoint.x = gridFrameRect.right - offset.x - popupPaddingPx - popupWidthPx;
|
||||
}
|
||||
if (popupPoint.x < gridFrameRect.left - offset.x + popupPaddingPx) {
|
||||
popupPoint.x = gridFrameRect.left - offset.x + popupPaddingPx;
|
||||
}
|
||||
if (popupPoint.y + popupHeightPx > gridFrameRect.bottom - offset.y - popupPaddingPx) {
|
||||
popupPoint.y = gridFrameRect.bottom - offset.y - popupPaddingPx - popupHeightPx;
|
||||
}
|
||||
if (popupPoint.y < gridFrameRect.top - offset.y + popupPaddingPx) {
|
||||
popupPoint.y = gridFrameRect.top - offset.y + popupPaddingPx;
|
||||
}
|
||||
|
||||
setPopupPosition({ point: popupPoint });
|
||||
setTrianglePosition(position);
|
||||
const rect = gridFrame.getBoundingClientRect();
|
||||
const triangPos = getPosition(selection.range, rect);
|
||||
const popupPos = getPopupPosition(triangPos, rect, popupWidth, popupHeight, popupPadding);
|
||||
setShowPopup(true);
|
||||
setPopupPosition(popupPos);
|
||||
setTrianglePosition(triangPos);
|
||||
}
|
||||
}, [selection, bookKey]);
|
||||
|
||||
@@ -178,16 +129,13 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const { tocHref: href } = progress;
|
||||
const cfi = view?.getCFI(selection.index, selection.range);
|
||||
if (!cfi) return;
|
||||
const annotation: BookNote = {
|
||||
type: 'annotation',
|
||||
cfi,
|
||||
href,
|
||||
style: globalReadSettings.highlightStyle,
|
||||
color: globalReadSettings.highlightStyles[globalReadSettings.highlightStyle],
|
||||
text: selection.text,
|
||||
note: '',
|
||||
created: Date.now(),
|
||||
};
|
||||
const style = globalReadSettings.highlightStyle;
|
||||
const color = globalReadSettings.highlightStyles[style];
|
||||
const text = selection.text;
|
||||
const type = 'annotation';
|
||||
const created = Date.now();
|
||||
const note = '';
|
||||
const annotation: BookNote = { type, cfi, href, style, color, text, note, created };
|
||||
const existingIndex = annotations.findIndex(
|
||||
(annotation) => annotation.cfi === cfi && annotation.type === 'annotation',
|
||||
);
|
||||
@@ -203,14 +151,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
} else {
|
||||
annotations.push(annotation);
|
||||
view?.addAnnotation(annotation);
|
||||
setSelection({ ...selection, annotated: true });
|
||||
}
|
||||
|
||||
const dedupedAnnotations = Array.from(
|
||||
new Map(
|
||||
annotations.map((annotation) => [`${annotation.type}-${annotation.cfi}`, annotation]),
|
||||
).values(),
|
||||
new Map(annotations.map((item) => [`${item.type}-${item.cfi}`, item])).values(),
|
||||
);
|
||||
|
||||
const updatedConfig = updateBooknotes(bookKey, dedupedAnnotations);
|
||||
if (updatedConfig) {
|
||||
saveConfig(envConfig, bookKey, updatedConfig, settings);
|
||||
@@ -225,7 +171,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
{ tooltipText: 'Copy', Icon: FiCopy, onClick: handleCopy },
|
||||
{
|
||||
tooltipText: selectionAnnotated ? 'Delete Highlight' : 'Highlight',
|
||||
Icon: selectionAnnotated ? RiDeleteBinFill : PiHighlighterFill,
|
||||
Icon: selectionAnnotated ? RiDeleteBinLine : PiHighlighterFill,
|
||||
onClick: handleHighlight,
|
||||
},
|
||||
{ tooltipText: 'Annotate', Icon: BsPencilSquare, onClick: handleAnnotate },
|
||||
@@ -236,64 +182,19 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
return (
|
||||
<div ref={popupRef}>
|
||||
{showPopup && trianglePosition && popupPosition && (
|
||||
<div>
|
||||
<div
|
||||
className='triangle absolute'
|
||||
style={{
|
||||
left: `${trianglePosition.point.x}px`,
|
||||
top: `${trianglePosition.point.y}px`,
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderBottom: isPopupAbove ? 'none' : '6px solid #465563',
|
||||
borderTop: isPopupAbove ? '6px solid #465563' : 'none',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='selection-popup absolute rounded-lg bg-gray-600 px-4 text-white shadow-lg'
|
||||
style={{
|
||||
width: `${popupWidthPx}px`,
|
||||
height: `${popupHeightPx}px`,
|
||||
left: `${popupPosition.point.x}px`,
|
||||
top: `${popupPosition.point.y}px`,
|
||||
}}
|
||||
>
|
||||
<div className='flex h-11 items-center justify-between'>
|
||||
{buttons.map((button, index) => (
|
||||
<PopupButton
|
||||
key={index}
|
||||
tooltipText={button.tooltipText}
|
||||
Icon={button.Icon}
|
||||
onClick={button.onClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{highlightOptionsVisible && (
|
||||
<HighlightOptions
|
||||
style={{
|
||||
width: `${popupWidthPx}px`,
|
||||
height: `${popupHeightPx}px`,
|
||||
left: `${popupPosition.point.x}px`,
|
||||
top: `${
|
||||
popupPosition.point.y +
|
||||
(highlightOptionsHeightPx + highlightOptionsPaddingPx) * (isPopupAbove ? -1 : 1)
|
||||
}px`,
|
||||
}}
|
||||
selectedStyle={selectedStyle}
|
||||
selectedColor={selectedColor}
|
||||
onHandleHighlight={handleHighlight}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{toastMessage && (
|
||||
<div className='toast toast-center toast-middle'>
|
||||
<div className='alert flex items-center justify-center border-0 bg-gray-600 text-white'>
|
||||
<span>{toastMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
<AnnotationPopup
|
||||
buttons={buttons}
|
||||
position={popupPosition}
|
||||
trianglePosition={trianglePosition}
|
||||
highlightOptionsVisible={highlightOptionsVisible}
|
||||
selectedStyle={selectedStyle}
|
||||
selectedColor={selectedColor}
|
||||
popupWidth={popupWidth}
|
||||
popupHeight={popupHeight}
|
||||
onHighlight={handleHighlight}
|
||||
/>
|
||||
)}
|
||||
{toastMessage && <Toast message={toastMessage} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,10 @@ const PopupButton: React.FC<PopupButtonProps> = ({ tooltipText, Icon, onClick })
|
||||
};
|
||||
return (
|
||||
<div className='tooltip tooltip-bottom' data-tip={!buttonClicked ? tooltipText : null}>
|
||||
<button onClick={handleClick} className='btn btn-ghost my-2 h-8 min-h-8 w-8 p-0'>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className='my-2 flex h-8 min-h-8 w-8 items-center justify-center p-0'
|
||||
>
|
||||
<Icon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
const Toast: React.FC<{ message: string }> = ({ message }) => (
|
||||
<div className='toast toast-center toast-middle'>
|
||||
<div className='alert flex items-center justify-center border-0 bg-gray-600 text-white'>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Toast;
|
||||
@@ -49,7 +49,7 @@ const getIframeElement = (range: Range): HTMLIFrameElement | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getPosition = (target: Range, offset = { x: 0, y: 0 }) => {
|
||||
export const getPosition = (target: Range, rect: Rect) => {
|
||||
// TODO: vertical text
|
||||
const frameElement = getIframeElement(target);
|
||||
const transform = frameElement ? getComputedStyle(frameElement).transform : '';
|
||||
@@ -61,11 +61,11 @@ export const getPosition = (target: Range, offset = { x: 0, y: 0 }) => {
|
||||
const first = frameRect(frame, rects[0] as Rect, sx, sy);
|
||||
const last = frameRect(frame, rects.at(-1) as Rect, sx, sy);
|
||||
const start = {
|
||||
point: { x: (first.left + first.right) / 2 - offset.x, y: first.top - offset.y },
|
||||
point: { x: (first.left + first.right) / 2 - rect.left, y: first.top - rect.top - 12 },
|
||||
dir: 'up',
|
||||
} as Position;
|
||||
const end = {
|
||||
point: { x: (last.left + last.right) / 2 - offset.x, y: last.bottom - offset.y },
|
||||
point: { x: (last.left + last.right) / 2 - rect.left, y: last.bottom - rect.top },
|
||||
dir: 'down',
|
||||
} as Position;
|
||||
const startInView = pointIsInView(start.point);
|
||||
@@ -75,3 +75,31 @@ export const getPosition = (target: Range, offset = { x: 0, y: 0 }) => {
|
||||
if (!endInView) return start;
|
||||
return start.point.y > window.innerHeight - end.point.y ? start : end;
|
||||
};
|
||||
|
||||
export const getPopupPosition = (
|
||||
position: Position,
|
||||
boundingReact: Rect,
|
||||
popupWidthPx: number,
|
||||
popupHeightPx: number,
|
||||
popupPaddingPx: number,
|
||||
) => {
|
||||
const popupPoint = {
|
||||
x: position.point.x - popupWidthPx / 2,
|
||||
y: position.dir === 'up' ? position.point.y - popupHeightPx : position.point.y + 6,
|
||||
};
|
||||
|
||||
if (popupPoint.x < popupPaddingPx) {
|
||||
popupPoint.x = popupPaddingPx;
|
||||
}
|
||||
if (popupPoint.y < popupPaddingPx) {
|
||||
popupPoint.y = popupPaddingPx;
|
||||
}
|
||||
if (popupPoint.x + popupWidthPx > boundingReact.right - boundingReact.left - popupPaddingPx) {
|
||||
popupPoint.x = boundingReact.right - boundingReact.left - popupPaddingPx - popupWidthPx;
|
||||
}
|
||||
if (popupPoint.y + popupHeightPx > boundingReact.bottom - boundingReact.top - popupPaddingPx) {
|
||||
popupPoint.y = boundingReact.bottom - boundingReact.top - popupPaddingPx - popupHeightPx;
|
||||
}
|
||||
|
||||
return { point: popupPoint, dir: position.dir } as Position;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user