Add highlights options and sidebar view

This commit is contained in:
chrox
2024-11-06 22:24:28 +01:00
parent 7ddca6fbca
commit 70a7fd9007
17 changed files with 550 additions and 87 deletions
@@ -2,7 +2,6 @@ import React from 'react';
import { BookState, useReaderStore } from '@/store/readerStore';
import SettingsDialog from './settings/SettingsDialog';
import FoliateViewer from './FoliateViewer';
import getGridTemplate from '@/utils/grid';
import SectionInfo from './SectionInfo';
@@ -10,6 +9,8 @@ import HeaderBar from './HeaderBar';
import FooterBar from './FooterBar';
import PageInfo from './PageInfo';
import Ribbon from './Ribbon';
import SettingsDialog from './settings/SettingsDialog';
import Annotator from './annotator/Annotator';
interface BookGridProps {
bookKeys: string[];
@@ -41,7 +42,11 @@ const BookGrid: React.FC<BookGridProps> = ({ bookKeys, bookStates, onCloseBook }
const marginGap = config.viewSettings ? `${config.viewSettings!.gapPercent!}%` : '';
return (
<div key={bookKey} className='relative h-full w-full overflow-hidden'>
<div
id={`gridcell-${bookKey}`}
key={bookKey}
className='relative h-full w-full overflow-hidden'
>
{isBookmarked && <Ribbon width={marginGap} />}
<HeaderBar
bookKey={bookKey}
@@ -62,6 +67,7 @@ const BookGrid: React.FC<BookGridProps> = ({ bookKeys, bookStates, onCloseBook }
/>
</>
)}
<Annotator bookKey={bookKey} />
<FooterBar bookKey={bookKey} pageinfo={pageinfo} isHoveredAnim={false} />
{isFontLayoutSettingsDialogOpen && <SettingsDialog bookKey={bookKey} config={config} />}
</div>
@@ -10,7 +10,7 @@ interface BookmarkTogglerProps {
}
const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
const { books, updateBookmarks, setBookmarkRibbonVisibility } = useReaderStore();
const { books, updateBooknotes, setBookmarkRibbonVisibility } = useReaderStore();
const bookState = books[bookKey]!;
const config = bookState.config!;
const progress = bookState.progress!;
@@ -18,7 +18,7 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
const [isBookmarked, setIsBookmarked] = useState(false);
const toggleBookmark = () => {
const { bookmarks = [] } = config;
const { booknotes: bookmarks = [] } = config;
const { location: cfi, tocHref: href, range } = progress;
if (!cfi) return;
if (!isBookmarked) {
@@ -34,12 +34,12 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
created: Date.now(),
};
bookmarks.push(bookmark);
updateBookmarks(bookKey, bookmarks);
updateBooknotes(bookKey, bookmarks);
} else {
setIsBookmarked(false);
const start = CFI.collapse(cfi);
const end = CFI.collapse(cfi, true);
updateBookmarks(
updateBooknotes(
bookKey,
bookmarks.filter((item) => CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) > 0),
);
@@ -47,14 +47,14 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
};
useEffect(() => {
const { location: cfi, bookmarks = [] } = config;
const { location: cfi, booknotes = [] } = config;
if (!cfi) return;
const start = CFI.collapse(cfi);
const end = CFI.collapse(cfi, true);
const locationBookmarked = bookmarks.some(
(item) => CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) <= 0,
);
const locationBookmarked = booknotes
.filter((booknote) => booknote.type === 'bookmark')
.some((item) => CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) <= 0);
setIsBookmarked(locationBookmarked);
setBookmarkRibbonVisibility(bookKey, locationBookmarked);
}, [config]);
@@ -13,6 +13,7 @@ export interface FoliateView extends HTMLElement {
goToFraction: (fraction: number) => void;
goLeft: () => void;
goRight: () => void;
getCFI: (index: number, range: Range) => string;
renderer: {
setStyles?: (css: string) => void;
setAttribute: (name: string, value: string | number) => void;
@@ -0,0 +1,229 @@
import React, { useState, useEffect } from 'react';
import { FiSearch } from 'react-icons/fi';
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 { BookNote } from '@/types/book';
import { useReaderStore } from '@/store/readerStore';
import { useFoliateEvents } from '../../hooks/useFoliateEvents';
import { getPosition, Position } from '@/utils/sel';
import useOutsideClick from '@/hooks/useOutsideClick';
import PopupButton from './PopupButton';
import HighlightOptions from './HighlightOptions';
interface TextSelection {
text: string;
range: Range;
sel: Selection;
index: number;
}
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const { books, settings, updateBooknotes, getFoliateView } = useReaderStore();
const globalReadSettings = settings.globalReadSettings;
const bookState = books[bookKey]!;
const config = bookState.config!;
const progress = bookState.progress!;
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('');
const [highlightOptionsVisible, setHighlightOptionsVisible] = useState(false);
const popupWidthPx = 240;
const popupHeightPx = 44;
const popupPaddingPx = 10;
const highlightOptionsHeightPx = 28;
const highlightOptionsPaddingPx = 16;
const docLoadHandler = (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), sel, index });
}
};
if (detail.doc) {
detail.doc.addEventListener('pointerup', handlePointerup);
}
};
const popupRef = useOutsideClick<HTMLDivElement>(() => {
setShowPopup(false);
setSelection(null);
});
useFoliateEvents(getFoliateView(bookKey), { onLoad: docLoadHandler });
useEffect(() => {
setHighlightOptionsVisible(false);
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);
setShowPopup(true);
}
}, [selection, bookKey]);
useEffect(() => {
const timer = setTimeout(() => setToastMessage(''), 2000);
return () => clearTimeout(timer);
}, [toastMessage]);
const handleCopy = () => {
setShowPopup(false);
setToastMessage('Copied to clipboard');
if (selection) navigator.clipboard.writeText(selection.text);
};
const handleHighlight = () => {
if (!selection || !selection.text) return;
setHighlightOptionsVisible(true);
const { booknotes: annotations = [] } = config;
const { tocHref: href } = progress;
const cfi = getFoliateView(bookKey)?.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 existingIndex = annotations.findIndex(
(annotation) => annotation.cfi === cfi && annotation.type === 'annotation',
);
if (existingIndex !== -1) {
annotations[existingIndex] = annotation;
} else {
annotations.push(annotation);
}
const dedupedAnnotations = Array.from(
new Map(
annotations.map((annotation) => [`${annotation.type}-${annotation.cfi}`, annotation]),
).values(),
);
updateBooknotes(bookKey, dedupedAnnotations);
};
const handleAnnotate = () => {};
const handleSearch = () => {};
const handleDictionary = () => {};
const buttons = [
{ tooltipText: 'Copy', Icon: FiCopy, onClick: handleCopy },
{ tooltipText: 'Highlight', Icon: PiHighlighterFill, onClick: handleHighlight },
{ tooltipText: 'Annotate', Icon: BsPencilSquare, onClick: handleAnnotate },
{ tooltipText: 'Search', Icon: FiSearch, onClick: handleSearch },
{ tooltipText: 'Dictionary', Icon: FaWikipediaW, onClick: handleDictionary },
];
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
onHandleHighlight={handleHighlight}
style={{
width: `${popupWidthPx}px`,
height: `${popupHeightPx}px`,
left: `${popupPosition.point.x}px`,
top: `${
popupPosition.point.y +
(highlightOptionsHeightPx + highlightOptionsPaddingPx) * (isPopupAbove ? -1 : 1)
}px`,
}}
/>
)}
</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>
)}
</div>
);
};
export default Annotator;
@@ -0,0 +1,85 @@
import clsx from 'clsx';
import React from 'react';
import { useReaderStore } from '@/store/readerStore';
import { HighlightColor, HighlightStyle } from '@/types/book';
import { FaCheckCircle } from 'react-icons/fa';
const styles = ['highlight', 'underline', 'squiggly'] as HighlightStyle[];
const colors = ['red', 'violet', 'blue', 'green', 'yellow'] as HighlightColor[];
interface HighlightOptionsProps {
style: React.CSSProperties;
onHandleHighlight: () => void;
}
const HighlightOptions: React.FC<HighlightOptionsProps> = ({ style, onHandleHighlight }) => {
const { settings, setSettings } = useReaderStore();
const globalReadSettings = settings.globalReadSettings;
const handleSelectStyle = (style: HighlightStyle) => {
globalReadSettings.highlightStyle = style;
setSettings(settings);
onHandleHighlight();
};
const handleSelectColor = (color: HighlightColor) => {
const style = globalReadSettings.highlightStyle;
globalReadSettings.highlightStyles[style] = color;
setSettings(settings);
onHandleHighlight();
};
const selectedStyle = globalReadSettings.highlightStyle;
const selectedColor = globalReadSettings.highlightStyles[selectedStyle];
return (
<div className='highlight-options absolute flex h-7 items-center justify-between' style={style}>
<div className='flex h-7 gap-2'>
{styles.map((style) => (
<button
key={style}
onClick={() => handleSelectStyle(style)}
className={`flex h-7 min-h-7 w-7 items-center justify-center rounded-full bg-gray-700 p-0`}
>
<div
className={clsx(
'h-4 w-4 p-0 text-center leading-none',
style === 'highlight' &&
(selectedStyle === 'highlight' ? `bg-${selectedColor}-400` : `bg-gray-300`),
(style === 'underline' || style === 'squiggly') &&
'text-gray-300 underline decoration-2',
style === 'underline' &&
(selectedStyle === 'underline'
? `decoration-${selectedColor}-400`
: `decoration-gray-300`),
style === 'squiggly' &&
(selectedStyle === 'squiggly'
? `decoration-wavy decoration-${selectedColor}-400`
: `decoration-gray-300 decoration-wavy`),
)}
>
A
</div>
</button>
))}
</div>
<div className='flex h-7 items-center justify-center gap-2 rounded-3xl bg-gray-700 px-2'>
{colors.map((color) => (
<button
key={color}
onClick={() => handleSelectColor(color)}
className={clsx(
`h-4 w-4 rounded-full p-0`,
selectedColor !== color && `bg-${color}-400`,
)}
>
{selectedColor === color && (
<FaCheckCircle size={16} className={clsx(`fill-${color}-400`)} />
)}
</button>
))}
</div>
</div>
);
};
export default HighlightOptions;
@@ -0,0 +1,24 @@
import React, { useState } from 'react';
interface PopupButtonProps {
tooltipText: string;
Icon: React.ElementType;
onClick: () => void;
}
const PopupButton: React.FC<PopupButtonProps> = ({ tooltipText, Icon, onClick }) => {
const [buttonClicked, setButtonClicked] = useState(false);
const handleClick = () => {
setButtonClicked(true);
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'>
<Icon size={20} />
</button>
</div>
);
};
export default PopupButton;
@@ -4,28 +4,27 @@ import * as CFI from 'foliate-js/epubcfi.js';
import { useReaderStore } from '@/store/readerStore';
import { findParentPath } from '@/utils/toc';
import { TOCItem } from '@/libs/document';
import { BookNote } from '@/types/book';
import { BookNote, BookNoteType } from '@/types/book';
import clsx from 'clsx';
interface BookmarkGroup {
interface BooknoteGroup {
id: number;
href: string;
label: string;
bookmarks: BookNote[];
booknotes: BookNote[];
}
interface BookmarkItemProps {
interface BooknoteItemProps {
bookKey: string;
text: string;
cfi: string;
toc: TOCItem[];
item: BookNote;
}
const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookKey, text, cfi }) => {
const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
const { getFoliateView } = useReaderStore();
const { books } = useReaderStore();
const [isCurrentBookmark, setIsCurrentBookmark] = useState(false);
const { text, cfi } = item;
const bookState = books[bookKey]!;
const progress = bookState.progress!;
@@ -40,7 +39,7 @@ const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookKey, text, cfi }) => {
event.preventDefault();
getFoliateView(bookKey)?.goTo(cfi);
};
console.log('isCurrentBookmark', isCurrentBookmark);
return (
<li
className={clsx(
@@ -49,42 +48,60 @@ const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookKey, text, cfi }) => {
)}
onClick={handleClickItem}
>
<span className='line-clamp-3'>{text}</span>
<div className='line-clamp-3 overflow-hidden'>
<span
className={clsx(
'inline',
(item.style === 'underline' || item.style === 'squiggly') && 'underline decoration-2',
item.type === 'annotation' && item.style === 'highlight' && `bg-${item.color}-100`,
item.type === 'annotation' &&
item.style === 'underline' &&
`decoration-${item.color}-400`,
item.type === 'annotation' &&
item.style === 'squiggly' &&
`decoration-wavy decoration-${item.color}-400`,
)}
>
{text || ''}
</span>
</div>
</li>
);
};
const BookmarkView: React.FC<{
const BooknoteView: React.FC<{
type: BookNoteType;
bookKey: string;
toc: TOCItem[];
}> = ({ bookKey, toc }) => {
}> = ({ type, bookKey, toc }) => {
const { books } = useReaderStore();
const bookState = books[bookKey]!;
const config = bookState.config!;
const { bookmarks = [] } = config;
const { booknotes: allNotes = [] } = config;
const booknotes = allNotes.filter((note) => note.type === type);
const bookmarkGroups: { [href: string]: BookmarkGroup } = {};
for (const bookmark of bookmarks) {
const parentPath = findParentPath(toc, bookmark.href);
const booknoteGroups: { [href: string]: BooknoteGroup } = {};
for (const booknote of booknotes) {
const parentPath = findParentPath(toc, booknote.href);
if (parentPath.length > 0) {
const href = parentPath[0]!.href || '';
const label = parentPath[0]!.label || '';
const id = toc.findIndex((item) => item.href === href) || Infinity;
bookmark.href = href;
if (!bookmarkGroups[href]) {
bookmarkGroups[href] = { id, href, label, bookmarks: [] };
booknote.href = href;
if (!booknoteGroups[href]) {
booknoteGroups[href] = { id, href, label, booknotes: [] };
}
bookmarkGroups[href].bookmarks.push(bookmark);
booknoteGroups[href].booknotes.push(booknote);
}
}
Object.values(bookmarkGroups).forEach((group) => {
group.bookmarks.sort((a, b) => {
Object.values(booknoteGroups).forEach((group) => {
group.booknotes.sort((a, b) => {
return CFI.compare(a.cfi, b.cfi);
});
});
const sortedGroups = Object.values(bookmarkGroups).sort((a, b) => {
const sortedGroups = Object.values(booknoteGroups).sort((a, b) => {
return a.id - b.id;
});
@@ -96,14 +113,8 @@ const BookmarkView: React.FC<{
<li key={group.href} className='p-2'>
<h3 className='line-clamp-1 font-normal'>{group.label}</h3>
<ul>
{group.bookmarks.map((item) => (
<BookmarkItem
key={item.cfi}
bookKey={bookKey}
text={item.text || ''}
cfi={item.cfi}
toc={toc}
/>
{group.booknotes.map((item, index) => (
<BooknoteItem key={`${index}-${item.cfi}`} bookKey={bookKey} item={item} />
))}
</ul>
</li>
@@ -114,4 +125,4 @@ const BookmarkView: React.FC<{
);
};
export default BookmarkView;
export default BooknoteView;
@@ -2,7 +2,7 @@ import React from 'react';
import { BookDoc } from '@/libs/document';
import TOCView from './TOCView';
import BookmarkView from './BookmarkView';
import BooknoteView from './BooknoteView';
const SidebarContent: React.FC<{
activeTab: string;
@@ -14,8 +14,12 @@ const SidebarContent: React.FC<{
{activeTab === 'toc' && bookDoc.toc && (
<TOCView toc={bookDoc.toc} bookKey={sideBarBookKey} currentHref={currentHref} />
)}
{activeTab === 'annotations' && <div>Annotations</div>}
{activeTab === 'bookmarks' && <BookmarkView toc={bookDoc.toc} bookKey={sideBarBookKey} />}
{activeTab === 'annotations' && (
<BooknoteView type={'annotation'} toc={bookDoc.toc} bookKey={sideBarBookKey} />
)}
{activeTab === 'bookmarks' && (
<BooknoteView type={'bookmark'} toc={bookDoc.toc} bookKey={sideBarBookKey} />
)}
</div>
);
+3 -23
View File
@@ -1,5 +1,6 @@
import clsx from 'clsx';
import React, { useRef, useState, isValidElement, ReactElement, useEffect } from 'react';
import React, { useState, isValidElement, ReactElement } from 'react';
import useOutsideClick from '@/hooks/useOutsideClick';
interface DropdownProps {
className?: string;
@@ -16,7 +17,6 @@ const Dropdown: React.FC<DropdownProps> = ({
children,
onToggle,
}) => {
const dropdownRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const toggleDropdown = () => {
@@ -36,27 +36,7 @@ const Dropdown: React.FC<DropdownProps> = ({
}
};
const handleClickOutside = (event: MouseEvent | Event) => {
if (event instanceof MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
} else if (event instanceof MessageEvent) {
if (event.data && event.data.type === 'iframe-mousedown') {
setIsDropdownOpen(false);
}
}
};
useEffect(() => {
window.addEventListener('mousedown', handleClickOutside);
window.addEventListener('message', handleClickOutside);
return () => {
window.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('message', handleClickOutside);
};
}, []);
const dropdownRef = useOutsideClick<HTMLDivElement>(() => setIsDropdownOpen(false));
const childrenWithToggle = isValidElement(children)
? React.cloneElement(children, { setIsDropdownOpen })
: children;
@@ -0,0 +1,31 @@
import { useEffect, useRef } from 'react';
function useOutsideClick<T extends HTMLElement>(callback: () => void) {
const ref = useRef<T | null>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent | Event) => {
if (event instanceof MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
} else if (event instanceof MessageEvent) {
if (event.data && event.data.type === 'iframe-mousedown') {
callback();
}
}
};
window.addEventListener('mousedown', handleClickOutside);
window.addEventListener('message', handleClickOutside);
return () => {
window.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('message', handleClickOutside);
};
}, []);
return ref;
}
export default useOutsideClick;
@@ -8,6 +8,13 @@ export const DEFAULT_READSETTINGS: ReadSettings = {
sideBarWidth: '25%',
isSideBarPinned: true,
autohideCursor: true,
highlightStyle: 'highlight',
highlightStyles: {
highlight: 'yellow',
underline: 'green',
squiggly: 'blue',
},
};
export const DEFAULT_BOOK_FONT: BookFont = {
+3 -3
View File
@@ -73,7 +73,7 @@ interface ReaderStore {
initBookState: (envConfig: EnvConfigType, id: string, key: string, isPrimary?: boolean) => void;
clearBookState: (key: string) => void;
updateBookmarks: (key: string, bookmarks: BookNote[]) => void;
updateBooknotes: (key: string, booknotes: BookNote[]) => void;
}
export const DEFAULT_BOOK_STATE = {
@@ -310,7 +310,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
},
})),
updateBookmarks: (key: string, bookmarks: BookNote[]) =>
updateBooknotes: (key: string, booknotes: BookNote[]) =>
set((state) => {
const book = state.books[key];
if (!book) return state;
@@ -322,7 +322,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
config: {
...book.config,
lastUpdated: Date.now(),
bookmarks,
booknotes,
},
},
},
+9
View File
@@ -128,6 +128,15 @@ foliate-view {
display: none;
}
.tooltip.no-triangle::before,
.tooltip.no-triangle::after,
.tooltip-top.no-triangle::before,
.tooltip-top.no-triangle::after,
.tooltip-bottom.no-triangle::before,
.tooltip-bottom.no-triangle::after {
display: none;
}
.dropdown-content, .settings-content {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
+5 -15
View File
@@ -1,16 +1,7 @@
export type BookFormat = 'EPUB' | 'PDF' | 'MOBI' | 'CBZ' | 'FB2' | 'FBZ';
export type BookNoteType = 'bookmark' | 'highlight' | 'annotation';
export type BookNoteStyle =
| 'underline'
| 'squiggly'
| 'strikethrough'
| 'yellow'
| 'orange'
| 'red'
| 'magenta'
| 'aqua'
| 'lime'
| 'custom';
export type HighlightStyle = 'highlight' | 'underline' | 'squiggly';
export type HighlightColor = 'red' | 'yellow' | 'green' | 'blue' | 'violet';
export interface Book {
hash: string;
@@ -35,8 +26,8 @@ export interface BookNote {
cfi: string;
href: string;
text?: string;
style?: string;
customStyle?: string;
style?: HighlightStyle;
color?: HighlightColor;
note: string;
created: number;
modified?: number;
@@ -90,8 +81,7 @@ export interface BookConfig {
progress?: [number, number];
location?: string;
bookmarks?: BookNote[];
annotations?: BookNote[];
booknotes?: BookNote[];
removedNotesTimestamps?: Record<string, number>;
viewSettings?: Partial<ViewSettings>;
+4 -1
View File
@@ -1,4 +1,4 @@
import { ViewSettings } from './book';
import { HighlightColor, HighlightStyle, ViewSettings } from './book';
export type ThemeType = 'light' | 'dark' | 'auto';
@@ -6,6 +6,9 @@ export interface ReadSettings {
sideBarWidth: string;
isSideBarPinned: boolean;
autohideCursor: boolean;
highlightStyle: HighlightStyle;
highlightStyles: Record<HighlightStyle, HighlightColor>;
}
export interface SystemSettings {
+77
View File
@@ -0,0 +1,77 @@
export interface Frame {
top: number;
left: number;
}
export interface Rect {
top: number;
right: number;
bottom: number;
left: number;
}
export interface Point {
x: number;
y: number;
}
export type PositionDir = 'up' | 'down';
export interface Position {
point: Point;
dir?: PositionDir;
}
const frameRect = (frame: Frame, rect: Rect, sx = 1, sy = 1) => {
const left = sx * rect.left + frame.left;
const right = sx * rect.right + frame.left;
const top = sy * rect.top + frame.top;
const bottom = sy * rect.bottom + frame.top;
return { left, right, top, bottom };
};
const pointIsInView = ({ x, y }: Point) =>
x > 0 && y > 0 && x < window.innerWidth && y < window.innerHeight;
const getIframeElement = (range: Range): HTMLIFrameElement | null => {
let node: Node | null = range.commonAncestorContainer;
while (node) {
if (node.nodeType === Node.DOCUMENT_NODE) {
const doc = node as Document;
if (doc.defaultView && doc.defaultView.frameElement) {
return doc.defaultView.frameElement as HTMLIFrameElement;
}
}
node = node.parentNode;
}
return null;
};
export const getPosition = (target: Range, offset = { x: 0, y: 0 }) => {
// TODO: vertical text
const frameElement = getIframeElement(target);
const transform = frameElement ? getComputedStyle(frameElement).transform : '';
const match = transform.match(/matrix\((.+)\)/);
const [sx, , , sy] = match?.[1]?.split(/\s*,\s*/)?.map((x) => parseFloat(x)) ?? [];
const frame = frameElement?.getBoundingClientRect() ?? { top: 0, left: 0 };
const rects = Array.from(target.getClientRects());
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 },
dir: 'up',
} as Position;
const end = {
point: { x: (last.left + last.right) / 2 - offset.x, y: last.bottom - offset.y },
dir: 'down',
} as Position;
const startInView = pointIsInView(start.point);
const endInView = pointIsInView(end.point);
if (!startInView && !endInView) return { point: { x: 0, y: 0 } };
if (!startInView) return end;
if (!endInView) return start;
return start.point.y > window.innerHeight - end.point.y ? start : end;
};
+6
View File
@@ -7,6 +7,12 @@ const config: Config = {
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
safelist: [
{ pattern: /bg-./ },
{ pattern: /text-./ },
{ pattern: /fill-./ },
{ pattern: /decoration-./ },
],
theme: {
extend: {
colors: {