;
9 | isLoading: boolean;
10 | loader: ReactNode;
11 | }
12 |
13 | // Since we are removing loader entirely, no need for aria-hidden={ !isLoading }
14 | // No need for a label either since we are using the string for the animation
15 | export const ThLoader = ({
16 | ref,
17 | isLoading,
18 | loader,
19 | children,
20 | ...props
21 | }: ThLoaderProps) => {
22 | return (
23 | <>
24 |
30 | { isLoading && loader }
31 | { children }
32 |
33 | >
34 | )
35 | }
--------------------------------------------------------------------------------
/src/preferences/ThPreferencesContext.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { createContext } from "react";
4 | import { defaultPreferences } from "./defaultPreferences";
5 | import { ThPreferences, DefaultKeys, CustomizableKeys } from "./preferences";
6 |
7 | export interface PreferencesContextValue {
8 | preferences: ThPreferences;
9 | updatePreferences: (prefs: ThPreferences) => void;
10 | }
11 |
12 | // Create a context with a default value that will be overridden
13 | export const ThPreferencesContext = createContext | null>(null);
14 |
15 | // Keep the default export for backward compatibility
16 | export const defaultPreferencesContextValue: PreferencesContextValue = {
17 | preferences: defaultPreferences as ThPreferences,
18 | updatePreferences: () => {
19 | throw new Error("updatePreferences must be used within a ThPreferencesProvider with an adapter");
20 | },
21 | };
--------------------------------------------------------------------------------
/src/core/Components/Reader/ThInteractiveOverlay.tsx:
--------------------------------------------------------------------------------
1 | export interface ThInteractiveOverlayProps extends React.HTMLAttributes {
2 | ref?: React.ForwardedRef;
3 | isActive: boolean;
4 | children?: never;
5 | }
6 |
7 | // This is meant to mount invisible zones that can be hovered, clicked, etc.
8 | export const ThInteractiveOverlay = ({
9 | ref,
10 | isActive,
11 | className,
12 | style,
13 | ...props
14 | }: ThInteractiveOverlayProps) => {
15 | const defaultStyles: React.CSSProperties = {
16 | opacity: 0,
17 | zIndex: 10000,
18 | pointerEvents: "auto",
19 | };
20 |
21 | const mergedStyles = className
22 | ? undefined
23 | : {
24 | ...defaultStyles,
25 | ...style
26 | };
27 |
28 | if (isActive) {
29 | return (
30 |
36 | )
37 | }
38 | }
--------------------------------------------------------------------------------
/src/app/api/verify-manifest/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 | import { verifyManifestUrlFromEnv } from "@/next-lib/helpers/verifyManifest";
3 |
4 | // Configure this route to use the Edge Runtime
5 | export const runtime = "edge";
6 |
7 | // This function runs on the server
8 | export async function GET(request: Request) {
9 | const { searchParams } = new URL(request.url);
10 | const manifestUrl = searchParams.get("url");
11 |
12 | if (!manifestUrl) {
13 | return NextResponse.json(
14 | { error: "URL parameter is required" },
15 | { status: 400 }
16 | );
17 | }
18 |
19 | const result = verifyManifestUrlFromEnv(manifestUrl);
20 |
21 | if (!result.allowed) {
22 | return NextResponse.json(
23 | { error: result.error || "Domain not allowed" },
24 | { status: result.error === "Invalid URL" ? 400 : 403 }
25 | );
26 | }
27 |
28 | return NextResponse.json({
29 | allowed: true,
30 | url: result.url
31 | });
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/Settings/Spacing/hooks/useLineHeight.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { ThLineHeightOptions, ThSettingsKeys } from "@/preferences/models/enums";
3 | import { usePreferences } from "@/preferences/hooks/usePreferences";
4 |
5 | /**
6 | * Hook that returns a mapping of line height options to their actual numeric values
7 | * This eliminates code duplication across spacing components
8 | */
9 | export const useLineHeight = () => {
10 | const { preferences } = usePreferences();
11 |
12 | return useMemo(() => ({
13 | [ThLineHeightOptions.publisher]: null,
14 | [ThLineHeightOptions.small]: preferences.settings.keys[ThSettingsKeys.lineHeight].keys[ThLineHeightOptions.small],
15 | [ThLineHeightOptions.medium]: preferences.settings.keys[ThSettingsKeys.lineHeight].keys[ThLineHeightOptions.medium],
16 | [ThLineHeightOptions.large]: preferences.settings.keys[ThSettingsKeys.lineHeight].keys[ThLineHeightOptions.large],
17 | }), [preferences.settings.keys]);
18 | };
19 |
--------------------------------------------------------------------------------
/src/core/Hooks/useLocalStorage.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useRef, useState } from "react";
4 |
5 | export const useLocalStorage = (key: string) => {
6 | const [localData, setLocalData] = useState(null);
7 | const cachedLocalData = useRef(null);
8 |
9 | const setValue = (newValue: any) => {
10 | setLocalData(newValue);
11 | localStorage.setItem(key, JSON.stringify(newValue));
12 | };
13 |
14 | const getValue = () => {
15 | if (localData !== null) return localData;
16 | const value = localStorage.getItem(key);
17 | return value ? JSON.parse(value) : null;
18 | };
19 |
20 | const clearValue = () => {
21 | setLocalData(null);
22 | localStorage.removeItem(key);
23 | };
24 |
25 | useEffect(() => {
26 | cachedLocalData.current = localData;
27 | }, [localData])
28 |
29 | return {
30 | setLocalData: setValue,
31 | getLocalData: getValue,
32 | clearLocalData: clearValue,
33 | localData,
34 | cachedLocalData
35 | };
36 | };
--------------------------------------------------------------------------------
/src/i18n/config.ts:
--------------------------------------------------------------------------------
1 | import i18n from "i18next";
2 | import { initReactI18next } from "react-i18next";
3 | import LanguageDetector from "i18next-browser-languagedetector";
4 | import Backend from "i18next-http-backend";
5 | import { InitOptions } from "i18next";
6 |
7 | export const DEFAULT_CONFIG: InitOptions = {
8 | fallbackLng: "en",
9 | load: "all",
10 | nonExplicitSupportedLngs: true,
11 | detection: {
12 | order: ["navigator"],
13 | caches: []
14 | },
15 | interpolation: {
16 | escapeValue: false
17 | },
18 | backend: {
19 | loadPath: "/locales/{{lng}}/{{ns}}.json"
20 | },
21 | ns: ["thorium-web"],
22 | defaultNS: "thorium-web"
23 | };
24 |
25 | export const initI18n = async (options: Partial = {}) => {
26 | if (i18n.isInitialized) {
27 | return i18n;
28 | }
29 |
30 | return i18n
31 | .use(Backend)
32 | .use(LanguageDetector)
33 | .use(initReactI18next)
34 | .init({
35 | ...DEFAULT_CONFIG,
36 | ...options
37 | });
38 | };
39 |
40 | export { i18n };
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 |
4 | import { ThStoreProvider } from "@/lib/ThStoreProvider";
5 | import { ThPreferencesProvider } from "@/preferences/ThPreferencesProvider";
6 | import { ThI18nProvider } from "@/i18n/ThI18nProvider";
7 |
8 | export const runtime = "edge";
9 |
10 | const inter = Inter({ subsets: ["latin"] });
11 |
12 | export const metadata: Metadata = {
13 | title: "Thorium Web",
14 | description: "Play with the capabilities of the Readium Web Toolkit",
15 | };
16 |
17 | export default function RootLayout({
18 | children,
19 | }: {
20 | children: React.ReactNode;
21 | }) {
22 | return (
23 |
24 |
25 |
26 |
27 |
28 | { children }
29 |
30 |
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/Settings/StatefulSwitch.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import settingsStyles from "./assets/styles/settings.module.css";
4 |
5 | import { ThSwitch, ThSwitchProps } from "@/core/Components/Settings/ThSwitch";
6 |
7 | export interface StatefulSwitchProps extends Omit {
8 | standalone?: boolean;
9 | }
10 |
11 | export const StatefulSwitch = ({
12 | standalone,
13 | label,
14 | heading,
15 | ...props
16 | }: StatefulSwitchProps) => {
17 | return(
18 | <>
19 |
36 | >
37 | )
38 | }
--------------------------------------------------------------------------------
/src/core/Components/Form/ThForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 |
5 | import { WithRef } from "../customTypes";
6 |
7 | import { Button, ButtonProps, Form, FormProps } from "react-aria-components";
8 |
9 | export interface ThFormProps extends FormProps {
10 | ref?: React.ForwardedRef;
11 | label: string;
12 | compounds?: {
13 | button?: Exclude, "type"> | React.ReactElement;
14 | }
15 | }
16 |
17 | export const ThForm = ({
18 | ref,
19 | label,
20 | compounds,
21 | children,
22 | ...props
23 | }: ThFormProps) => {
24 | return(
25 | <>
26 |
42 | >
43 | )
44 | }
--------------------------------------------------------------------------------
/src/components/index.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | export * from "./Actions";
4 | export * from "./Docking";
5 | export * from "./Plugins";
6 | export * from "./Settings";
7 | export * from "./Sheets";
8 | export * from "./StatefulLoader";
9 | export * from "./PublicationGrid";
10 | export * from "./StatefulPreferencesProvider";
11 |
12 | // export * from "../StatefulReaderArrowButton";
13 | // export * from "../StatefulReaderFooter";
14 | // export * from "../StatefulReaderHeader";
15 | // export * from "../StatefulReaderPagination";
16 | // export * from "../StatefulReaderProgression";
17 | // export * from "../StatefulReaderRunningHead";
18 | // export * from "../StatefulBackLink";
19 |
20 | export {
21 | useNavigator
22 | } from "../core/Navigator";
23 |
24 | export * from "../core/Helpers";
25 | export * from "../lib";
26 |
27 | export {
28 | usePreferences,
29 | ThPreferencesProvider,
30 | } from "../preferences";
31 |
32 | export {
33 | useTheming
34 | } from "../preferences/hooks";
35 |
36 | export * from "../i18n";
37 |
38 | export {
39 | usePublication,
40 | useReaderTransitions
41 | } from "../hooks";
--------------------------------------------------------------------------------
/src/core/Components/Containers/ThContainerHeader/ThContainerHeaderWithClose.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { WithRef } from "../../customTypes";
4 |
5 | import { HeadingProps } from "react-aria-components";
6 | import { ThActionButtonProps, ThCloseButton } from "../../Buttons";
7 | import { ThContainerHeader, ThContainerHeaderProps } from "./ThContainerHeader"
8 |
9 | export interface THContainerWithCloseProps extends ThContainerHeaderProps {
10 | closeRef?: React.ForwardedRef;
11 | children?: never;
12 | compounds?: {
13 | heading: WithRef;
14 | button: ThActionButtonProps;
15 | }
16 | }
17 | export const ThContainerHeaderWithClose = ({
18 | ref,
19 | closeRef,
20 | label,
21 | compounds,
22 | ...props
23 | }: THContainerWithCloseProps) => {
24 | return (
25 |
31 |
32 |
33 | )
34 | }
--------------------------------------------------------------------------------
/src/core/Components/Buttons/ThNavigationButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 |
5 | import ArrowBack from "../assets/icons/arrow_back.svg";
6 | import ArrowForward from "../assets/icons/arrow_forward.svg";
7 |
8 | import { ThActionButton, ThActionButtonProps } from "./ThActionButton";
9 |
10 | export interface ThNavigationButtonProps extends ThActionButtonProps {
11 | direction?: "left" | "right";
12 | }
13 |
14 | export const ThNavigationButton = ({
15 | direction,
16 | label,
17 | ref,
18 | compounds,
19 | children,
20 | ...props
21 | }: ThNavigationButtonProps) => {
22 | const fallBackChildren = (
23 |
24 | { direction === "right"
25 | ?
26 | :
27 | }
28 | { label }
29 |
30 | );
31 |
32 | return (
33 |
38 | { children || fallBackChildren }
39 |
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/StatefulPreferencesProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ReactNode, useMemo } from "react";
4 | import { useStore } from "react-redux";
5 |
6 | import { DefaultKeys, ThPreferences } from "@/preferences/preferences";
7 | import { defaultPreferences } from "@/preferences/defaultPreferences";
8 |
9 | import { ThPreferencesProvider } from "@/preferences/ThPreferencesProvider";
10 | import { ThReduxPreferencesAdapter } from "@/lib/ThReduxPreferencesAdapter";
11 |
12 | import { RootState } from "@/lib/store";
13 |
14 | export const StatefulPreferencesProvider = ({
15 | children,
16 | initialPreferences = defaultPreferences as ThPreferences
17 | }: {
18 | children: ReactNode;
19 | initialPreferences?: ThPreferences;
20 | }) => {
21 | const store = useStore();
22 |
23 | const adapter = useMemo(() => {
24 | return new ThReduxPreferencesAdapter(store, initialPreferences);
25 | }, [store, initialPreferences]);
26 |
27 | return (
28 |
29 | { children }
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/core/Hooks/useFullscreen.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useCallback, useEffect, useState } from "react";
4 | import { useIsClient } from "./useIsClient";
5 |
6 | export const useFullscreen = (onChange?: (isFullscreen: boolean) => void) => {
7 | const [isFullscreen, setIsFullscreen] = useState(false);
8 | const isClient = useIsClient();
9 |
10 | const handleFullscreen = useCallback(() => {
11 | if (!isClient) return;
12 |
13 | if (!document.fullscreenElement) {
14 | document.documentElement.requestFullscreen();
15 | } else if (document.exitFullscreen) {
16 | document.exitFullscreen();
17 | }
18 | }, [isClient]);
19 |
20 | useEffect(() => {
21 | const onFSchange = () => {
22 | const isFs = Boolean(document.fullscreenElement);
23 | setIsFullscreen(isFs);
24 | onChange && onChange(isFs);
25 | }
26 | document.addEventListener("fullscreenchange", onFSchange);
27 |
28 | return () => {
29 | document.removeEventListener("fullscreenchange", onFSchange);
30 | }
31 | }, [onChange]);
32 |
33 | return {
34 | isFullscreen,
35 | handleFullscreen
36 | }
37 | }
--------------------------------------------------------------------------------
/src/core/Hooks/useMediaQuery.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 |
5 | export const useMediaQuery = (query: string | null) => {
6 | const [matches, setMatches] = useState(false);
7 |
8 | useEffect(() => {
9 | if (!query) return;
10 |
11 | const mq = window.matchMedia(query);
12 |
13 | // Checking if media query is supported or well-formed
14 | // The media property is the normalized and resolved string representation of the query.
15 | // If matchMedia encounters something it doesn’t understand, that changes to "not all"
16 | const resolvedMediaQuery = mq.media;
17 | if (query !== resolvedMediaQuery) {
18 | console.error("Either this query is not supported or not well formed. Please double-check.");
19 | return;
20 | };
21 |
22 | if (mq.matches !== matches) {
23 | setMatches(mq.matches);
24 | }
25 |
26 | const handleMatch = () => setMatches(mq.matches);
27 | mq.addEventListener("change", handleMatch);
28 |
29 | return () => mq.removeEventListener("change", handleMatch);
30 | }, [matches, query]);
31 |
32 | return matches;
33 | }
--------------------------------------------------------------------------------
/src/core/Components/Containers/ThContainerHeader/ThContainerHeaderWithPrevious.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { WithRef } from "../../customTypes";
4 |
5 | import { HeadingProps } from "react-aria-components";
6 | import { ThNavigationButton, ThNavigationButtonProps } from "../../Buttons";
7 | import { ThContainerHeader, ThContainerHeaderProps } from "./ThContainerHeader"
8 |
9 | export interface THContainerWithPreviousProps extends ThContainerHeaderProps {
10 | previousRef?: React.ForwardedRef;
11 | children?: never;
12 | compounds?: {
13 | heading: WithRef;
14 | button: ThNavigationButtonProps;
15 | }
16 | }
17 | export const ThContainerHeaderWithPrevious = ({
18 | ref,
19 | previousRef,
20 | label,
21 | compounds,
22 | ...props
23 | }: THContainerWithPreviousProps) => {
24 | return (
25 |
31 |
32 |
33 | )
34 | }
--------------------------------------------------------------------------------
/src/components/Settings/hooks/useGridTemplate.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 |
5 | import debounce from "debounce";
6 |
7 | export const useGridTemplate = (ref: React.RefObject, type: "columns" | "rows" = "columns") => {
8 | const [visibleColumns, setVisibleColumns] = useState(null);
9 |
10 | const updateVisibleColumns = () => {
11 | if (!ref.current) return;
12 | const computedStyle = window.getComputedStyle(ref.current);
13 | const columns = computedStyle.getPropertyValue(`grid-template-${ type }`);
14 | const columnCount = columns.replace("0px", "").split(" ").length;
15 | setVisibleColumns(columnCount);
16 | };
17 |
18 | const debouncedUpdateVisibleColumns = debounce(updateVisibleColumns, 100);
19 |
20 | useEffect(() => {
21 | updateVisibleColumns();
22 |
23 | const resizeObserver = new ResizeObserver(debouncedUpdateVisibleColumns);
24 | if (ref.current) {
25 | resizeObserver.observe(ref.current);
26 | }
27 |
28 | return () => {
29 | resizeObserver.disconnect();
30 | debouncedUpdateVisibleColumns.clear();
31 | };
32 | });
33 |
34 | return visibleColumns;
35 | };
--------------------------------------------------------------------------------
/src/components/Actions/JumpToPosition/assets/styles/jumpToPosition.module.css:
--------------------------------------------------------------------------------
1 | .jumpToPositionForm {
2 | display: flex;
3 | gap: calc(var(--layout-spacing) / 2);
4 | }
5 |
6 | .jumpToPositionLabel {
7 | margin-block: var(--layout-spacing);
8 | display: block;
9 | }
10 |
11 | .jumpToPositionInput {
12 | display: block;
13 | font-weight: bold;
14 | padding: calc(var(--icon-size, 24px) * (1/4)) calc(var(--layout-spacing) / 2);
15 | border-radius: var(--layout-radius);
16 | border: 2px solid var(--theme-subdue);
17 | }
18 |
19 | .jumpToPositionButton {
20 | box-sizing: content-box;
21 | border: 2px solid var(--theme-subdue);
22 | padding: calc(var(--icon-size, 24px) * (1/4)) calc(var(--icon-size, 24px) * (1/2));
23 | text-align: center;
24 | border-radius: var(--layout-radius);
25 | margin-inline-start: auto;
26 | align-self: flex-end;
27 | }
28 |
29 | .jumpToPositionButton[data-hovered] {
30 | background-color: var(--theme-hover);
31 | }
32 |
33 | .jumpToPositionInput[data-focus-visible],
34 | .jumpToPositionButton[data-focus-visible] {
35 | outline: 2px solid var(--theme-focus);
36 | }
37 |
38 | .jumpToPositionNumberField[data-disabled],
39 | .jumpToPositionButton[data-disabled] {
40 | color: var(--theme-disable);
41 | }
--------------------------------------------------------------------------------
/src/core/Components/ThGrid.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 | import { HTMLAttributesWithRef } from "./customTypes";
5 |
6 | export interface ThGridProps extends HTMLAttributesWithRef {
7 | items: T[];
8 | children?: never;
9 | renderItem: (item: T, index: number) => React.ReactNode;
10 | columnWidth?: number | string;
11 | gap?: number | string;
12 | }
13 |
14 | export const ThGrid = ({
15 | ref,
16 | items,
17 | renderItem,
18 | columnWidth,
19 | gap,
20 | ...props
21 | }: ThGridProps) => {
22 | return (
23 |
37 | { items.map((item, index) => (
38 | -
39 | { renderItem(item, index) }
40 |
41 | )) }
42 |
43 | );
44 | };
--------------------------------------------------------------------------------
/src/i18n/ThI18nProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { ReactNode, useEffect, useState } from "react";
4 | import { I18nextProvider } from "react-i18next";
5 | import { i18n, initI18n } from "./config";
6 | import { InitOptions } from "i18next";
7 | import { usePreferences } from "@/preferences";
8 |
9 | export type ThI18nProviderProps = {
10 | children: ReactNode;
11 | } & Partial;
12 |
13 | export const ThI18nProvider = ({
14 | children,
15 | ...options
16 | }: ThI18nProviderProps) => {
17 | const { preferences } = usePreferences();
18 | const [isInitialized, setIsInitialized] = useState(false);
19 |
20 | useEffect(() => {
21 | if (!i18n.isInitialized) {
22 | initI18n({
23 | ...options,
24 | lng: preferences?.locale || options.lng,
25 | }).then(() => setIsInitialized(true));
26 | }
27 | });
28 |
29 | useEffect(() => {
30 | if (isInitialized && preferences?.locale) {
31 | i18n.changeLanguage(preferences.locale);
32 | }
33 | }, [preferences?.locale, isInitialized]);
34 |
35 | if (!isInitialized) {
36 | return null;
37 | }
38 |
39 | return { children };
40 | };
41 |
42 | export default ThI18nProvider;
43 |
--------------------------------------------------------------------------------
/src/core/Components/Containers/ThModal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 |
5 | import { WithRef } from "../customTypes";
6 |
7 | import { ThContainerProps } from "./ThContainer";
8 |
9 | import { Dialog, DialogProps, Modal, ModalOverlayProps } from "react-aria-components";
10 |
11 | import { useObjectRef } from "react-aria";
12 | import { useFirstFocusable } from "./hooks/useFirstFocusable";
13 |
14 | export interface ThModalProps extends Omit, ThContainerProps {
15 | compounds?: {
16 | dialog: WithRef;
17 | }
18 | }
19 |
20 | export const ThModal = ({
21 | ref,
22 | focusOptions,
23 | compounds,
24 | children,
25 | ...props
26 | }: ThModalProps) => {
27 | const resolvedRef = useObjectRef(ref as React.RefObject);
28 |
29 | const updatedFocusOptions = focusOptions ? {
30 | ...focusOptions,
31 | scrollerRef: focusOptions.scrollerRef || resolvedRef
32 | } : undefined;
33 |
34 | useFirstFocusable(updatedFocusOptions);
35 |
36 | return (
37 |
41 |
44 |
45 | )
46 | }
--------------------------------------------------------------------------------
/src/preferences/adapters/ThMemoryPreferencesAdapter.ts:
--------------------------------------------------------------------------------
1 | import { ThPreferences, CustomizableKeys } from "../preferences";
2 | import { ThPreferencesAdapter } from "./ThPreferencesAdapter";
3 |
4 | export class ThMemoryPreferencesAdapter implements ThPreferencesAdapter {
5 | private currentPreferences: ThPreferences;
6 | private listeners: Set<(prefs: ThPreferences) => void> = new Set();
7 |
8 | constructor(initialPreferences: ThPreferences) {
9 | this.currentPreferences = { ...initialPreferences };
10 | }
11 |
12 | public getPreferences(): ThPreferences {
13 | return { ...this.currentPreferences };
14 | }
15 |
16 | public setPreferences(prefs: ThPreferences): void {
17 | this.currentPreferences = { ...prefs };
18 | this.notifyListeners(this.currentPreferences);
19 | }
20 |
21 | public subscribe(listener: (prefs: ThPreferences) => void): void {
22 | this.listeners.add(listener);
23 | }
24 |
25 | public unsubscribe(listener: (prefs: ThPreferences) => void): void {
26 | this.listeners.delete(listener);
27 | }
28 |
29 | private notifyListeners(prefs: ThPreferences): void {
30 | this.listeners.forEach(listener => listener({ ...prefs }));
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/core/Helpers/focusUtilities.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | export const isActiveElement = (el: Element | undefined | null) => {
4 | if (el) return document.activeElement === el;
5 | return false;
6 | }
7 |
8 | export const isKeyboardTriggered = (el: Element | undefined | null) => {
9 | if (el) return el.matches(":focus-visible");
10 | return false;
11 | }
12 |
13 | export const isInteractiveElement = (element: Element | null) => {
14 | const iElements = ["A", "AREA", "BUTTON", "DETAILS", "INPUT", "SELECT", "TEXTAREA"];
15 | const iRoles = ["dialog", "radiogroup", "radio", "menu", "menuitem"]
16 |
17 | if (element && (element instanceof HTMLElement || element instanceof SVGElement)) {
18 | if (element.closest("[inert]")) return false;
19 | if (element.hasAttribute("disabled")) return false;
20 | if (element.role && iRoles.includes(element.role)) return true;
21 |
22 | // Panel Resize Handler cos’ of typo on tabIndex/tabindex
23 | if (element.hasAttribute("tabindex")) {
24 | const attr = element.getAttribute("tabindex");
25 | return attr && parseInt(attr, 10) >= 0;
26 | }
27 |
28 | if (element.tabIndex) return element.tabIndex >= 0;
29 | if (iElements.includes(element.tagName)) return true;
30 | }
31 |
32 | return false;
33 | }
--------------------------------------------------------------------------------
/src/core/Components/Form/Fields/ThFormNumberField.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { WithRef } from "../../customTypes";
4 |
5 | import {
6 | Input,
7 | InputProps,
8 | Label,
9 | LabelProps,
10 | NumberField,
11 | NumberFieldProps,
12 | Text
13 | } from "react-aria-components";
14 |
15 | export interface ThFormNumberFieldProps extends NumberFieldProps {
16 | ref?: React.ForwardedRef;
17 | label?: string;
18 | compounds?: {
19 | label?: WithRef;
20 | input?: WithRef;
21 | description?: string;
22 | }
23 | }
24 |
25 | export const ThFormNumberField = ({
26 | ref,
27 | label,
28 | compounds,
29 | children,
30 | ...props
31 | }: ThFormNumberFieldProps) => {
32 | return(
33 | <>
34 |
38 | { children
39 | ? children
40 | : <>
41 | { label &&
44 | }
45 |
46 |
47 |
48 | { compounds?.description &&
49 | { compounds?.description }
50 |
51 | }
52 | >
53 | }
54 |
55 | >
56 | )
57 | }
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import svgrPlugin from "esbuild-plugin-svgr";
2 |
3 | import { defineConfig } from "tsup";
4 |
5 | export default defineConfig({
6 | name: "Thorium Web",
7 | tsconfig: "./tsconfig.bundle.json",
8 | format: ["esm"],
9 | entry: [
10 | "src/core/Components/index.ts",
11 | "src/core/Helpers/index.ts",
12 | "src/core/Hooks/index.ts",
13 | "src/components/Epub/index.ts",
14 | "src/i18n/index.ts",
15 | "src/lib/index.ts",
16 | "src/preferences/index.ts",
17 | "src/next-lib/index.ts",
18 | "src/components/WebPub/index.ts"
19 | ],
20 | loader: {
21 | ".css": "copy"
22 | },
23 | esbuildPlugins: [svgrPlugin()],
24 | sourcemap: true,
25 | clean: true,
26 | dts: true,
27 | treeshake: true,
28 | splitting: true,
29 | bundle: true,
30 | noExternal: [
31 | "classNames",
32 | "debounce"
33 | ],
34 | external: [
35 | "react",
36 | "react-dom",
37 | "react-redux",
38 | "@reduxjs/toolkit",
39 | "react-aria",
40 | "react-aria-components",
41 | "react-stately",
42 | "react-resizable-panels",
43 | "react-modal-sheet",
44 | "i18next",
45 | "i18next-browser-languagedetector",
46 | "i18next-http-backend",
47 | "motion",
48 | "@readium/css",
49 | "@readium/navigator",
50 | "@readium/navigator-html-injectables",
51 | "@readium/shared"
52 | ]
53 | });
--------------------------------------------------------------------------------
/docs/EnvironmentVariables.md:
--------------------------------------------------------------------------------
1 | # Environment Variables
2 |
3 | The environment variables are used to configure the application. They can be set in the `.env` file or directly in bash when running the application.
4 |
5 | Remember that you have to rebuild and restart the app for the changes to take effect since environment variables in Next.js are embedded at build time.
6 |
7 | ## Manifest
8 |
9 | By default, the `/read/manifest/[base64url-encoded-manifest]` route is disabled in production for security reasons. Environment variables are therefore provided to enable it, as well as to configure the allowed domains for fetching the publication.
10 |
11 | ### MANIFEST_ROUTE_FORCE_ENABLE
12 |
13 | Set to true to enable manifest route in production.
14 |
15 | ```bash
16 | MANIFEST_ROUTE_FORCE_ENABLE=true
17 | ```
18 |
19 | ### MANIFEST_ALLOWED_DOMAINS
20 |
21 | Comma-separated list of allowed domains for manifest URLs in production.
22 |
23 | ```bash
24 | MANIFEST_ALLOWED_DOMAINS="publication-server.readium.org"
25 | ```
26 |
27 | You can also use `*` to allow all domains.
28 |
29 | ## Assets
30 |
31 | By default, the assets are fetched from the same domain as the application. An environment variable is therefore provided to configure the base path if needed.
32 |
33 | ### ASSET_PREFIX
34 |
35 | Set the base path for assets (e.g., CDN URL or subdirectory).
36 |
37 | ```bash
38 | ASSET_PREFIX="https://cdn.example.com"
39 | ```
--------------------------------------------------------------------------------
/src/core/Hooks/useContrast.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 | import { useMediaQuery } from "./useMediaQuery";
5 |
6 | export enum ThContrast {
7 | none = "no-preference",
8 | more = "more",
9 | less = "less",
10 | custom = "custom"
11 | }
12 |
13 | export const useContrast = (onChange?: (contrast: ThContrast) => void) => {
14 | const [contrast, setContrast] = useState(ThContrast.none);
15 |
16 | const prefersNoContrast = useMediaQuery(`(prefers-contrast: ${ ThContrast.none })`);
17 | const prefersLessContrast = useMediaQuery(`(prefers-contrast: ${ ThContrast.less })`);
18 | const prefersMoreContrast = useMediaQuery(`(prefers-contrast: ${ ThContrast.more })`);
19 | const prefersCustomContrast = useMediaQuery(`(prefers-contrast: ${ ThContrast.custom })`);
20 |
21 | useEffect(() => {
22 | let newContrast: ThContrast = ThContrast.none;
23 | if (prefersNoContrast) {
24 | newContrast = ThContrast.none;
25 | } else if (prefersLessContrast) {
26 | newContrast = ThContrast.less;
27 | } else if (prefersMoreContrast) {
28 | newContrast = ThContrast.more;
29 | } else if (prefersCustomContrast) {
30 | newContrast = ThContrast.custom;
31 | }
32 | setContrast(newContrast);
33 | onChange && onChange(newContrast);
34 | }, [onChange, prefersNoContrast, prefersLessContrast, prefersMoreContrast, prefersCustomContrast]);
35 |
36 | return contrast;
37 | }
--------------------------------------------------------------------------------
/src/components/StatefulReaderPagination.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRef, KeyboardEvent } from "react";
4 |
5 | import { ThPagination, ThPaginationProps } from "@/core/Components/Reader/ThPagination";
6 |
7 | import readerPaginationStyles from "./assets/styles/readerPagination.module.css";
8 |
9 | export const StatefulReaderPagination = ({
10 | ref,
11 | links,
12 | compounds,
13 | children,
14 | ...props
15 | }: ThPaginationProps) => {
16 | const previousButtonRef = useRef(null);
17 | const nextButtonRef = useRef(null);
18 |
19 | const updatedCompounds = {
20 | ...compounds,
21 | previousButton: {
22 | ...compounds?.previousButton,
23 | ref: previousButtonRef,
24 | onKeyDown: (e: KeyboardEvent) => {
25 | if (e.key === "Escape") {
26 | previousButtonRef.current?.blur();
27 | }
28 | }
29 | },
30 | nextButton: {
31 | ...compounds?.nextButton,
32 | ref: nextButtonRef,
33 | onKeyDown: (e: KeyboardEvent) => {
34 | if (e.key === "Escape") {
35 | nextButtonRef.current?.blur();
36 | }
37 | }
38 | }
39 | };
40 |
41 | return (
42 |
49 | { children }
50 |
51 | )
52 | }
--------------------------------------------------------------------------------
/src/components/Actions/StatefulCollapsibleActionsBar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRef } from "react";
4 |
5 | import { ThActionsKeys, ThDockingKeys } from "@/preferences";
6 |
7 | import { ThActionEntry } from "@/core/Components/Actions/ThActionsBar";
8 | import { ThCollapsibleActionsBar, ThCollapsibleActionsBarProps } from "@/core/Components/Actions/ThCollapsibleActionsBar";
9 | import { StatefulOverflowMenu } from "./StatefulOverflowMenu";
10 |
11 | import { useAppSelector } from "@/lib/hooks";
12 |
13 | export interface StatefulCollapsibleActionsBarProps extends ThCollapsibleActionsBarProps {
14 | items: ThActionEntry[];
15 | overflowMenuClassName?: string;
16 | }
17 |
18 | export const StatefulCollapsibleActionsBar = ({
19 | id,
20 | items,
21 | overflowMenuClassName,
22 | ...props
23 | }: StatefulCollapsibleActionsBarProps) => {
24 | const ref = useRef(null);
25 | const breakpoint = useAppSelector(state => state.theming.breakpoint);
26 |
27 | return (
28 | <>
29 | ) }}
41 | { ...props }
42 | />
43 | >
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/src/core/Components/Settings/ThSwitch.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { HTMLAttributesWithRef, WithRef } from "../customTypes";
4 |
5 | import { Heading, HeadingProps, Switch, SwitchProps } from "react-aria-components";
6 |
7 | export interface ThSwitchProps extends SwitchProps {
8 | ref?: React.ForwardedRef;
9 | label: string;
10 | heading?: string;
11 | compounds?: {
12 | /**
13 | * Props for the wrapper component. See `HTMLAttributesWithRef` for more information.
14 | */
15 | wrapper?: HTMLAttributesWithRef;
16 | /**
17 | * Props for the heading component. See `HeadingProps` for more information.
18 | */
19 | heading?: WithRef;
20 | /**
21 | * Props for the indicator component. See `HTMLAttributesWithRef` for more information.
22 | */
23 | indicator?: HTMLAttributesWithRef;
24 | }
25 | }
26 |
27 | export const ThSwitch = ({
28 | ref,
29 | label,
30 | compounds,
31 | heading,
32 | ...props
33 | }: ThSwitchProps) => {
34 | return(
35 | <>
36 |
37 | { heading &&
38 | { heading }
39 |
40 | }
41 |
45 |
46 | { label }
47 |
48 |
49 | >
50 | )
51 | }
--------------------------------------------------------------------------------
/src/core/Components/Containers/ThDockedPanel.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 | import { createPortal } from "react-dom";
5 |
6 | import { ThContainerProps } from "./ThContainer";
7 |
8 | import { FocusScope, useObjectRef } from "react-aria";
9 | import { useFirstFocusable } from "./hooks/useFirstFocusable";
10 |
11 | export interface ThDockedPanelProps extends Omit, "children">, ThContainerProps {
12 | isOpen: boolean;
13 | portal: HTMLElement | null;
14 | }
15 |
16 | export const ThDockedPanel = ({
17 | ref,
18 | isOpen,
19 | portal,
20 | focusOptions,
21 | children,
22 | ...props
23 | }: ThDockedPanelProps) => {
24 | const resolvedRef = useObjectRef(ref as React.RefObject);
25 |
26 | const updatedFocusOptions = focusOptions ? {
27 | ...focusOptions,
28 | scrollerRef: focusOptions.scrollerRef || resolvedRef
29 | } : undefined;
30 |
31 | useFirstFocusable(updatedFocusOptions);
32 |
33 | return (
34 | <>
35 | { isOpen && portal && createPortal(
36 |
41 |
45 | { children }
46 |
47 |
48 | , portal)
49 | }
50 | >
51 | )
52 | }
--------------------------------------------------------------------------------
/src/components/Actions/Triggers/StatefulOverflowMenuItem.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 |
5 | import overflowMenuStyles from "../assets/styles/overflowMenu.module.css";
6 |
7 | import { Text } from "react-aria-components";
8 | import { UnstableStatefulShortcut as StatefulShortcut } from "./UnstableStatefulShortcut";
9 |
10 | import { ThMenuItem, ThMenuItemProps } from "@/core/Components/Menu/ThMenuItem";
11 |
12 | export interface StatefulOverflowMenuItemProps extends Omit {
13 | shortcut?: string | null
14 | }
15 |
16 | export const StatefulOverflowMenuItem = ({
17 | id,
18 | label,
19 | SVGIcon,
20 | shortcut = undefined,
21 | ...props
22 | }: StatefulOverflowMenuItemProps) => {
23 | const menuItemLabelId = `${id}-label`;
24 |
25 | return(
26 | <>
27 |
34 | { SVGIcon && }
35 |
40 | { label }
41 |
42 | { shortcut && }
46 |
47 | >
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/assets/styles/publicationGrid.module.css:
--------------------------------------------------------------------------------
1 | .publicationGrid {
2 | --color-text: #333;
3 | --color-text-secondary: #666;
4 | --color-background: #fff;
5 | --color-primary: #e0e0e0;
6 |
7 | padding: 1rem;
8 | width: 100%;
9 | }
10 |
11 | .publicationCard {
12 | display: flex;
13 | text-decoration: none;
14 | color: inherit;
15 | border: 1px solid var(--color-primary);
16 | border-radius: 8px;
17 | overflow: hidden;
18 | transition: transform 0.2s ease, box-shadow 0.2s ease;
19 | background: white;
20 | }
21 |
22 | .publicationCard:hover {
23 | transform: translateY(-2px);
24 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
25 | }
26 |
27 | .publicationCover {
28 | width: 120px;
29 | height: 180px;
30 | flex-shrink: 0;
31 | margin: 0;
32 | }
33 |
34 | .publicationImage {
35 | width: 120px;
36 | height: 180px;
37 | object-fit: cover;
38 | }
39 |
40 | .publicationInfo {
41 | padding: 1rem;
42 | display: flex;
43 | flex-direction: column;
44 | flex-grow: 1;
45 | }
46 |
47 | .publicationTitle {
48 | margin: 0 0 0.5rem;
49 | font-weight: 600;
50 | font-size: 1.25rem;
51 | color: var(--color-text);
52 | }
53 |
54 | .publicationAuthor {
55 | margin: 0 0 0.75rem;
56 | color: var(--color-text-secondary);
57 | font-size: 1rem;
58 | }
59 |
60 | .publicationRendition {
61 | background: var(--color-primary);
62 | color: var(--color-text);
63 | padding: 0.25rem 0.75rem;
64 | margin: 0;
65 | border-radius: 20px;
66 | font-size: 0.875rem;
67 | font-weight: 500;
68 | margin-top: auto;
69 | align-self: flex-start;
70 | }
--------------------------------------------------------------------------------
/src/core/Components/Menu/ThMenuItem.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 | import { KeyboardProps } from "react-aria";
5 |
6 | import { WithRef } from "../customTypes";
7 |
8 | import { Keyboard, LabelProps, MenuItem, MenuItemProps, Text } from "react-aria-components";
9 |
10 | export interface ThMenuItemProps extends MenuItemProps {
11 | ref?: React.Ref;
12 | id: string;
13 | SVGIcon?: React.ComponentType>;
14 | label: string;
15 | shortcut?: string;
16 | compounds?: {
17 | label: WithRef;
18 | shortcut: WithRef;
19 | }
20 | }
21 |
22 | export const ThMenuItem = ({
23 | ref,
24 | id,
25 | SVGIcon,
26 | label,
27 | shortcut,
28 | compounds,
29 | children,
30 | ...props
31 | }: ThMenuItemProps) => {
32 | const menuItemLabelId = `${ id }-label`;
33 | return(
34 | <>
35 |
56 | >
57 | )
58 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2024, EDRLab (European Digital Reading Lab)
4 |
5 | Redistribution and use in source and binary forms, with or without
6 | modification, are permitted provided that the following conditions are met:
7 |
8 | 1. Redistributions of source code must retain the above copyright notice, this
9 | list of conditions and the following disclaimer.
10 |
11 | 2. Redistributions in binary form must reproduce the above copyright notice,
12 | this list of conditions and the following disclaimer in the documentation
13 | and/or other materials provided with the distribution.
14 |
15 | 3. Neither the name of the copyright holder nor the names of its
16 | contributors may be used to endorse or promote products derived from
17 | this software without specific prior written permission.
18 |
19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 |
--------------------------------------------------------------------------------
/src/core/Components/Containers/ThPopover.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 |
5 | import { WithRef } from "../customTypes";
6 |
7 | import { ThContainerProps } from "./ThContainer";
8 |
9 | import { Dialog, DialogProps, Popover, PopoverProps } from "react-aria-components";
10 |
11 | import { useObjectRef } from "react-aria";
12 | import { useFirstFocusable } from "./hooks/useFirstFocusable";
13 |
14 | export interface ThPopoverProps extends Omit, ThContainerProps {
15 | triggerRef: React.RefObject;
16 | compounds?: {
17 | dialog: WithRef;
18 | }
19 | }
20 |
21 | export const ThPopover = ({
22 | ref,
23 | triggerRef,
24 | focusOptions,
25 | compounds,
26 | maxHeight,
27 | children,
28 | ...props
29 | }: ThPopoverProps) => {
30 | const resolvedRef = useObjectRef(ref as React.RefObject);
31 |
32 | const updatedFocusOptions = focusOptions ? {
33 | ...focusOptions,
34 | scrollerRef: focusOptions.scrollerRef || resolvedRef
35 | } : undefined;
36 |
37 | useFirstFocusable(updatedFocusOptions);
38 |
39 | const computeMaxHeight = () => {
40 | if (!resolvedRef.current) return;
41 | return window.innerHeight - resolvedRef.current.offsetTop;
42 | };
43 |
44 | return (
45 |
51 |
54 |
55 | )
56 | }
--------------------------------------------------------------------------------
/src/preferences/helpers/buildThemeObject.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ThColorScheme } from "@/core/Hooks/useColorScheme";
4 | import { ThemeTokens } from "../hooks/useTheming";
5 |
6 | export interface buildThemeProps {
7 | theme?: string;
8 | themeKeys: { [key in T]?: ThemeTokens },
9 | systemThemes?: {
10 | light: T,
11 | dark: T
12 | },
13 | colorScheme?: ThColorScheme;
14 | }
15 |
16 | export const buildThemeObject = ({
17 | theme,
18 | themeKeys,
19 | systemThemes,
20 | colorScheme
21 | }: buildThemeProps) => {
22 | if (!theme) {
23 | return {};
24 | }
25 |
26 | if (theme === "auto" && colorScheme && systemThemes) {
27 | theme = colorScheme === ThColorScheme.dark ? systemThemes.dark : systemThemes.light;
28 | }
29 |
30 | let themeProps = {};
31 |
32 | const themeToken = themeKeys[theme as T];
33 | if (themeToken) {
34 | themeProps = {
35 | backgroundColor: themeToken.background,
36 | textColor: themeToken.text,
37 | linkColor: themeToken.link,
38 | selectionBackgroundColor: themeToken.select,
39 | selectionTextColor: themeToken.onSelect,
40 | visitedColor: themeToken.visited
41 | };
42 | } else {
43 | // Fallback if theme doesn't exist
44 | console.warn(`Theme key "${String(theme)}" not found in themeKeys.`);
45 | themeProps = {
46 | backgroundColor: null,
47 | textColor: null,
48 | linkColor: null,
49 | selectionBackgroundColor: null,
50 | selectionTextColor: null,
51 | visitedColor: null
52 | };
53 | }
54 |
55 | return themeProps;
56 | };
--------------------------------------------------------------------------------
/src/components/assets/styles/readerArrowButton.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | position: absolute;
3 | z-index: 2;
4 | }
5 |
6 | #left {
7 | top: 50vh;
8 | top: 50dvh;
9 | transform: translateY(-50%);
10 | left: 0;
11 | }
12 |
13 | #left button {
14 | margin-left: var(--arrow-offset, 0);
15 | }
16 |
17 | #right {
18 | top: 50vh;
19 | top: 50dvh;
20 | transform: translateY(-50%);
21 | right: 0;
22 | }
23 |
24 | #right button {
25 | margin-right: var(--arrow-offset, 0);
26 | }
27 |
28 | .container button {
29 | width: var(--arrow-size, 40px);
30 | /* height: var(--arrow-size, 40px); */
31 | height: 40vh;
32 | height: 40dvh;
33 | border-radius: var(--layout-radius);
34 | pointer-events: auto;
35 | box-sizing: border-box;
36 | padding: 5px;
37 |
38 | background-color: var(--theme-background);
39 | border: 1px solid var(--theme-text);
40 | }
41 |
42 | .container .viewportLarge {
43 | background-color: transparent;
44 | border: none;
45 | }
46 |
47 | .container button:disabled {
48 | pointer-events: none;
49 | }
50 |
51 | .container .visuallyHidden {
52 | opacity: 0;
53 | }
54 |
55 | .container button[data-focused] {
56 | outline: 2px solid var(--theme-focus);
57 | opacity: 1;
58 | }
59 |
60 | .container button[data-disabled] {
61 | opacity: 0;
62 | }
63 |
64 | /* Exclude taps as they will eventually apply hover state */
65 | @media (hover: hover) and (pointer: fine) {
66 | .container button:not(:disabled):hover {
67 | opacity: 1;
68 | transition: all 200ms;
69 | }
70 | }
71 |
72 | .container button svg {
73 | fill: var(--theme-text);
74 | stroke: var(--theme-text);
75 | width: 100%;
76 | height: 100%;
77 | }
--------------------------------------------------------------------------------
/src/components/Sheets/StatefulSheetWrapper.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ReactNode } from "react";
4 |
5 | import { ThDockingKeys, ThSheetTypes } from "@/preferences/models/enums";
6 |
7 | import { ThTypedComponentRenderer } from "@/core/Components/Containers/ThTypedComponentRenderer";
8 | import { StatefulPopoverSheet, StatefulPopoverSheetProps } from "./StatefulPopoverSheet";
9 | import { StatefulBottomSheet, StatefulBottomSheetProps } from "./StatefulBottomSheet";
10 | import { StatefulFullScreenSheet, StatefulFullScreenSheetProps } from "./StatefulFullScreenSheet";
11 | import { StatefulDockedSheet, StatefulDockedSheetProps } from "./StatefulDockedSheet";
12 |
13 | const componentMap = {
14 | [ThSheetTypes.popover]: StatefulPopoverSheet,
15 | [ThSheetTypes.bottomSheet]: StatefulBottomSheet,
16 | [ThSheetTypes.fullscreen]: StatefulFullScreenSheet,
17 | [ThSheetTypes.dockedStart]: (props: StatefulDockedSheetProps) => ,
18 | [ThSheetTypes.dockedEnd]: (props: StatefulDockedSheetProps) =>
19 | };
20 |
21 | export const StatefulSheetWrapper = ({
22 | sheetType,
23 | sheetProps,
24 | children
25 | }: {
26 | sheetType: ThSheetTypes,
27 | sheetProps: StatefulPopoverSheetProps | StatefulFullScreenSheetProps | StatefulDockedSheetProps | StatefulBottomSheetProps,
28 | children: ReactNode
29 | }) => {
30 |
31 | return (
32 |
37 | { children }
38 |
39 | );
40 | }
--------------------------------------------------------------------------------
/src/core/Components/Buttons/ThActionButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { WithRef } from "../customTypes";
4 |
5 | import { Button, ButtonProps, Tooltip, TooltipProps, TooltipTrigger } from "react-aria-components";
6 | import { TooltipTriggerProps } from "react-aria";
7 |
8 | export interface ThActionButtonProps extends ButtonProps {
9 | label?: string,
10 | ref?: React.ForwardedRef,
11 | compounds?: {
12 | /**
13 | * Props for the tooltipTrigger component. See `TooltipTriggerProps` for more information.
14 | */
15 | tooltipTrigger?: WithRef,
16 | /**
17 | * Props for the tooltip component. See `TooltipProps` for more information.
18 | */
19 | tooltip?: WithRef,
20 | /**
21 | * String for the tooltip
22 | */
23 | label: string
24 | }
25 | }
26 |
27 | export const ThActionButton = ({
28 | ref,
29 | compounds,
30 | children,
31 | ...props
32 | }: ThActionButtonProps) => {
33 | if (compounds) {
34 | return (
35 | <>
36 |
39 |
45 |
49 | { compounds.label }
50 |
51 |
52 | >
53 | )
54 | } else {
55 | return (
56 | <>
57 |
62 | >
63 | )
64 | }
65 | }
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import("next").NextConfig} */
2 | const nextConfig = {
3 | // Disable React running twice as it messes up with iframes
4 | reactStrictMode: false,
5 | typedRoutes: true,
6 | experimental: {
7 | webpackBuildWorker: true,
8 | },
9 | // Configure asset prefix for CDN or subdirectory support
10 | assetPrefix: process.env.ASSET_PREFIX || undefined,
11 | webpack(config) {
12 | const fileLoaderRule = config.module.rules.find((rule) =>
13 | rule.test?.test?.(".svg"),
14 | )
15 |
16 | config.module.rules.push(
17 | // Reapply the existing rule, but only for svg imports ending in ?url
18 | {
19 | ...fileLoaderRule,
20 | test: /\.svg$/i,
21 | resourceQuery: /url/, // *.svg?url
22 | },
23 | // Convert all other *.svg imports to React components
24 | {
25 | test: /\.svg$/i,
26 | issuer: fileLoaderRule.issuer,
27 | resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, // exclude if *.svg?url
28 | use: ["@svgr/webpack"],
29 | },
30 | )
31 |
32 | // Modify the file loader rule to ignore *.svg, since we have it handled now.
33 | fileLoaderRule.exclude = /\.svg$/i
34 |
35 | return config
36 | },
37 | async redirects() {
38 | const isProduction = process.env.NODE_ENV === "production";
39 | const isManifestEnabled = !isProduction || process.env.MANIFEST_ROUTE_FORCE_ENABLE === "true";
40 |
41 | if (isProduction && !isManifestEnabled) {
42 | return [
43 | {
44 | source: "/read/manifest/:path*",
45 | destination: "/",
46 | permanent: false,
47 | },
48 | ];
49 | }
50 | return [];
51 | }
52 | };
53 |
54 | export default nextConfig;
55 |
--------------------------------------------------------------------------------
/src/core/Components/Form/Fields/ThFormTextField.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { WithRef } from "../../customTypes";
4 |
5 | import {
6 | FieldError,
7 | FieldErrorProps,
8 | Input,
9 | InputProps,
10 | Label,
11 | LabelProps,
12 | Text,
13 | TextField,
14 | TextFieldProps,
15 | ValidationResult
16 | } from "react-aria-components";
17 |
18 | export interface ThFormTextFieldProps extends TextFieldProps {
19 | ref?: React.ForwardedRef;
20 | label?: string;
21 | compounds?: {
22 | label?: WithRef;
23 | input?: WithRef;
24 | description?: string;
25 | fieldError?: WithRef;
26 | },
27 | errorMessage?: string | ((validation: ValidationResult) => string);
28 | }
29 |
30 | export const ThFormTextField = ({
31 | ref,
32 | label,
33 | children,
34 | compounds,
35 | errorMessage,
36 | ...props
37 | }: ThFormTextFieldProps) => {
38 | return(
39 | <>
40 |
44 | <>
45 | { children
46 | ? children
47 | : <>
48 | { label &&
51 | }
52 |
53 | { errorMessage &&
54 | { errorMessage }
55 |
56 | }
57 |
58 |
59 |
60 | { compounds?.description &&
61 | { compounds?.description }
62 |
63 | }
64 | >
65 | }
66 | >
67 |
68 | >
69 | )
70 | }
--------------------------------------------------------------------------------
/src/core/Helpers/getPlatform.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | // Extend Navigator interface to include userAgentData
4 | declare global {
5 | interface Navigator {
6 | userAgentData?: {
7 | brands: Array<{brand: string; version: string}>;
8 | mobile: boolean;
9 | platform: string;
10 | };
11 | }
12 | }
13 |
14 | // See https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgentData
15 | export const getPlatform = () => {
16 | if (typeof window !== "undefined") {
17 | const nav = window.navigator;
18 |
19 | if (nav.userAgentData) {
20 | return nav.userAgentData.platform.toLowerCase();
21 | }
22 |
23 | // Deprecated but userAgentData still experimental…
24 | if (typeof nav.platform !== "undefined") {
25 | // android navigator.platform is often set as "linux", so we have to check userAgent
26 | if (typeof nav.userAgent !== "undefined" && /android/.test(nav.userAgent.toLowerCase())) {
27 | return "android";
28 | }
29 | return nav.platform.toLowerCase();
30 | }
31 | }
32 |
33 | return "unknown";
34 | };
35 |
36 | export const isMacish = () => {
37 | const MacOSPattern = /mac|ipod|iphone|ipad/i;
38 | const platform = getPlatform();
39 | return MacOSPattern.test(platform);
40 | }
41 |
42 | // “Desktop-class” iPadOS
43 | export const isIpadOS = () => {
44 | return !!(navigator.maxTouchPoints
45 | && navigator.maxTouchPoints > 2
46 | && navigator.userAgent.includes("Intel"));
47 | }
48 |
49 | // Stopgap measure for fullscreen on iPadOS, do not use elsewhere
50 | export const isIOSish = () => {
51 | const AppleMobilePattern = /ipod|iphone|ipad/i;
52 | const platform = getPlatform();
53 | if (AppleMobilePattern.test(platform)) {
54 | return true;
55 | } else {
56 | return isIpadOS();
57 | }
58 | }
--------------------------------------------------------------------------------
/src/core/Components/Links/ThLink.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Link, LinkProps, Tooltip, TooltipProps, TooltipTrigger } from "react-aria-components";
4 | import { WithRef } from "../customTypes";
5 | import { TooltipTriggerProps } from "react-aria";
6 |
7 | export interface ThLinkProps extends LinkProps {
8 | ref?: React.ForwardedRef;
9 | href: string;
10 | children: React.ReactNode;
11 | compounds?: {
12 | /**
13 | * Props for the tooltipTrigger component. See `TooltipTriggerProps` for more information.
14 | */
15 | tooltipTrigger?: WithRef,
16 | /**
17 | * Props for the tooltip component. See `TooltipProps` for more information.
18 | */
19 | tooltip?: WithRef,
20 | /**
21 | * String for the tooltip
22 | */
23 | label: string
24 | }
25 | }
26 |
27 | export interface ThLinkIconProps extends Omit {
28 | "aria-label": string;
29 | }
30 |
31 | export const ThLink = ({
32 | ref,
33 | href,
34 | children,
35 | compounds,
36 | ...props
37 | }: ThLinkProps) => {
38 | if (compounds) {
39 | return (
40 |
43 |
48 | { children }
49 |
50 |
54 | { compounds.label }
55 |
56 |
57 | );
58 | } else {
59 | return (
60 |
65 | { children }
66 |
67 | );
68 | }
69 | };
--------------------------------------------------------------------------------
/src/app/read/manifest/[manifest]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { use, useEffect, useState } from "react";
4 | import { StatefulReader } from "@/components/Epub";
5 | import { StatefulLoader } from "@/components/StatefulLoader";
6 | import { usePublication } from "@/hooks/usePublication";
7 | import { useAppSelector } from "@/lib/hooks";
8 | import { verifyManifestUrl } from "@/app/api/verify-manifest/verifyDomain";
9 |
10 | import "@/app/app.css";
11 |
12 | type Params = { manifest: string };
13 |
14 | type Props = {
15 | params: Promise;
16 | };
17 |
18 | export default function ManifestPage({ params }: Props) {
19 | const [domainError, setDomainError] = useState(null);
20 | const isLoading = useAppSelector(state => state.reader.isLoading);
21 | const manifestUrl = use(params).manifest;
22 |
23 | useEffect(() => {
24 | if (manifestUrl) {
25 | verifyManifestUrl(manifestUrl).then(allowed => {
26 | if (!allowed) {
27 | setDomainError(`Domain not allowed: ${ new URL(manifestUrl).hostname }`);
28 | }
29 | });
30 | }
31 | }, [manifestUrl]);
32 |
33 | const { error, manifest, selfLink } = usePublication({
34 | url: manifestUrl,
35 | onError: (error) => {
36 | console.error("Manifest loading error:", error);
37 | }
38 | });
39 |
40 | if (domainError) {
41 | return (
42 |
43 |
Access Denied
44 |
{ domainError }
45 |
46 | );
47 | }
48 |
49 | if (error) {
50 | return (
51 |
52 |
Error
53 |
{ error }
54 |
55 | );
56 | }
57 |
58 | return (
59 |
60 | { manifest && selfLink && }
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/components/Actions/assets/styles/overflowMenu.module.css:
--------------------------------------------------------------------------------
1 | :global(.layered-ui.isImmersive:not(.isHovering)) .hintButton {
2 | transform: translateY(calc(var(--icon-size, 24px) * 2.5));
3 | transition-property: transform;
4 | transition-duration: 200ms;
5 | transition-timing-function: ease-in-out;
6 | }
7 |
8 | :global(.layered-ui.isReflow.isScroll.isImmersive:not(.isHovering)) .hintButton {
9 | outline: 1px solid var(--theme-subdue);
10 | background-color: var(--theme-background);
11 | }
12 |
13 | .overflowPopover {
14 | background-color: var(--theme-background);
15 | color: var(--theme-text);
16 | padding: calc(var(--layout-spacing) / 2);
17 | border-radius: var(--layout-radius);
18 | border: 1px solid var(--theme-subdue);
19 | filter: drop-shadow(var(--theme-elevate));
20 | box-sizing: border-box;
21 | max-width: var(--constraints-popover, 500px);
22 | width: max-content;
23 | }
24 |
25 | .overflowMenu {
26 | outline: none;
27 | }
28 |
29 | .menuItem {
30 | display: flex;
31 | align-items: center;
32 | gap: calc(var(--layout-spacing) / 2);
33 | padding: calc(var(--layout-spacing) / 2);
34 | border-radius: var(--layout-radius);
35 | outline: none;
36 | }
37 |
38 | .menuItem[data-hovered] {
39 | background-color: var(--theme-hover);
40 | }
41 |
42 | .menuItem[data-focus-visible] {
43 | outline: 2px solid var(--theme-focus);
44 | }
45 |
46 | .menuItem[data-disabled] {
47 | color: var(--theme-disable);
48 | }
49 |
50 | .menuItem > svg {
51 | width: calc(var(--icon-size, 24px) / 1.5);
52 | height: calc(var(--icon-size, 24px) / 1.5);
53 | fill: currentColor;
54 | }
55 |
56 | .menuItemLabel {
57 | font-size: 1rem;
58 | }
59 |
60 | .menuItemKbdShortcut {
61 | font-family: monospace;
62 | font-weight: bold;
63 | color: var(--theme-subdue);
64 | padding: 5px;
65 | margin-inline-start: auto;
66 | border-radius: var(--layout-radius);
67 | border: 1px solid var(--theme-subdue);
68 | }
--------------------------------------------------------------------------------
/src/components/Actions/Triggers/UnstableStatefulShortcut.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 |
5 | import { UnstableShortcut, UnstableShortcutRepresentation, buildShortcut, metaKeys } from "@/core/Helpers/keyboardUtilities";
6 |
7 | import { Keyboard } from "react-aria-components";
8 |
9 | import { usePreferences } from "@/preferences/hooks/usePreferences";
10 |
11 | import { useAppSelector } from "@/lib/hooks";
12 |
13 | export const UnstableStatefulShortcut = ({
14 | className,
15 | rawForm,
16 | representation,
17 | joiner
18 | }: UnstableShortcut) => {
19 | const { preferences } = usePreferences();
20 | const platformModifier = useAppSelector(state => state.reader.platformModifier);
21 |
22 | representation = representation ? representation : preferences.shortcuts.representation || UnstableShortcutRepresentation.symbol;
23 | joiner = joiner ? joiner : preferences.shortcuts.joiner || " + ";
24 |
25 | const shortcutObj = buildShortcut(rawForm);
26 |
27 | if (shortcutObj) {
28 | let shortcutRepresentation = [];
29 |
30 | for (const prop in shortcutObj.modifiers) {
31 | if (shortcutObj.modifiers[prop]) {
32 | if (prop === "platformKey") {
33 | shortcutRepresentation.push(platformModifier[representation]);
34 | } else {
35 | const metaKey = metaKeys[prop];
36 | shortcutRepresentation.push(metaKey[representation as UnstableShortcutRepresentation]);
37 | }
38 | }
39 | }
40 |
41 | if (shortcutObj.char) {
42 | shortcutRepresentation.push(shortcutObj.char);
43 | }
44 |
45 | if (shortcutRepresentation.length > 0) {
46 | const displayShortcut = shortcutRepresentation.join(joiner);
47 |
48 | return (
49 | { displayShortcut }
50 | )
51 | } else {
52 | return (
53 | <>>
54 | )
55 | }
56 | }
57 |
58 | return (
59 | <>>
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/src/components/Settings/hooks/usePlaceholder.ts:
--------------------------------------------------------------------------------
1 | import { ThSettingsRangePlaceholder } from "@/preferences";
2 |
3 | import { useI18n } from "@/i18n/useI18n";
4 |
5 | export const usePlaceholder = (
6 | placeholder: ThSettingsRangePlaceholder | string | { key: string; fallback?: string } | undefined,
7 | range: [number, number],
8 | format?: "percent" | "number" | "multiplier"
9 | ): string | undefined => {
10 | const { t } = useI18n();
11 |
12 | if (!placeholder) {
13 | return undefined;
14 | }
15 |
16 | // Handle enum values
17 | if (placeholder === ThSettingsRangePlaceholder.none) {
18 | return undefined;
19 | }
20 | if (placeholder === ThSettingsRangePlaceholder.range) {
21 | switch (format) {
22 | case "percent":
23 | const minRange = range[0] * 100;
24 | const maxRange = range[1] * 100;
25 | const minPercent = minRange === 0 ? "0" : `${minRange}%`;
26 | const maxPercent = maxRange === 0 ? "0" : `${maxRange}%`;
27 | return `${ minPercent } - ${ maxPercent }`;
28 | case "multiplier":
29 | const minMultiplierRange = range[0];
30 | const maxMultiplierRange = range[1];
31 | const minMultiplier = minMultiplierRange === 0 ? "0" : `${minMultiplierRange}×`;
32 | const maxMultiplier = maxMultiplierRange === 0 ? "0" : `${maxMultiplierRange}×`;
33 | return `${ minMultiplier } - ${ maxMultiplier }`;
34 | case "number":
35 | default:
36 | return `${ range[0] } - ${ range[1] }`;
37 | }
38 | }
39 |
40 | // Handle i18n object
41 | if (typeof placeholder === "object" && "key" in placeholder) {
42 | const translatedPlaceholder = t(placeholder.key);
43 | return translatedPlaceholder !== placeholder.key ? translatedPlaceholder : placeholder.fallback;
44 | }
45 |
46 | // Handle string values (literal text, not translated)
47 | if (typeof placeholder === "string") {
48 | return placeholder;
49 | }
50 |
51 | return undefined;
52 | };
--------------------------------------------------------------------------------
/src/components/Settings/Text/StatefulTextNormalize.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useCallback } from "react";
4 |
5 | import { StatefulSettingsItemProps } from "../models/settings";
6 |
7 | import { StatefulSwitch } from "../StatefulSwitch";
8 |
9 | import { useNavigator } from "@/core/Navigator";
10 | import { useI18n } from "@/i18n/useI18n";
11 |
12 | import { useAppDispatch, useAppSelector } from "@/lib/hooks";
13 | import { setTextNormalization } from "@/lib/settingsReducer";
14 | import { setWebPubTextNormalization } from "@/lib/webPubSettingsReducer";
15 |
16 | // TMP Component that is not meant to be implemented AS-IS, for testing purposes
17 | export const StatefulTextNormalize = ({ standalone = true }: StatefulSettingsItemProps) => {
18 | const { t } = useI18n();
19 |
20 | const profile = useAppSelector(state => state.reader.profile);
21 | const isWebPub = profile === "webPub";
22 |
23 | const textNormalization = useAppSelector(state => isWebPub ? state.webPubSettings.textNormalization : state.settings.textNormalization) ?? false;
24 | const dispatch = useAppDispatch();
25 |
26 | const { getSetting, submitPreferences } = useNavigator();
27 |
28 | const updatePreference = useCallback(async (value: boolean) => {
29 | await submitPreferences({ textNormalization: value });
30 | const effectiveSetting = getSetting("textNormalization");
31 |
32 | if (isWebPub) {
33 | dispatch(setWebPubTextNormalization(effectiveSetting));
34 | } else {
35 | dispatch(setTextNormalization(effectiveSetting));
36 | }
37 | }, [isWebPub, submitPreferences, getSetting, dispatch]);
38 |
39 | return(
40 | <>
41 | await updatePreference(isSelected) }
46 | isSelected={ textNormalization ?? false }
47 | />
48 | >
49 | )
50 | }
--------------------------------------------------------------------------------
/docs/packages/ReadMe.md:
--------------------------------------------------------------------------------
1 | # Thorium Web Package
2 |
3 | Thorium Web provides a collection of React components, hooks, and helpers that can be used to build a web reading application.
4 |
5 | ## Docs
6 |
7 | The Thorium Web package is a collection of components that can be used to build a web application. You will find docs in folders [Core](./Core/) and [Epub](./Epub/).
8 |
9 | ## Usage
10 |
11 | To use the Thorium Web package, you will need to add it as a dependency in your project. You can do this by running the following command:
12 |
13 | ```bash
14 | npm install @edrlab/thorium-web @readium/css @readium/navigator @readium/navigator-html-injectables @readium/shared react-redux @reduxjs/toolkit i18next i18next-browser-languagedetector i18next-http-backend motion react-aria react-aria-components react-stately react-modal-sheet react-resizable-panels
15 | ```
16 |
17 | Components are relying on peer dependencies to work. You must install them manually.
18 |
19 | Note that these components do not require Next.js, you should be able to use them in any React application or React framework of your choice.
20 |
21 | ## Contributing
22 |
23 | If you want to contribute to the Thorium Web package, here are the steps to bundle them and test locally.
24 |
25 | - Add your exports to their relevant `index.ts` files, following the existing format (per folder)
26 | - Run `pnpm bundle` to bundle the package
27 | - Run `pnpm link` to link the package
28 | - In your local project, run `pnpm link @edrlab/thorium-web` to link the package
29 | - Add the package as a dependency in your `package.json` file using the `link:` protocol
30 | - Run `pnpm install` to install the package in your local project
31 |
32 | > [!Important]
33 | > Make sure to add dependencies in both `dependencies` and `peerDependencies` in the `package.json` file, and exclude them from [tsup.config.ts](../../tsup.config.ts) by listing them in `external`. If you are unsure whether a smaller dependency should be included or not, please ask the maintainers of this project.
--------------------------------------------------------------------------------
/docs/packages/Core/API/Components/Actions.md:
--------------------------------------------------------------------------------
1 | # Actions Components API Documentation
2 |
3 | ## ThActionsBar
4 |
5 | A toolbar component that serves as a container for action buttons and menus.
6 |
7 | ### Props
8 |
9 | `ThActionsBarProps` extends `ToolbarProps` from react-aria-components:
10 |
11 | ```typescript
12 | interface ThActionsBarProps extends ToolbarProps {
13 | ref?: React.ForwardedRef
14 | }
15 | ```
16 |
17 | ### Types
18 |
19 | ```typescript
20 | enum ThActionsTriggerVariant {
21 | button = "iconButton",
22 | menu = "menuItem"
23 | }
24 |
25 | interface ThActionEntry {
26 | key: T; // Unique identifier for the action
27 | associatedKey?: string; // Optional associated key for linking actions
28 | Trigger: React.ComponentType; // Component that triggers the action
29 | Target?: React.ComponentType; // Optional component rendered when action is triggered
30 | }
31 | ```
32 |
33 | ## ThCollapsibleActionsBar
34 |
35 | An extension of ThActionsBar that supports collapsible actions with overflow menu functionality.
36 |
37 | ### Props
38 |
39 | ```typescript
40 | interface ThCollapsibleActionsBarProps extends ThActionsBarProps {
41 | id: string; // Unique identifier for the collapsible actions bar
42 | items: ThActionEntry[]; // Array of action items to display
43 | prefs: CollapsiblePref; // Preferences for collapsible behavior
44 | breakpoint?: string; // Optional breakpoint for responsive behavior
45 | compounds?: {
46 | menu: THMenuProps | React.ReactElement; // Configuration for overflow menu
47 | }
48 | }
49 | ```
50 |
51 | ### Features
52 |
53 | - Automatically collapses actions into an overflow menu based on preferences and breakpoints
54 | - Supports both button and menu item variants for actions
55 | - Handles associated actions through `associatedKey` property
56 | - Integrates with ThMenu component for overflow menu functionality
--------------------------------------------------------------------------------
/src/components/Actions/Toc/StatefulTocTrigger.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ThActionsKeys } from "@/preferences/models/enums";
4 |
5 | import TocIcon from "./assets/icons/toc.svg";
6 |
7 | import { StatefulActionTriggerProps } from "../models/actions";
8 | import { ThActionsTriggerVariant } from "@/core/Components/Actions/ThActionsBar";
9 |
10 | import { StatefulActionIcon } from "../Triggers/StatefulActionIcon";
11 | import { StatefulOverflowMenuItem } from "../Triggers/StatefulOverflowMenuItem";
12 |
13 | import { usePreferences } from "@/preferences/hooks/usePreferences";
14 | import { useI18n } from "@/i18n/useI18n";
15 |
16 | import { useAppDispatch, useAppSelector } from "@/lib/hooks";
17 | import { setActionOpen } from "@/lib/actionsReducer";
18 |
19 | export const StatefulTocTrigger = ({ variant }: StatefulActionTriggerProps) => {
20 | const { preferences } = usePreferences();
21 | const { t } = useI18n();
22 | const actionState = useAppSelector(state => state.actions.keys[ThActionsKeys.toc]);
23 | const dispatch = useAppDispatch();
24 |
25 | const setOpen = (value: boolean) => {
26 | dispatch(setActionOpen({
27 | key: ThActionsKeys.toc,
28 | isOpen: value
29 | }));
30 | }
31 |
32 | return(
33 | <>
34 | { (variant && variant === ThActionsTriggerVariant.menu)
35 | ? setOpen(!actionState?.isOpen) }
41 | />
42 | : setOpen(!actionState?.isOpen) }
48 | >
49 |
50 |
51 | }
52 | >
53 | )
54 | }
--------------------------------------------------------------------------------
/src/components/assets/styles/readerPagination.module.css:
--------------------------------------------------------------------------------
1 | .pagination {
2 | box-sizing: border-box;
3 | display: grid;
4 | gap: calc(var(--layout-spacing) / 2);
5 | grid-template-areas: "pagination-start pagination-center pagination-end";
6 | grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
7 | background-color: var(--theme-background);
8 | color: var(--theme-text);
9 | align-items: center;
10 | width: 100%;
11 | max-width: var(--constraints-pagination, 100%);
12 | }
13 |
14 | .paginationListItem {
15 | box-sizing: border-box;
16 | list-style: none;
17 | }
18 |
19 | .paginationListItem:has(.previousButton) {
20 | grid-area: pagination-start;
21 | justify-self: start;
22 | }
23 |
24 | .paginationListItem:has(.progression) {
25 | grid-area: pagination-center;
26 | justify-self: center;
27 | }
28 |
29 | .paginationListItem:has(.nextButton) {
30 | grid-area: pagination-end;
31 | justify-self: end;
32 | }
33 |
34 | .paginationListItem button {
35 | box-sizing: border-box;
36 | padding: calc(var(--icon-size, 24px) * (1/4)) calc(var(--layout-spacing) / 2);
37 | gap: calc(var(--layout-spacing) / 2);
38 | max-height: calc(var(--icon-size, 24px) * 2);
39 | max-width: 100%;
40 | border-radius: var(--layout-radius);
41 | display: flex;
42 | align-items: center;
43 | }
44 |
45 | .nextButton {
46 | margin-inline-start: auto;
47 | text-align: end;
48 | }
49 |
50 | .paginationListItem button[data-hovered] {
51 | background-color: var(--theme-hover);
52 | }
53 |
54 | .paginationListItem button[data-focus-visible] {
55 | outline: 2px solid var(--theme-focus);
56 | }
57 |
58 | .paginationListItem button[data-disabled] {
59 | color: var(--theme-disable);
60 | }
61 |
62 | .paginationListItem button .paginationLabel {
63 | display: -webkit-box;
64 | -webkit-box-orient: vertical;
65 | overflow: hidden;
66 | white-space: normal;
67 | -webkit-line-clamp: 1;
68 | line-clamp: 1;
69 | }
70 |
71 | .paginationListItem button svg {
72 | flex: none;
73 | width: var(--icon-size, 24px);
74 | height: var(--icon-size, 24px);
75 | fill: var(--theme-text);
76 | }
--------------------------------------------------------------------------------
/src/hooks/usePublication.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 | import { Link } from "@readium/shared";
5 | import { HttpFetcher } from "@readium/shared";
6 |
7 | export interface UsePublicationOptions {
8 | url: string;
9 | onError?: (error: string) => void;
10 | }
11 |
12 | export const usePublication = ({
13 | url,
14 | onError = () => {}
15 | }: UsePublicationOptions) => {
16 | const [error, setError] = useState("");
17 | const [manifest, setManifest] = useState