more reader
Some checks failed
continuous-integration/drone/pr Build is failing

This commit is contained in:
2026-04-03 13:26:13 -04:00
parent aa812c6917
commit 0930054847
4 changed files with 372 additions and 189 deletions

View File

@@ -1,32 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta
name="theme-color"
content="#F3F4F6"
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content="#1F2937"
media="(prefers-color-scheme: dark)"
/>
<title>AnthoLume</title>
<link rel="manifest" href="/manifest.json" />
<script type="module" crossorigin src="/assets/index-BQhAeK6-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CdRalUYN.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -1,4 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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 { EBookReader, type ReaderStats, type ReaderTocItem } from '../lib/reader/EBookReader';
import type { ReaderColorScheme, ReaderFontFamily } from '../utils/localSettings'; import type { ReaderColorScheme, ReaderFontFamily } from '../utils/localSettings';
@@ -10,6 +13,10 @@ interface UseEpubReaderOptions {
colorScheme: ReaderColorScheme; colorScheme: ReaderColorScheme;
fontFamily: ReaderFontFamily; fontFamily: ReaderFontFamily;
fontSize: number; fontSize: number;
isPaginationDisabled: () => boolean;
onSwipeDown: () => void;
onSwipeUp: () => void;
onCenterTap: () => void;
} }
interface UseEpubReaderResult { interface UseEpubReaderResult {
@@ -37,9 +44,17 @@ export function useEpubReader({
colorScheme, colorScheme,
fontFamily, fontFamily,
fontSize, fontSize,
isPaginationDisabled,
onSwipeDown,
onSwipeUp,
onCenterTap,
}: UseEpubReaderOptions): UseEpubReaderResult { }: UseEpubReaderOptions): UseEpubReaderResult {
const [viewerNode, setViewerNode] = useState<HTMLDivElement | null>(null); const [viewerNode, setViewerNode] = useState<HTMLDivElement | null>(null);
const readerRef = useRef<EBookReader | null>(null); const readerRef = useRef<EBookReader | null>(null);
const isPaginationDisabledRef = useRef(isPaginationDisabled);
const onSwipeDownRef = useRef(onSwipeDown);
const onSwipeUpRef = useRef(onSwipeUp);
const onCenterTapRef = useRef(onCenterTap);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -51,12 +66,23 @@ export function useEpubReader({
percentage: 0, percentage: 0,
}); });
useEffect(() => {
isPaginationDisabledRef.current = isPaginationDisabled;
onSwipeDownRef.current = onSwipeDown;
onSwipeUpRef.current = onSwipeUp;
onCenterTapRef.current = onCenterTap;
}, [isPaginationDisabled, onCenterTap, onSwipeDown, onSwipeUp]);
useEffect(() => { useEffect(() => {
const container = viewerNode; const container = viewerNode;
if (!container) { if (!container) {
return; return;
} }
let isCancelled = false;
let objectUrl: string | null = null;
let reader: EBookReader | null = null;
setIsReady(false); setIsReady(false);
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
@@ -68,29 +94,92 @@ export function useEpubReader({
percentage: 0, percentage: 0,
}); });
const reader = new EBookReader({ const saveProgress = async (payload: UpdateProgressRequest) => {
container, const response = await updateProgress(payload);
documentId, if (response.status >= 400) {
initialProgress, throw new Error(
deviceId, 'message' in response.data ? response.data.message : 'Unable to save reader progress'
deviceName, );
colorScheme, }
fontFamily, };
fontSize,
onReady: () => setIsReady(true),
onLoading: loading => setIsLoading(loading),
onError: message => setError(message),
onStats: nextStats => setStats(nextStats),
onToc: nextToc => setToc(nextToc),
});
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 () => { return () => {
reader.destroy(); isCancelled = true;
reader?.destroy();
if (readerRef.current === reader) { if (readerRef.current === reader) {
readerRef.current = null; readerRef.current = null;
} }
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
}; };
}, [deviceId, deviceName, documentId, initialProgress, viewerNode]); }, [deviceId, deviceName, documentId, initialProgress, viewerNode]);

