@@ -3,12 +3,10 @@ import { Link, useLocation, Outlet } from 'react-router-dom';
|
|||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { UserIcon, DropdownIcon } from '../icons';
|
import { UserIcon, DropdownIcon } from '../icons';
|
||||||
import { useTheme } from '../theme/ThemeProvider';
|
import { useTheme } from '../theme/ThemeProvider';
|
||||||
import type { ThemeMode } from '../utils/localSettings';
|
import { THEME_MODES } from '../utils/localSettings';
|
||||||
import HamburgerMenu from './HamburgerMenu';
|
import HamburgerMenu from './HamburgerMenu';
|
||||||
import { getPageTitle } from './navigation';
|
import { getPageTitle } from './navigation';
|
||||||
|
|
||||||
const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
|
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
@@ -72,7 +70,7 @@ export default function Layout() {
|
|||||||
Theme
|
Theme
|
||||||
</p>
|
</p>
|
||||||
<div className="inline-flex w-full rounded border border-border bg-surface-muted p-1">
|
<div className="inline-flex w-full rounded border border-border bg-surface-muted p-1">
|
||||||
{themeModes.map(mode => (
|
{THEME_MODES.map(mode => (
|
||||||
<button
|
<button
|
||||||
key={mode}
|
key={mode}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ElementType } from 'react';
|
import type { ElementType } from 'react';
|
||||||
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon } from '../icons';
|
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, ClockIcon } from '../icons';
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -10,16 +10,16 @@ export interface NavItem {
|
|||||||
export const navItems: NavItem[] = [
|
export const navItems: NavItem[] = [
|
||||||
{ path: '/', label: 'Home', icon: HomeIcon },
|
{ path: '/', label: 'Home', icon: HomeIcon },
|
||||||
{ path: '/documents', label: 'Documents', icon: DocumentsIcon },
|
{ 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: '/activity', label: 'Activity', icon: ActivityIcon },
|
||||||
{ path: '/search', label: 'Search', icon: SearchIcon },
|
{ path: '/search', label: 'Search', icon: SearchIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const adminNavItems: NavItem[] = [
|
export const adminNavItems: { path: string; label: string }[] = [
|
||||||
{ path: '/admin', label: 'General', icon: SettingsIcon },
|
{ path: '/admin', label: 'General' },
|
||||||
{ path: '/admin/import', label: 'Import', icon: SettingsIcon },
|
{ path: '/admin/import', label: 'Import' },
|
||||||
{ path: '/admin/users', label: 'Users', icon: SettingsIcon },
|
{ path: '/admin/users', label: 'Users' },
|
||||||
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon },
|
{ path: '/admin/logs', label: 'Logs' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Ordered most-specific-first so prefix matching resolves nested routes correctly.
|
// 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 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';
|
||||||
|
import { useToasts } from '../components/ToastContext';
|
||||||
|
import { getErrorMessage, getResponseError } from '../utils/errors';
|
||||||
|
|
||||||
interface UseEpubReaderOptions {
|
interface UseEpubReaderOptions {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -60,6 +62,7 @@ export function useEpubReader({
|
|||||||
sectionTotalPages: 0,
|
sectionTotalPages: 0,
|
||||||
percentage: 0,
|
percentage: 0,
|
||||||
});
|
});
|
||||||
|
const { showError } = useToasts();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isPaginationDisabledRef.current = isPaginationDisabled;
|
isPaginationDisabledRef.current = isPaginationDisabled;
|
||||||
@@ -90,20 +93,28 @@ export function useEpubReader({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const saveProgress = async (payload: UpdateProgressRequest) => {
|
const saveProgress = async (payload: UpdateProgressRequest) => {
|
||||||
|
// 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 response = await updateProgress(payload);
|
||||||
if (response.status >= 400) {
|
const message = getResponseError(response);
|
||||||
throw new Error(
|
if (message) {
|
||||||
'message' in response.data ? response.data.message : 'Unable to save reader progress'
|
showError(`Failed to save progress: ${message}`);
|
||||||
);
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showError(`Failed to save progress: ${getErrorMessage(err)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveActivity = async (payload: CreateActivityRequest) => {
|
const saveActivity = async (payload: CreateActivityRequest) => {
|
||||||
|
try {
|
||||||
const response = await createActivity(payload);
|
const response = await createActivity(payload);
|
||||||
if (response.status >= 400) {
|
const message = getResponseError(response);
|
||||||
throw new Error(
|
if (message) {
|
||||||
'message' in response.data ? response.data.message : 'Unable to save reader activity'
|
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 { useToasts } from '../components/ToastContext';
|
||||||
import { getErrorMessage } from '../utils/errors';
|
import { getErrorMessage, getResponseError, type ApiResponseLike } from '../utils/errors';
|
||||||
|
|
||||||
interface ApiResponse {
|
|
||||||
status: number;
|
|
||||||
data: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToastMutationOptions {
|
interface ToastMutationOptions {
|
||||||
success: string;
|
success: string;
|
||||||
@@ -21,9 +16,10 @@ export function useMutationWithToast() {
|
|||||||
|
|
||||||
return function toastMutationOptions({ success, error, onSuccess }: ToastMutationOptions) {
|
return function toastMutationOptions({ success, error, onSuccess }: ToastMutationOptions) {
|
||||||
return {
|
return {
|
||||||
onSuccess: (response: ApiResponse) => {
|
onSuccess: (response: ApiResponseLike) => {
|
||||||
if (response.status < 200 || response.status >= 300) {
|
const message = getResponseError(response);
|
||||||
showError(`${error}: ${getErrorMessage(response.data)}`);
|
if (message) {
|
||||||
|
showError(`${error}: ${message}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
|
|||||||
@@ -356,72 +356,6 @@ main {
|
|||||||
display: none;
|
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 */
|
/* Skeleton Wave Animation */
|
||||||
@keyframes wave {
|
@keyframes wave {
|
||||||
0% {
|
0% {
|
||||||
|
|||||||
@@ -2,125 +2,27 @@ import ePub from 'epubjs';
|
|||||||
import NoSleep from 'nosleep.js';
|
import NoSleep from 'nosleep.js';
|
||||||
import type { CreateActivityRequest } from '../../generated/model/createActivityRequest';
|
import type { CreateActivityRequest } from '../../generated/model/createActivityRequest';
|
||||||
import type { UpdateProgressRequest } from '../../generated/model/updateProgressRequest';
|
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 THEME_FILE = '/assets/reader/themes.css';
|
||||||
const FONT_FILE = '/assets/reader/fonts.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 {
|
interface BookState {
|
||||||
pages: number;
|
pages: number;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
@@ -174,6 +76,7 @@ export class EBookReader {
|
|||||||
private rendition: EpubRendition;
|
private rendition: EpubRendition;
|
||||||
private noSleep: NoSleep | null = null;
|
private noSleep: NoSleep | null = null;
|
||||||
private wakeTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
private wakeTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private gestureDispose: (() => void) | null = null;
|
||||||
private destroyed = false;
|
private destroyed = false;
|
||||||
private onReady: () => void;
|
private onReady: () => void;
|
||||||
private onLoading: (_loading: boolean) => void;
|
private onLoading: (_loading: boolean) => void;
|
||||||
@@ -187,7 +90,6 @@ export class EBookReader {
|
|||||||
private onSwipeUp: () => void;
|
private onSwipeUp: () => void;
|
||||||
private onCenterTap: () => 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;
|
||||||
@@ -217,7 +119,6 @@ export class EBookReader {
|
|||||||
pageStart: Date.now(),
|
pageStart: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.loadSettings();
|
|
||||||
this.readerSettings.theme = {
|
this.readerSettings.theme = {
|
||||||
colorScheme: options.colorScheme,
|
colorScheme: options.colorScheme,
|
||||||
fontFamily: options.fontFamily,
|
fontFamily: options.fontFamily,
|
||||||
@@ -237,7 +138,16 @@ export class EBookReader {
|
|||||||
this.initCSP();
|
this.initCSP();
|
||||||
this.initWakeLock();
|
this.initWakeLock();
|
||||||
this.initThemes();
|
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.initDocumentListeners();
|
||||||
|
|
||||||
this.book.ready.then(this.setupReader.bind(this)).catch(error => {
|
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() {
|
private initWakeLock() {
|
||||||
this.noSleep = new NoSleep();
|
this.noSleep = new NoSleep();
|
||||||
document.addEventListener('wakelock', this.handleWakeLock);
|
document.addEventListener('wakelock', this.handleWakeLock);
|
||||||
@@ -279,7 +183,7 @@ export class EBookReader {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private initThemes() {
|
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;
|
let themeLinkEl = document.querySelector('#themes') as HTMLLinkElement | null;
|
||||||
if (!themeLinkEl) {
|
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() {
|
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);
|
||||||
@@ -469,9 +252,11 @@ export class EBookReader {
|
|||||||
}
|
}
|
||||||
if ((event.keyCode || event.which) === 84) {
|
if ((event.keyCode || event.which) === 84) {
|
||||||
const currentTheme = this.readerSettings.theme?.colorScheme || 'tan';
|
const currentTheme = this.readerSettings.theme?.colorScheme || 'tan';
|
||||||
const currentThemeIdx = THEMES.indexOf(currentTheme);
|
const currentThemeIdx = READER_COLOR_SCHEMES.indexOf(currentTheme);
|
||||||
const colorScheme =
|
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) {
|
if (colorScheme) {
|
||||||
this.setTheme({ colorScheme });
|
this.setTheme({ colorScheme });
|
||||||
}
|
}
|
||||||
@@ -482,44 +267,20 @@ export class EBookReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async setupReader() {
|
private async setupReader() {
|
||||||
this.bookState.words = await this.countWords();
|
this.bookState.words = await countWords(this.book);
|
||||||
const { cfi } = await this.getCFIFromXPath(this.bookState.progress);
|
const { cfi } = await getCFIFromXPath(this.book, this.rendition, this.bookState.progress);
|
||||||
await this.setPosition(cfi);
|
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.bookState.progressElement = element ?? null;
|
||||||
this.highlightPositionMarker();
|
this.highlightPositionMarker();
|
||||||
const stats = await this.getBookStats();
|
const stats = await this.getBookStats();
|
||||||
this.onStats(stats);
|
this.onStats(stats);
|
||||||
this.bookState.pageStart = Date.now();
|
this.bookState.pageStart = Date.now();
|
||||||
this.onToc(this.getParsedTOC());
|
this.onToc(getParsedTOC(this.book));
|
||||||
this.onLoading(false);
|
this.onLoading(false);
|
||||||
this.onReady();
|
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 }) {
|
setTheme(newTheme?: { 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
|
||||||
@@ -615,6 +376,8 @@ export class EBookReader {
|
|||||||
return;
|
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);
|
await this.rendition.display(cfi);
|
||||||
await this.rendition.display(cfi);
|
await this.rendition.display(cfi);
|
||||||
@@ -627,10 +390,10 @@ export class EBookReader {
|
|||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
}) {
|
}) {
|
||||||
const currentProgress = this.bookState.progress;
|
const currentProgress = this.bookState.progress;
|
||||||
const { cfi } = await this.getCFIFromXPath(currentProgress);
|
const { cfi } = await getCFIFromXPath(this.book, this.rendition, currentProgress);
|
||||||
this.setTheme(newTheme);
|
this.setTheme(newTheme);
|
||||||
await this.setPosition(cfi);
|
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.bookState.progressElement = element ?? null;
|
||||||
this.highlightPositionMarker();
|
this.highlightPositionMarker();
|
||||||
}
|
}
|
||||||
@@ -641,8 +404,8 @@ export class EBookReader {
|
|||||||
|
|
||||||
const pageStart = this.bookState.pageStart;
|
const pageStart = this.bookState.pageStart;
|
||||||
let elapsedTime = Date.now() - pageStart;
|
let elapsedTime = Date.now() - pageStart;
|
||||||
const pageWords = await this.getVisibleWordCount();
|
const pageWords = await getVisibleWordCount(this.book, this.rendition);
|
||||||
const currentWord = await this.getBookWordPosition();
|
const currentWord = await getBookWordPosition(this.book, this.rendition);
|
||||||
const percentRead = pageWords / this.bookState.words;
|
const percentRead = pageWords / this.bookState.words;
|
||||||
const pageWPM = pageWords / (elapsedTime / 60000);
|
const pageWPM = pageWords / (elapsedTime / 60000);
|
||||||
|
|
||||||
@@ -686,8 +449,8 @@ export class EBookReader {
|
|||||||
|
|
||||||
async createProgress() {
|
async createProgress() {
|
||||||
const currentCFI = await this.rendition.currentLocation();
|
const currentCFI = await this.rendition.currentLocation();
|
||||||
const { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
|
const { element, xpath } = await getXPathFromCFI(this.book, this.rendition, currentCFI.start.cfi);
|
||||||
const currentWord = await this.getBookWordPosition();
|
const currentWord = await getBookWordPosition(this.book, this.rendition);
|
||||||
this.bookState.progress = xpath ?? '';
|
this.bookState.progress = xpath ?? '';
|
||||||
this.bookState.progressElement = element ?? null;
|
this.bookState.progressElement = element ?? null;
|
||||||
|
|
||||||
@@ -744,7 +507,7 @@ export class EBookReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentLocation = await this.rendition.currentLocation();
|
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(
|
const currentTOC = this.book.navigation?.toc?.find(
|
||||||
item => item.href === currentLocation.start.href
|
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() {
|
destroy() {
|
||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
if (this.keyupHandler) {
|
if (this.keyupHandler) {
|
||||||
@@ -991,9 +532,7 @@ export class EBookReader {
|
|||||||
if (this.wakeTimeoutId) {
|
if (this.wakeTimeoutId) {
|
||||||
clearTimeout(this.wakeTimeoutId);
|
clearTimeout(this.wakeTimeoutId);
|
||||||
}
|
}
|
||||||
if (this.wheelTimeoutId) {
|
this.gestureDispose?.();
|
||||||
clearTimeout(this.wheelTimeoutId);
|
|
||||||
}
|
|
||||||
void this.noSleep?.disable();
|
void this.noSleep?.disable();
|
||||||
this.rendition.destroy?.();
|
this.rendition.destroy?.();
|
||||||
this.book.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 { LoadingState } from '../components';
|
||||||
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
|
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
|
||||||
import type { DirectoryItem } from '../generated/model';
|
import type { DirectoryItem } from '../generated/model';
|
||||||
import { getErrorMessage } from '../utils/errors';
|
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
import { FolderOpenIcon } from '../icons';
|
import { FolderOpenIcon } from '../icons';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||||
|
|
||||||
export default function AdminImportPage() {
|
export default function AdminImportPage() {
|
||||||
const [currentPath, setCurrentPath] = useState<string>('');
|
const [currentPath, setCurrentPath] = useState<string>('');
|
||||||
const [selectedDirectory, setSelectedDirectory] = useState<string>('');
|
const [selectedDirectory, setSelectedDirectory] = useState<string>('');
|
||||||
const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT');
|
const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT');
|
||||||
const { showInfo, showError } = useToasts();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const toastMutationOptions = useMutationWithToast();
|
||||||
|
|
||||||
const { data: directoryData, isLoading } = useGetImportDirectory(
|
const { data: directoryData, isLoading } = useGetImportDirectory(
|
||||||
currentPath ? { directory: currentPath } : {}
|
currentPath ? { directory: currentPath } : {}
|
||||||
@@ -48,15 +47,11 @@ export default function AdminImportPage() {
|
|||||||
type: importType,
|
type: importType,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
toastMutationOptions({
|
||||||
onSuccess: _response => {
|
success: 'Import completed successfully',
|
||||||
showInfo('Import completed successfully');
|
error: 'Import failed',
|
||||||
navigate('/admin/import-results');
|
onSuccess: () => navigate('/admin/import-results'),
|
||||||
},
|
})
|
||||||
onError: error => {
|
|
||||||
showError('Import failed: ' + getErrorMessage(error));
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from '../generated/anthoLumeAPIV1';
|
} from '../generated/anthoLumeAPIV1';
|
||||||
import type { EditDocumentBody } from '../generated/model';
|
import type { EditDocumentBody } from '../generated/model';
|
||||||
import { formatDuration } from '../utils/formatters';
|
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 { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons';
|
||||||
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
|
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
@@ -137,8 +137,9 @@ export default function DocumentPage() {
|
|||||||
const save = async (data: EditDocumentBody): Promise<boolean> => {
|
const save = async (data: EditDocumentBody): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await editMutation.mutateAsync({ id: document.id, data });
|
const response = await editMutation.mutateAsync({ id: document.id, data });
|
||||||
if (response.status !== 200) {
|
const message = getResponseError(response);
|
||||||
showError('Failed to save: ' + getErrorMessage(response.data));
|
if (message) {
|
||||||
|
showError('Failed to save: ' + message);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
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 { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||||
import type { Document } from '../generated/model';
|
import type { Document } from '../generated/model';
|
||||||
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
||||||
@@ -8,11 +8,7 @@ import { useToasts } from '../components/ToastContext';
|
|||||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||||
import { formatDuration } from '../utils/formatters';
|
import { formatDuration } from '../utils/formatters';
|
||||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||||
import {
|
import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
|
||||||
getDocumentsViewMode,
|
|
||||||
setDocumentsViewMode,
|
|
||||||
type DocumentsViewMode,
|
|
||||||
} from '../utils/localSettings';
|
|
||||||
|
|
||||||
const DOCUMENTS_PAGE_SIZE = 9;
|
const DOCUMENTS_PAGE_SIZE = 9;
|
||||||
|
|
||||||
@@ -22,25 +18,16 @@ interface DocumentItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DocumentItem({ doc, layout }: DocumentItemProps) {
|
function DocumentItem({ doc, layout }: DocumentItemProps) {
|
||||||
const navigate = useNavigate();
|
|
||||||
const percentage = doc.percentage || 0;
|
const percentage = doc.percentage || 0;
|
||||||
const totalTimeSeconds = doc.total_time_seconds || 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 = (
|
const icons = (
|
||||||
<div className="flex shrink-0 items-center justify-end gap-4 text-content-muted">
|
<div className="flex shrink-0 items-center justify-end gap-4 text-content-muted pointer-events-auto">
|
||||||
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
|
<Link to={`/activity?document=${doc.id}`}>
|
||||||
<ActivityIcon size={20} />
|
<ActivityIcon size={20} />
|
||||||
</Link>
|
</Link>
|
||||||
{doc.filepath ? (
|
{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} />
|
<DownloadIcon size={20} />
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
@@ -58,22 +45,21 @@ function DocumentItem({ doc, layout }: DocumentItemProps) {
|
|||||||
|
|
||||||
if (layout === 'grid') {
|
if (layout === 'grid') {
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative flex size-full gap-4 rounded bg-surface p-4 shadow-lg transition-colors hover:bg-surface-muted">
|
||||||
<div
|
{/* Stretched Link - Covers the whole card; display content is pointer-events-none so clicks fall through to navigate. */}
|
||||||
role="link"
|
<Link
|
||||||
tabIndex={0}
|
to={`/documents/${doc.id}`}
|
||||||
className="flex size-full cursor-pointer gap-4 rounded bg-surface p-4 shadow-lg transition-colors hover:bg-surface-muted focus:outline-hidden"
|
aria-label={`Open ${doc.title || 'document'}`}
|
||||||
onClick={open}
|
className="absolute inset-0"
|
||||||
onKeyDown={onKeyDown}
|
/>
|
||||||
>
|
<div className="pointer-events-none my-auto h-48 min-w-fit">
|
||||||
<div className="relative my-auto h-48 min-w-fit">
|
|
||||||
<img
|
<img
|
||||||
className="h-full rounded object-cover"
|
className="h-full rounded object-cover"
|
||||||
src={`/api/v1/documents/${doc.id}/cover`}
|
src={`/api/v1/documents/${doc.id}/cover`}
|
||||||
alt={doc.title}
|
alt={doc.title}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col justify-around text-sm text-content">
|
<div className="pointer-events-none flex w-full flex-col justify-around text-sm text-content">
|
||||||
{fields.map(f => (
|
{fields.map(f => (
|
||||||
<div key={f.label} className="inline-flex shrink-0 items-center">
|
<div key={f.label} className="inline-flex shrink-0 items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -87,19 +73,18 @@ function DocumentItem({ doc, layout }: DocumentItemProps) {
|
|||||||
{icons}
|
{icons}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative block rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted">
|
||||||
role="link"
|
{/* Stretched Link - Covers the whole card; display content is pointer-events-none so clicks fall through to navigate. */}
|
||||||
tabIndex={0}
|
<Link
|
||||||
className="block cursor-pointer rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted focus:outline-hidden"
|
to={`/documents/${doc.id}`}
|
||||||
onClick={open}
|
aria-label={`Open ${doc.title || 'document'}`}
|
||||||
onKeyDown={onKeyDown}
|
className="absolute inset-0"
|
||||||
>
|
/>
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
<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">
|
<div className="grid flex-1 grid-cols-1 gap-3 text-sm md:grid-cols-4">
|
||||||
{fields.map(f => (
|
{fields.map(f => (
|
||||||
<div key={f.label}>
|
<div key={f.label}>
|
||||||
@@ -119,15 +104,11 @@ export default function DocumentsPage() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const limit = DOCUMENTS_PAGE_SIZE;
|
const limit = DOCUMENTS_PAGE_SIZE;
|
||||||
const [uploadMode, setUploadMode] = useState(false);
|
const [uploadMode, setUploadMode] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode);
|
const [viewMode, setViewMode] = useLocalSetting('documentsViewMode', 'grid');
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { showWarning } = useToasts();
|
const { showWarning } = useToasts();
|
||||||
const toastMutationOptions = useMutationWithToast();
|
const toastMutationOptions = useMutationWithToast();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDocumentsViewMode(viewMode);
|
|
||||||
}, [viewMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [debouncedSearch]);
|
}, [debouncedSearch]);
|
||||||
|
|||||||
@@ -4,28 +4,20 @@ import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
|
|||||||
import { LoadingState } from '../components/LoadingState';
|
import { LoadingState } from '../components/LoadingState';
|
||||||
import { CloseIcon } from '../icons';
|
import { CloseIcon } from '../icons';
|
||||||
import {
|
import {
|
||||||
getReaderColorScheme,
|
READER_COLOR_SCHEMES,
|
||||||
|
READER_FONT_FAMILIES,
|
||||||
getReaderDevice,
|
getReaderDevice,
|
||||||
getReaderFontFamily,
|
useLocalSetting,
|
||||||
getReaderFontSize,
|
|
||||||
setReaderColorScheme,
|
|
||||||
setReaderFontFamily,
|
|
||||||
setReaderFontSize,
|
|
||||||
type ReaderColorScheme,
|
|
||||||
type ReaderFontFamily,
|
|
||||||
} from '../utils/localSettings';
|
} from '../utils/localSettings';
|
||||||
import { useEpubReader } from '../hooks/useEpubReader';
|
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() {
|
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(false);
|
const [isBottomBarOpen, setIsBottomBarOpen] = useState(false);
|
||||||
const [colorScheme, setColorSchemeState] = useState<ReaderColorScheme>(getReaderColorScheme());
|
const [colorScheme, setColorScheme] = useLocalSetting('readerColorScheme', 'tan');
|
||||||
const [fontFamily, setFontFamilyState] = useState<ReaderFontFamily>(getReaderFontFamily());
|
const [fontFamily, setFontFamily] = useLocalSetting('readerFontFamily', 'Serif');
|
||||||
const [fontSize, setFontSizeState] = useState<number>(getReaderFontSize());
|
const [fontSize, setFontSize] = useLocalSetting('readerFontSize', 1);
|
||||||
|
|
||||||
const { id: defaultDeviceId, name: defaultDeviceName } = useMemo(() => getReaderDevice(), []);
|
const { id: defaultDeviceId, name: defaultDeviceName } = useMemo(() => getReaderDevice(), []);
|
||||||
|
|
||||||
@@ -219,14 +211,11 @@ export default function ReaderPage() {
|
|||||||
Theme
|
Theme
|
||||||
</p>
|
</p>
|
||||||
<div className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5">
|
<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
|
<button
|
||||||
key={option}
|
key={option}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => setColorScheme(option)}
|
||||||
setColorSchemeState(option);
|
|
||||||
setReaderColorScheme(option);
|
|
||||||
}}
|
|
||||||
className={`rounded border px-2 py-1.5 text-xs capitalize sm:text-sm ${
|
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'
|
||||||
@@ -242,14 +231,11 @@ export default function ReaderPage() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Font</p>
|
<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">
|
<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
|
<button
|
||||||
key={option}
|
key={option}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => setFontFamily(option)}
|
||||||
setFontFamilyState(option);
|
|
||||||
setReaderFontFamily(option);
|
|
||||||
}}
|
|
||||||
className={`rounded border px-2 py-1.5 text-xs sm: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'
|
||||||
@@ -269,11 +255,9 @@ export default function ReaderPage() {
|
|||||||
<div className="flex items-center gap-1.5 lg:justify-end">
|
<div className="flex items-center gap-1.5 lg:justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() =>
|
||||||
const nextSize = Math.max(0.8, Number((fontSize - 0.1).toFixed(2)));
|
setFontSize(Math.max(0.8, Number((fontSize - 0.1).toFixed(2))))
|
||||||
setFontSizeState(nextSize);
|
}
|
||||||
setReaderFontSize(nextSize);
|
|
||||||
}}
|
|
||||||
className="rounded border border-border px-2.5 py-1.5 text-sm 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"
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
@@ -283,11 +267,9 @@ export default function ReaderPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() =>
|
||||||
const nextSize = Math.min(2.2, Number((fontSize + 0.1).toFixed(2)));
|
setFontSize(Math.min(2.2, Number((fontSize + 0.1).toFixed(2))))
|
||||||
setFontSizeState(nextSize);
|
}
|
||||||
setReaderFontSize(nextSize);
|
|
||||||
}}
|
|
||||||
className="rounded border border-border px-2.5 py-1.5 text-sm 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"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -8,9 +7,8 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
getThemeMode,
|
useLocalSetting,
|
||||||
setThemeMode,
|
readLocalSetting,
|
||||||
LOCAL_SETTINGS_KEY,
|
|
||||||
type ThemeMode,
|
type ThemeMode,
|
||||||
} from '../utils/localSettings';
|
} from '../utils/localSettings';
|
||||||
|
|
||||||
@@ -49,23 +47,23 @@ export function applyThemeMode(themeMode: ThemeMode): ResolvedThemeMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initializeThemeMode(): ResolvedThemeMode {
|
export function initializeThemeMode(): ResolvedThemeMode {
|
||||||
return applyThemeMode(getThemeMode());
|
return applyThemeMode(readLocalSetting('themeMode', 'system'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [themeModeState, setThemeModeState] = useState<ThemeMode>(() => getThemeMode());
|
const [themeMode, setThemeMode] = useLocalSetting('themeMode', 'system');
|
||||||
const [resolvedThemeMode, setResolvedThemeMode] = useState<ResolvedThemeMode>(() =>
|
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(() => {
|
useEffect(() => {
|
||||||
setResolvedThemeMode(applyThemeMode(themeModeState));
|
setResolvedThemeMode(applyThemeMode(themeMode));
|
||||||
}, [themeModeState]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined' || themeModeState !== 'system') {
|
if (typeof window === 'undefined' || themeMode !== 'system') {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,39 +76,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => {
|
return () => {
|
||||||
mediaQueryList.removeEventListener('change', handleSystemThemeChange);
|
mediaQueryList.removeEventListener('change', handleSystemThemeChange);
|
||||||
};
|
};
|
||||||
}, [themeModeState]);
|
}, [themeMode]);
|
||||||
|
|
||||||
// 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);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
themeMode: themeModeState,
|
themeMode,
|
||||||
resolvedThemeMode,
|
resolvedThemeMode,
|
||||||
setThemeMode: updateThemeMode,
|
setThemeMode,
|
||||||
}),
|
}),
|
||||||
[resolvedThemeMode, themeModeState, updateThemeMode]
|
[resolvedThemeMode, themeMode, setThemeMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||||
|
|||||||
@@ -12,3 +12,20 @@ export function getErrorMessage(error: unknown, fallback = 'Unknown error'): str
|
|||||||
|
|
||||||
return fallback;
|
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';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
export type DocumentsViewMode = 'grid' | 'list';
|
|
||||||
export type ReaderColorScheme = 'light' | 'tan' | 'blue' | 'gray' | 'black';
|
// Value arrays are the single source of truth; the union types derive from them so the
|
||||||
export type ReaderFontFamily = 'Serif' | 'Open Sans' | 'Arbutus Slab' | 'Lato';
|
// 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';
|
export const LOCAL_SETTINGS_KEY = 'antholume:settings';
|
||||||
|
|
||||||
interface LocalSettings {
|
interface LocalSettingsMap {
|
||||||
themeMode?: ThemeMode;
|
themeMode: ThemeMode;
|
||||||
documentsViewMode?: DocumentsViewMode;
|
documentsViewMode: DocumentsViewMode;
|
||||||
readerColorScheme?: ReaderColorScheme;
|
readerColorScheme: ReaderColorScheme;
|
||||||
readerFontFamily?: ReaderFontFamily;
|
readerFontFamily: ReaderFontFamily;
|
||||||
readerFontSize?: number;
|
readerFontSize: number;
|
||||||
readerDeviceId?: string;
|
readerDeviceId: string;
|
||||||
readerDeviceName?: string;
|
readerDeviceName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LocalSettingKey = keyof LocalSettingsMap;
|
||||||
|
|
||||||
function canUseLocalStorage(): boolean {
|
function canUseLocalStorage(): boolean {
|
||||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||||
}
|
}
|
||||||
|
|
||||||
function readLocalSettings(): LocalSettings {
|
function readRawSettings(): Record<string, unknown> {
|
||||||
if (!canUseLocalStorage()) {
|
if (!canUseLocalStorage()) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -37,111 +50,103 @@ function readLocalSettings(): LocalSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeLocalSettings(settings: LocalSettings): void {
|
function writeRawSettings(settings: Record<string, unknown>): void {
|
||||||
if (!canUseLocalStorage()) {
|
if (!canUseLocalStorage()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
|
window.localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateLocalSettings(partialSettings: LocalSettings): void {
|
function isValidValue(key: LocalSettingKey, value: unknown): boolean {
|
||||||
writeLocalSettings({
|
switch (key) {
|
||||||
...readLocalSettings(),
|
case 'themeMode':
|
||||||
...partialSettings,
|
return (THEME_MODES as readonly unknown[]).includes(value);
|
||||||
});
|
case 'documentsViewMode':
|
||||||
}
|
return (DOCUMENTS_VIEW_MODES as readonly unknown[]).includes(value);
|
||||||
|
case 'readerColorScheme':
|
||||||
export function getThemeMode(): ThemeMode {
|
return (READER_COLOR_SCHEMES as readonly unknown[]).includes(value);
|
||||||
const settings = readLocalSettings();
|
case 'readerFontFamily':
|
||||||
return settings.themeMode === 'light' || settings.themeMode === 'dark'
|
return (READER_FONT_FAMILIES as readonly unknown[]).includes(value);
|
||||||
? settings.themeMode
|
case 'readerFontSize':
|
||||||
: 'system';
|
return typeof value === 'number' && value > 0;
|
||||||
}
|
case 'readerDeviceId':
|
||||||
|
case 'readerDeviceName':
|
||||||
export function setThemeMode(themeMode: ThemeMode): void {
|
return typeof value === 'string' && value.length > 0;
|
||||||
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;
|
|
||||||
default:
|
default:
|
||||||
return 'tan';
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setReaderColorScheme(readerColorScheme: ReaderColorScheme): void {
|
export function readLocalSetting<K extends LocalSettingKey>(
|
||||||
updateLocalSettings({ readerColorScheme });
|
key: K,
|
||||||
|
defaultValue: LocalSettingsMap[K]
|
||||||
|
): LocalSettingsMap[K] {
|
||||||
|
const value = readRawSettings()[key];
|
||||||
|
return isValidValue(key, value) ? (value as LocalSettingsMap[K]) : defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReaderFontFamily(): ReaderFontFamily {
|
export function writeLocalSetting<K extends LocalSettingKey>(
|
||||||
const settings = readLocalSettings();
|
key: K,
|
||||||
switch (settings.readerFontFamily) {
|
value: LocalSettingsMap[K]
|
||||||
case 'Serif':
|
): void {
|
||||||
case 'Open Sans':
|
writeRawSettings({ ...readRawSettings(), [key]: value });
|
||||||
case 'Arbutus Slab':
|
|
||||||
case 'Lato':
|
|
||||||
return settings.readerFontFamily;
|
|
||||||
default:
|
|
||||||
return 'Serif';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setReaderFontFamily(readerFontFamily: ReaderFontFamily): void {
|
/**
|
||||||
updateLocalSettings({ readerFontFamily });
|
* 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 getReaderFontSize(): number {
|
*/
|
||||||
const settings = readLocalSettings();
|
export function useLocalSetting<K extends LocalSettingKey>(
|
||||||
return typeof settings.readerFontSize === 'number' && settings.readerFontSize > 0
|
key: K,
|
||||||
? settings.readerFontSize
|
defaultValue: LocalSettingsMap[K]
|
||||||
: 1;
|
) {
|
||||||
}
|
const [value, setValue] = useState<LocalSettingsMap[K]>(() => readLocalSetting(key, defaultValue));
|
||||||
|
|
||||||
export function setReaderFontSize(readerFontSize: number): void {
|
useEffect(() => {
|
||||||
updateLocalSettings({ readerFontSize });
|
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 } {
|
export function getReaderDevice(): { id: string; name: string } {
|
||||||
const settings = readLocalSettings();
|
const raw = readRawSettings();
|
||||||
const id =
|
const id = isValidValue('readerDeviceId', raw.readerDeviceId)
|
||||||
typeof settings.readerDeviceId === 'string' && settings.readerDeviceId.length > 0
|
? (raw.readerDeviceId as string)
|
||||||
? settings.readerDeviceId
|
|
||||||
: crypto.randomUUID();
|
: crypto.randomUUID();
|
||||||
const name =
|
const name = isValidValue('readerDeviceName', raw.readerDeviceName)
|
||||||
typeof settings.readerDeviceName === 'string' && settings.readerDeviceName.length > 0
|
? (raw.readerDeviceName as string)
|
||||||
? settings.readerDeviceName
|
|
||||||
: 'Web Reader';
|
: 'Web Reader';
|
||||||
|
|
||||||
if (id !== settings.readerDeviceId || name !== settings.readerDeviceName) {
|
if (id !== raw.readerDeviceId || name !== raw.readerDeviceName) {
|
||||||
updateLocalSettings({
|
writeLocalSetting('readerDeviceId', id);
|
||||||
readerDeviceId: id,
|
writeLocalSetting('readerDeviceName', name);
|
||||||
readerDeviceName: name,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { id, name };
|
return { id, name };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setReaderDevice(name: string, id?: string): void {
|
export function setReaderDevice(name: string, id?: string): void {
|
||||||
updateLocalSettings({
|
writeLocalSetting('readerDeviceName', name);
|
||||||
readerDeviceId: id ?? crypto.randomUUID(),
|
writeLocalSetting('readerDeviceId', id ?? crypto.randomUUID());
|
||||||
readerDeviceName: name,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user