From 093005484729e416db72e648eddcbdf2f9d6cb4c Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Fri, 3 Apr 2026 13:26:13 -0400 Subject: [PATCH] more reader --- frontend/dist/index.html | 32 --- frontend/src/hooks/useEpubReader.ts | 123 +++++++++-- frontend/src/lib/reader/EBookReader.ts | 288 ++++++++++++++++++------- frontend/src/pages/ReaderPage.tsx | 118 +++++----- 4 files changed, 372 insertions(+), 189 deletions(-) delete mode 100644 frontend/dist/index.html diff --git a/frontend/dist/index.html b/frontend/dist/index.html deleted file mode 100644 index 029ece9..0000000 --- a/frontend/dist/index.html +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - AnthoLume - - - - - -
- - \ No newline at end of file diff --git a/frontend/src/hooks/useEpubReader.ts b/frontend/src/hooks/useEpubReader.ts index 8f13f6c..47b58fa 100644 --- a/frontend/src/hooks/useEpubReader.ts +++ b/frontend/src/hooks/useEpubReader.ts @@ -1,4 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createActivity, getGetDocumentFileUrl, updateProgress } from '../generated/anthoLumeAPIV1'; +import type { CreateActivityRequest } from '../generated/model/createActivityRequest'; +import type { UpdateProgressRequest } from '../generated/model/updateProgressRequest'; import { EBookReader, type ReaderStats, type ReaderTocItem } from '../lib/reader/EBookReader'; import type { ReaderColorScheme, ReaderFontFamily } from '../utils/localSettings'; @@ -10,6 +13,10 @@ interface UseEpubReaderOptions { colorScheme: ReaderColorScheme; fontFamily: ReaderFontFamily; fontSize: number; + isPaginationDisabled: () => boolean; + onSwipeDown: () => void; + onSwipeUp: () => void; + onCenterTap: () => void; } interface UseEpubReaderResult { @@ -37,9 +44,17 @@ export function useEpubReader({ colorScheme, fontFamily, fontSize, + isPaginationDisabled, + onSwipeDown, + onSwipeUp, + onCenterTap, }: UseEpubReaderOptions): UseEpubReaderResult { const [viewerNode, setViewerNode] = useState(null); const readerRef = useRef(null); + const isPaginationDisabledRef = useRef(isPaginationDisabled); + const onSwipeDownRef = useRef(onSwipeDown); + const onSwipeUpRef = useRef(onSwipeUp); + const onCenterTapRef = useRef(onCenterTap); const [isReady, setIsReady] = useState(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -51,12 +66,23 @@ export function useEpubReader({ percentage: 0, }); + useEffect(() => { + isPaginationDisabledRef.current = isPaginationDisabled; + onSwipeDownRef.current = onSwipeDown; + onSwipeUpRef.current = onSwipeUp; + onCenterTapRef.current = onCenterTap; + }, [isPaginationDisabled, onCenterTap, onSwipeDown, onSwipeUp]); + useEffect(() => { const container = viewerNode; if (!container) { return; } + let isCancelled = false; + let objectUrl: string | null = null; + let reader: EBookReader | null = null; + setIsReady(false); setIsLoading(true); setError(null); @@ -68,29 +94,92 @@ export function useEpubReader({ percentage: 0, }); - const reader = new EBookReader({ - container, - documentId, - initialProgress, - deviceId, - deviceName, - colorScheme, - fontFamily, - fontSize, - onReady: () => setIsReady(true), - onLoading: loading => setIsLoading(loading), - onError: message => setError(message), - onStats: nextStats => setStats(nextStats), - onToc: nextToc => setToc(nextToc), - }); + const saveProgress = async (payload: UpdateProgressRequest) => { + const response = await updateProgress(payload); + if (response.status >= 400) { + throw new Error( + 'message' in response.data ? response.data.message : 'Unable to save reader progress' + ); + } + }; - readerRef.current = reader; + const saveActivity = async (payload: CreateActivityRequest) => { + const response = await createActivity(payload); + if (response.status >= 400) { + throw new Error( + 'message' in response.data ? response.data.message : 'Unable to save reader activity' + ); + } + }; + + const initializeReader = async () => { + try { + const response = await fetch(getGetDocumentFileUrl(documentId)); + const contentType = response.headers.get('content-type') || ''; + + if (!response.ok || contentType.includes('application/json')) { + let message = 'Unable to load document file'; + try { + const errorData = (await response.json()) as { message?: string }; + if (errorData.message) { + message = errorData.message; + } + } catch { + // ignore parse failure and use fallback message + } + throw new Error(message); + } + + const blob = await response.blob(); + if (isCancelled) { + return; + } + + objectUrl = URL.createObjectURL(blob); + reader = new EBookReader({ + container, + bookUrl: objectUrl, + documentId, + initialProgress, + deviceId, + deviceName, + colorScheme, + fontFamily, + fontSize, + onReady: () => setIsReady(true), + onLoading: loading => setIsLoading(loading), + onError: message => setError(message), + onStats: nextStats => setStats(nextStats), + onToc: nextToc => setToc(nextToc), + onSaveProgress: saveProgress, + onCreateActivity: saveActivity, + isPaginationDisabled: () => isPaginationDisabledRef.current(), + onSwipeDown: () => onSwipeDownRef.current(), + onSwipeUp: () => onSwipeUpRef.current(), + onCenterTap: () => onCenterTapRef.current(), + }); + + readerRef.current = reader; + } catch (err) { + if (isCancelled) { + return; + } + setError(err instanceof Error ? err.message : 'Unable to load document file'); + setIsLoading(false); + } + }; + + void initializeReader(); return () => { - reader.destroy(); + isCancelled = true; + reader?.destroy(); if (readerRef.current === reader) { readerRef.current = null; } + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } }; }, [deviceId, deviceName, documentId, initialProgress, viewerNode]); diff --git a/frontend/src/lib/reader/EBookReader.ts b/frontend/src/lib/reader/EBookReader.ts index 4d71767..872bed4 100644 --- a/frontend/src/lib/reader/EBookReader.ts +++ b/frontend/src/lib/reader/EBookReader.ts @@ -1,5 +1,7 @@ import ePub from 'epubjs'; import NoSleep from 'nosleep.js'; +import type { CreateActivityRequest } from '../../generated/model/createActivityRequest'; +import type { UpdateProgressRequest } from '../../generated/model/updateProgressRequest'; import type { ReaderColorScheme, ReaderFontFamily } from '../../utils/localSettings'; const THEMES: ReaderColorScheme[] = ['light', 'tan', 'blue', 'gray', 'black']; @@ -139,6 +141,7 @@ interface ReaderSettings { interface EBookReaderOptions { container: HTMLElement; + bookUrl: string; documentId: string; initialProgress?: string; deviceId: string; @@ -151,10 +154,17 @@ interface EBookReaderOptions { onError: (_message: string) => void; onStats: (_stats: ReaderStats) => void; onToc: (_toc: ReaderTocItem[]) => void; + onSaveProgress: (_payload: UpdateProgressRequest) => Promise; + onCreateActivity: (_payload: CreateActivityRequest) => Promise; + isPaginationDisabled: () => boolean; + onSwipeDown: () => void; + onSwipeUp: () => void; + onCenterTap: () => void; } export class EBookReader { private container: HTMLElement; + private bookUrl: string; private documentId: string; private deviceId: string; private deviceName: string; @@ -170,10 +180,18 @@ export class EBookReader { private onError: (_message: string) => void; private onStats: (_stats: ReaderStats) => void; private onToc: (_toc: ReaderTocItem[]) => void; + private onSaveProgress: (_payload: UpdateProgressRequest) => Promise; + private onCreateActivity: (_payload: CreateActivityRequest) => Promise; + private isPaginationDisabled: () => boolean; + private onSwipeDown: () => void; + private onSwipeUp: () => void; + private onCenterTap: () => void; private keyupHandler: ((event: KeyboardEvent) => void) | null = null; + private wheelTimeoutId: ReturnType | null = null; constructor(options: EBookReaderOptions) { this.container = options.container; + this.bookUrl = options.bookUrl; this.documentId = options.documentId; this.deviceId = options.deviceId; this.deviceName = options.deviceName; @@ -182,6 +200,12 @@ export class EBookReader { this.onError = options.onError; this.onStats = options.onStats; this.onToc = options.onToc; + this.onSaveProgress = options.onSaveProgress; + this.onCreateActivity = options.onCreateActivity; + this.isPaginationDisabled = options.isPaginationDisabled; + this.onSwipeDown = options.onSwipeDown; + this.onSwipeUp = options.onSwipeUp; + this.onCenterTap = options.onCenterTap; this.bookState = { pages: 0, @@ -201,7 +225,7 @@ export class EBookReader { }; this.onLoading(true); - this.book = ePub(`/api/v1/documents/${this.documentId}/file`, { openAs: 'epub' }) as EpubBook; + this.book = ePub(this.bookUrl, { openAs: 'epub' }) as EpubBook; this.rendition = this.book.renderTo(this.container, { manager: 'default', flow: 'paginated', @@ -216,15 +240,13 @@ export class EBookReader { this.initViewerListeners(); this.initDocumentListeners(); - this.book.ready - .then(this.setupReader.bind(this)) - .catch(error => { - if (this.destroyed) { - return; - } - this.onError(error instanceof Error ? error.message : 'Unable to initialize reader'); - this.onLoading(false); - }); + this.book.ready.then(this.setupReader.bind(this)).catch(error => { + if (this.destroyed) { + return; + } + this.onError(error instanceof Error ? error.message : 'Unable to initialize reader'); + this.onLoading(false); + }); } private loadSettings() { @@ -246,9 +268,12 @@ export class EBookReader { if (this.wakeTimeoutId) { clearTimeout(this.wakeTimeoutId); } - this.wakeTimeoutId = setTimeout(() => { - void this.noSleep?.disable(); - }, 1000 * 60 * 10); + this.wakeTimeoutId = setTimeout( + () => { + void this.noSleep?.disable(); + }, + 1000 * 60 * 10 + ); void this.noSleep.enable(); }; @@ -277,7 +302,9 @@ export class EBookReader { this.rendition.getContents().forEach(content => { const existing = content.document.getElementById('reader-fonts'); if (!existing) { - const nextLink = content.document.head.appendChild(content.document.createElement('link')); + const nextLink = content.document.head.appendChild( + content.document.createElement('link') + ); nextLink.id = 'reader-fonts'; nextLink.rel = 'stylesheet'; nextLink.href = FONT_FILE; @@ -312,6 +339,41 @@ export class EBookReader { const nextPage = this.nextPage.bind(this); const prevPage = this.prevPage.bind(this); + let touchStartX = 0; + let touchStartY = 0; + let touchEndX = 0; + let touchEndY = 0; + + const handleSwipeDown = () => { + this.resetWheelCooldown(); + this.onSwipeDown(); + }; + + const handleSwipeUp = () => { + this.resetWheelCooldown(); + this.onSwipeUp(); + }; + + const handleGesture = () => { + const drasticity = 50; + + if (touchEndY - drasticity > touchStartY) { + return handleSwipeDown(); + } + + if (touchEndY + drasticity < touchStartY) { + return handleSwipeUp(); + } + + if (!this.isPaginationDisabled() && touchEndX + drasticity < touchStartX) { + void nextPage(); + } + + if (!this.isPaginationDisabled() && touchEndX - drasticity > touchStartX) { + void prevPage(); + } + }; + this.rendition.hooks.render.register((contents: EpubContents) => { const renderDoc = contents.document; @@ -335,18 +397,65 @@ export class EBookReader { const yCoord = event.clientY; const xCoord = event.clientX - leftOffset; - if (yCoord < top || yCoord > bottom) { - return; - } - if (xCoord < left) { + if (yCoord < top) { + handleSwipeDown(); + } else if (yCoord > bottom) { + handleSwipeUp(); + } else if (!this.isPaginationDisabled() && xCoord < left) { void prevPage(); - } else if (xCoord > right) { + } else if (!this.isPaginationDisabled() && xCoord > right) { void nextPage(); + } else { + this.onCenterTap(); } }); + + renderDoc.addEventListener('wheel', (event: WheelEvent) => { + if (this.wheelTimeoutId) { + return; + } + + if (event.deltaY > 25) { + handleSwipeUp(); + return; + } + if (event.deltaY < -25) { + handleSwipeDown(); + } + }); + + renderDoc.addEventListener( + 'touchstart', + (event: TouchEvent) => { + touchStartX = event.changedTouches[0]?.screenX ?? 0; + touchStartY = event.changedTouches[0]?.screenY ?? 0; + }, + false + ); + + renderDoc.addEventListener( + 'touchend', + (event: TouchEvent) => { + touchEndX = event.changedTouches[0]?.screenX ?? 0; + touchEndY = event.changedTouches[0]?.screenY ?? 0; + handleGesture(); + }, + false + ); }); } + private resetWheelCooldown() { + if (this.wheelTimeoutId) { + clearTimeout(this.wheelTimeoutId); + this.wheelTimeoutId = null; + } + + this.wheelTimeoutId = setTimeout(() => { + this.wheelTimeoutId = null; + }, 400); + } + private initDocumentListeners() { const nextPage = this.nextPage.bind(this); const prevPage = this.prevPage.bind(this); @@ -411,11 +520,7 @@ export class EBookReader { }, []); } - setTheme(newTheme?: { - colorScheme?: ReaderColorScheme; - fontFamily?: string; - fontSize?: number; - }) { + setTheme(newTheme?: { colorScheme?: ReaderColorScheme; fontFamily?: string; fontSize?: number }) { this.readerSettings.theme = typeof this.readerSettings.theme === 'object' && this.readerSettings.theme !== null ? this.readerSettings.theme @@ -468,7 +573,9 @@ export class EBookReader { }); }); - const backgroundColor = getComputedStyle(this.bookState.progressElement.ownerDocument.body).backgroundColor; + const backgroundColor = getComputedStyle( + this.bookState.progressElement.ownerDocument.body + ).backgroundColor; Object.assign((this.bookState.progressElement as HTMLElement).style, { background: backgroundColor, @@ -478,7 +585,12 @@ export class EBookReader { } async nextPage() { - await this.createActivity(); + try { + await this.createActivity(); + } catch (error) { + this.onError(error instanceof Error ? error.message : 'Unable to save reader activity'); + } + await this.rendition.next(); this.bookState.pageStart = Date.now(); const stats = await this.getBookStats(); @@ -541,30 +653,35 @@ export class EBookReader { elapsedTime = (pageWords / WPM_MIN) * 60000; } + if (!Number.isFinite(percentRead) || percentRead <= 0 || this.bookState.words <= 0) { + return; + } + const totalPages = Math.round(1 / percentRead); - if (totalPages === 0) { + if (!Number.isFinite(totalPages) || totalPages <= 0) { return; } const currentPage = Math.round((currentWord * totalPages) / this.bookState.words); + if (!Number.isFinite(currentPage) || currentPage < 0) { + return; + } - await fetch('/api/v1/activity', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - device_id: this.deviceId, - device_name: this.deviceName, - activity: [ - { - document_id: this.documentId, - duration: Math.round(elapsedTime / 1000), - start_time: Math.round(pageStart / 1000), - page: currentPage, - pages: totalPages, - }, - ], - }), - }); + const payload: CreateActivityRequest = { + device_id: this.deviceId, + device_name: this.deviceName, + activity: [ + { + document_id: this.documentId, + duration: Math.round(elapsedTime / 1000), + start_time: Math.round(pageStart / 1000), + page: currentPage, + pages: totalPages, + }, + ], + }; + + await this.onCreateActivity(payload); } async createProgress() { @@ -580,17 +697,19 @@ export class EBookReader { : 0; this.bookState.percentage = Math.round(percentage * 10000) / 100; - await fetch('/api/v1/progress', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - document_id: this.documentId, - device_id: this.deviceId, - device_name: this.deviceName, - percentage, - progress: this.bookState.progress, - }), - }); + const payload: UpdateProgressRequest = { + document_id: this.documentId, + device_id: this.deviceId, + device_name: this.deviceName, + percentage, + progress: this.bookState.progress, + }; + + try { + await this.onSaveProgress(payload); + } catch (error) { + this.onError(error instanceof Error ? error.message : 'Unable to save reader progress'); + } } sectionProgress() { @@ -626,7 +745,9 @@ export class EBookReader { const currentLocation = await this.rendition.currentLocation(); const currentWord = await this.getBookWordPosition(); - const currentTOC = this.book.navigation?.toc?.find(item => item.href === currentLocation.start.href); + const currentTOC = this.book.navigation?.toc?.find( + item => item.href === currentLocation.start.href + ); return { sectionPage: currentProgress.sectionCurrentPage, @@ -720,37 +841,36 @@ export class EBookReader { const derivedSelectorElement = remainingXPath .replace(/^\/html\/body/, 'body') .split('/') - .reduce((element: ParentNode | null, item: string) => { - if (!element) { - return null; - } + .reduce( + (element: ParentNode | null, item: string) => { + if (!element) { + return null; + } - const indexMatch = item.match(/(\w+)\[(\d+)\]$/); - if (!indexMatch) { - return element.querySelector(item); - } + const indexMatch = item.match(/(\w+)\[(\d+)\]$/); + if (!indexMatch) { + return element.querySelector(item); + } - const [, tag, rawIndex] = indexMatch; - if (!tag || !rawIndex) { - return null; - } - return element.querySelectorAll(tag)[Number.parseInt(rawIndex, 10) - 1] ?? null; - }, docItem as ParentNode | null); + const [, tag, rawIndex] = indexMatch; + if (!tag || !rawIndex) { + return null; + } + return element.querySelectorAll(tag)[Number.parseInt(rawIndex, 10) - 1] ?? null; + }, + docItem as ParentNode | null + ); if (namespaceURI) { remainingXPath = remainingXPath.split('/').join('/ns:'); } - const docSearch = docItem.evaluate( - remainingXPath, - docItem, - prefix => { - if (prefix === 'ns') { - return namespaceURI; - } - return null; + const docSearch = docItem.evaluate(remainingXPath, docItem, prefix => { + if (prefix === 'ns') { + return namespaceURI; } - ); + return null; + }); const xpathElement = docSearch.iterateNext(); const element = xpathElement || derivedSelectorElement; @@ -771,7 +891,7 @@ export class EBookReader { async getVisibleWordCount() { const visibleText = await this.getVisibleText(); - return visibleText.trim().split(/\s+/).filter(Boolean).length; + return visibleText.trim().split(/\s+/).length; } async getBookWordPosition() { @@ -791,7 +911,7 @@ export class EBookReader { const cfiRange = this.getCFIRange(firstCFI, currentLocation.start.cfi); const textRange = await this.book.getRange(cfiRange); const chapterText = textRange.toString(); - const chapterWordPosition = chapterText.trim().split(/\s+/).filter(Boolean).length; + const chapterWordPosition = chapterText.trim().split(/\s+/).length; const preChapterWordPosition = this.book.spine.spineItems .slice(0, contents.sectionIndex ?? 0) .reduce((totalCount, item) => totalCount + (item.wordCount ?? 0), 0); @@ -853,8 +973,7 @@ export class EBookReader { const newDoc = await item.load(this.book.load.bind(this.book)); const spineWords = ((newDoc as unknown as HTMLElement).innerText || '') .trim() - .split(/\s+/) - .filter(Boolean).length; + .split(/\s+/).length; item.wordCount = spineWords; return spineWords; }) @@ -872,6 +991,9 @@ export class EBookReader { if (this.wakeTimeoutId) { clearTimeout(this.wakeTimeoutId); } + if (this.wheelTimeoutId) { + clearTimeout(this.wheelTimeoutId); + } void this.noSleep?.disable(); this.rendition.destroy?.(); this.book.destroy?.(); diff --git a/frontend/src/pages/ReaderPage.tsx b/frontend/src/pages/ReaderPage.tsx index 9ff40e8..e5b0fc3 100644 --- a/frontend/src/pages/ReaderPage.tsx +++ b/frontend/src/pages/ReaderPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useParams } from 'react-router-dom'; import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1'; import { LoadingState } from '../components/LoadingState'; @@ -22,7 +22,7 @@ const fontFamilies: ReaderFontFamily[] = ['Serif', 'Open Sans', 'Arbutus Slab', export default function ReaderPage() { const { id } = useParams<{ id: string }>(); const [isTopBarOpen, setIsTopBarOpen] = useState(false); - const [isBottomBarOpen, setIsBottomBarOpen] = useState(true); + const [isBottomBarOpen, setIsBottomBarOpen] = useState(false); const [colorScheme, setColorSchemeState] = useState(getReaderColorScheme()); const [fontFamily, setFontFamilyState] = useState(getReaderFontFamily()); const [fontSize, setFontSizeState] = useState(getReaderFontSize()); @@ -41,6 +41,31 @@ export default function ReaderPage() { const deviceId = defaultDeviceId; const deviceName = defaultDeviceName; + const handleSwipeDown = useCallback(() => { + if (isBottomBarOpen) { + setIsBottomBarOpen(false); + return; + } + if (!isTopBarOpen) { + setIsTopBarOpen(true); + } + }, [isBottomBarOpen, isTopBarOpen]); + + const handleSwipeUp = useCallback(() => { + if (isTopBarOpen) { + setIsTopBarOpen(false); + return; + } + if (!isBottomBarOpen) { + setIsBottomBarOpen(true); + } + }, [isBottomBarOpen, isTopBarOpen]); + + const handleCenterTap = useCallback(() => { + setIsTopBarOpen(false); + setIsBottomBarOpen(false); + }, []); + const reader = useEpubReader({ documentId: id || '', initialProgress: progress?.progress, @@ -49,6 +74,10 @@ export default function ReaderPage() { colorScheme, fontFamily, fontSize, + isPaginationDisabled: useCallback(() => isTopBarOpen || isBottomBarOpen, [isTopBarOpen, isBottomBarOpen]), + onSwipeDown: handleSwipeDown, + onSwipeUp: handleSwipeUp, + onCenterTap: handleCenterTap, }); useEffect(() => { @@ -61,6 +90,17 @@ export default function ReaderPage() { reader.setTheme({ colorScheme, fontFamily, fontSize }); }, [colorScheme, fontFamily, fontSize, reader.setTheme]); + useEffect(() => { + if (isTopBarOpen || isBottomBarOpen) { + return; + } + + const activeElement = window.document.activeElement; + if (activeElement instanceof HTMLElement) { + activeElement.blur(); + } + }, [isBottomBarOpen, isTopBarOpen]); + if (isDocumentLoading || isProgressLoading) { return ; } @@ -77,7 +117,7 @@ export default function ReaderPage() { isTopBarOpen ? 'translate-y-0' : '-translate-y-full' }`} > -
+
@@ -113,7 +153,7 @@ export default function ReaderPage() {
-
+
{reader.toc.map(item => (
-
- - -
-
{reader.isLoading && ( -
-
+
+
Chapter: {reader.stats.chapterName}
@@ -184,17 +207,17 @@ export default function ReaderPage() {
-
+
-
-
-

Theme

-
+
+
+

Theme

+
{colorSchemes.map(option => (
-
-

Font

-
+
+

Font

+
{fontFamilies.map(option => ( -
+
{fontSize.toFixed(1)}x
- -
- - -