@@ -3,12 +3,10 @@ import { Link, useLocation, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { UserIcon, DropdownIcon } from '../icons';
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
import type { ThemeMode } from '../utils/localSettings';
|
||||
import { THEME_MODES } from '../utils/localSettings';
|
||||
import HamburgerMenu from './HamburgerMenu';
|
||||
import { getPageTitle } from './navigation';
|
||||
|
||||
const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuth();
|
||||
@@ -72,7 +70,7 @@ export default function Layout() {
|
||||
Theme
|
||||
</p>
|
||||
<div className="inline-flex w-full rounded border border-border bg-surface-muted p-1">
|
||||
{themeModes.map(mode => (
|
||||
{THEME_MODES.map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ElementType } from 'react';
|
||||
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon } from '../icons';
|
||||
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, ClockIcon } from '../icons';
|
||||
|
||||
export interface NavItem {
|
||||
path: string;
|
||||
@@ -10,16 +10,16 @@ export interface NavItem {
|
||||
export const navItems: NavItem[] = [
|
||||
{ path: '/', label: 'Home', icon: HomeIcon },
|
||||
{ path: '/documents', label: 'Documents', icon: DocumentsIcon },
|
||||
{ path: '/progress', label: 'Progress', icon: ActivityIcon },
|
||||
{ path: '/progress', label: 'Progress', icon: ClockIcon },
|
||||
{ path: '/activity', label: 'Activity', icon: ActivityIcon },
|
||||
{ path: '/search', label: 'Search', icon: SearchIcon },
|
||||
];
|
||||
|
||||
export const adminNavItems: NavItem[] = [
|
||||
{ path: '/admin', label: 'General', icon: SettingsIcon },
|
||||
{ path: '/admin/import', label: 'Import', icon: SettingsIcon },
|
||||
{ path: '/admin/users', label: 'Users', icon: SettingsIcon },
|
||||
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon },
|
||||
export const adminNavItems: { path: string; label: string }[] = [
|
||||
{ path: '/admin', label: 'General' },
|
||||
{ path: '/admin/import', label: 'Import' },
|
||||
{ path: '/admin/users', label: 'Users' },
|
||||
{ path: '/admin/logs', label: 'Logs' },
|
||||
];
|
||||
|
||||
// Ordered most-specific-first so prefix matching resolves nested routes correctly.
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { CreateActivityRequest } from '../generated/model/createActivityReq
|
||||
import type { UpdateProgressRequest } from '../generated/model/updateProgressRequest';
|
||||
import { EBookReader, type ReaderStats, type ReaderTocItem } from '../lib/reader/EBookReader';
|
||||
import type { ReaderColorScheme, ReaderFontFamily } from '../utils/localSettings';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage, getResponseError } from '../utils/errors';
|
||||
|
||||
interface UseEpubReaderOptions {
|
||||
documentId: string;
|
||||
@@ -60,6 +62,7 @@ export function useEpubReader({
|
||||
sectionTotalPages: 0,
|
||||
percentage: 0,
|
||||
});
|
||||
const { showError } = useToasts();
|
||||
|
||||
useEffect(() => {
|
||||
isPaginationDisabledRef.current = isPaginationDisabled;
|
||||
@@ -90,20 +93,28 @@ export function useEpubReader({
|
||||
});
|
||||
|
||||
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'
|
||||
);
|
||||
// Swallow Save Failures - Transient progress-save errors must not take down the reader
|
||||
// (they previously routed to onError, which hides the whole book behind an error overlay).
|
||||
try {
|
||||
const response = await updateProgress(payload);
|
||||
const message = getResponseError(response);
|
||||
if (message) {
|
||||
showError(`Failed to save progress: ${message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(`Failed to save progress: ${getErrorMessage(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
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'
|
||||
);
|
||||
try {
|
||||
const response = await createActivity(payload);
|
||||
const message = getResponseError(response);
|
||||
if (message) {
|
||||
showError(`Failed to save activity: ${message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(`Failed to save activity: ${getErrorMessage(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
interface ApiResponse {
|
||||
status: number;
|
||||
data: unknown;
|
||||
}
|
||||
import { getErrorMessage, getResponseError, type ApiResponseLike } from '../utils/errors';
|
||||
|
||||
interface ToastMutationOptions {
|
||||
success: string;
|
||||
@@ -21,9 +16,10 @@ export function useMutationWithToast() {
|
||||
|
||||
return function toastMutationOptions({ success, error, onSuccess }: ToastMutationOptions) {
|
||||
return {
|
||||
onSuccess: (response: ApiResponse) => {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
showError(`${error}: ${getErrorMessage(response.data)}`);
|
||||
onSuccess: (response: ApiResponseLike) => {
|
||||
const message = getResponseError(response);
|
||||
if (message) {
|
||||
showError(`${error}: ${message}`);
|
||||
return;
|
||||
}
|
||||
onSuccess?.();
|
||||
|
||||
@@ -356,72 +356,6 @@ main {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Button visibility toggle */
|
||||
.css-button:checked + div {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.css-button + div {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Mobile Navigation */
|
||||
#mobile-nav-button span {
|
||||
transform-origin: 5px 0;
|
||||
transition:
|
||||
transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
|
||||
background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
|
||||
opacity 0.55s ease;
|
||||
}
|
||||
|
||||
#mobile-nav-button span:first-child {
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
#mobile-nav-button span:nth-last-child(2) {
|
||||
transform-origin: 0 100%;
|
||||
}
|
||||
|
||||
#mobile-nav-button:checked ~ span {
|
||||
opacity: 1;
|
||||
transform: rotate(45deg) translate(2px, -2px);
|
||||
}
|
||||
|
||||
#mobile-nav-button:checked ~ span:nth-last-child(3) {
|
||||
opacity: 0;
|
||||
transform: rotate(0deg) scale(0.2, 0.2);
|
||||
}
|
||||
|
||||
#mobile-nav-button:checked ~ span:nth-last-child(2) {
|
||||
transform: rotate(-45deg) translate(0, 6px);
|
||||
}
|
||||
|
||||
#mobile-nav-button:checked ~ #menu {
|
||||
transform: translate(0, 0) !important;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
#mobile-nav-button ~ #menu {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
#menu {
|
||||
top: 0;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
transform-origin: 0 0;
|
||||
transform: translate(-100%, 0);
|
||||
transition: transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1);
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
#menu {
|
||||
transform: translate(calc(-1 * (env(safe-area-inset-left) + 100%)), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Skeleton Wave Animation */
|
||||
@keyframes wave {
|
||||
0% {
|
||||
|
||||
@@ -2,125 +2,27 @@ 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';
|
||||
import {
|
||||
READER_COLOR_SCHEMES,
|
||||
type ReaderColorScheme,
|
||||
type ReaderFontFamily,
|
||||
} from '../../utils/localSettings';
|
||||
import type { EpubBook, EpubRendition, ReaderStats, ReaderTocItem } from './types';
|
||||
import {
|
||||
countWords,
|
||||
getBookWordPosition,
|
||||
getCFIFromXPath,
|
||||
getParsedTOC,
|
||||
getVisibleWordCount,
|
||||
getXPathFromCFI,
|
||||
} from './epubUtils';
|
||||
import { registerRenditionGestures } from './gestures';
|
||||
|
||||
export type { ReaderStats, ReaderTocItem } from './types';
|
||||
|
||||
const THEMES: ReaderColorScheme[] = ['light', 'tan', 'blue', 'gray', 'black'];
|
||||
const THEME_FILE = '/assets/reader/themes.css';
|
||||
const FONT_FILE = '/assets/reader/fonts.css';
|
||||
|
||||
interface TocNode {
|
||||
href: string;
|
||||
label?: string;
|
||||
subitems?: TocNode[];
|
||||
}
|
||||
|
||||
interface EpubContents {
|
||||
document: Document;
|
||||
sectionIndex?: number;
|
||||
range: (cfi: string) => Range;
|
||||
}
|
||||
|
||||
interface EpubVisibleSection {
|
||||
index: number;
|
||||
layout: { width: number; divisor: number };
|
||||
width: () => number;
|
||||
expand: () => void;
|
||||
}
|
||||
|
||||
interface EpubLocation {
|
||||
start: {
|
||||
cfi: string;
|
||||
href?: string;
|
||||
};
|
||||
end: {
|
||||
cfi: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface EpubNavigation {
|
||||
toc?: TocNode[];
|
||||
}
|
||||
|
||||
interface EpubSpineItem {
|
||||
cfiBase: string;
|
||||
index: number;
|
||||
document: Document;
|
||||
load: (_loader: unknown) => Promise<Document>;
|
||||
cfiFromElement: (element: Element) => string;
|
||||
wordCount?: number;
|
||||
}
|
||||
|
||||
interface EpubBook {
|
||||
ready: Promise<void>;
|
||||
navigation?: EpubNavigation;
|
||||
loaded: { navigation: Promise<EpubNavigation> };
|
||||
spine: {
|
||||
spineItems: EpubSpineItem[];
|
||||
get: (index: number) => EpubSpineItem;
|
||||
hooks: {
|
||||
content: { register: (_callback: (output: Document) => void) => void };
|
||||
};
|
||||
};
|
||||
load: (...args: unknown[]) => unknown;
|
||||
renderTo: (element: HTMLElement, options: Record<string, unknown>) => EpubRendition;
|
||||
getRange: (cfiRange: string) => Promise<Range>;
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
interface EpubRendition {
|
||||
next: () => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
display: (target?: string) => Promise<void>;
|
||||
currentLocation: () => Promise<EpubLocation>;
|
||||
getContents: () => EpubContents[];
|
||||
themes: {
|
||||
default: (styles: Record<string, unknown>) => void;
|
||||
register: (name: string, styles: Record<string, unknown> | string) => void;
|
||||
select: (name: string) => void;
|
||||
};
|
||||
hooks: {
|
||||
content: { register: (_callback: () => void) => void };
|
||||
render: { register: (_callback: (contents: EpubContents) => void) => void };
|
||||
};
|
||||
manager?: {
|
||||
visible?: () => EpubVisibleSection[];
|
||||
};
|
||||
views: () => { container: { scrollLeft: number } };
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
interface ParsedCfiPath {
|
||||
steps: unknown[];
|
||||
terminal: unknown;
|
||||
}
|
||||
|
||||
interface ParsedCfi {
|
||||
base: unknown;
|
||||
path: ParsedCfiPath;
|
||||
}
|
||||
|
||||
interface EpubCfiHelper {
|
||||
parse: (_value: string) => ParsedCfi;
|
||||
equalStep: (_a: unknown, _b: unknown) => boolean;
|
||||
segmentString: (_value: unknown) => string;
|
||||
}
|
||||
|
||||
interface EpubWithCfiConstructor {
|
||||
CFI: new () => EpubCfiHelper;
|
||||
}
|
||||
|
||||
export interface ReaderStats {
|
||||
chapterName: string;
|
||||
sectionPage: number;
|
||||
sectionTotalPages: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface ReaderTocItem {
|
||||
title: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface BookState {
|
||||
pages: number;
|
||||
percentage: number;
|
||||
@@ -174,6 +76,7 @@ export class EBookReader {
|
||||
private rendition: EpubRendition;
|
||||
private noSleep: NoSleep | null = null;
|
||||
private wakeTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
private gestureDispose: (() => void) | null = null;
|
||||
private destroyed = false;
|
||||
private onReady: () => void;
|
||||
private onLoading: (_loading: boolean) => void;
|
||||
@@ -187,7 +90,6 @@ export class EBookReader {
|
||||
private onSwipeUp: () => void;
|
||||
private onCenterTap: () => void;
|
||||
private keyupHandler: ((event: KeyboardEvent) => void) | null = null;
|
||||
private wheelTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(options: EBookReaderOptions) {
|
||||
this.container = options.container;
|
||||
@@ -217,7 +119,6 @@ export class EBookReader {
|
||||
pageStart: Date.now(),
|
||||
};
|
||||
|
||||
this.loadSettings();
|
||||
this.readerSettings.theme = {
|
||||
colorScheme: options.colorScheme,
|
||||
fontFamily: options.fontFamily,
|
||||
@@ -237,7 +138,16 @@ export class EBookReader {
|
||||
this.initCSP();
|
||||
this.initWakeLock();
|
||||
this.initThemes();
|
||||
this.initViewerListeners();
|
||||
|
||||
this.gestureDispose = registerRenditionGestures(this.rendition, {
|
||||
isPaginationDisabled: () => this.isPaginationDisabled(),
|
||||
nextPage: () => this.nextPage(),
|
||||
prevPage: () => this.prevPage(),
|
||||
onSwipeDown: () => this.onSwipeDown(),
|
||||
onSwipeUp: () => this.onSwipeUp(),
|
||||
onCenterTap: () => this.onCenterTap(),
|
||||
});
|
||||
|
||||
this.initDocumentListeners();
|
||||
|
||||
this.book.ready.then(this.setupReader.bind(this)).catch(error => {
|
||||
@@ -249,12 +159,6 @@ export class EBookReader {
|
||||
});
|
||||
}
|
||||
|
||||
private loadSettings() {
|
||||
this.readerSettings = {
|
||||
theme: this.readerSettings.theme ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
private initWakeLock() {
|
||||
this.noSleep = new NoSleep();
|
||||
document.addEventListener('wakelock', this.handleWakeLock);
|
||||
@@ -279,7 +183,7 @@ export class EBookReader {
|
||||
};
|
||||
|
||||
private initThemes() {
|
||||
THEMES.forEach(theme => this.rendition.themes.register(theme, THEME_FILE));
|
||||
READER_COLOR_SCHEMES.forEach(theme => this.rendition.themes.register(theme, THEME_FILE));
|
||||
|
||||
let themeLinkEl = document.querySelector('#themes') as HTMLLinkElement | null;
|
||||
if (!themeLinkEl) {
|
||||
@@ -335,127 +239,6 @@ export class EBookReader {
|
||||
});
|
||||
}
|
||||
|
||||
private initViewerListeners() {
|
||||
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;
|
||||
|
||||
const wakeLockListener = () => {
|
||||
renderDoc.dispatchEvent(new CustomEvent('wakelock'));
|
||||
};
|
||||
renderDoc.addEventListener('click', wakeLockListener);
|
||||
renderDoc.addEventListener('gesturechange', wakeLockListener);
|
||||
renderDoc.addEventListener('touchstart', wakeLockListener);
|
||||
|
||||
renderDoc.addEventListener('click', (event: MouseEvent) => {
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
const barPixels = windowHeight * 0.2;
|
||||
const pagePixels = windowWidth * 0.2;
|
||||
const top = barPixels;
|
||||
const bottom = window.innerHeight - top;
|
||||
const left = pagePixels;
|
||||
const right = windowWidth - left;
|
||||
const leftOffset = this.rendition.views().container.scrollLeft;
|
||||
const yCoord = event.clientY;
|
||||
const xCoord = event.clientX - leftOffset;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -469,9 +252,11 @@ export class EBookReader {
|
||||
}
|
||||
if ((event.keyCode || event.which) === 84) {
|
||||
const currentTheme = this.readerSettings.theme?.colorScheme || 'tan';
|
||||
const currentThemeIdx = THEMES.indexOf(currentTheme);
|
||||
const currentThemeIdx = READER_COLOR_SCHEMES.indexOf(currentTheme);
|
||||
const colorScheme =
|
||||
THEMES.length === currentThemeIdx + 1 ? THEMES[0] : THEMES[currentThemeIdx + 1];
|
||||
READER_COLOR_SCHEMES.length === currentThemeIdx + 1
|
||||
? READER_COLOR_SCHEMES[0]
|
||||
: READER_COLOR_SCHEMES[currentThemeIdx + 1];
|
||||
if (colorScheme) {
|
||||
this.setTheme({ colorScheme });
|
||||
}
|
||||
@@ -482,44 +267,20 @@ export class EBookReader {
|
||||
}
|
||||
|
||||
private async setupReader() {
|
||||
this.bookState.words = await this.countWords();
|
||||
const { cfi } = await this.getCFIFromXPath(this.bookState.progress);
|
||||
this.bookState.words = await countWords(this.book);
|
||||
const { cfi } = await getCFIFromXPath(this.book, this.rendition, this.bookState.progress);
|
||||
await this.setPosition(cfi);
|
||||
const { element } = await this.getCFIFromXPath(this.bookState.progress);
|
||||
const { element } = await getCFIFromXPath(this.book, this.rendition, this.bookState.progress);
|
||||
this.bookState.progressElement = element ?? null;
|
||||
this.highlightPositionMarker();
|
||||
const stats = await this.getBookStats();
|
||||
this.onStats(stats);
|
||||
this.bookState.pageStart = Date.now();
|
||||
this.onToc(this.getParsedTOC());
|
||||
this.onToc(getParsedTOC(this.book));
|
||||
this.onLoading(false);
|
||||
this.onReady();
|
||||
}
|
||||
|
||||
private getParsedTOC(): ReaderTocItem[] {
|
||||
if (!this.book.navigation?.toc) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.book.navigation.toc.reduce((agg: ReaderTocItem[], item) => {
|
||||
const sectionTitle = item.label?.trim() ?? '';
|
||||
agg.push({ title: sectionTitle || 'Untitled', href: item.href });
|
||||
if (!item.subitems || item.subitems.length === 0) {
|
||||
return agg;
|
||||
}
|
||||
|
||||
const allSubSections = item.subitems.map(subitem => {
|
||||
let itemTitle = subitem.label?.trim() ?? 'Untitled';
|
||||
if (sectionTitle !== '') {
|
||||
itemTitle = `${sectionTitle} - ${itemTitle}`;
|
||||
}
|
||||
return { title: itemTitle, href: subitem.href };
|
||||
});
|
||||
agg.push(...allSubSections);
|
||||
return agg;
|
||||
}, []);
|
||||
}
|
||||
|
||||
setTheme(newTheme?: { colorScheme?: ReaderColorScheme; fontFamily?: string; fontSize?: number }) {
|
||||
this.readerSettings.theme =
|
||||
typeof this.readerSettings.theme === 'object' && this.readerSettings.theme !== null
|
||||
@@ -615,6 +376,8 @@ export class EBookReader {
|
||||
return;
|
||||
}
|
||||
|
||||
// Triple Display - epubjs occasionally renders at the wrong position on a single display()
|
||||
// when restoring a CFI; calling it three times is a known workaround to land on the exact page.
|
||||
await this.rendition.display(cfi);
|
||||
await this.rendition.display(cfi);
|
||||
await this.rendition.display(cfi);
|
||||
@@ -627,10 +390,10 @@ export class EBookReader {
|
||||
fontSize?: number;
|
||||
}) {
|
||||
const currentProgress = this.bookState.progress;
|
||||
const { cfi } = await this.getCFIFromXPath(currentProgress);
|
||||
const { cfi } = await getCFIFromXPath(this.book, this.rendition, currentProgress);
|
||||
this.setTheme(newTheme);
|
||||
await this.setPosition(cfi);
|
||||
const { element } = await this.getCFIFromXPath(currentProgress);
|
||||
const { element } = await getCFIFromXPath(this.book, this.rendition, currentProgress);
|
||||
this.bookState.progressElement = element ?? null;
|
||||
this.highlightPositionMarker();
|
||||
}
|
||||
@@ -641,8 +404,8 @@ export class EBookReader {
|
||||
|
||||
const pageStart = this.bookState.pageStart;
|
||||
let elapsedTime = Date.now() - pageStart;
|
||||
const pageWords = await this.getVisibleWordCount();
|
||||
const currentWord = await this.getBookWordPosition();
|
||||
const pageWords = await getVisibleWordCount(this.book, this.rendition);
|
||||
const currentWord = await getBookWordPosition(this.book, this.rendition);
|
||||
const percentRead = pageWords / this.bookState.words;
|
||||
const pageWPM = pageWords / (elapsedTime / 60000);
|
||||
|
||||
@@ -686,8 +449,8 @@ export class EBookReader {
|
||||
|
||||
async createProgress() {
|
||||
const currentCFI = await this.rendition.currentLocation();
|
||||
const { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
|
||||
const currentWord = await this.getBookWordPosition();
|
||||
const { element, xpath } = await getXPathFromCFI(this.book, this.rendition, currentCFI.start.cfi);
|
||||
const currentWord = await getBookWordPosition(this.book, this.rendition);
|
||||
this.bookState.progress = xpath ?? '';
|
||||
this.bookState.progressElement = element ?? null;
|
||||
|
||||
@@ -744,7 +507,7 @@ export class EBookReader {
|
||||
}
|
||||
|
||||
const currentLocation = await this.rendition.currentLocation();
|
||||
const currentWord = await this.getBookWordPosition();
|
||||
const currentWord = await getBookWordPosition(this.book, this.rendition);
|
||||
const currentTOC = this.book.navigation?.toc?.find(
|
||||
item => item.href === currentLocation.start.href
|
||||
);
|
||||
@@ -760,228 +523,6 @@ export class EBookReader {
|
||||
};
|
||||
}
|
||||
|
||||
async getXPathFromCFI(cfi: string) {
|
||||
const cfiBaseMatch = cfi.match(/\(([^!]+)/);
|
||||
if (!cfiBaseMatch?.[1]) {
|
||||
return {} as { xpath?: string; element?: Element | null };
|
||||
}
|
||||
const startCFI = cfiBaseMatch[1];
|
||||
|
||||
const docFragmentIndex =
|
||||
(this.book.spine.spineItems.find(item => item.cfiBase === startCFI)?.index ?? -1) + 1;
|
||||
if (docFragmentIndex <= 0) {
|
||||
return {} as { xpath?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
const basePos = `/body/DocFragment[${docFragmentIndex}]/body`;
|
||||
const contents = this.rendition.getContents()[0];
|
||||
const currentNodeStart = contents?.range(cfi).startContainer;
|
||||
if (!currentNodeStart) {
|
||||
return {} as { xpath?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
let currentNode: Node | null = currentNodeStart;
|
||||
const element =
|
||||
currentNode.nodeType === Node.ELEMENT_NODE
|
||||
? (currentNode as Element)
|
||||
: currentNode.parentElement;
|
||||
|
||||
let allPos = '';
|
||||
while (currentNode && currentNode.nodeName !== 'BODY') {
|
||||
let parentElement: Element | null = currentNode.parentElement;
|
||||
if (!parentElement) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentNode.nodeType !== Node.ELEMENT_NODE) {
|
||||
currentNode = parentElement;
|
||||
continue;
|
||||
}
|
||||
|
||||
while (parentElement.nodeName === 'A' && parentElement.parentElement) {
|
||||
parentElement = parentElement.parentElement;
|
||||
}
|
||||
|
||||
const currentElement = currentNode as Element;
|
||||
const allDescendents = parentElement.querySelectorAll(currentElement.nodeName);
|
||||
const relativeIndex = Array.from(allDescendents).indexOf(currentElement) + 1;
|
||||
const nodePos = `${currentElement.nodeName.toLowerCase()}[${relativeIndex}]`;
|
||||
currentNode = parentElement;
|
||||
allPos = `/${nodePos}${allPos}`;
|
||||
}
|
||||
|
||||
return { xpath: `${basePos}${allPos}`, element };
|
||||
}
|
||||
|
||||
async getCFIFromXPath(xpath?: string) {
|
||||
if (!xpath) {
|
||||
return {} as { cfi?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
const fragMatch = xpath.match(/^\/body\/DocFragment\[(\d+)\]/);
|
||||
if (!fragMatch?.[1]) {
|
||||
return {} as { cfi?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
const spinePosition = Number.parseInt(fragMatch[1], 10) - 1;
|
||||
const sectionItem = this.book.spine.get(spinePosition);
|
||||
await sectionItem.load(this.book.load.bind(this.book));
|
||||
|
||||
const renderedContent = this.rendition
|
||||
.getContents()
|
||||
.find(item => item.sectionIndex == spinePosition);
|
||||
const docItem = renderedContent?.document || sectionItem.document;
|
||||
|
||||
const namespaceURI = docItem.documentElement.namespaceURI;
|
||||
let remainingXPath = xpath
|
||||
.replace(fragMatch[0], '/html')
|
||||
.replace(/\.(\d+)$/, '')
|
||||
.replace(/\/text\(\)(\[\d+\])?$/, '');
|
||||
|
||||
const derivedSelectorElement = remainingXPath
|
||||
.replace(/^\/html\/body/, 'body')
|
||||
.split('/')
|
||||
.reduce(
|
||||
(element: ParentNode | null, item: string) => {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
if (namespaceURI) {
|
||||
remainingXPath = remainingXPath.split('/').join('/ns:');
|
||||
}
|
||||
|
||||
const docSearch = docItem.evaluate(remainingXPath, docItem, prefix => {
|
||||
if (prefix === 'ns') {
|
||||
return namespaceURI;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const xpathElement = docSearch.iterateNext();
|
||||
const element = xpathElement || derivedSelectorElement;
|
||||
const isElementNode = Boolean(element && (element as Node).nodeType === Node.ELEMENT_NODE);
|
||||
if (!isElementNode) {
|
||||
return {} as { cfi?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
const resolvedElement = element as Element;
|
||||
|
||||
let cfi = sectionItem.cfiFromElement(resolvedElement);
|
||||
if (cfi.endsWith('!/)')) {
|
||||
cfi = `${cfi.slice(0, -1)}0)`;
|
||||
}
|
||||
|
||||
return { cfi, element: resolvedElement };
|
||||
}
|
||||
|
||||
async getVisibleWordCount() {
|
||||
const visibleText = await this.getVisibleText();
|
||||
return visibleText.trim().split(/\s+/).length;
|
||||
}
|
||||
|
||||
async getBookWordPosition() {
|
||||
const contents = this.rendition.getContents()[0];
|
||||
if (!contents) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const spineItem = this.book.spine.get(contents.sectionIndex ?? 0);
|
||||
const firstElement = spineItem.document.body.children[0];
|
||||
if (!firstElement) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const firstCFI = spineItem.cfiFromElement(firstElement);
|
||||
const currentLocation = await this.rendition.currentLocation();
|
||||
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+/).length;
|
||||
const preChapterWordPosition = this.book.spine.spineItems
|
||||
.slice(0, contents.sectionIndex ?? 0)
|
||||
.reduce((totalCount, item) => totalCount + (item.wordCount ?? 0), 0);
|
||||
|
||||
return chapterWordPosition + preChapterWordPosition;
|
||||
}
|
||||
|
||||
async getVisibleText() {
|
||||
this.rendition.manager?.visible?.()?.forEach(item => item.expand());
|
||||
const currentLocation = await this.rendition.currentLocation();
|
||||
const cfiRange = this.getCFIRange(currentLocation.start.cfi, currentLocation.end.cfi);
|
||||
const textRange = await this.book.getRange(cfiRange);
|
||||
return textRange.toString();
|
||||
}
|
||||
|
||||
getCFIRange(a: string, b: string) {
|
||||
const CFI = new (ePub as unknown as EpubWithCfiConstructor).CFI();
|
||||
const start = CFI.parse(a);
|
||||
const end = CFI.parse(b);
|
||||
const cfi: {
|
||||
range: boolean;
|
||||
base: unknown;
|
||||
path: ParsedCfiPath;
|
||||
start: ParsedCfiPath;
|
||||
end: ParsedCfiPath;
|
||||
} = {
|
||||
range: true,
|
||||
base: start.base,
|
||||
path: { steps: [], terminal: null },
|
||||
start: start.path,
|
||||
end: end.path,
|
||||
};
|
||||
|
||||
const len = cfi.start.steps.length;
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
if (CFI.equalStep(cfi.start.steps[i], cfi.end.steps[i])) {
|
||||
if (i === len - 1) {
|
||||
if (cfi.start.terminal === cfi.end.terminal) {
|
||||
cfi.path.steps.push(cfi.start.steps[i]);
|
||||
cfi.range = false;
|
||||
}
|
||||
} else {
|
||||
cfi.path.steps.push(cfi.start.steps[i]);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cfi.start.steps = cfi.start.steps.slice(cfi.path.steps.length);
|
||||
cfi.end.steps = cfi.end.steps.slice(cfi.path.steps.length);
|
||||
|
||||
return `epubcfi(${CFI.segmentString(cfi.base)}!${CFI.segmentString(cfi.path)},${CFI.segmentString(cfi.start)},${CFI.segmentString(cfi.end)})`;
|
||||
}
|
||||
|
||||
async countWords() {
|
||||
const spineWC = await Promise.all(
|
||||
this.book.spine.spineItems.map(async item => {
|
||||
const newDoc = await item.load(this.book.load.bind(this.book));
|
||||
const spineWords = ((newDoc as unknown as HTMLElement).innerText || '')
|
||||
.trim()
|
||||
.split(/\s+/).length;
|
||||
item.wordCount = spineWords;
|
||||
return spineWords;
|
||||
})
|
||||
);
|
||||
|
||||
return spineWC.reduce((totalCount, itemCount) => totalCount + itemCount, 0);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
if (this.keyupHandler) {
|
||||
@@ -991,9 +532,7 @@ export class EBookReader {
|
||||
if (this.wakeTimeoutId) {
|
||||
clearTimeout(this.wakeTimeoutId);
|
||||
}
|
||||
if (this.wheelTimeoutId) {
|
||||
clearTimeout(this.wheelTimeoutId);
|
||||
}
|
||||
this.gestureDispose?.();
|
||||
void this.noSleep?.disable();
|
||||
this.rendition.destroy?.();
|
||||
this.book.destroy?.();
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import ePub from 'epubjs';
|
||||
import type {
|
||||
EpubBook,
|
||||
EpubRendition,
|
||||
EpubWithCfiConstructor,
|
||||
ParsedCfiPath,
|
||||
ReaderTocItem,
|
||||
} from './types';
|
||||
|
||||
export function getParsedTOC(book: EpubBook): ReaderTocItem[] {
|
||||
if (!book.navigation?.toc) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return book.navigation.toc.reduce((agg: ReaderTocItem[], item) => {
|
||||
const sectionTitle = item.label?.trim() ?? '';
|
||||
agg.push({ title: sectionTitle || 'Untitled', href: item.href });
|
||||
if (!item.subitems || item.subitems.length === 0) {
|
||||
return agg;
|
||||
}
|
||||
|
||||
const allSubSections = item.subitems.map(subitem => {
|
||||
let itemTitle = subitem.label?.trim() ?? 'Untitled';
|
||||
if (sectionTitle !== '') {
|
||||
itemTitle = `${sectionTitle} - ${itemTitle}`;
|
||||
}
|
||||
return { title: itemTitle, href: subitem.href };
|
||||
});
|
||||
agg.push(...allSubSections);
|
||||
return agg;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export async function countWords(book: EpubBook) {
|
||||
const spineWC = await Promise.all(
|
||||
book.spine.spineItems.map(async item => {
|
||||
const newDoc = await item.load(book.load.bind(book));
|
||||
const spineWords = ((newDoc as unknown as HTMLElement).innerText || '')
|
||||
.trim()
|
||||
.split(/\s+/).length;
|
||||
item.wordCount = spineWords;
|
||||
return spineWords;
|
||||
})
|
||||
);
|
||||
|
||||
return spineWC.reduce((totalCount, itemCount) => totalCount + itemCount, 0);
|
||||
}
|
||||
|
||||
export function getCFIRange(a: string, b: string) {
|
||||
const CFI = new (ePub as unknown as EpubWithCfiConstructor).CFI();
|
||||
const start = CFI.parse(a);
|
||||
const end = CFI.parse(b);
|
||||
const cfi: {
|
||||
range: boolean;
|
||||
base: unknown;
|
||||
path: ParsedCfiPath;
|
||||
start: ParsedCfiPath;
|
||||
end: ParsedCfiPath;
|
||||
} = {
|
||||
range: true,
|
||||
base: start.base,
|
||||
path: { steps: [], terminal: null },
|
||||
start: start.path,
|
||||
end: end.path,
|
||||
};
|
||||
|
||||
const len = cfi.start.steps.length;
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
if (CFI.equalStep(cfi.start.steps[i], cfi.end.steps[i])) {
|
||||
if (i === len - 1) {
|
||||
if (cfi.start.terminal === cfi.end.terminal) {
|
||||
cfi.path.steps.push(cfi.start.steps[i]);
|
||||
cfi.range = false;
|
||||
}
|
||||
} else {
|
||||
cfi.path.steps.push(cfi.start.steps[i]);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cfi.start.steps = cfi.start.steps.slice(cfi.path.steps.length);
|
||||
cfi.end.steps = cfi.end.steps.slice(cfi.path.steps.length);
|
||||
|
||||
return `epubcfi(${CFI.segmentString(cfi.base)}!${CFI.segmentString(cfi.path)},${CFI.segmentString(cfi.start)},${CFI.segmentString(cfi.end)})`;
|
||||
}
|
||||
|
||||
export async function getVisibleText(book: EpubBook, rendition: EpubRendition) {
|
||||
rendition.manager?.visible?.()?.forEach(item => item.expand());
|
||||
const currentLocation = await rendition.currentLocation();
|
||||
const cfiRange = getCFIRange(currentLocation.start.cfi, currentLocation.end.cfi);
|
||||
const textRange = await book.getRange(cfiRange);
|
||||
return textRange.toString();
|
||||
}
|
||||
|
||||
export async function getVisibleWordCount(book: EpubBook, rendition: EpubRendition) {
|
||||
const visibleText = await getVisibleText(book, rendition);
|
||||
return visibleText.trim().split(/\s+/).length;
|
||||
}
|
||||
|
||||
export async function getBookWordPosition(book: EpubBook, rendition: EpubRendition) {
|
||||
const contents = rendition.getContents()[0];
|
||||
if (!contents) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const spineItem = book.spine.get(contents.sectionIndex ?? 0);
|
||||
const firstElement = spineItem.document.body.children[0];
|
||||
if (!firstElement) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const firstCFI = spineItem.cfiFromElement(firstElement);
|
||||
const currentLocation = await rendition.currentLocation();
|
||||
const cfiRange = getCFIRange(firstCFI, currentLocation.start.cfi);
|
||||
const textRange = await book.getRange(cfiRange);
|
||||
const chapterText = textRange.toString();
|
||||
const chapterWordPosition = chapterText.trim().split(/\s+/).length;
|
||||
const preChapterWordPosition = book.spine.spineItems
|
||||
.slice(0, contents.sectionIndex ?? 0)
|
||||
.reduce((totalCount, item) => totalCount + (item.wordCount ?? 0), 0);
|
||||
|
||||
return chapterWordPosition + preChapterWordPosition;
|
||||
}
|
||||
|
||||
export async function getXPathFromCFI(book: EpubBook, rendition: EpubRendition, cfi: string) {
|
||||
const cfiBaseMatch = cfi.match(/\(([^!]+)/);
|
||||
if (!cfiBaseMatch?.[1]) {
|
||||
return {} as { xpath?: string; element?: Element | null };
|
||||
}
|
||||
const startCFI = cfiBaseMatch[1];
|
||||
|
||||
const docFragmentIndex =
|
||||
(book.spine.spineItems.find(item => item.cfiBase === startCFI)?.index ?? -1) + 1;
|
||||
if (docFragmentIndex <= 0) {
|
||||
return {} as { xpath?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
const basePos = `/body/DocFragment[${docFragmentIndex}]/body`;
|
||||
const contents = rendition.getContents()[0];
|
||||
const currentNodeStart = contents?.range(cfi).startContainer;
|
||||
if (!currentNodeStart) {
|
||||
return {} as { xpath?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
let currentNode: Node | null = currentNodeStart;
|
||||
const element =
|
||||
currentNode.nodeType === Node.ELEMENT_NODE
|
||||
? (currentNode as Element)
|
||||
: currentNode.parentElement;
|
||||
|
||||
let allPos = '';
|
||||
while (currentNode && currentNode.nodeName !== 'BODY') {
|
||||
let parentElement: Element | null = currentNode.parentElement;
|
||||
if (!parentElement) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentNode.nodeType !== Node.ELEMENT_NODE) {
|
||||
currentNode = parentElement;
|
||||
continue;
|
||||
}
|
||||
|
||||
while (parentElement.nodeName === 'A' && parentElement.parentElement) {
|
||||
parentElement = parentElement.parentElement;
|
||||
}
|
||||
|
||||
const currentElement = currentNode as Element;
|
||||
const allDescendents = parentElement.querySelectorAll(currentElement.nodeName);
|
||||
const relativeIndex = Array.from(allDescendents).indexOf(currentElement) + 1;
|
||||
const nodePos = `${currentElement.nodeName.toLowerCase()}[${relativeIndex}]`;
|
||||
currentNode = parentElement;
|
||||
allPos = `/${nodePos}${allPos}`;
|
||||
}
|
||||
|
||||
return { xpath: `${basePos}${allPos}`, element };
|
||||
}
|
||||
|
||||
export async function getCFIFromXPath(
|
||||
book: EpubBook,
|
||||
rendition: EpubRendition,
|
||||
xpath?: string
|
||||
) {
|
||||
if (!xpath) {
|
||||
return {} as { cfi?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
const fragMatch = xpath.match(/^\/body\/DocFragment\[(\d+)\]/);
|
||||
if (!fragMatch?.[1]) {
|
||||
return {} as { cfi?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
const spinePosition = Number.parseInt(fragMatch[1], 10) - 1;
|
||||
const sectionItem = book.spine.get(spinePosition);
|
||||
await sectionItem.load(book.load.bind(book));
|
||||
|
||||
const renderedContent = rendition
|
||||
.getContents()
|
||||
.find(item => item.sectionIndex == spinePosition);
|
||||
const docItem = renderedContent?.document || sectionItem.document;
|
||||
|
||||
const namespaceURI = docItem.documentElement.namespaceURI;
|
||||
let remainingXPath = xpath
|
||||
.replace(fragMatch[0], '/html')
|
||||
.replace(/\.(\d+)$/, '')
|
||||
.replace(/\/text\(\)(\[\d+\])?$/, '');
|
||||
|
||||
const derivedSelectorElement = remainingXPath
|
||||
.replace(/^\/html\/body/, 'body')
|
||||
.split('/')
|
||||
.reduce(
|
||||
(element: ParentNode | null, item: string) => {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
if (namespaceURI) {
|
||||
remainingXPath = remainingXPath.split('/').join('/ns:');
|
||||
}
|
||||
|
||||
const docSearch = docItem.evaluate(remainingXPath, docItem, prefix => {
|
||||
if (prefix === 'ns') {
|
||||
return namespaceURI;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const xpathElement = docSearch.iterateNext();
|
||||
const element = xpathElement || derivedSelectorElement;
|
||||
const isElementNode = Boolean(element && (element as Node).nodeType === Node.ELEMENT_NODE);
|
||||
if (!isElementNode) {
|
||||
return {} as { cfi?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
const resolvedElement = element as Element;
|
||||
|
||||
let cfi = sectionItem.cfiFromElement(resolvedElement);
|
||||
if (cfi.endsWith('!/)')) {
|
||||
cfi = `${cfi.slice(0, -1)}0)`;
|
||||
}
|
||||
|
||||
return { cfi, element: resolvedElement };
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { EpubContents, EpubRendition } from './types';
|
||||
|
||||
export interface GestureHandlers {
|
||||
isPaginationDisabled: () => boolean;
|
||||
nextPage: () => Promise<void>;
|
||||
prevPage: () => Promise<void>;
|
||||
onSwipeDown: () => void;
|
||||
onSwipeUp: () => void;
|
||||
onCenterTap: () => void;
|
||||
}
|
||||
|
||||
const WHEEL_COOLDOWN_MS = 400;
|
||||
const SWIPE_THRESHOLD = 25;
|
||||
|
||||
/**
|
||||
* Registers touch / click-zone / wheel listeners on every rendered section. Returns a dispose
|
||||
* that clears the pending wheel-cooldown timeout (called from EBookReader.destroy).
|
||||
*/
|
||||
export function registerRenditionGestures(
|
||||
rendition: EpubRendition,
|
||||
handlers: GestureHandlers
|
||||
): () => void {
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let touchEndX = 0;
|
||||
let touchEndY = 0;
|
||||
let wheelTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const resetWheelCooldown = () => {
|
||||
if (wheelTimeoutId) {
|
||||
clearTimeout(wheelTimeoutId);
|
||||
}
|
||||
wheelTimeoutId = setTimeout(() => {
|
||||
wheelTimeoutId = null;
|
||||
}, WHEEL_COOLDOWN_MS);
|
||||
};
|
||||
|
||||
const handleSwipeDown = () => {
|
||||
resetWheelCooldown();
|
||||
handlers.onSwipeDown();
|
||||
};
|
||||
|
||||
const handleSwipeUp = () => {
|
||||
resetWheelCooldown();
|
||||
handlers.onSwipeUp();
|
||||
};
|
||||
|
||||
const handleGesture = () => {
|
||||
const drasticity = 50;
|
||||
|
||||
if (touchEndY - drasticity > touchStartY) {
|
||||
return handleSwipeDown();
|
||||
}
|
||||
|
||||
if (touchEndY + drasticity < touchStartY) {
|
||||
return handleSwipeUp();
|
||||
}
|
||||
|
||||
if (!handlers.isPaginationDisabled() && touchEndX + drasticity < touchStartX) {
|
||||
void handlers.nextPage();
|
||||
}
|
||||
|
||||
if (!handlers.isPaginationDisabled() && touchEndX - drasticity > touchStartX) {
|
||||
void handlers.prevPage();
|
||||
}
|
||||
};
|
||||
|
||||
rendition.hooks.render.register((contents: EpubContents) => {
|
||||
const renderDoc = contents.document;
|
||||
|
||||
const wakeLockListener = () => {
|
||||
renderDoc.dispatchEvent(new CustomEvent('wakelock'));
|
||||
};
|
||||
renderDoc.addEventListener('click', wakeLockListener);
|
||||
renderDoc.addEventListener('gesturechange', wakeLockListener);
|
||||
renderDoc.addEventListener('touchstart', wakeLockListener);
|
||||
|
||||
renderDoc.addEventListener('click', (event: MouseEvent) => {
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
const barPixels = windowHeight * 0.2;
|
||||
const pagePixels = windowWidth * 0.2;
|
||||
const top = barPixels;
|
||||
const bottom = window.innerHeight - top;
|
||||
const left = pagePixels;
|
||||
const right = windowWidth - left;
|
||||
const leftOffset = rendition.views().container.scrollLeft;
|
||||
const yCoord = event.clientY;
|
||||
const xCoord = event.clientX - leftOffset;
|
||||
|
||||
if (yCoord < top) {
|
||||
handleSwipeDown();
|
||||
} else if (yCoord > bottom) {
|
||||
handleSwipeUp();
|
||||
} else if (!handlers.isPaginationDisabled() && xCoord < left) {
|
||||
void handlers.prevPage();
|
||||
} else if (!handlers.isPaginationDisabled() && xCoord > right) {
|
||||
void handlers.nextPage();
|
||||
} else {
|
||||
handlers.onCenterTap();
|
||||
}
|
||||
});
|
||||
|
||||
renderDoc.addEventListener('wheel', (event: WheelEvent) => {
|
||||
if (wheelTimeoutId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.deltaY > SWIPE_THRESHOLD) {
|
||||
handleSwipeUp();
|
||||
return;
|
||||
}
|
||||
if (event.deltaY < -SWIPE_THRESHOLD) {
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (wheelTimeoutId) {
|
||||
clearTimeout(wheelTimeoutId);
|
||||
wheelTimeoutId = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
export interface TocNode {
|
||||
href: string;
|
||||
label?: string;
|
||||
subitems?: TocNode[];
|
||||
}
|
||||
|
||||
export interface EpubContents {
|
||||
document: Document;
|
||||
sectionIndex?: number;
|
||||
range: (cfi: string) => Range;
|
||||
}
|
||||
|
||||
export interface EpubVisibleSection {
|
||||
index: number;
|
||||
layout: { width: number; divisor: number };
|
||||
width: () => number;
|
||||
expand: () => void;
|
||||
}
|
||||
|
||||
export interface EpubLocation {
|
||||
start: {
|
||||
cfi: string;
|
||||
href?: string;
|
||||
};
|
||||
end: {
|
||||
cfi: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EpubNavigation {
|
||||
toc?: TocNode[];
|
||||
}
|
||||
|
||||
export interface EpubSpineItem {
|
||||
cfiBase: string;
|
||||
index: number;
|
||||
document: Document;
|
||||
load: (_loader: unknown) => Promise<Document>;
|
||||
cfiFromElement: (element: Element) => string;
|
||||
wordCount?: number;
|
||||
}
|
||||
|
||||
export interface EpubBook {
|
||||
ready: Promise<void>;
|
||||
navigation?: EpubNavigation;
|
||||
loaded: { navigation: Promise<EpubNavigation> };
|
||||
spine: {
|
||||
spineItems: EpubSpineItem[];
|
||||
get: (index: number) => EpubSpineItem;
|
||||
hooks: {
|
||||
content: { register: (_callback: (output: Document) => void) => void };
|
||||
};
|
||||
};
|
||||
load: (...args: unknown[]) => unknown;
|
||||
renderTo: (element: HTMLElement, options: Record<string, unknown>) => EpubRendition;
|
||||
getRange: (cfiRange: string) => Promise<Range>;
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
export interface EpubRendition {
|
||||
next: () => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
display: (target?: string) => Promise<void>;
|
||||
currentLocation: () => Promise<EpubLocation>;
|
||||
getContents: () => EpubContents[];
|
||||
themes: {
|
||||
default: (styles: Record<string, unknown>) => void;
|
||||
register: (name: string, styles: Record<string, unknown> | string) => void;
|
||||
select: (name: string) => void;
|
||||
};
|
||||
hooks: {
|
||||
content: { register: (_callback: () => void) => void };
|
||||
render: { register: (_callback: (contents: EpubContents) => void) => void };
|
||||
};
|
||||
manager?: {
|
||||
visible?: () => EpubVisibleSection[];
|
||||
};
|
||||
views: () => { container: { scrollLeft: number } };
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
export interface ParsedCfiPath {
|
||||
steps: unknown[];
|
||||
terminal: unknown;
|
||||
}
|
||||
|
||||
export interface ParsedCfi {
|
||||
base: unknown;
|
||||
path: ParsedCfiPath;
|
||||
}
|
||||
|
||||
export interface EpubCfiHelper {
|
||||
parse: (_value: string) => ParsedCfi;
|
||||
equalStep: (_a: unknown, _b: unknown) => boolean;
|
||||
segmentString: (_value: unknown) => string;
|
||||
}
|
||||
|
||||
export interface EpubWithCfiConstructor {
|
||||
CFI: new () => EpubCfiHelper;
|
||||
}
|
||||
|
||||
export interface ReaderStats {
|
||||
chapterName: string;
|
||||
sectionPage: number;
|
||||
sectionTotalPages: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface ReaderTocItem {
|
||||
title: string;
|
||||
href: string;
|
||||
}
|
||||
@@ -3,17 +3,16 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { LoadingState } from '../components';
|
||||
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
|
||||
import type { DirectoryItem } from '../generated/model';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { Button } from '../components/Button';
|
||||
import { FolderOpenIcon } from '../icons';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||
|
||||
export default function AdminImportPage() {
|
||||
const [currentPath, setCurrentPath] = useState<string>('');
|
||||
const [selectedDirectory, setSelectedDirectory] = useState<string>('');
|
||||
const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT');
|
||||
const { showInfo, showError } = useToasts();
|
||||
const navigate = useNavigate();
|
||||
const toastMutationOptions = useMutationWithToast();
|
||||
|
||||
const { data: directoryData, isLoading } = useGetImportDirectory(
|
||||
currentPath ? { directory: currentPath } : {}
|
||||
@@ -48,15 +47,11 @@ export default function AdminImportPage() {
|
||||
type: importType,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: _response => {
|
||||
showInfo('Import completed successfully');
|
||||
navigate('/admin/import-results');
|
||||
},
|
||||
onError: error => {
|
||||
showError('Import failed: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
toastMutationOptions({
|
||||
success: 'Import completed successfully',
|
||||
error: 'Import failed',
|
||||
onSuccess: () => navigate('/admin/import-results'),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '../generated/anthoLumeAPIV1';
|
||||
import type { EditDocumentBody } from '../generated/model';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { getErrorMessage, getResponseError } from '../utils/errors';
|
||||
import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons';
|
||||
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
@@ -137,8 +137,9 @@ export default function DocumentPage() {
|
||||
const save = async (data: EditDocumentBody): Promise<boolean> => {
|
||||
try {
|
||||
const response = await editMutation.mutateAsync({ id: document.id, data });
|
||||
if (response.status !== 200) {
|
||||
showError('Failed to save: ' + getErrorMessage(response.data));
|
||||
const message = getResponseError(response);
|
||||
if (message) {
|
||||
showError('Failed to save: ' + message);
|
||||
return false;
|
||||
}
|
||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||
import type { Document } from '../generated/model';
|
||||
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
||||
@@ -8,11 +8,7 @@ import { useToasts } from '../components/ToastContext';
|
||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||
import {
|
||||
getDocumentsViewMode,
|
||||
setDocumentsViewMode,
|
||||
type DocumentsViewMode,
|
||||
} from '../utils/localSettings';
|
||||
import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
|
||||
|
||||
const DOCUMENTS_PAGE_SIZE = 9;
|
||||
|
||||
@@ -22,25 +18,16 @@ interface DocumentItemProps {
|
||||
}
|
||||
|
||||
function DocumentItem({ doc, layout }: DocumentItemProps) {
|
||||
const navigate = useNavigate();
|
||||
const percentage = doc.percentage || 0;
|
||||
const totalTimeSeconds = doc.total_time_seconds || 0;
|
||||
|
||||
const open = () => navigate(`/documents/${doc.id}`);
|
||||
const onKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
open();
|
||||
}
|
||||
};
|
||||
|
||||
const icons = (
|
||||
<div className="flex shrink-0 items-center justify-end gap-4 text-content-muted">
|
||||
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
|
||||
<div className="flex shrink-0 items-center justify-end gap-4 text-content-muted pointer-events-auto">
|
||||
<Link to={`/activity?document=${doc.id}`}>
|
||||
<ActivityIcon size={20} />
|
||||
</Link>
|
||||
{doc.filepath ? (
|
||||
<a href={`/api/v1/documents/${doc.id}/file`} onClick={e => e.stopPropagation()}>
|
||||
<a href={`/api/v1/documents/${doc.id}/file`}>
|
||||
<DownloadIcon size={20} />
|
||||
</a>
|
||||
) : (
|
||||
@@ -58,48 +45,46 @@ function DocumentItem({ doc, layout }: DocumentItemProps) {
|
||||
|
||||
if (layout === 'grid') {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
className="flex size-full cursor-pointer gap-4 rounded bg-surface p-4 shadow-lg transition-colors hover:bg-surface-muted focus:outline-hidden"
|
||||
onClick={open}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<div className="relative my-auto h-48 min-w-fit">
|
||||
<img
|
||||
className="h-full rounded object-cover"
|
||||
src={`/api/v1/documents/${doc.id}/cover`}
|
||||
alt={doc.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col justify-around text-sm text-content">
|
||||
{fields.map(f => (
|
||||
<div key={f.label} className="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p className="text-content-subtle">{f.label}</p>
|
||||
<p className="font-medium">{f.value}</p>
|
||||
</div>
|
||||
<div className="relative flex size-full gap-4 rounded bg-surface p-4 shadow-lg transition-colors hover:bg-surface-muted">
|
||||
{/* Stretched Link - Covers the whole card; display content is pointer-events-none so clicks fall through to navigate. */}
|
||||
<Link
|
||||
to={`/documents/${doc.id}`}
|
||||
aria-label={`Open ${doc.title || 'document'}`}
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
<div className="pointer-events-none my-auto h-48 min-w-fit">
|
||||
<img
|
||||
className="h-full rounded object-cover"
|
||||
src={`/api/v1/documents/${doc.id}/cover`}
|
||||
alt={doc.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none flex w-full flex-col justify-around text-sm text-content">
|
||||
{fields.map(f => (
|
||||
<div key={f.label} className="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p className="text-content-subtle">{f.label}</p>
|
||||
<p className="font-medium">{f.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-content-muted">
|
||||
{icons}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-content-muted">
|
||||
{icons}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
className="block cursor-pointer rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted focus:outline-hidden"
|
||||
onClick={open}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative block rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted">
|
||||
{/* Stretched Link - Covers the whole card; display content is pointer-events-none so clicks fall through to navigate. */}
|
||||
<Link
|
||||
to={`/documents/${doc.id}`}
|
||||
aria-label={`Open ${doc.title || 'document'}`}
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
<div className="pointer-events-none flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="grid flex-1 grid-cols-1 gap-3 text-sm md:grid-cols-4">
|
||||
{fields.map(f => (
|
||||
<div key={f.label}>
|
||||
@@ -119,15 +104,11 @@ export default function DocumentsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = DOCUMENTS_PAGE_SIZE;
|
||||
const [uploadMode, setUploadMode] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode);
|
||||
const [viewMode, setViewMode] = useLocalSetting('documentsViewMode', 'grid');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { showWarning } = useToasts();
|
||||
const toastMutationOptions = useMutationWithToast();
|
||||
|
||||
useEffect(() => {
|
||||
setDocumentsViewMode(viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
@@ -4,28 +4,20 @@ import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
|
||||
import { LoadingState } from '../components/LoadingState';
|
||||
import { CloseIcon } from '../icons';
|
||||
import {
|
||||
getReaderColorScheme,
|
||||
READER_COLOR_SCHEMES,
|
||||
READER_FONT_FAMILIES,
|
||||
getReaderDevice,
|
||||
getReaderFontFamily,
|
||||
getReaderFontSize,
|
||||
setReaderColorScheme,
|
||||
setReaderFontFamily,
|
||||
setReaderFontSize,
|
||||
type ReaderColorScheme,
|
||||
type ReaderFontFamily,
|
||||
useLocalSetting,
|
||||
} from '../utils/localSettings';
|
||||
import { useEpubReader } from '../hooks/useEpubReader';
|
||||
|
||||
const colorSchemes: ReaderColorScheme[] = ['light', 'tan', 'blue', 'gray', 'black'];
|
||||
const fontFamilies: ReaderFontFamily[] = ['Serif', 'Open Sans', 'Arbutus Slab', 'Lato'];
|
||||
|
||||
export default function ReaderPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [isTopBarOpen, setIsTopBarOpen] = useState(false);
|
||||
const [isBottomBarOpen, setIsBottomBarOpen] = useState(false);
|
||||
const [colorScheme, setColorSchemeState] = useState<ReaderColorScheme>(getReaderColorScheme());
|
||||
const [fontFamily, setFontFamilyState] = useState<ReaderFontFamily>(getReaderFontFamily());
|
||||
const [fontSize, setFontSizeState] = useState<number>(getReaderFontSize());
|
||||
const [colorScheme, setColorScheme] = useLocalSetting('readerColorScheme', 'tan');
|
||||
const [fontFamily, setFontFamily] = useLocalSetting('readerFontFamily', 'Serif');
|
||||
const [fontSize, setFontSize] = useLocalSetting('readerFontSize', 1);
|
||||
|
||||
const { id: defaultDeviceId, name: defaultDeviceName } = useMemo(() => getReaderDevice(), []);
|
||||
|
||||
@@ -219,14 +211,11 @@ export default function ReaderPage() {
|
||||
Theme
|
||||
</p>
|
||||
<div className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{colorSchemes.map(option => (
|
||||
{READER_COLOR_SCHEMES.map(option => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setColorSchemeState(option);
|
||||
setReaderColorScheme(option);
|
||||
}}
|
||||
onClick={() => setColorScheme(option)}
|
||||
className={`rounded border px-2 py-1.5 text-xs capitalize sm:text-sm ${
|
||||
colorScheme === option
|
||||
? 'border-primary-500 bg-primary-500/10 text-content'
|
||||
@@ -242,14 +231,11 @@ export default function ReaderPage() {
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Font</p>
|
||||
<div className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{fontFamilies.map(option => (
|
||||
{READER_FONT_FAMILIES.map(option => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFontFamilyState(option);
|
||||
setReaderFontFamily(option);
|
||||
}}
|
||||
onClick={() => setFontFamily(option)}
|
||||
className={`rounded border px-2 py-1.5 text-xs sm:text-sm ${
|
||||
fontFamily === option
|
||||
? 'border-primary-500 bg-primary-500/10 text-content'
|
||||
@@ -269,11 +255,9 @@ export default function ReaderPage() {
|
||||
<div className="flex items-center gap-1.5 lg:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextSize = Math.max(0.8, Number((fontSize - 0.1).toFixed(2)));
|
||||
setFontSizeState(nextSize);
|
||||
setReaderFontSize(nextSize);
|
||||
}}
|
||||
onClick={() =>
|
||||
setFontSize(Math.max(0.8, Number((fontSize - 0.1).toFixed(2))))
|
||||
}
|
||||
className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
||||
>
|
||||
-
|
||||
@@ -283,11 +267,9 @@ export default function ReaderPage() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextSize = Math.min(2.2, Number((fontSize + 0.1).toFixed(2)));
|
||||
setFontSizeState(nextSize);
|
||||
setReaderFontSize(nextSize);
|
||||
}}
|
||||
onClick={() =>
|
||||
setFontSize(Math.min(2.2, Number((fontSize + 0.1).toFixed(2))))
|
||||
}
|
||||
className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
||||
>
|
||||
+
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -8,9 +7,8 @@ import {
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import {
|
||||
getThemeMode,
|
||||
setThemeMode,
|
||||
LOCAL_SETTINGS_KEY,
|
||||
useLocalSetting,
|
||||
readLocalSetting,
|
||||
type ThemeMode,
|
||||
} from '../utils/localSettings';
|
||||
|
||||
@@ -49,23 +47,23 @@ export function applyThemeMode(themeMode: ThemeMode): ResolvedThemeMode {
|
||||
}
|
||||
|
||||
export function initializeThemeMode(): ResolvedThemeMode {
|
||||
return applyThemeMode(getThemeMode());
|
||||
return applyThemeMode(readLocalSetting('themeMode', 'system'));
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [themeModeState, setThemeModeState] = useState<ThemeMode>(() => getThemeMode());
|
||||
const [themeMode, setThemeMode] = useLocalSetting('themeMode', 'system');
|
||||
const [resolvedThemeMode, setResolvedThemeMode] = useState<ResolvedThemeMode>(() =>
|
||||
resolveThemeMode(getThemeMode())
|
||||
resolveThemeMode(themeMode)
|
||||
);
|
||||
|
||||
// Single Source Of Truth - The mode effect is the only place that applies the theme to the DOM and resolves it into state. Every other code path just sets `themeModeState`.
|
||||
// Single Source Of Truth - The mode effect is the only place that applies the theme to the DOM and resolves it into state. Every other code path just calls `setThemeMode`.
|
||||
useEffect(() => {
|
||||
setResolvedThemeMode(applyThemeMode(themeModeState));
|
||||
}, [themeModeState]);
|
||||
setResolvedThemeMode(applyThemeMode(themeMode));
|
||||
}, [themeMode]);
|
||||
|
||||
// System Preference - When the user follows 'system', the resolved theme must react to OS changes even though `themeModeState` is unchanged.
|
||||
// System Preference - When the user follows 'system', the resolved theme must react to OS changes even though `themeMode` is unchanged.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || themeModeState !== 'system') {
|
||||
if (typeof window === 'undefined' || themeMode !== 'system') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -78,39 +76,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
return () => {
|
||||
mediaQueryList.removeEventListener('change', handleSystemThemeChange);
|
||||
};
|
||||
}, [themeModeState]);
|
||||
|
||||
// Cross-Tab Sync - Another tab changed the persisted theme; adopt its mode and let the mode effect apply it.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key && event.key !== LOCAL_SETTINGS_KEY) {
|
||||
return;
|
||||
}
|
||||
setThemeModeState(getThemeMode());
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateThemeMode = useCallback((nextThemeMode: ThemeMode) => {
|
||||
setThemeMode(nextThemeMode);
|
||||
setThemeModeState(nextThemeMode);
|
||||
}, []);
|
||||
}, [themeMode]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
themeMode: themeModeState,
|
||||
themeMode,
|
||||
resolvedThemeMode,
|
||||
setThemeMode: updateThemeMode,
|
||||
setThemeMode,
|
||||
}),
|
||||
[resolvedThemeMode, themeModeState, updateThemeMode]
|
||||
[resolvedThemeMode, themeMode, setThemeMode]
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
|
||||
@@ -12,3 +12,20 @@ export function getErrorMessage(error: unknown, fallback = 'Unknown error'): str
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export interface ApiResponseLike {
|
||||
status: number;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-2xx Check - The generated client resolves non-2xx instead of throwing; this returns the
|
||||
* extracted error message for failure responses, or null for success (2xx) so callers can branch.
|
||||
*/
|
||||
export function getResponseError(response: ApiResponseLike): string | null {
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getErrorMessage(response.data, 'Request failed');
|
||||
}
|
||||
|
||||
@@ -1,25 +1,38 @@
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
export type DocumentsViewMode = 'grid' | 'list';
|
||||
export type ReaderColorScheme = 'light' | 'tan' | 'blue' | 'gray' | 'black';
|
||||
export type ReaderFontFamily = 'Serif' | 'Open Sans' | 'Arbutus Slab' | 'Lato';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
// Value arrays are the single source of truth; the union types derive from them so the
|
||||
// runtime iteration lists (reader theme picker, nav, etc.) and the schema can't drift.
|
||||
export const THEME_MODES = ['light', 'dark', 'system'] as const;
|
||||
export type ThemeMode = (typeof THEME_MODES)[number];
|
||||
|
||||
export const DOCUMENTS_VIEW_MODES = ['grid', 'list'] as const;
|
||||
export type DocumentsViewMode = (typeof DOCUMENTS_VIEW_MODES)[number];
|
||||
|
||||
export const READER_COLOR_SCHEMES = ['light', 'tan', 'blue', 'gray', 'black'] as const;
|
||||
export type ReaderColorScheme = (typeof READER_COLOR_SCHEMES)[number];
|
||||
|
||||
export const READER_FONT_FAMILIES = ['Serif', 'Open Sans', 'Arbutus Slab', 'Lato'] as const;
|
||||
export type ReaderFontFamily = (typeof READER_FONT_FAMILIES)[number];
|
||||
|
||||
export const LOCAL_SETTINGS_KEY = 'antholume:settings';
|
||||
|
||||
interface LocalSettings {
|
||||
themeMode?: ThemeMode;
|
||||
documentsViewMode?: DocumentsViewMode;
|
||||
readerColorScheme?: ReaderColorScheme;
|
||||
readerFontFamily?: ReaderFontFamily;
|
||||
readerFontSize?: number;
|
||||
readerDeviceId?: string;
|
||||
readerDeviceName?: string;
|
||||
interface LocalSettingsMap {
|
||||
themeMode: ThemeMode;
|
||||
documentsViewMode: DocumentsViewMode;
|
||||
readerColorScheme: ReaderColorScheme;
|
||||
readerFontFamily: ReaderFontFamily;
|
||||
readerFontSize: number;
|
||||
readerDeviceId: string;
|
||||
readerDeviceName: string;
|
||||
}
|
||||
|
||||
type LocalSettingKey = keyof LocalSettingsMap;
|
||||
|
||||
function canUseLocalStorage(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
}
|
||||
|
||||
function readLocalSettings(): LocalSettings {
|
||||
function readRawSettings(): Record<string, unknown> {
|
||||
if (!canUseLocalStorage()) {
|
||||
return {};
|
||||
}
|
||||
@@ -37,111 +50,103 @@ function readLocalSettings(): LocalSettings {
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalSettings(settings: LocalSettings): void {
|
||||
function writeRawSettings(settings: Record<string, unknown>): void {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
function updateLocalSettings(partialSettings: LocalSettings): void {
|
||||
writeLocalSettings({
|
||||
...readLocalSettings(),
|
||||
...partialSettings,
|
||||
});
|
||||
}
|
||||
|
||||
export function getThemeMode(): ThemeMode {
|
||||
const settings = readLocalSettings();
|
||||
return settings.themeMode === 'light' || settings.themeMode === 'dark'
|
||||
? settings.themeMode
|
||||
: 'system';
|
||||
}
|
||||
|
||||
export function setThemeMode(themeMode: ThemeMode): void {
|
||||
updateLocalSettings({ themeMode });
|
||||
}
|
||||
|
||||
export function getDocumentsViewMode(): DocumentsViewMode {
|
||||
const settings = readLocalSettings();
|
||||
return settings.documentsViewMode === 'list' ? 'list' : 'grid';
|
||||
}
|
||||
|
||||
export function setDocumentsViewMode(documentsViewMode: DocumentsViewMode): void {
|
||||
updateLocalSettings({ documentsViewMode });
|
||||
}
|
||||
|
||||
export function getReaderColorScheme(): ReaderColorScheme {
|
||||
const settings = readLocalSettings();
|
||||
switch (settings.readerColorScheme) {
|
||||
case 'light':
|
||||
case 'tan':
|
||||
case 'blue':
|
||||
case 'gray':
|
||||
case 'black':
|
||||
return settings.readerColorScheme;
|
||||
function isValidValue(key: LocalSettingKey, value: unknown): boolean {
|
||||
switch (key) {
|
||||
case 'themeMode':
|
||||
return (THEME_MODES as readonly unknown[]).includes(value);
|
||||
case 'documentsViewMode':
|
||||
return (DOCUMENTS_VIEW_MODES as readonly unknown[]).includes(value);
|
||||
case 'readerColorScheme':
|
||||
return (READER_COLOR_SCHEMES as readonly unknown[]).includes(value);
|
||||
case 'readerFontFamily':
|
||||
return (READER_FONT_FAMILIES as readonly unknown[]).includes(value);
|
||||
case 'readerFontSize':
|
||||
return typeof value === 'number' && value > 0;
|
||||
case 'readerDeviceId':
|
||||
case 'readerDeviceName':
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
default:
|
||||
return 'tan';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setReaderColorScheme(readerColorScheme: ReaderColorScheme): void {
|
||||
updateLocalSettings({ readerColorScheme });
|
||||
export function readLocalSetting<K extends LocalSettingKey>(
|
||||
key: K,
|
||||
defaultValue: LocalSettingsMap[K]
|
||||
): LocalSettingsMap[K] {
|
||||
const value = readRawSettings()[key];
|
||||
return isValidValue(key, value) ? (value as LocalSettingsMap[K]) : defaultValue;
|
||||
}
|
||||
|
||||
export function getReaderFontFamily(): ReaderFontFamily {
|
||||
const settings = readLocalSettings();
|
||||
switch (settings.readerFontFamily) {
|
||||
case 'Serif':
|
||||
case 'Open Sans':
|
||||
case 'Arbutus Slab':
|
||||
case 'Lato':
|
||||
return settings.readerFontFamily;
|
||||
default:
|
||||
return 'Serif';
|
||||
}
|
||||
export function writeLocalSetting<K extends LocalSettingKey>(
|
||||
key: K,
|
||||
value: LocalSettingsMap[K]
|
||||
): void {
|
||||
writeRawSettings({ ...readRawSettings(), [key]: value });
|
||||
}
|
||||
|
||||
export function setReaderFontFamily(readerFontFamily: ReaderFontFamily): void {
|
||||
updateLocalSettings({ readerFontFamily });
|
||||
}
|
||||
|
||||
export function getReaderFontSize(): number {
|
||||
const settings = readLocalSettings();
|
||||
return typeof settings.readerFontSize === 'number' && settings.readerFontSize > 0
|
||||
? settings.readerFontSize
|
||||
: 1;
|
||||
}
|
||||
|
||||
export function setReaderFontSize(readerFontSize: number): void {
|
||||
updateLocalSettings({ readerFontSize });
|
||||
/**
|
||||
* Stateful accessor for a single localStorage-backed setting. Persists on change and re-reads
|
||||
* on cross-tab `storage` events. Validation rejects stale/invalid stored values in favor of
|
||||
* `defaultValue`, so callers never need their own get/set/validate pair.
|
||||
*/
|
||||
export function useLocalSetting<K extends LocalSettingKey>(
|
||||
key: K,
|
||||
defaultValue: LocalSettingsMap[K]
|
||||
) {
|
||||
const [value, setValue] = useState<LocalSettingsMap[K]>(() => readLocalSetting(key, defaultValue));
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key && event.key !== LOCAL_SETTINGS_KEY) {
|
||||
return;
|
||||
}
|
||||
setValue(readLocalSetting(key, defaultValue));
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
};
|
||||
}, [key, defaultValue]);
|
||||
|
||||
const setValuePersisted = useCallback(
|
||||
(next: LocalSettingsMap[K]) => {
|
||||
writeLocalSetting(key, next);
|
||||
setValue(next);
|
||||
},
|
||||
[key]
|
||||
);
|
||||
|
||||
return [value, setValuePersisted] as const;
|
||||
}
|
||||
|
||||
// Reader Device - First-run UUID registration is a read side-effect that doesn't fit a value hook.
|
||||
export function getReaderDevice(): { id: string; name: string } {
|
||||
const settings = readLocalSettings();
|
||||
const id =
|
||||
typeof settings.readerDeviceId === 'string' && settings.readerDeviceId.length > 0
|
||||
? settings.readerDeviceId
|
||||
: crypto.randomUUID();
|
||||
const name =
|
||||
typeof settings.readerDeviceName === 'string' && settings.readerDeviceName.length > 0
|
||||
? settings.readerDeviceName
|
||||
: 'Web Reader';
|
||||
const raw = readRawSettings();
|
||||
const id = isValidValue('readerDeviceId', raw.readerDeviceId)
|
||||
? (raw.readerDeviceId as string)
|
||||
: crypto.randomUUID();
|
||||
const name = isValidValue('readerDeviceName', raw.readerDeviceName)
|
||||
? (raw.readerDeviceName as string)
|
||||
: 'Web Reader';
|
||||
|
||||
if (id !== settings.readerDeviceId || name !== settings.readerDeviceName) {
|
||||
updateLocalSettings({
|
||||
readerDeviceId: id,
|
||||
readerDeviceName: name,
|
||||
});
|
||||
if (id !== raw.readerDeviceId || name !== raw.readerDeviceName) {
|
||||
writeLocalSetting('readerDeviceId', id);
|
||||
writeLocalSetting('readerDeviceName', name);
|
||||
}
|
||||
|
||||
return { id, name };
|
||||
}
|
||||
|
||||
export function setReaderDevice(name: string, id?: string): void {
|
||||
updateLocalSettings({
|
||||
readerDeviceId: id ?? crypto.randomUUID(),
|
||||
readerDeviceName: name,
|
||||
});
|
||||
writeLocalSetting('readerDeviceName', name);
|
||||
writeLocalSetting('readerDeviceId', id ?? crypto.randomUUID());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user