View File

@@ -1,5 +1,7 @@
import ePub from 'epubjs'; import ePub from 'epubjs';
import NoSleep from 'nosleep.js'; 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'; import type { ReaderColorScheme, ReaderFontFamily } from '../../utils/localSettings';
const THEMES: ReaderColorScheme[] = ['light', 'tan', 'blue', 'gray', 'black']; const THEMES: ReaderColorScheme[] = ['light', 'tan', 'blue', 'gray', 'black'];
@@ -139,6 +141,7 @@ interface ReaderSettings {
interface EBookReaderOptions { interface EBookReaderOptions {
container: HTMLElement; container: HTMLElement;
bookUrl: string;
documentId: string; documentId: string;
initialProgress?: string; initialProgress?: string;
deviceId: string; deviceId: string;
@@ -151,10 +154,17 @@ interface EBookReaderOptions {
onError: (_message: string) => void; onError: (_message: string) => void;
onStats: (_stats: ReaderStats) => void; onStats: (_stats: ReaderStats) => void;
onToc: (_toc: ReaderTocItem[]) => void; onToc: (_toc: ReaderTocItem[]) => void;
onSaveProgress: (_payload: UpdateProgressRequest) => Promise<void>;
onCreateActivity: (_payload: CreateActivityRequest) => Promise<void>;
isPaginationDisabled: () => boolean;
onSwipeDown: () => void;
onSwipeUp: () => void;
onCenterTap: () => void;
} }
export class EBookReader { export class EBookReader {
private container: HTMLElement; private container: HTMLElement;
private bookUrl: string;
private documentId: string; private documentId: string;
private deviceId: string; private deviceId: string;
private deviceName: string; private deviceName: string;
@@ -170,10 +180,18 @@ export class EBookReader {
private onError: (_message: string) => void; private onError: (_message: string) => void;
private onStats: (_stats: ReaderStats) => void; private onStats: (_stats: ReaderStats) => void;
private onToc: (_toc: ReaderTocItem[]) => void; private onToc: (_toc: ReaderTocItem[]) => void;
private onSaveProgress: (_payload: UpdateProgressRequest) => Promise<void>;
private onCreateActivity: (_payload: CreateActivityRequest) => Promise<void>;
private isPaginationDisabled: () => boolean;
private onSwipeDown: () => void;
private onSwipeUp: () => void;
private onCenterTap: () => void;
private keyupHandler: ((event: KeyboardEvent) => void) | null = null; private keyupHandler: ((event: KeyboardEvent) => void) | null = null;
private wheelTimeoutId: ReturnType<typeof setTimeout> | null = null;
constructor(options: EBookReaderOptions) { constructor(options: EBookReaderOptions) {
this.container = options.container; this.container = options.container;
this.bookUrl = options.bookUrl;
this.documentId = options.documentId; this.documentId = options.documentId;
this.deviceId = options.deviceId; this.deviceId = options.deviceId;
this.deviceName = options.deviceName; this.deviceName = options.deviceName;
@@ -182,6 +200,12 @@ export class EBookReader {
this.onError = options.onError; this.onError = options.onError;
this.onStats = options.onStats; this.onStats = options.onStats;
this.onToc = options.onToc; 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 = { this.bookState = {
pages: 0, pages: 0,
@@ -201,7 +225,7 @@ export class EBookReader {
}; };
this.onLoading(true); 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, { this.rendition = this.book.renderTo(this.container, {
manager: 'default', manager: 'default',
flow: 'paginated', flow: 'paginated',
@@ -216,15 +240,13 @@ export class EBookReader {
this.initViewerListeners(); this.initViewerListeners();
this.initDocumentListeners(); this.initDocumentListeners();
this.book.ready this.book.ready.then(this.setupReader.bind(this)).catch(error => {
.then(this.setupReader.bind(this)) if (this.destroyed) {
.catch(error => { return;
if (this.destroyed) { }
return; this.onError(error instanceof Error ? error.message : 'Unable to initialize reader');
} this.onLoading(false);
this.onError(error instanceof Error ? error.message : 'Unable to initialize reader'); });
this.onLoading(false);
});
} }
private loadSettings() { private loadSettings() {
@@ -246,9 +268,12 @@ export class EBookReader {
if (this.wakeTimeoutId) { if (this.wakeTimeoutId) {
clearTimeout(this.wakeTimeoutId); clearTimeout(this.wakeTimeoutId);
} }
this.wakeTimeoutId = setTimeout(() => { this.wakeTimeoutId = setTimeout(
void this.noSleep?.disable(); () => {
}, 1000 * 60 * 10); void this.noSleep?.disable();
},
1000 * 60 * 10
);
void this.noSleep.enable(); void this.noSleep.enable();
}; };
@@ -277,7 +302,9 @@ export class EBookReader {
this.rendition.getContents().forEach(content => { this.rendition.getContents().forEach(content => {
const existing = content.document.getElementById('reader-fonts'); const existing = content.document.getElementById('reader-fonts');
if (!existing) { 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.id = 'reader-fonts';
nextLink.rel = 'stylesheet'; nextLink.rel = 'stylesheet';
nextLink.href = FONT_FILE; nextLink.href = FONT_FILE;
@@ -312,6 +339,41 @@ export class EBookReader {
const nextPage = this.nextPage.bind(this); const nextPage = this.nextPage.bind(this);
const prevPage = this.prevPage.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) => { this.rendition.hooks.render.register((contents: EpubContents) => {
const renderDoc = contents.document; const renderDoc = contents.document;
@@ -335,18 +397,65 @@ export class EBookReader {
const yCoord = event.clientY; const yCoord = event.clientY;
const xCoord = event.clientX - leftOffset; const xCoord = event.clientX - leftOffset;
if (yCoord < top || yCoord > bottom) { if (yCoord < top) {
return; handleSwipeDown();
} } else if (yCoord > bottom) {
if (xCoord < left) { handleSwipeUp();
} else if (!this.isPaginationDisabled() && xCoord < left) {
void prevPage(); void prevPage();
} else if (xCoord > right) { } else if (!this.isPaginationDisabled() && xCoord > right) {
void nextPage(); 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() { private initDocumentListeners() {
const nextPage = this.nextPage.bind(this); const nextPage = this.nextPage.bind(this);
const prevPage = this.prevPage.bind(this); const prevPage = this.prevPage.bind(this);
@@ -411,11 +520,7 @@ export class EBookReader {
}, []); }, []);
} }
setTheme(newTheme?: { setTheme(newTheme?: { colorScheme?: ReaderColorScheme; fontFamily?: string; fontSize?: number }) {
colorScheme?: ReaderColorScheme;
fontFamily?: string;
fontSize?: number;
}) {
this.readerSettings.theme = this.readerSettings.theme =
typeof this.readerSettings.theme === 'object' && this.readerSettings.theme !== null typeof this.readerSettings.theme === 'object' && this.readerSettings.theme !== null
? this.readerSettings.theme ? 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, { Object.assign((this.bookState.progressElement as HTMLElement).style, {
background: backgroundColor, background: backgroundColor,
@@ -478,7 +585,12 @@ export class EBookReader {
} }
async nextPage() { 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(); await this.rendition.next();
this.bookState.pageStart = Date.now(); this.bookState.pageStart = Date.now();
const stats = await this.getBookStats(); const stats = await this.getBookStats();
@@ -541,30 +653,35 @@ export class EBookReader {
elapsedTime = (pageWords / WPM_MIN) * 60000; elapsedTime = (pageWords / WPM_MIN) * 60000;
} }
if (!Number.isFinite(percentRead) || percentRead <= 0 || this.bookState.words <= 0) {
return;
}
const totalPages = Math.round(1 / percentRead); const totalPages = Math.round(1 / percentRead);
if (totalPages === 0) { if (!Number.isFinite(totalPages) || totalPages <= 0) {
return; return;
} }
const currentPage = Math.round((currentWord * totalPages) / this.bookState.words); const currentPage = Math.round((currentWord * totalPages) / this.bookState.words);
if (!Number.isFinite(currentPage) || currentPage < 0) {
return;
}
await fetch('/api/v1/activity', { const payload: CreateActivityRequest = {
method: 'POST', device_id: this.deviceId,
headers: { 'Content-Type': 'application/json' }, device_name: this.deviceName,
body: JSON.stringify({ activity: [
device_id: this.deviceId, {
device_name: this.deviceName, document_id: this.documentId,
activity: [ duration: Math.round(elapsedTime / 1000),
{ start_time: Math.round(pageStart / 1000),
document_id: this.documentId, page: currentPage,
duration: Math.round(elapsedTime / 1000), pages: totalPages,
start_time: Math.round(pageStart / 1000), },
page: currentPage, ],
pages: totalPages, };
},
], await this.onCreateActivity(payload);
}),
});
} }
async createProgress() { async createProgress() {
@@ -580,17 +697,19 @@ export class EBookReader {
: 0; : 0;
this.bookState.percentage = Math.round(percentage * 10000) / 100; this.bookState.percentage = Math.round(percentage * 10000) / 100;
await fetch('/api/v1/progress', { const payload: UpdateProgressRequest = {
method: 'PUT', document_id: this.documentId,
headers: { 'Content-Type': 'application/json' }, device_id: this.deviceId,
body: JSON.stringify({ device_name: this.deviceName,
document_id: this.documentId, percentage,
device_id: this.deviceId, progress: this.bookState.progress,
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() { sectionProgress() {
@@ -626,7 +745,9 @@ export class EBookReader {
const currentLocation = await this.rendition.currentLocation(); const currentLocation = await this.rendition.currentLocation();
const currentWord = await this.getBookWordPosition(); 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 { return {
sectionPage: currentProgress.sectionCurrentPage, sectionPage: currentProgress.sectionCurrentPage,
@@ -720,37 +841,36 @@ export class EBookReader {
const derivedSelectorElement = remainingXPath const derivedSelectorElement = remainingXPath
.replace(/^\/html\/body/, 'body') .replace(/^\/html\/body/, 'body')
.split('/') .split('/')
.reduce((element: ParentNode | null, item: string) => { .reduce(
if (!element) { (element: ParentNode | null, item: string) => {
return null; if (!element) {
} return null;
}
const indexMatch = item.match(/(\w+)\[(\d+)\]$/); const indexMatch = item.match(/(\w+)\[(\d+)\]$/);
if (!indexMatch) { if (!indexMatch) {
return element.querySelector(item); return element.querySelector(item);
} }
const [, tag, rawIndex] = indexMatch; const [, tag, rawIndex] = indexMatch;
if (!tag || !rawIndex) { if (!tag || !rawIndex) {
return null; return null;
} }
return element.querySelectorAll(tag)[Number.parseInt(rawIndex, 10) - 1] ?? null; return element.querySelectorAll(tag)[Number.parseInt(rawIndex, 10) - 1] ?? null;
}, docItem as ParentNode | null); },
docItem as ParentNode | null
);
if (namespaceURI) { if (namespaceURI) {
remainingXPath = remainingXPath.split('/').join('/ns:'); remainingXPath = remainingXPath.split('/').join('/ns:');
} }
const docSearch = docItem.evaluate( const docSearch = docItem.evaluate(remainingXPath, docItem, prefix => {
remainingXPath, if (prefix === 'ns') {
docItem, return namespaceURI;
prefix => {
if (prefix === 'ns') {
return namespaceURI;
}
return null;
} }
); return null;
});
const xpathElement = docSearch.iterateNext(); const xpathElement = docSearch.iterateNext();
const element = xpathElement || derivedSelectorElement; const element = xpathElement || derivedSelectorElement;
@@ -771,7 +891,7 @@ export class EBookReader {
async getVisibleWordCount() { async getVisibleWordCount() {
const visibleText = await this.getVisibleText(); const visibleText = await this.getVisibleText();
return visibleText.trim().split(/\s+/).filter(Boolean).length; return visibleText.trim().split(/\s+/).length;
} }
async getBookWordPosition() { async getBookWordPosition() {
@@ -791,7 +911,7 @@ export class EBookReader {
const cfiRange = this.getCFIRange(firstCFI, currentLocation.start.cfi); const cfiRange = this.getCFIRange(firstCFI, currentLocation.start.cfi);
const textRange = await this.book.getRange(cfiRange); const textRange = await this.book.getRange(cfiRange);
const chapterText = textRange.toString(); 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 const preChapterWordPosition = this.book.spine.spineItems
.slice(0, contents.sectionIndex ?? 0) .slice(0, contents.sectionIndex ?? 0)
.reduce((totalCount, item) => totalCount + (item.wordCount ?? 0), 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 newDoc = await item.load(this.book.load.bind(this.book));
const spineWords = ((newDoc as unknown as HTMLElement).innerText || '') const spineWords = ((newDoc as unknown as HTMLElement).innerText || '')
.trim() .trim()
.split(/\s+/) .split(/\s+/).length;
.filter(Boolean).length;
item.wordCount = spineWords; item.wordCount = spineWords;
return spineWords; return spineWords;
}) })
@@ -872,6 +991,9 @@ export class EBookReader {
if (this.wakeTimeoutId) { if (this.wakeTimeoutId) {
clearTimeout(this.wakeTimeoutId); clearTimeout(this.wakeTimeoutId);
} }
if (this.wheelTimeoutId) {
clearTimeout(this.wheelTimeoutId);
}
void this.noSleep?.disable(); void this.noSleep?.disable();
this.rendition.destroy?.(); this.rendition.destroy?.();
this.book.destroy?.(); this.book.destroy?.();

View File

@@ -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 { Link, useParams } from 'react-router-dom';
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1'; import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
import { LoadingState } from '../components/LoadingState'; import { LoadingState } from '../components/LoadingState';
@@ -22,7 +22,7 @@ const fontFamilies: ReaderFontFamily[] = ['Serif', 'Open Sans', 'Arbutus Slab',
export default function ReaderPage() { export default function ReaderPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [isTopBarOpen, setIsTopBarOpen] = useState(false); const [isTopBarOpen, setIsTopBarOpen] = useState(false);
const [isBottomBarOpen, setIsBottomBarOpen] = useState(true); const [isBottomBarOpen, setIsBottomBarOpen] = useState(false);
const [colorScheme, setColorSchemeState] = useState<ReaderColorScheme>(getReaderColorScheme()); const [colorScheme, setColorSchemeState] = useState<ReaderColorScheme>(getReaderColorScheme());
const [fontFamily, setFontFamilyState] = useState<ReaderFontFamily>(getReaderFontFamily()); const [fontFamily, setFontFamilyState] = useState<ReaderFontFamily>(getReaderFontFamily());
const [fontSize, setFontSizeState] = useState<number>(getReaderFontSize()); const [fontSize, setFontSizeState] = useState<number>(getReaderFontSize());
@@ -41,6 +41,31 @@ export default function ReaderPage() {
const deviceId = defaultDeviceId; const deviceId = defaultDeviceId;
const deviceName = defaultDeviceName; 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({ const reader = useEpubReader({
documentId: id || '', documentId: id || '',
initialProgress: progress?.progress, initialProgress: progress?.progress,
@@ -49,6 +74,10 @@ export default function ReaderPage() {
colorScheme, colorScheme,
fontFamily, fontFamily,
fontSize, fontSize,
isPaginationDisabled: useCallback(() => isTopBarOpen || isBottomBarOpen, [isTopBarOpen, isBottomBarOpen]),
onSwipeDown: handleSwipeDown,
onSwipeUp: handleSwipeUp,
onCenterTap: handleCenterTap,
}); });
useEffect(() => { useEffect(() => {
@@ -61,6 +90,17 @@ export default function ReaderPage() {
reader.setTheme({ colorScheme, fontFamily, fontSize }); reader.setTheme({ colorScheme, fontFamily, fontSize });
}, [colorScheme, fontFamily, fontSize, reader.setTheme]); }, [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) { if (isDocumentLoading || isProgressLoading) {
return <LoadingState className="min-h-screen bg-canvas" message="Loading reader..." />; return <LoadingState className="min-h-screen bg-canvas" message="Loading reader..." />;
} }
@@ -77,7 +117,7 @@ export default function ReaderPage() {
isTopBarOpen ? 'translate-y-0' : '-translate-y-full' isTopBarOpen ? 'translate-y-0' : '-translate-y-full'
}`} }`}
> >
<div className="mx-auto flex max-h-[70vh] w-full max-w-6xl flex-col gap-4 overflow-auto p-4"> <div className="mx-auto flex max-h-[70vh] min-h-0 w-full max-w-6xl flex-col gap-4 p-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-4"> <div className="flex min-w-0 items-start gap-4">
<Link to={`/documents/${document.id}`} className="block shrink-0"> <Link to={`/documents/${document.id}`} className="block shrink-0">
@@ -113,7 +153,7 @@ export default function ReaderPage() {
</div> </div>
</div> </div>
<div className="grid gap-2 pb-2 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid min-h-0 flex-1 auto-rows-min gap-2 overflow-y-auto pb-2 sm:grid-cols-2 lg:grid-cols-3">
{reader.toc.map(item => ( {reader.toc.map(item => (
<button <button
key={`${item.href}-${item.title}`} key={`${item.href}-${item.title}`}
@@ -131,23 +171,6 @@ export default function ReaderPage() {
</div> </div>
</div> </div>
<div className="absolute left-4 top-4 z-10 flex gap-2">
<button
type="button"
onClick={() => setIsTopBarOpen(open => !open)}
className="rounded bg-surface/90 px-3 py-2 text-sm font-medium text-content shadow backdrop-blur hover:bg-surface"
>
Contents
</button>
<button
type="button"
onClick={() => setIsBottomBarOpen(open => !open)}
className="rounded bg-surface/90 px-3 py-2 text-sm font-medium text-content shadow backdrop-blur hover:bg-surface"
>
Controls
</button>
</div>
<div className="absolute inset-0 pt-[env(safe-area-inset-top)]"> <div className="absolute inset-0 pt-[env(safe-area-inset-top)]">
{reader.isLoading && ( {reader.isLoading && (
<LoadingState <LoadingState
@@ -169,8 +192,8 @@ export default function ReaderPage() {
isBottomBarOpen ? 'translate-y-0' : 'translate-y-full' isBottomBarOpen ? 'translate-y-0' : 'translate-y-full'
}`} }`}
> >
<div className="mx-auto flex max-w-6xl flex-col gap-4 p-4"> <div className="mx-auto flex w-full max-w-screen-2xl flex-col gap-3 p-3">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-content-muted"> <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-content-muted sm:text-sm">
<div> <div>
<span className="text-content-subtle">Chapter:</span> {reader.stats.chapterName} <span className="text-content-subtle">Chapter:</span> {reader.stats.chapterName}
</div> </div>
@@ -184,17 +207,17 @@ export default function ReaderPage() {
</div> </div>
</div> </div>
<div className="h-2 overflow-hidden rounded-full bg-surface-strong"> <div className="h-1.5 overflow-hidden rounded-full bg-surface-strong">
<div <div
className="h-full bg-tertiary-500 transition-all" className="h-full bg-tertiary-500 transition-all"
style={{ width: `${reader.stats.percentage}%` }} style={{ width: `${reader.stats.percentage}%` }}
/> />
</div> </div>
<div className="grid gap-4 lg:grid-cols-[1fr_1fr_1fr_auto]"> <div className="grid gap-3 lg:grid-cols-[minmax(0,2fr)_minmax(0,2fr)_auto] lg:items-start">
<div> <div className="min-w-0">
<p className="mb-2 text-xs uppercase tracking-wide text-content-subtle">Theme</p> <p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Theme</p>
<div className="flex flex-wrap gap-2"> <div className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5">
{colorSchemes.map(option => ( {colorSchemes.map(option => (
<button <button
key={option} key={option}
@@ -203,7 +226,7 @@ export default function ReaderPage() {
setColorSchemeState(option); setColorSchemeState(option);
setReaderColorScheme(option); setReaderColorScheme(option);
}} }}
className={`rounded border px-3 py-2 text-sm capitalize ${ className={`rounded border px-2 py-1.5 text-xs capitalize sm:text-sm ${
colorScheme === option colorScheme === option
? 'border-primary-500 bg-primary-500/10 text-content' ? 'border-primary-500 bg-primary-500/10 text-content'
: 'border-border text-content-muted hover:bg-surface-muted hover:text-content' : 'border-border text-content-muted hover:bg-surface-muted hover:text-content'
@@ -215,9 +238,9 @@ export default function ReaderPage() {
</div> </div>
</div> </div>
<div> <div className="min-w-0">
<p className="mb-2 text-xs uppercase tracking-wide text-content-subtle">Font</p> <p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Font</p>
<div className="flex flex-wrap gap-2"> <div className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4">
{fontFamilies.map(option => ( {fontFamilies.map(option => (
<button <button
key={option} key={option}
@@ -226,7 +249,7 @@ export default function ReaderPage() {
setFontFamilyState(option); setFontFamilyState(option);
setReaderFontFamily(option); setReaderFontFamily(option);
}} }}
className={`rounded border px-3 py-2 text-sm ${ className={`rounded border px-2 py-1.5 text-xs sm:text-sm ${
fontFamily === option fontFamily === option
? 'border-primary-500 bg-primary-500/10 text-content' ? 'border-primary-500 bg-primary-500/10 text-content'
: 'border-border text-content-muted hover:bg-surface-muted hover:text-content' : 'border-border text-content-muted hover:bg-surface-muted hover:text-content'
@@ -239,10 +262,10 @@ export default function ReaderPage() {
</div> </div>
<div> <div>
<p className="mb-2 text-xs uppercase tracking-wide text-content-subtle"> <p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">
Font Size Font Size
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5 lg:justify-end">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -250,11 +273,11 @@ export default function ReaderPage() {
setFontSizeState(nextSize); setFontSizeState(nextSize);
setReaderFontSize(nextSize); setReaderFontSize(nextSize);
}} }}
className="rounded border border-border px-3 py-2 text-content-muted hover:bg-surface-muted hover:text-content" className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
> >
- -
</button> </button>
<div className="min-w-16 text-center text-sm text-content"> <div className="min-w-12 text-center text-xs text-content sm:text-sm">
{fontSize.toFixed(1)}x {fontSize.toFixed(1)}x
</div> </div>
<button <button
@@ -264,31 +287,12 @@ export default function ReaderPage() {
setFontSizeState(nextSize); setFontSizeState(nextSize);
setReaderFontSize(nextSize); setReaderFontSize(nextSize);
}} }}
className="rounded border border-border px-3 py-2 text-content-muted hover:bg-surface-muted hover:text-content" className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
> >
+ +
</button> </button>
</div> </div>
</div> </div>
<div className="flex items-end gap-2">
<button
type="button"
onClick={() => void reader.prevPage()}
disabled={!reader.isReady}
className="rounded bg-secondary-700 px-4 py-2 text-sm font-medium text-white hover:bg-secondary-800 disabled:cursor-not-allowed disabled:opacity-50"
>
Previous
</button>
<button
type="button"
onClick={() => void reader.nextPage()}
disabled={!reader.isReady}
className="rounded bg-secondary-700 px-4 py-2 text-sm font-medium text-white hover:bg-secondary-800 disabled:cursor-not-allowed disabled:opacity-50"
>
Next
</button>
</div>
</div> </div>
</div> </div>
</div> </div>