From 70a7fd9007f1c3aa45216a9b2e6d5d9e9d95f53c Mon Sep 17 00:00:00 2001 From: chrox Date: Wed, 6 Nov 2024 22:24:28 +0100 Subject: [PATCH] Add highlights options and sidebar view --- .../src/app/reader/components/BookGrid.tsx | 10 +- .../app/reader/components/BookmarkToggler.tsx | 16 +- .../app/reader/components/FoliateViewer.tsx | 1 + .../reader/components/annotator/Annotator.tsx | 229 ++++++++++++++++++ .../components/annotator/HighlightOptions.tsx | 85 +++++++ .../components/annotator/PopupButton.tsx | 24 ++ .../{BookmarkView.tsx => BooknoteView.tsx} | 75 +++--- .../app/reader/components/sidebar/Content.tsx | 10 +- apps/readest-app/src/components/Dropdown.tsx | 26 +- apps/readest-app/src/hooks/useOutsideClick.ts | 31 +++ apps/readest-app/src/services/constants.ts | 7 + apps/readest-app/src/store/readerStore.ts | 6 +- apps/readest-app/src/styles/globals.css | 9 + apps/readest-app/src/types/book.ts | 20 +- apps/readest-app/src/types/settings.ts | 5 +- apps/readest-app/src/utils/sel.ts | 77 ++++++ apps/readest-app/tailwind.config.ts | 6 + 17 files changed, 550 insertions(+), 87 deletions(-) create mode 100644 apps/readest-app/src/app/reader/components/annotator/Annotator.tsx create mode 100644 apps/readest-app/src/app/reader/components/annotator/HighlightOptions.tsx create mode 100644 apps/readest-app/src/app/reader/components/annotator/PopupButton.tsx rename apps/readest-app/src/app/reader/components/sidebar/{BookmarkView.tsx => BooknoteView.tsx} (54%) create mode 100644 apps/readest-app/src/hooks/useOutsideClick.ts create mode 100644 apps/readest-app/src/utils/sel.ts diff --git a/apps/readest-app/src/app/reader/components/BookGrid.tsx b/apps/readest-app/src/app/reader/components/BookGrid.tsx index 63e31943..d9abf7e5 100644 --- a/apps/readest-app/src/app/reader/components/BookGrid.tsx +++ b/apps/readest-app/src/app/reader/components/BookGrid.tsx @@ -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 = ({ bookKeys, bookStates, onCloseBook } const marginGap = config.viewSettings ? `${config.viewSettings!.gapPercent!}%` : ''; return ( -
+
{isBookmarked && } = ({ bookKeys, bookStates, onCloseBook } /> )} + {isFontLayoutSettingsDialogOpen && }
diff --git a/apps/readest-app/src/app/reader/components/BookmarkToggler.tsx b/apps/readest-app/src/app/reader/components/BookmarkToggler.tsx index 6077e3b5..b5b02afa 100644 --- a/apps/readest-app/src/app/reader/components/BookmarkToggler.tsx +++ b/apps/readest-app/src/app/reader/components/BookmarkToggler.tsx @@ -10,7 +10,7 @@ interface BookmarkTogglerProps { } const BookmarkToggler: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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]); diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 0997fb1b..f3025063 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -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; diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx new file mode 100644 index 00000000..c1fc0aae --- /dev/null +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -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(); + const [showPopup, setShowPopup] = useState(false); + const [isPopupAbove, setIsPopupAbove] = useState(false); + const [trianglePosition, setTrianglePosition] = useState(); + const [popupPosition, setPopupPosition] = useState(); + 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(() => { + 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 ( +
+ {showPopup && trianglePosition && popupPosition && ( +
+
+
+
+ {buttons.map((button, index) => ( + + ))} +
+
+ {highlightOptionsVisible && ( + + )} +
+ )} + {toastMessage && ( +
+
+ {toastMessage} +
+
+ )} +
+ ); +}; + +export default Annotator; diff --git a/apps/readest-app/src/app/reader/components/annotator/HighlightOptions.tsx b/apps/readest-app/src/app/reader/components/annotator/HighlightOptions.tsx new file mode 100644 index 00000000..1b95ffef --- /dev/null +++ b/apps/readest-app/src/app/reader/components/annotator/HighlightOptions.tsx @@ -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 = ({ 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 ( +
+
+ {styles.map((style) => ( + + ))} +
+ +
+ {colors.map((color) => ( + + ))} +
+
+ ); +}; + +export default HighlightOptions; diff --git a/apps/readest-app/src/app/reader/components/annotator/PopupButton.tsx b/apps/readest-app/src/app/reader/components/annotator/PopupButton.tsx new file mode 100644 index 00000000..d89648f9 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/annotator/PopupButton.tsx @@ -0,0 +1,24 @@ +import React, { useState } from 'react'; + +interface PopupButtonProps { + tooltipText: string; + Icon: React.ElementType; + onClick: () => void; +} + +const PopupButton: React.FC = ({ tooltipText, Icon, onClick }) => { + const [buttonClicked, setButtonClicked] = useState(false); + const handleClick = () => { + setButtonClicked(true); + onClick(); + }; + return ( +
+ +
+ ); +}; + +export default PopupButton; diff --git a/apps/readest-app/src/app/reader/components/sidebar/BookmarkView.tsx b/apps/readest-app/src/app/reader/components/sidebar/BooknoteView.tsx similarity index 54% rename from apps/readest-app/src/app/reader/components/sidebar/BookmarkView.tsx rename to apps/readest-app/src/app/reader/components/sidebar/BooknoteView.tsx index 42d0d252..6871bb39 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/BookmarkView.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/BooknoteView.tsx @@ -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 = ({ bookKey, text, cfi }) => { +const BooknoteItem: React.FC = ({ 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 = ({ bookKey, text, cfi }) => { event.preventDefault(); getFoliateView(bookKey)?.goTo(cfi); }; - console.log('isCurrentBookmark', isCurrentBookmark); + return (
  • = ({ bookKey, text, cfi }) => { )} onClick={handleClickItem} > - {text} +
    + + {text || ''} + +
  • ); }; -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<{
  • {group.label}

      - {group.bookmarks.map((item) => ( - + {group.booknotes.map((item, index) => ( + ))}
  • @@ -114,4 +125,4 @@ const BookmarkView: React.FC<{ ); }; -export default BookmarkView; +export default BooknoteView; diff --git a/apps/readest-app/src/app/reader/components/sidebar/Content.tsx b/apps/readest-app/src/app/reader/components/sidebar/Content.tsx index ac23effd..43043bb3 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/Content.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/Content.tsx @@ -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 && ( )} - {activeTab === 'annotations' &&
    Annotations
    } - {activeTab === 'bookmarks' && } + {activeTab === 'annotations' && ( + + )} + {activeTab === 'bookmarks' && ( + + )}
    ); diff --git a/apps/readest-app/src/components/Dropdown.tsx b/apps/readest-app/src/components/Dropdown.tsx index 3496fd61..020426eb 100644 --- a/apps/readest-app/src/components/Dropdown.tsx +++ b/apps/readest-app/src/components/Dropdown.tsx @@ -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 = ({ children, onToggle, }) => { - const dropdownRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const toggleDropdown = () => { @@ -36,27 +36,7 @@ const Dropdown: React.FC = ({ } }; - 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(() => setIsDropdownOpen(false)); const childrenWithToggle = isValidElement(children) ? React.cloneElement(children, { setIsDropdownOpen }) : children; diff --git a/apps/readest-app/src/hooks/useOutsideClick.ts b/apps/readest-app/src/hooks/useOutsideClick.ts new file mode 100644 index 00000000..df7a39e9 --- /dev/null +++ b/apps/readest-app/src/hooks/useOutsideClick.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef } from 'react'; + +function useOutsideClick(callback: () => void) { + const ref = useRef(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; diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 7d8f28fc..930c65aa 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -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 = { diff --git a/apps/readest-app/src/store/readerStore.ts b/apps/readest-app/src/store/readerStore.ts index 6f3c3792..ff3f66d1 100644 --- a/apps/readest-app/src/store/readerStore.ts +++ b/apps/readest-app/src/store/readerStore.ts @@ -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((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((set, get) => ({ config: { ...book.config, lastUpdated: Date.now(), - bookmarks, + booknotes, }, }, }, diff --git a/apps/readest-app/src/styles/globals.css b/apps/readest-app/src/styles/globals.css index 625bcf1b..58fad6bb 100644 --- a/apps/readest-app/src/styles/globals.css +++ b/apps/readest-app/src/styles/globals.css @@ -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; diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index 70ba2b12..763a9ad5 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -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; viewSettings?: Partial; diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index 1c852521..1152cf23 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -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; } export interface SystemSettings { diff --git a/apps/readest-app/src/utils/sel.ts b/apps/readest-app/src/utils/sel.ts new file mode 100644 index 00000000..3cffdffd --- /dev/null +++ b/apps/readest-app/src/utils/sel.ts @@ -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; +}; diff --git a/apps/readest-app/tailwind.config.ts b/apps/readest-app/tailwind.config.ts index ae9c9bbb..0556e1ef 100644 --- a/apps/readest-app/tailwind.config.ts +++ b/apps/readest-app/tailwind.config.ts @@ -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: {