Draw highlights in bookview
This commit is contained in:
@@ -39,7 +39,7 @@ const BookGrid: React.FC<BookGridProps> = ({ bookKeys, bookStates, onCloseBook }
|
||||
const { book, config, progress, bookDoc } = bookState;
|
||||
if (!book || !config || !progress || !bookDoc) return null;
|
||||
const { section, pageinfo, tocLabel: chapter } = progress;
|
||||
const marginGap = config.viewSettings ? `${config.viewSettings!.gapPercent!}%` : '';
|
||||
const marginGap = `${config.viewSettings!.gapPercent}%`;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { BookConfig } from '@/types/book';
|
||||
import { BookConfig, BookNote } from '@/types/book';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { getStyles } from '@/utils/style';
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface FoliateView extends HTMLElement {
|
||||
goLeft: () => void;
|
||||
goRight: () => void;
|
||||
getCFI: (index: number, range: Range) => string;
|
||||
addAnnotation: (note: BookNote, remove?: boolean) => { index: number; label: string };
|
||||
renderer: {
|
||||
setStyles?: (css: string) => void;
|
||||
setAttribute: (name: string, value: string | number) => void;
|
||||
@@ -23,6 +24,19 @@ export interface FoliateView extends HTMLElement {
|
||||
};
|
||||
}
|
||||
|
||||
const wrappedFoliateView = (originalView: FoliateView): FoliateView => {
|
||||
const originalAddAnnotation = originalView.addAnnotation.bind(originalView);
|
||||
originalView.addAnnotation = (note: BookNote, remove = false) => {
|
||||
// transform BookNote to foliate annotation
|
||||
const annotation = {
|
||||
...note,
|
||||
value: note.cfi,
|
||||
};
|
||||
return originalAddAnnotation(annotation, remove);
|
||||
};
|
||||
return originalView;
|
||||
};
|
||||
|
||||
const FoliateViewer: React.FC<{
|
||||
bookKey: string;
|
||||
bookDoc: BookDoc;
|
||||
@@ -30,6 +44,7 @@ const FoliateViewer: React.FC<{
|
||||
}> = ({ bookKey, bookDoc, bookConfig }) => {
|
||||
const viewRef = useRef<HTMLDivElement>(null);
|
||||
const [view, setView] = useState<FoliateView | null>(null);
|
||||
const [viewInited, setViewInited] = useState(false);
|
||||
const isViewCreated = useRef(false);
|
||||
const { setFoliateView } = useReaderStore();
|
||||
const setProgress = useReaderStore((state) => state.setProgress);
|
||||
@@ -92,7 +107,7 @@ const FoliateViewer: React.FC<{
|
||||
const openBook = async () => {
|
||||
console.log('Opening book', bookKey);
|
||||
await import('foliate-js/view.js');
|
||||
const view = document.createElement('foliate-view') as FoliateView;
|
||||
const view = wrappedFoliateView(document.createElement('foliate-view') as FoliateView);
|
||||
document.body.append(view);
|
||||
viewRef.current?.appendChild(view);
|
||||
setView(view);
|
||||
@@ -123,10 +138,11 @@ const FoliateViewer: React.FC<{
|
||||
|
||||
const lastLocation = bookConfig.location;
|
||||
if (lastLocation) {
|
||||
view.init({ lastLocation });
|
||||
await view.init({ lastLocation });
|
||||
} else {
|
||||
view.goToFraction(0);
|
||||
await view.goToFraction(0);
|
||||
}
|
||||
setViewInited(true);
|
||||
};
|
||||
|
||||
openBook();
|
||||
@@ -139,6 +155,22 @@ const FoliateViewer: React.FC<{
|
||||
};
|
||||
}, []);
|
||||
|
||||
const initAnnotations = () => {
|
||||
const { booknotes = [] } = bookConfig;
|
||||
const annotations = booknotes.filter((item) => item.type === 'annotation');
|
||||
try {
|
||||
Promise.all(annotations.map((annotation) => view?.addAnnotation(annotation)));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (viewInited) {
|
||||
initAnnotations();
|
||||
}
|
||||
}, [viewInited]);
|
||||
|
||||
return <div className='foliate-viewer h-[100%] w-[100%]' ref={viewRef} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
interface RibbonProps {
|
||||
@@ -6,7 +7,10 @@ interface RibbonProps {
|
||||
|
||||
const Ribbon: React.FC<RibbonProps> = ({ width }) => {
|
||||
return (
|
||||
<div className='absolute inset-0 z-10 flex h-11 justify-center' style={{ width }}>
|
||||
<div
|
||||
className={clsx('absolute inset-0 z-40 flex h-11 justify-center')}
|
||||
style={{ width: width }}
|
||||
>
|
||||
<svg
|
||||
stroke='currentColor'
|
||||
fill='currentColor'
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { useEnv } from '@/context/EnvContext';
|
||||
import { BookNote } from '@/types/book';
|
||||
@@ -13,11 +14,12 @@ import { getPosition, Position } from '@/utils/sel';
|
||||
import useOutsideClick from '@/hooks/useOutsideClick';
|
||||
import PopupButton from './PopupButton';
|
||||
import HighlightOptions from './HighlightOptions';
|
||||
import { Overlayer } from 'foliate-js/overlayer.js';
|
||||
|
||||
interface TextSelection {
|
||||
annotated?: boolean;
|
||||
text: string;
|
||||
range: Range;
|
||||
sel: Selection;
|
||||
index: number;
|
||||
}
|
||||
|
||||
@@ -28,6 +30,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const bookState = books[bookKey]!;
|
||||
const config = bookState.config!;
|
||||
const progress = bookState.progress!;
|
||||
const view = getFoliateView(bookKey);
|
||||
|
||||
const [selection, setSelection] = useState<TextSelection | null>();
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
@@ -50,7 +53,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const handlePointerup = () => {
|
||||
const sel = doc.getSelection();
|
||||
if (sel && sel.toString().trim().length > 0) {
|
||||
setSelection({ text: sel.toString(), range: sel.getRangeAt(0), sel, index });
|
||||
setSelection({ text: sel.toString(), range: sel.getRangeAt(0), index });
|
||||
}
|
||||
};
|
||||
if (detail.doc) {
|
||||
@@ -58,14 +61,54 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const drawAnnotationHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
const { draw, annotation, doc, range } = detail;
|
||||
const { style, color } = annotation as BookNote;
|
||||
if (style === 'highlight') {
|
||||
draw(Overlayer.highlight, { color });
|
||||
} else if (['underline', 'squiggly'].includes(style as string)) {
|
||||
const { defaultView } = doc;
|
||||
const node = range.startContainer;
|
||||
const el = node.nodeType === 1 ? node : node.parentElement;
|
||||
const { writingMode } = defaultView.getComputedStyle(el);
|
||||
draw(Overlayer[style as keyof typeof Overlayer], { writingMode, color });
|
||||
}
|
||||
};
|
||||
|
||||
const showAnnotationHandler = (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,
|
||||
};
|
||||
setSelection(selection as TextSelection);
|
||||
};
|
||||
|
||||
useFoliateEvents(
|
||||
view,
|
||||
{
|
||||
onLoad: docLoadHandler,
|
||||
onDrawAnnotation: drawAnnotationHandler,
|
||||
onShowAnnotation: showAnnotationHandler,
|
||||
},
|
||||
[bookState],
|
||||
);
|
||||
|
||||
const popupRef = useOutsideClick<HTMLDivElement>(() => {
|
||||
setShowPopup(false);
|
||||
setSelection(null);
|
||||
});
|
||||
useFoliateEvents(getFoliateView(bookKey), { onLoad: docLoadHandler });
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightOptionsVisible(false);
|
||||
setHighlightOptionsVisible(!!(selection && selection.annotated));
|
||||
if (selection && selection.text.trim().length > 0) {
|
||||
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
|
||||
if (!gridFrame) return;
|
||||
@@ -119,12 +162,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
if (selection) navigator.clipboard.writeText(selection.text);
|
||||
};
|
||||
|
||||
const handleHighlight = () => {
|
||||
const handleHighlight = (update = false) => {
|
||||
if (!selection || !selection.text) return;
|
||||
setHighlightOptionsVisible(true);
|
||||
const { booknotes: annotations = [] } = config;
|
||||
const { tocHref: href } = progress;
|
||||
const cfi = getFoliateView(bookKey)?.getCFI(selection.index, selection.range);
|
||||
const cfi = view?.getCFI(selection.index, selection.range);
|
||||
if (!cfi) return;
|
||||
const annotation: BookNote = {
|
||||
type: 'annotation',
|
||||
@@ -140,9 +183,17 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
(annotation) => annotation.cfi === cfi && annotation.type === 'annotation',
|
||||
);
|
||||
if (existingIndex !== -1) {
|
||||
annotations[existingIndex] = annotation;
|
||||
view?.addAnnotation(annotation, true);
|
||||
if (update) {
|
||||
annotations[existingIndex] = annotation;
|
||||
view?.addAnnotation(annotation);
|
||||
} else {
|
||||
annotations.splice(existingIndex, 1);
|
||||
setShowPopup(false);
|
||||
}
|
||||
} else {
|
||||
annotations.push(annotation);
|
||||
view?.addAnnotation(annotation);
|
||||
}
|
||||
|
||||
const dedupedAnnotations = Array.from(
|
||||
@@ -160,9 +211,14 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const handleSearch = () => {};
|
||||
const handleDictionary = () => {};
|
||||
|
||||
const selectionAnnotated = selection?.annotated;
|
||||
const buttons = [
|
||||
{ tooltipText: 'Copy', Icon: FiCopy, onClick: handleCopy },
|
||||
{ tooltipText: 'Highlight', Icon: PiHighlighterFill, onClick: handleHighlight },
|
||||
{
|
||||
tooltipText: selectionAnnotated ? 'Delete Highlight' : 'Highlight',
|
||||
Icon: selectionAnnotated ? RiDeleteBinFill : PiHighlighterFill,
|
||||
onClick: handleHighlight,
|
||||
},
|
||||
{ tooltipText: 'Annotate', Icon: BsPencilSquare, onClick: handleAnnotate },
|
||||
{ tooltipText: 'Search', Icon: FiSearch, onClick: handleSearch },
|
||||
{ tooltipText: 'Dictionary', Icon: FaWikipediaW, onClick: handleDictionary },
|
||||
|
||||
@@ -10,7 +10,7 @@ const colors = ['red', 'violet', 'blue', 'green', 'yellow'] as HighlightColor[];
|
||||
|
||||
interface HighlightOptionsProps {
|
||||
style: React.CSSProperties;
|
||||
onHandleHighlight: () => void;
|
||||
onHandleHighlight: (update: boolean) => void;
|
||||
}
|
||||
|
||||
const HighlightOptions: React.FC<HighlightOptionsProps> = ({ style, onHandleHighlight }) => {
|
||||
@@ -20,13 +20,13 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({ style, onHandleHigh
|
||||
const handleSelectStyle = (style: HighlightStyle) => {
|
||||
globalReadSettings.highlightStyle = style;
|
||||
setSettings(settings);
|
||||
onHandleHighlight();
|
||||
onHandleHighlight(true);
|
||||
};
|
||||
const handleSelectColor = (color: HighlightColor) => {
|
||||
const style = globalReadSettings.highlightStyle;
|
||||
globalReadSettings.highlightStyles[style] = color;
|
||||
setSettings(settings);
|
||||
onHandleHighlight();
|
||||
onHandleHighlight(true);
|
||||
};
|
||||
const selectedStyle = globalReadSettings.highlightStyle;
|
||||
const selectedColor = globalReadSettings.highlightStyles[selectedStyle];
|
||||
|
||||
@@ -4,21 +4,32 @@ import { FoliateView } from '@/app/reader/components/FoliateViewer';
|
||||
type FoliateEventHandler = {
|
||||
onLoad?: (event: Event) => void;
|
||||
onRelocate?: (event: Event) => void;
|
||||
onDrawAnnotation?: (event: Event) => void;
|
||||
onShowAnnotation?: (event: Event) => void;
|
||||
};
|
||||
|
||||
export const useFoliateEvents = (view: FoliateView | null, handlers?: FoliateEventHandler) => {
|
||||
export const useFoliateEvents = (
|
||||
view: FoliateView | null,
|
||||
handlers?: FoliateEventHandler,
|
||||
dependencies: React.DependencyList = [],
|
||||
) => {
|
||||
const onLoad = handlers?.onLoad;
|
||||
const onRelocate = handlers?.onRelocate;
|
||||
const onDrawAnnotation = handlers?.onDrawAnnotation;
|
||||
const onShowAnnotation = handlers?.onShowAnnotation;
|
||||
|
||||
useEffect(() => {
|
||||
if (!view) return;
|
||||
|
||||
if (onLoad) view.addEventListener('load', onLoad);
|
||||
if (onRelocate) view.addEventListener('relocate', onRelocate);
|
||||
if (onDrawAnnotation) view.addEventListener('draw-annotation', onDrawAnnotation);
|
||||
if (onShowAnnotation) view.addEventListener('show-annotation', onShowAnnotation);
|
||||
|
||||
return () => {
|
||||
if (onLoad) view.removeEventListener('load', onLoad);
|
||||
if (onRelocate) view.removeEventListener('relocate', onRelocate);
|
||||
if (onDrawAnnotation) view.removeEventListener('draw-annotation', onDrawAnnotation);
|
||||
if (onShowAnnotation) view.removeEventListener('show-annotation', onShowAnnotation);
|
||||
};
|
||||
}, [view, onLoad, onRelocate]);
|
||||
}, [view, ...dependencies]);
|
||||
};
|
||||
|
||||
@@ -325,7 +325,11 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
...state.books,
|
||||
[key]: {
|
||||
...book,
|
||||
config: updatedConfig,
|
||||
config: {
|
||||
...book.config,
|
||||
lastUpdated: Date.now(),
|
||||
booknotes,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user