This commit is contained in:
32
frontend/dist/index.html
vendored
32
frontend/dist/index.html
vendored
@@ -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>
|
|
||||||
@@ -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,8 +94,51 @@ export function useEpubReader({
|
|||||||
percentage: 0,
|
percentage: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const reader = new EBookReader({
|
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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
container,
|
||||||
|
bookUrl: objectUrl,
|
||||||
documentId,
|
documentId,
|
||||||
initialProgress,
|
initialProgress,
|
||||||
deviceId,
|
deviceId,
|
||||||
@@ -82,15 +151,35 @@ export function useEpubReader({
|
|||||||
onError: message => setError(message),
|
onError: message => setError(message),
|
||||||
onStats: nextStats => setStats(nextStats),
|
onStats: nextStats => setStats(nextStats),
|
||||||
onToc: nextToc => setToc(nextToc),
|
onToc: nextToc => setToc(nextToc),
|
||||||
|
onSaveProgress: saveProgress,
|
||||||
|
onCreateActivity: saveActivity,
|
||||||
|
isPaginationDisabled: () => isPaginationDisabledRef.current(),
|
||||||
|
onSwipeDown: () => onSwipeDownRef.current(),
|
||||||
|
onSwipeUp: () => onSwipeUpRef.current(),
|
||||||
|
onCenterTap: () => onCenterTapRef.current(),
|
||||||
});
|
});
|
||||||
|
|
||||||
readerRef.current = reader;
|
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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +240,7 @@ 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))
|
|
||||||
.catch(error => {
|
|
||||||
if (this.destroyed) {
|
if (this.destroyed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -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();
|
void this.noSleep?.disable();
|
||||||
}, 1000 * 60 * 10);
|
},
|
||||||
|
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) {
|
||||||
|
handleSwipeDown();
|
||||||
|
} else if (yCoord > bottom) {
|
||||||
|
handleSwipeUp();
|
||||||
|
} else if (!this.isPaginationDisabled() && xCoord < left) {
|
||||||
|
void prevPage();
|
||||||
|
} else if (!this.isPaginationDisabled() && xCoord > right) {
|
||||||
|
void nextPage();
|
||||||
|
} else {
|
||||||
|
this.onCenterTap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
renderDoc.addEventListener('wheel', (event: WheelEvent) => {
|
||||||
|
if (this.wheelTimeoutId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (xCoord < left) {
|
|
||||||
void prevPage();
|
if (event.deltaY > 25) {
|
||||||
} else if (xCoord > right) {
|
handleSwipeUp();
|
||||||
void nextPage();
|
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() {
|
||||||
|
try {
|
||||||
await this.createActivity();
|
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,17 +653,21 @@ 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',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
device_id: this.deviceId,
|
device_id: this.deviceId,
|
||||||
device_name: this.deviceName,
|
device_name: this.deviceName,
|
||||||
activity: [
|
activity: [
|
||||||
@@ -563,8 +679,9 @@ export class EBookReader {
|
|||||||
pages: totalPages,
|
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',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
document_id: this.documentId,
|
document_id: this.documentId,
|
||||||
device_id: this.deviceId,
|
device_id: this.deviceId,
|
||||||
device_name: this.deviceName,
|
device_name: this.deviceName,
|
||||||
percentage,
|
percentage,
|
||||||
progress: this.bookState.progress,
|
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,7 +841,8 @@ 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(
|
||||||
|
(element: ParentNode | null, item: string) => {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -735,22 +857,20 @@ export class EBookReader {
|
|||||||
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,
|
|
||||||
docItem,
|
|
||||||
prefix => {
|
|
||||||
if (prefix === 'ns') {
|
if (prefix === 'ns') {
|
||||||
return namespaceURI;
|
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?.();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user