├── .gitmodules ├── .nvmrc ├── public ├── locales │ ├── uk │ │ └── thorium-web.json │ └── ar │ │ └── thorium-web.json └── images │ ├── Bella.jpg │ ├── MobyDick.jpg │ ├── readium-css.jpg │ ├── LesDiaboliques.png │ ├── accessibleEpub3.jpg │ ├── ChildrensLiterature.png │ └── TheHouseOfTheSevenGables.jpg ├── src ├── components │ ├── Docking │ │ ├── index.ts │ │ ├── hooks │ │ │ └── index.ts │ │ └── assets │ │ │ ├── icons │ │ │ ├── dialogs.svg │ │ │ ├── dock_to_left.svg │ │ │ ├── dock_to_right.svg │ │ │ └── stack.svg │ │ │ └── styles │ │ │ └── docking.module.css │ ├── Actions │ │ ├── models │ │ │ ├── index.ts │ │ │ └── actions.ts │ │ ├── Fullscreen │ │ │ ├── index.ts │ │ │ └── assets │ │ │ │ └── icons │ │ │ │ ├── fullscreen.svg │ │ │ │ └── fullscreen_exit.svg │ │ ├── Toc │ │ │ ├── index.ts │ │ │ ├── assets │ │ │ │ └── icons │ │ │ │ │ ├── chevron_right.svg │ │ │ │ │ └── toc.svg │ │ │ └── StatefulTocTrigger.tsx │ │ ├── Triggers │ │ │ ├── index.ts │ │ │ ├── StatefulOverflowMenuItem.tsx │ │ │ └── UnstableStatefulShortcut.tsx │ │ ├── Settings │ │ │ ├── index.ts │ │ │ ├── assets │ │ │ │ └── icons │ │ │ │ │ └── match_case.svg │ │ │ └── StatefulSettingsTrigger.tsx │ │ ├── JumpToPosition │ │ │ ├── index.ts │ │ │ ├── assets │ │ │ │ ├── icons │ │ │ │ │ └── pin_drop.svg │ │ │ │ └── styles │ │ │ │ │ └── jumpToPosition.module.css │ │ │ └── StatefulJumpToPositionTrigger.tsx │ │ ├── index.ts │ │ ├── assets │ │ │ ├── icons │ │ │ │ └── more_vert.svg │ │ │ └── styles │ │ │ │ └── overflowMenu.module.css │ │ └── StatefulCollapsibleActionsBar.tsx │ ├── Sheets │ │ ├── models │ │ │ ├── index.ts │ │ │ └── sheets.ts │ │ ├── index.ts │ │ └── StatefulSheetWrapper.tsx │ ├── Settings │ │ ├── models │ │ │ ├── index.ts │ │ │ └── settings.ts │ │ ├── hooks │ │ │ ├── index.ts │ │ │ ├── useGridTemplate.ts │ │ │ └── usePlaceholder.ts │ │ ├── Spacing │ │ │ ├── hooks │ │ │ │ ├── index.ts │ │ │ │ └── useLineHeight.ts │ │ │ ├── assets │ │ │ │ └── icons │ │ │ │ │ ├── density_large.svg │ │ │ │ │ ├── density_medium.svg │ │ │ │ │ ├── density_small.svg │ │ │ │ │ ├── tune.svg │ │ │ │ │ └── accessibility.svg │ │ │ ├── index.ts │ │ │ ├── helpers │ │ │ │ └── spacingSettings.ts │ │ │ └── StatefulSpacingGroup.tsx │ │ ├── assets │ │ │ └── icons │ │ │ │ ├── text_decrease.svg │ │ │ │ ├── text_increase.svg │ │ │ │ ├── book.svg │ │ │ │ ├── zoom_out.svg │ │ │ │ └── zoom_in.svg │ │ ├── Text │ │ │ ├── assets │ │ │ │ └── icons │ │ │ │ │ ├── format_align_justify.svg │ │ │ │ │ ├── format_align_left.svg │ │ │ │ │ ├── format_align_right.svg │ │ │ │ │ ├── format_bold_wght200.svg │ │ │ │ │ └── format_bold_wght500.svg │ │ │ ├── index.ts │ │ │ ├── StatefulTextNormalize.tsx │ │ │ ├── StatefulHyphens.tsx │ │ │ └── StatefulTextGroup.tsx │ │ ├── index.ts │ │ ├── StatefulSwitch.tsx │ │ ├── StatefulDropdown.tsx │ │ └── StatefulNumberField.tsx │ ├── Plugins │ │ ├── helpers │ │ │ └── index.ts │ │ └── index.ts │ ├── Epub │ │ ├── Settings │ │ │ ├── index.ts │ │ │ ├── assets │ │ │ │ └── icons │ │ │ │ │ ├── check.svg │ │ │ │ │ ├── article.svg │ │ │ │ │ ├── docs.svg │ │ │ │ │ ├── contract.svg │ │ │ │ │ ├── document_scanner.svg │ │ │ │ │ └── menu_book.svg │ │ │ └── StatefulLayout.tsx │ │ └── index.ts │ ├── assets │ │ └── styles │ │ │ ├── readerProgression.module.css │ │ │ ├── backLink.module.css │ │ │ ├── readerLoader.module.css │ │ │ ├── readerHeader.module.css │ │ │ ├── publicationGrid.module.css │ │ │ ├── readerArrowButton.module.css │ │ │ ├── readerPagination.module.css │ │ │ └── readerSharedUI.module.css │ ├── WebPub │ │ └── index.ts │ ├── StatefulLoader.tsx │ ├── index.ts │ ├── StatefulPreferencesProvider.tsx │ └── StatefulReaderPagination.tsx ├── next-lib │ └── index.ts ├── core │ ├── Hooks │ │ ├── Epub │ │ │ └── index.ts │ │ ├── WebPub │ │ │ └── index.ts │ │ ├── useDocumentTitle.ts │ │ ├── usePrevious.ts │ │ ├── useIsClient.ts │ │ ├── useMonochrome.ts │ │ ├── useForcedColors.ts │ │ ├── useReducedMotion.ts │ │ ├── index.ts │ │ ├── useReducedTransparency.ts │ │ ├── useColorScheme.ts │ │ ├── useLocalStorage.ts │ │ ├── useFullscreen.ts │ │ ├── useMediaQuery.ts │ │ └── useContrast.ts │ ├── Navigator │ │ ├── hooks │ │ │ ├── index.ts │ │ │ └── useNavigatorContext.ts │ │ ├── index.ts │ │ └── NavigatorProvider.tsx │ ├── Components │ │ ├── Containers │ │ │ ├── hooks │ │ │ │ └── index.ts │ │ │ ├── ThBottomSheet │ │ │ │ ├── index.ts │ │ │ │ ├── assets │ │ │ │ │ └── icons │ │ │ │ │ │ └── horizontal_rule.svg │ │ │ │ └── ThDragIndicatorButton.tsx │ │ │ ├── ThContainerHeader │ │ │ │ ├── index.ts │ │ │ │ ├── ThContainerHeader.tsx │ │ │ │ ├── ThContainerHeaderWithClose.tsx │ │ │ │ └── ThContainerHeaderWithPrevious.tsx │ │ │ ├── index.ts │ │ │ ├── ThContainerBody.tsx │ │ │ ├── ThContainer.ts │ │ │ ├── ThTypedComponentRenderer.tsx │ │ │ ├── ThModal.tsx │ │ │ ├── ThDockedPanel.tsx │ │ │ └── ThPopover.tsx │ │ ├── Form │ │ │ ├── index.ts │ │ │ ├── Fields │ │ │ │ ├── index.ts │ │ │ │ ├── assets │ │ │ │ │ └── icons │ │ │ │ │ │ └── search.svg │ │ │ │ ├── ThFormNumberField.tsx │ │ │ │ └── ThFormTextField.tsx │ │ │ └── ThForm.tsx │ │ ├── Actions │ │ │ ├── hooks │ │ │ │ ├── index.ts │ │ │ │ └── useActions.ts │ │ │ ├── index.ts │ │ │ ├── ThActionsBar.tsx │ │ │ └── ThCollapsibleActionsBar.tsx │ │ ├── Settings │ │ │ ├── ThDropdown │ │ │ │ ├── index.ts │ │ │ │ ├── assets │ │ │ │ │ └── icons │ │ │ │ │ │ └── arrow_drop_down.svg │ │ │ │ └── ThDropdownButton.tsx │ │ │ ├── ThSettingsWrapper │ │ │ │ ├── index.ts │ │ │ │ ├── ThSettingsWrapperButton.tsx │ │ │ │ └── assets │ │ │ │ │ └── icons │ │ │ │ │ └── settings.svg │ │ │ ├── assets │ │ │ │ └── icons │ │ │ │ │ ├── remove.svg │ │ │ │ │ ├── add.svg │ │ │ │ │ └── undo.svg │ │ │ ├── index.ts │ │ │ ├── ThSettingsResetButton.tsx │ │ │ └── ThSwitch.tsx │ │ ├── Menu │ │ │ ├── index.ts │ │ │ ├── assets │ │ │ │ └── icons │ │ │ │ │ └── more_vert.svg │ │ │ ├── ThMenuButton.tsx │ │ │ └── ThMenuItem.tsx │ │ ├── Links │ │ │ ├── index.ts │ │ │ ├── assets │ │ │ │ └── icons │ │ │ │ │ ├── home.svg │ │ │ │ │ └── newsstand.svg │ │ │ ├── ThHome.tsx │ │ │ ├── ThLibrary.tsx │ │ │ ├── ThBackArrow.tsx │ │ │ └── ThLink.tsx │ │ ├── assets │ │ │ └── icons │ │ │ │ ├── arrow_back.svg │ │ │ │ └── arrow_forward.svg │ │ ├── Buttons │ │ │ ├── assets │ │ │ │ └── icons │ │ │ │ │ ├── close.svg │ │ │ │ │ └── delete.svg │ │ │ ├── index.ts │ │ │ ├── ThCloseButton.tsx │ │ │ ├── ThDeleteButton.tsx │ │ │ ├── ThNavigationButton.tsx │ │ │ └── ThActionButton.tsx │ │ ├── Reader │ │ │ ├── index.ts │ │ │ ├── ThFooter.tsx │ │ │ ├── ThHeader.tsx │ │ │ ├── ThProgression.tsx │ │ │ ├── ThRunningHead.tsx │ │ │ ├── ThLoader.tsx │ │ │ └── ThInteractiveOverlay.tsx │ │ ├── index.ts │ │ ├── customTypes.ts │ │ └── ThGrid.tsx │ └── Helpers │ │ ├── index.ts │ │ ├── propsToCSSVars.ts │ │ ├── focusUtilities.ts │ │ ├── getPlatform.ts │ │ ├── progressionFormat.ts │ │ └── breakpointsMap.ts ├── app │ ├── favicon.ico │ ├── app.css │ ├── ManifestRouteEnabled.ts │ ├── api │ │ └── verify-manifest │ │ │ ├── verifyDomain.ts │ │ │ └── route.ts │ ├── home.css │ ├── layout.tsx │ └── read │ │ ├── manifest │ │ └── [manifest] │ │ │ └── page.tsx │ │ ├── [identifier] │ │ └── page.tsx │ │ └── experimental │ │ └── [identifier] │ │ └── page.tsx ├── preferences │ ├── helpers │ │ ├── index.ts │ │ └── buildThemeObject.ts │ ├── models │ │ └── index.ts │ ├── adapters │ │ ├── index.ts │ │ ├── ThPreferencesAdapter.ts │ │ └── ThMemoryPreferencesAdapter.ts │ ├── hooks │ │ ├── index.ts │ │ ├── usePreferences.ts │ │ └── usePreferenceKeys.ts │ ├── index.ts │ ├── ThDirectionSetter.tsx │ ├── ThPreferencesContext.ts │ └── CSSValues.ts ├── hooks │ ├── index.ts │ ├── usePublication.ts │ └── useReaderTransitions.ts ├── i18n │ ├── index.ts │ ├── useI18n.ts │ ├── config.ts │ └── ThI18nProvider.tsx ├── lib │ ├── hooks.ts │ ├── index.ts │ ├── ThStoreProvider.tsx │ ├── ThReduxPreferencesAdapter.ts │ └── themeReducer.ts └── helpers │ └── deserializePositions.ts ├── .eslintrc.json ├── .npmrc ├── thorium-web.png ├── wrangler.toml ├── react.d.ts ├── svgr.d.ts ├── docs ├── packages │ ├── Core │ │ ├── API │ │ │ └── Components │ │ │ │ ├── Misc.md │ │ │ │ ├── Actions.md │ │ │ │ └── Menu.md │ │ └── ReadMe.md │ ├── Epub │ │ └── API │ │ │ └── Hooks.md │ └── ReadMe.md └── EnvironmentVariables.md ├── .env ├── .gitignore ├── tsconfig.json ├── tsconfig.bundle.json ├── tsup.config.ts ├── LICENSE └── next.config.mjs /.gitmodules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22 -------------------------------------------------------------------------------- /public/locales/uk/thorium-web.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /src/components/Docking/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hooks"; -------------------------------------------------------------------------------- /src/next-lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./helpers/verifyManifest"; -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # Disable running scripts during installation 2 | ignore-scripts=true -------------------------------------------------------------------------------- /src/components/Actions/models/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./actions"; -------------------------------------------------------------------------------- /src/components/Sheets/models/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./sheets"; -------------------------------------------------------------------------------- /src/core/Hooks/Epub/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./useEpubNavigator"; -------------------------------------------------------------------------------- /thorium-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edrlab/thorium-web/HEAD/thorium-web.png -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edrlab/thorium-web/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /src/components/Docking/hooks/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./useDocking"; -------------------------------------------------------------------------------- /src/components/Settings/models/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./settings"; -------------------------------------------------------------------------------- /src/core/Hooks/WebPub/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./useWebPubNavigator"; -------------------------------------------------------------------------------- /src/preferences/helpers/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./buildThemeObject"; -------------------------------------------------------------------------------- /src/core/Navigator/hooks/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./useNavigatorContext"; -------------------------------------------------------------------------------- /public/images/Bella.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edrlab/thorium-web/HEAD/public/images/Bella.jpg -------------------------------------------------------------------------------- /src/components/Plugins/helpers/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./createDefaultPlugin"; -------------------------------------------------------------------------------- /public/images/MobyDick.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edrlab/thorium-web/HEAD/public/images/MobyDick.jpg -------------------------------------------------------------------------------- /src/core/Components/Containers/hooks/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./useFirstFocusable"; -------------------------------------------------------------------------------- /src/preferences/models/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./const"; 4 | export * from "./enums"; -------------------------------------------------------------------------------- /public/images/readium-css.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edrlab/thorium-web/HEAD/public/images/readium-css.jpg -------------------------------------------------------------------------------- /src/components/Actions/Fullscreen/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./StatefulFullscreenTrigger"; -------------------------------------------------------------------------------- /public/images/LesDiaboliques.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edrlab/thorium-web/HEAD/public/images/LesDiaboliques.png -------------------------------------------------------------------------------- /src/core/Components/Form/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./ThForm"; 4 | export * from "./Fields"; 5 | -------------------------------------------------------------------------------- /src/core/Navigator/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./NavigatorProvider"; 4 | export * from "./hooks"; -------------------------------------------------------------------------------- /public/images/accessibleEpub3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edrlab/thorium-web/HEAD/public/images/accessibleEpub3.jpg -------------------------------------------------------------------------------- /src/components/Settings/models/settings.ts: -------------------------------------------------------------------------------- 1 | export interface StatefulSettingsItemProps { 2 | standalone?: boolean; 3 | } -------------------------------------------------------------------------------- /public/images/ChildrensLiterature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edrlab/thorium-web/HEAD/public/images/ChildrensLiterature.png -------------------------------------------------------------------------------- /public/images/TheHouseOfTheSevenGables.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edrlab/thorium-web/HEAD/public/images/TheHouseOfTheSevenGables.jpg -------------------------------------------------------------------------------- /src/components/Settings/hooks/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./useGridNavigation"; 4 | export * from "./useGridTemplate"; -------------------------------------------------------------------------------- /src/core/Components/Actions/hooks/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./useActions"; 4 | export * from "./useCollapsibility"; -------------------------------------------------------------------------------- /src/components/Actions/Toc/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./StatefulTocContainer"; 4 | export * from "./StatefulTocTrigger"; -------------------------------------------------------------------------------- /src/components/Plugins/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export type { ThPlugin } from "./PluginRegistry"; 4 | 5 | export * from "./helpers"; -------------------------------------------------------------------------------- /src/components/Settings/Spacing/hooks/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./useLineHeight"; 4 | export * from "./useSpacingPresets"; -------------------------------------------------------------------------------- /src/core/Components/Settings/ThDropdown/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./ThDropdown"; 4 | export * from "./ThDropdownButton"; -------------------------------------------------------------------------------- /src/components/Actions/Triggers/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./StatefulActionIcon"; 4 | export * from "./StatefulOverflowMenuItem"; -------------------------------------------------------------------------------- /src/preferences/adapters/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./ThPreferencesAdapter"; 4 | export * from "./ThMemoryPreferencesAdapter"; -------------------------------------------------------------------------------- /src/app/app.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | color: var(--theme-text); 3 | background-color: var(--theme-background); 4 | height: 100%; 5 | margin: 0; 6 | } -------------------------------------------------------------------------------- /src/core/Components/Containers/ThBottomSheet/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./ThBottomSheet"; 4 | export * from "./ThDragIndicatorButton"; -------------------------------------------------------------------------------- /src/core/Components/Menu/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./ThMenu"; 4 | export * from "./ThMenuButton"; 5 | export * from "./ThMenuItem"; -------------------------------------------------------------------------------- /src/components/Actions/Settings/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./StatefulSettingsContainer"; 4 | export * from "./StatefulSettingsTrigger"; -------------------------------------------------------------------------------- /src/core/Components/Settings/ThSettingsWrapper/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./ThSettingsWrapper"; 4 | export * from "./ThSettingsWrapperButton"; -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./usePaginatedArrows"; 4 | export * from "./usePublication"; 5 | export * from "./useReaderTransitions"; -------------------------------------------------------------------------------- /src/preferences/hooks/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./usePreferences"; 4 | export * from "./usePreferenceKeys"; 5 | export * from "./useTheming"; -------------------------------------------------------------------------------- /src/core/Components/Actions/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./hooks"; 4 | export * from "./ThActionsBar"; 5 | export * from "./ThCollapsibleActionsBar"; -------------------------------------------------------------------------------- /src/components/Actions/JumpToPosition/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./StatefulJumpToPositionContainer"; 4 | export * from "./StatefulJumpToPositionTrigger"; -------------------------------------------------------------------------------- /src/components/Epub/Settings/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./StatefulColumns"; 4 | export * from "./StatefulLayout"; 5 | export * from "./StatefulTheme"; -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "thorium-web" 2 | compatibility_flags = [ "nodejs_compat" ] 3 | compatibility_date = "2024-09-23" 4 | pages_build_output_dir = ".vercel/output/static" -------------------------------------------------------------------------------- /src/core/Components/Form/Fields/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./ThFormNumberField"; 4 | export * from "./ThFormTextField"; 5 | export * from "./ThFormSearchField"; -------------------------------------------------------------------------------- /src/core/Components/Links/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./ThBackArrow"; 4 | export * from "./ThLink"; 5 | export * from "./ThHome"; 6 | export * from "./ThLibrary"; -------------------------------------------------------------------------------- /src/components/assets/styles/readerProgression.module.css: -------------------------------------------------------------------------------- 1 | #current { 2 | color: var(--theme-text); 3 | font-variant-numeric: lining-nums tabular-nums; 4 | text-align: center; 5 | } -------------------------------------------------------------------------------- /src/components/WebPub/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "../index"; 4 | 5 | export * from "./StatefulReader"; 6 | 7 | export { 8 | useWebPubNavigator 9 | } from "../../core/Hooks"; -------------------------------------------------------------------------------- /react.d.ts: -------------------------------------------------------------------------------- 1 | declare module "react" { 2 | // allow CSS custom properties 3 | interface CSSProperties { 4 | [varName: `--${string}`]: string | number | undefined; 5 | } 6 | } 7 | 8 | export {}; -------------------------------------------------------------------------------- /src/core/Components/Settings/assets/icons/remove.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Components/Containers/ThContainerHeader/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./ThContainerHeader"; 4 | export * from "./ThContainerHeaderWithClose"; 5 | export * from "./ThContainerHeaderWithPrevious"; -------------------------------------------------------------------------------- /src/core/Components/Settings/ThDropdown/assets/icons/arrow_drop_down.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Components/assets/icons/arrow_back.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Components/assets/icons/arrow_forward.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Epub/Settings/assets/icons/check.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Epub/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "../index"; 4 | 5 | export * from "./Settings"; 6 | export * from "./StatefulReader"; 7 | 8 | export { 9 | useEpubNavigator 10 | } from "../../core/Hooks"; -------------------------------------------------------------------------------- /src/components/Settings/Spacing/assets/icons/density_large.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Components/Settings/assets/icons/add.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Actions/Toc/assets/icons/chevron_right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Hooks/useDocumentTitle.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | 5 | export const useDocumentTitle = (title?: string) => { 6 | useEffect(() => { 7 | if (title) document.title = title; 8 | }, [title]); 9 | }; -------------------------------------------------------------------------------- /src/app/ManifestRouteEnabled.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | export const isManifestRouteEnabled = async (): Promise => { 4 | return process.env.NODE_ENV === "development" || 5 | process.env.MANIFEST_ROUTE_FORCE_ENABLE === "true"; 6 | }; -------------------------------------------------------------------------------- /src/components/Settings/Spacing/assets/icons/density_medium.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | // Re-export the Trans component from react-i18next for complex translations 2 | export { Trans } from "react-i18next"; 3 | 4 | export { ThI18nProvider } from "./ThI18nProvider"; 5 | export * from "./config"; 6 | export * from "./useI18n"; -------------------------------------------------------------------------------- /svgr.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | import { FC, SVGProps } from "react" 3 | const content: FC> 4 | export default content 5 | } 6 | 7 | declare module "*.svg?url" { 8 | const content: any 9 | export default content 10 | } -------------------------------------------------------------------------------- /src/core/Components/Buttons/assets/icons/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Settings/Spacing/assets/icons/density_small.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Helpers/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./breakpointsMap"; 4 | export * from "./focusUtilities"; 5 | export * from "./getPlatform"; 6 | export * from "./keyboardUtilities"; 7 | export * from "./progressionFormat"; 8 | export * from "./propsToCSSVars"; -------------------------------------------------------------------------------- /src/core/Components/Buttons/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./ThActionButton"; 4 | export * from "./ThCloseButton"; 5 | export * from "./ThDeleteButton"; 6 | export * from "../Containers/ThBottomSheet/ThDragIndicatorButton"; 7 | export * from "./ThNavigationButton"; -------------------------------------------------------------------------------- /src/components/Settings/assets/icons/text_decrease.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Components/Links/assets/icons/home.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Settings/Text/assets/icons/format_align_justify.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Settings/Text/assets/icons/format_align_left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Sheets/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./StatefulBottomSheet"; 4 | export * from "./StatefulDockedSheet"; 5 | export * from "./StatefulFullScreenSheet"; 6 | export * from "./StatefulPopoverSheet"; 7 | export * from "./StatefulSheetWrapper"; 8 | export * from "./models"; -------------------------------------------------------------------------------- /src/core/Components/Reader/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./ThFooter"; 4 | export * from "./ThLoader"; 5 | export * from "./ThHeader"; 6 | export * from "./ThInteractiveOverlay"; 7 | export * from "./ThPagination"; 8 | export * from "./ThProgression"; 9 | export * from "./ThRunningHead"; -------------------------------------------------------------------------------- /src/components/Settings/Text/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./StatefulFontFamily"; 4 | export * from "./StatefulFontWeight"; 5 | export * from "./StatefulHyphens"; 6 | export * from "./StatefulTextAlign"; 7 | export * from "./StatefulTextGroup"; 8 | export * from "./StatefulTextNormalize"; -------------------------------------------------------------------------------- /src/core/Components/Links/assets/icons/newsstand.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Settings/Text/assets/icons/format_align_right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Components/Containers/ThBottomSheet/assets/icons/horizontal_rule.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Actions/Fullscreen/assets/icons/fullscreen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Actions/Fullscreen/assets/icons/fullscreen_exit.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Settings/assets/icons/text_increase.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Components/Settings/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./ThNumberField"; 4 | export * from "./ThRadioGroup"; 5 | export * from "./ThSlider"; 6 | export * from "./ThSwitch"; 7 | export * from "./ThDropdown"; 8 | export * from "./ThSettingsWrapper"; 9 | export * from "./ThSettingsResetButton"; 10 | -------------------------------------------------------------------------------- /src/core/Components/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./Actions"; 4 | export * from "./Buttons"; 5 | export * from "./Containers"; 6 | export * from "./Form"; 7 | export * from "./Links"; 8 | export * from "./Menu"; 9 | export * from "./Reader"; 10 | export * from "./Settings"; 11 | export * from "./ThGrid"; -------------------------------------------------------------------------------- /src/core/Components/Settings/assets/icons/undo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useRef } from "react"; 4 | 5 | export const usePrevious = (value: T): T | null => { 6 | const ref = useRef(null); 7 | 8 | useEffect(() => { 9 | ref.current = value; 10 | }, [value]); 11 | 12 | return ref.current; 13 | } 14 | -------------------------------------------------------------------------------- /src/core/Components/customTypes.ts: -------------------------------------------------------------------------------- 1 | // We have to patch React Aria Components as they do not define ref in their prop types 2 | 3 | export interface HTMLAttributesWithRef extends React.HTMLAttributes { 4 | ref?: React.ForwardedRef; 5 | } 6 | 7 | export type WithRef = T & { 8 | ref?: React.ForwardedRef; 9 | }; -------------------------------------------------------------------------------- /src/preferences/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./preferences"; 4 | export * from "./ThPreferencesContext"; 5 | export * from "./defaultPreferences"; 6 | export * from "./ThPreferencesProvider"; 7 | export * from "./adapters"; 8 | export * from "./helpers"; 9 | export * from "./hooks"; 10 | export * from "./models"; -------------------------------------------------------------------------------- /src/components/Actions/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./models"; 4 | 5 | export * from "./Fullscreen"; 6 | export * from "./JumpToPosition"; 7 | export * from "./Settings"; 8 | export * from "./Toc"; 9 | export * from "./Triggers"; 10 | export * from "./StatefulCollapsibleActionsBar"; 11 | export * from "./StatefulOverflowMenu"; -------------------------------------------------------------------------------- /src/components/Docking/assets/icons/dialogs.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Docking/assets/icons/dock_to_left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Docking/assets/icons/dock_to_right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Components/Containers/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./hooks"; 4 | export * from "./ThPopover"; 5 | export * from "./ThModal"; 6 | export * from "./ThDockedPanel"; 7 | export * from "./ThBottomSheet"; 8 | export * from "./ThContainerBody"; 9 | export * from "./ThContainerHeader"; 10 | export * from "./ThTypedComponentRenderer"; -------------------------------------------------------------------------------- /src/core/Hooks/useIsClient.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useLayoutEffect, useState } from "react"; 4 | 5 | export const useIsClient = () => { 6 | const [isClient, setIsClient] = useState(false); 7 | 8 | useLayoutEffect(() => { 9 | if (typeof window !== "undefined") setIsClient(true); 10 | }, []); 11 | 12 | return isClient; 13 | } -------------------------------------------------------------------------------- /src/components/Settings/Spacing/assets/icons/tune.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Components/Buttons/assets/icons/delete.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Settings/assets/icons/book.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Epub/Settings/assets/icons/article.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Epub/Settings/assets/icons/docs.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Components/Reader/ThFooter.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { HTMLAttributesWithRef } from "../customTypes"; 4 | 5 | export const ThFooter = ({ 6 | ref, 7 | children, 8 | ...props 9 | }: HTMLAttributesWithRef) => { 10 | return ( 11 | 17 | ) 18 | } -------------------------------------------------------------------------------- /src/core/Components/Reader/ThHeader.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { HTMLAttributesWithRef } from "../customTypes"; 4 | 5 | export const ThHeader = ({ 6 | ref, 7 | children, 8 | ...props 9 | }: HTMLAttributesWithRef) => { 10 | return ( 11 |
15 | { children } 16 |
17 | ) 18 | } -------------------------------------------------------------------------------- /src/components/Settings/Spacing/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./StatefulLetterSpacing"; 4 | export * from "./StatefulLineHeight"; 5 | export * from "./StatefulParagraphIndent"; 6 | export * from "./StatefulParagraphSpacing"; 7 | export * from "./StatefulSpacingGroup"; 8 | export * from "./StatefulSpacingPresets"; 9 | export * from "./StatefulWordSpacing"; 10 | 11 | export * from "./hooks"; -------------------------------------------------------------------------------- /src/components/Settings/Spacing/assets/icons/accessibility.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Components/Form/Fields/assets/icons/search.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Docking/assets/icons/stack.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Epub/Settings/assets/icons/contract.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Settings/assets/icons/zoom_out.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/preferences/adapters/ThPreferencesAdapter.ts: -------------------------------------------------------------------------------- 1 | import { ThPreferences, CustomizableKeys } from "../preferences"; 2 | 3 | export interface ThPreferencesAdapter { 4 | getPreferences(): ThPreferences; 5 | setPreferences(prefs: ThPreferences): void; 6 | subscribe(callback: (prefs: ThPreferences) => void): void; 7 | unsubscribe(callback: (prefs: ThPreferences) => void): void; 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/hooks.ts: -------------------------------------------------------------------------------- 1 | import { TypedUseSelectorHook, useDispatch, useSelector, useStore } from "react-redux"; 2 | import type { AppDispatch, AppStore, RootState } from "./store"; 3 | 4 | // Use throughout your app instead of plain `useDispatch` and `useSelector` 5 | export const useAppDispatch: () => AppDispatch = useDispatch; 6 | export const useAppSelector: TypedUseSelectorHook = useSelector; 7 | export const useAppStore: () => AppStore = useStore; -------------------------------------------------------------------------------- /src/app/api/verify-manifest/verifyDomain.ts: -------------------------------------------------------------------------------- 1 | export async function verifyManifestUrl(url: string): Promise { 2 | if (!url) return false; 3 | 4 | try { 5 | // Decode the URL first in case it's encoded 6 | const decodedUrl = decodeURIComponent(url); 7 | const response = await fetch(`/api/verify-manifest?url=${ encodeURIComponent(decodedUrl) }`); 8 | return response.ok; 9 | } catch { 10 | return false; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/components/Actions/assets/icons/more_vert.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Settings/assets/icons/zoom_in.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Components/Menu/assets/icons/more_vert.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export { ThStoreProvider } from "./ThStoreProvider"; 4 | export * from "./ThReduxPreferencesAdapter"; 5 | export * from "./store"; 6 | export * from "./hooks"; 7 | export * from "./actionsReducer"; 8 | export * from "./publicationReducer"; 9 | export * from "./settingsReducer"; 10 | export * from "./themeReducer"; 11 | export * from "./preferencesReducer"; 12 | export * from "./readerReducer"; 13 | export * from "./webPubSettingsReducer"; -------------------------------------------------------------------------------- /src/helpers/deserializePositions.ts: -------------------------------------------------------------------------------- 1 | import { Locator } from "@readium/shared"; 2 | 3 | export const deserializePositions = (positionsList?: Locator[]) => { 4 | return positionsList?.map((locator) => ({ 5 | href: locator.href, 6 | type: locator.type, 7 | locations: { 8 | position: locator.locations.position, 9 | progression: locator.locations.progression, 10 | totalProgression: locator.locations.totalProgression 11 | }, 12 | })); 13 | }; -------------------------------------------------------------------------------- /src/core/Components/Containers/ThContainerBody.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { HTMLAttributesWithRef } from "../customTypes"; 4 | 5 | export interface ThContainerBodyProps extends HTMLAttributesWithRef {} 6 | 7 | export const ThContainerBody = ({ 8 | ref, 9 | children, 10 | ...props 11 | }: ThContainerBodyProps) => { 12 | return ( 13 |
17 | { children } 18 |
19 | ) 20 | } -------------------------------------------------------------------------------- /src/preferences/ThDirectionSetter.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | 5 | import { ThLayoutDirection } from "./models/enums"; 6 | 7 | export const ThDirectionSetter = ({ 8 | direction, 9 | children 10 | }: { 11 | direction?: ThLayoutDirection, 12 | children: React.ReactNode 13 | }) => { 14 | 15 | useEffect(() => { 16 | if (direction) document.documentElement.dir = direction; 17 | }, [direction]); 18 | 19 | return children; 20 | }; -------------------------------------------------------------------------------- /src/components/Actions/JumpToPosition/assets/icons/pin_drop.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Settings/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./StatefulGroupWrapper"; 4 | export * from "./StatefulDropdown"; 5 | export * from "./StatefulNumberField"; 6 | export * from "./StatefulRadioGroup"; 7 | export * from "./StatefulSlider"; 8 | export * from "./StatefulSwitch"; 9 | export * from "./hooks"; 10 | export * from "./models"; 11 | 12 | export * from "./Spacing"; 13 | export * from "./Text"; 14 | export * from "./StatefulPublisherStyles"; 15 | export * from "./StatefulZoom"; -------------------------------------------------------------------------------- /src/components/Epub/Settings/assets/icons/document_scanner.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Components/Reader/ThProgression.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | 5 | export interface ThProgressionProps extends React.HTMLAttributes { 6 | ref?: React.RefObject 7 | } 8 | 9 | export const ThProgression = ({ 10 | ref, 11 | children, 12 | ...props 13 | }: ThProgressionProps) => { 14 | return ( 15 | <> 16 |
20 | { children } 21 |
22 | 23 | ) 24 | } -------------------------------------------------------------------------------- /src/components/Actions/Toc/assets/icons/toc.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Hooks/useMonochrome.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { useMediaQuery } from "./useMediaQuery"; 5 | 6 | export const useMonochrome = (onChange?: (isMonochrome: boolean) => void) => { 7 | const [isMonochrome, setMonochrome] = useState(false); 8 | 9 | const monochrome = useMediaQuery("(monochrome)"); 10 | 11 | useEffect(() => { 12 | setMonochrome(monochrome); 13 | onChange && onChange(monochrome); 14 | }, [monochrome, onChange]); 15 | 16 | return isMonochrome; 17 | } -------------------------------------------------------------------------------- /src/core/Navigator/hooks/useNavigatorContext.ts: -------------------------------------------------------------------------------- 1 | import { useNavigatorContext } from "../NavigatorProvider"; 2 | 3 | // Visual Navigator Hook (specific to visual navigators) 4 | export const useVisualNavigator = () => { 5 | const navigator = useNavigatorContext(); 6 | 7 | if (!navigator.goLink || !navigator.goForward) { 8 | throw new Error("Provided navigator does not support visual navigation"); 9 | } 10 | 11 | return navigator; 12 | } 13 | 14 | export const useNavigator = () => { 15 | return useNavigatorContext(); 16 | } -------------------------------------------------------------------------------- /src/components/Actions/Settings/assets/icons/match_case.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Components/Links/ThHome.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import HomeIcon from "./assets/icons/home.svg"; 4 | 5 | import { ThLink, ThLinkIconProps } from "./ThLink"; 6 | 7 | export const ThHome = ({ 8 | ref, 9 | href, 10 | "aria-label": ariaLabel, 11 | ...props 12 | }: ThLinkIconProps) => { 13 | return ( 14 | 20 | 22 | ); 23 | }; -------------------------------------------------------------------------------- /src/core/Hooks/useForcedColors.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { useMediaQuery } from "./useMediaQuery"; 5 | 6 | export const useForcedColors = (onChange?: (forcedColors: boolean) => void) => { 7 | const [colors, setForcedColors] = useState(false); 8 | 9 | const forcedColors = useMediaQuery("(forced-colors: active)"); 10 | 11 | useEffect(() => { 12 | setForcedColors(forcedColors); 13 | onChange && onChange(forcedColors); 14 | }, [forcedColors, onChange]); 15 | 16 | return colors; 17 | } -------------------------------------------------------------------------------- /docs/packages/Core/API/Components/Misc.md: -------------------------------------------------------------------------------- 1 | # Misc Components 2 | 3 | ## ThGrid 4 | 5 | A grid component for displaying items in a grid layout. 6 | 7 | ### Props 8 | 9 | ```tsx 10 | interface ThGridProps extends HTMLAttributesWithRef { 11 | items: T[]; 12 | children?: never; 13 | renderItem: (item: T, index: number) => React.ReactNode; 14 | columnWidth?: number; 15 | gap?: number; 16 | } 17 | ``` 18 | 19 | ### Features 20 | 21 | - Responsive grid layout 22 | - Customizable column width and gap 23 | - Customizable item rendering 24 | -------------------------------------------------------------------------------- /src/core/Components/Links/ThLibrary.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import LibraryIcon from "./assets/icons/newsstand.svg"; 4 | 5 | import { ThLink, ThLinkIconProps } from "./ThLink"; 6 | 7 | export const ThLibrary = ({ 8 | ref, 9 | href, 10 | "aria-label": ariaLabel, 11 | ...props 12 | }: ThLinkIconProps) => { 13 | return ( 14 | 20 | 22 | ); 23 | }; -------------------------------------------------------------------------------- /src/components/Settings/Text/assets/icons/format_bold_wght200.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Manifest Route Configuration 2 | 3 | # Enable manifest route in production (default: false) 4 | # Set to true to enable manifest route in production 5 | MANIFEST_ROUTE_FORCE_ENABLE=false 6 | 7 | # Comma-separated list of allowed domains for manifest URLs in production 8 | # Required when forceEnable is true 9 | # Set allowed domains in production 10 | MANIFEST_ALLOWED_DOMAINS=publication-server.readium.org, readium.org 11 | 12 | # Asset Prefix Configuration 13 | # Set the base path for assets (e.g., CDN URL or subdirectory) 14 | # Example: https://cdn.example.com or /subdirectory 15 | ASSET_PREFIX= -------------------------------------------------------------------------------- /src/components/Settings/Text/assets/icons/format_bold_wght500.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Components/Reader/ThRunningHead.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | 5 | import { HTMLAttributesWithRef } from "../customTypes"; 6 | 7 | export interface ThRunningHeadProps extends HTMLAttributesWithRef { 8 | ref?: React.RefObject 9 | label: string; 10 | } 11 | 12 | export const ThRunningHead = ({ 13 | ref, 14 | label, 15 | ...props 16 | }: ThRunningHeadProps) => { 17 | 18 | return( 19 | <> 20 |

24 | { label } 25 |

26 | 27 | ) 28 | } -------------------------------------------------------------------------------- /src/core/Hooks/useReducedMotion.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { useMediaQuery } from "./useMediaQuery"; 5 | 6 | export const useReducedMotion = (onChange?: (reducedMotion: boolean) => void) => { 7 | const [reducedMotion, setReducedMotion] = useState(false); 8 | 9 | const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); 10 | 11 | useEffect(() => { 12 | setReducedMotion(prefersReducedMotion); 13 | onChange && onChange(prefersReducedMotion); 14 | }, [prefersReducedMotion, onChange]); 15 | 16 | return reducedMotion; 17 | } -------------------------------------------------------------------------------- /src/app/home.css: -------------------------------------------------------------------------------- 1 | #home { 2 | margin: 20px; 3 | } 4 | 5 | .header { 6 | text-align: center; 7 | margin-bottom: 4.5rem; 8 | } 9 | 10 | .logo-container { 11 | margin: 1.5rem auto; 12 | width: 200px; 13 | height: 60px; 14 | } 15 | 16 | .logo-container img { 17 | max-width: 100%; 18 | object-fit: contain; 19 | } 20 | 21 | h1 { 22 | font-size: 2rem; 23 | margin: 0 0 1rem; 24 | color: var(--color-text); 25 | } 26 | 27 | .subtitle { 28 | font-size: 1.25rem; 29 | color: var(--color-text-secondary); 30 | max-width: 800px; 31 | margin: 0 auto; 32 | } 33 | 34 | .dev-books { 35 | margin-top: 3rem; 36 | } -------------------------------------------------------------------------------- /src/core/Hooks/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "./useBreakpoints"; 4 | export * from "./useColorScheme"; 5 | export * from "./useContrast"; 6 | export * from "./useDocumentTitle"; 7 | export * from "./useForcedColors"; 8 | export * from "./useFullscreen"; 9 | export * from "./useLocalStorage"; 10 | export * from "./useMediaQuery" 11 | export * from "./useMonochrome"; 12 | export * from "./useIsClient"; 13 | export * from "./usePrevious"; 14 | export * from "./useReducedMotion"; 15 | export * from "./useReducedTransparency"; 16 | export * from "./useTimeline"; 17 | export * from "./Epub"; 18 | export * from "./WebPub"; -------------------------------------------------------------------------------- /src/lib/ThStoreProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRef } from "react"; 4 | import { Provider } from "react-redux"; 5 | import { makeStore, AppStore } from "./store"; 6 | 7 | export const ThStoreProvider = ({ 8 | storageKey, 9 | store, 10 | children 11 | }: { 12 | storageKey?: string, 13 | store?: AppStore, 14 | children: React.ReactNode 15 | }) => { 16 | const storeRef = useRef(null); 17 | if (!storeRef.current) { 18 | storeRef.current = store || makeStore(storageKey); 19 | } 20 | 21 | return { children } 22 | } 23 | 24 | export default ThStoreProvider; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # bundle 20 | /dist 21 | 22 | # misc 23 | .DS_Store 24 | *.pem 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # local env files 32 | .env*.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # wrangler 38 | .wrangler 39 | 40 | # typescript 41 | *.tsbuildinfo 42 | next-env.d.ts 43 | 44 | # Rollup 45 | .rollup.cache -------------------------------------------------------------------------------- /src/components/Actions/models/actions.ts: -------------------------------------------------------------------------------- 1 | import { RefObject } from "react"; 2 | import { ThActionsTriggerVariant } from "@/core/Components/Actions/ThActionsBar"; 3 | import { ActionsStateKeys } from "@/lib/actionsReducer"; 4 | 5 | export interface StatefulActionsMapObject { 6 | Trigger: React.ComponentType; 7 | Target?: React.ComponentType; 8 | } 9 | 10 | export interface StatefulActionTriggerProps { 11 | variant: ThActionsTriggerVariant; 12 | associatedKey?: ActionsStateKeys; 13 | } 14 | 15 | export interface StatefulActionContainerProps { 16 | triggerRef: RefObject; 17 | } -------------------------------------------------------------------------------- /src/core/Hooks/useReducedTransparency.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { useMediaQuery } from "./useMediaQuery"; 5 | 6 | export const useReducedTransparency = (onChange?: (reducedTransparency: boolean) => void) => { 7 | const [reducedTransparency, setReducedTransparency] = useState(false); 8 | 9 | const prefersReducedTransparency = useMediaQuery("(prefers-reduced-transparency: reduce)"); 10 | 11 | useEffect(() => { 12 | setReducedTransparency(prefersReducedTransparency); 13 | onChange && onChange(prefersReducedTransparency); 14 | }, [prefersReducedTransparency, onChange]); 15 | 16 | return reducedTransparency; 17 | } -------------------------------------------------------------------------------- /src/core/Components/Buttons/ThCloseButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Close from "./assets/icons/close.svg"; 4 | 5 | import { ThActionButton, ThActionButtonProps } from "./ThActionButton"; 6 | 7 | export const ThCloseButton = ({ 8 | label, 9 | ref, 10 | compounds, 11 | children, 12 | ...props 13 | }: ThActionButtonProps) => { 14 | return ( 15 | 20 | { children 21 | ? children 22 | : <> 23 | 28 | ) 29 | } -------------------------------------------------------------------------------- /src/core/Components/Buttons/ThDeleteButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Delete from "./assets/icons/delete.svg"; 4 | 5 | import { ThActionButton, ThActionButtonProps } from "./ThActionButton"; 6 | 7 | export const ThDeleteButton = ({ 8 | label, 9 | ref, 10 | compounds, 11 | children, 12 | ...props 13 | }: ThActionButtonProps) => { 14 | return ( 15 | 20 | { children 21 | ? children 22 | : <> 23 | 28 | ) 29 | } -------------------------------------------------------------------------------- /src/preferences/hooks/usePreferences.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useContext } from "react"; 4 | import { ThPreferencesContext } from "../ThPreferencesContext"; 5 | import { CustomizableKeys, DefaultKeys, ThPreferences } from "../preferences"; 6 | 7 | export function usePreferences() { 8 | const context = useContext(ThPreferencesContext); 9 | 10 | if (!context) { 11 | throw new Error("usePreferences must be used within a ThPreferencesProvider"); 12 | } 13 | 14 | return { 15 | preferences: context.preferences as ThPreferences, 16 | updatePreferences: context.updatePreferences as (prefs: ThPreferences) => void, 17 | }; 18 | } -------------------------------------------------------------------------------- /public/locales/ar/thorium-web.json: -------------------------------------------------------------------------------- 1 | { 2 | "reader": { 3 | "app": { 4 | "loading": "جارٍ التحميل", 5 | "publicationWrapper": "أنت الآن داخل المنشور.", 6 | "docking": { 7 | "dockingLeft": "لوحة مثبتة على اليسار", 8 | "dockingRight": "لوحة مثبتة على اليمين" 9 | } 10 | }, 11 | "settings": { 12 | "lineHeight": { 13 | "large": "واسع" 14 | }, 15 | "paraSpacing": { 16 | "title": "تباعد الفقرات", 17 | "increase": "زيادة تباعد الفقرات", 18 | "decrease": "تقليل تباعد الفقرات" 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/core/Components/Menu/ThMenuButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import MoreVertIcon from "./assets/icons/more_vert.svg"; 4 | 5 | import { ThActionButton, ThActionButtonProps } from "../Buttons/ThActionButton"; 6 | 7 | export const ThMenuButton = ({ 8 | label, 9 | ref, 10 | compounds, 11 | children, 12 | ...props 13 | }: ThActionButtonProps) => { 14 | return ( 15 | 20 | { children 21 | ? children 22 | : <> 23 | 28 | ) 29 | } -------------------------------------------------------------------------------- /src/core/Components/Settings/ThSettingsResetButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Undo from "./assets/icons/undo.svg"; 4 | 5 | import { ThActionButton, ThActionButtonProps } from "../Buttons/ThActionButton"; 6 | 7 | export const ThSettingsResetButton = ({ 8 | label, 9 | ref, 10 | compounds, 11 | children, 12 | ...props 13 | }: ThActionButtonProps) => { 14 | return ( 15 | 20 | { children 21 | ? children 22 | : <> 23 | 28 | ) 29 | } -------------------------------------------------------------------------------- /src/core/Components/Containers/ThBottomSheet/ThDragIndicatorButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import HorizontalRule from "./assets/icons/horizontal_rule.svg"; 4 | 5 | import { Button, ButtonProps } from "react-aria-components"; 6 | 7 | export interface ThDragIndicatorButtonProps extends ButtonProps { 8 | ref?: React.ForwardedRef; 9 | } 10 | 11 | export const ThDragIndicatorButton = ({ 12 | ref, 13 | children, 14 | ...props 15 | }: ThDragIndicatorButtonProps) => { 16 | return ( 17 | <> 18 | 24 | 25 | ) 26 | } -------------------------------------------------------------------------------- /docs/packages/Epub/API/Hooks.md: -------------------------------------------------------------------------------- 1 | # Hooks API Reference 2 | 3 | ## usePublication 4 | 5 | The `usePublication` hook is a custom hook that provides a way to fetch the Readium Web Publication Manifest from a given URL. It returns the manifest data and a self-link the `StatefulReader` component uses to load and navigate the publication. 6 | 7 | The `onError` callback is an optional callback that is called with the error message if the fetch fails. 8 | 9 | ```typescript 10 | interface UsePublicationOptions { 11 | url: string; 12 | onError?: (error: string) => void; 13 | } 14 | ``` 15 | 16 | Features: 17 | - Fetches the Readium Web Publication Manifest from a given URL 18 | - Extracts the self-link from the manifest 19 | - Error handling -------------------------------------------------------------------------------- /src/core/Components/Containers/ThContainer.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | 5 | import { ThContainerHeaderProps } from "./ThContainerHeader"; 6 | import { ThContainerBodyProps } from "./ThContainerBody"; 7 | import { SheetRef } from "react-modal-sheet"; 8 | 9 | import { UseFirstFocusableProps } from "./hooks/useFirstFocusable"; 10 | 11 | export enum ThContainerHeaderVariant { 12 | close = "close", 13 | docker = "docker", 14 | previous = "previous" 15 | } 16 | 17 | export interface ThContainerProps { 18 | ref?: React.RefObject; 19 | focusOptions?: UseFirstFocusableProps; 20 | children: [React.ReactElement, React.ReactElement]; 21 | } -------------------------------------------------------------------------------- /src/core/Hooks/useColorScheme.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { useMediaQuery } from "./useMediaQuery"; 5 | 6 | export enum ThColorScheme { 7 | light = "light", 8 | dark = "dark" 9 | } 10 | 11 | export const useColorScheme = (onChange?: (colorScheme: ThColorScheme) => void) => { 12 | const [colorScheme, setColorScheme] = useState(ThColorScheme.light); 13 | const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); 14 | 15 | useEffect(() => { 16 | const scheme = prefersDarkMode ? ThColorScheme.dark : ThColorScheme.light; 17 | setColorScheme(scheme); 18 | onChange && onChange(scheme); 19 | }, [onChange, prefersDarkMode]); 20 | 21 | return colorScheme; 22 | } -------------------------------------------------------------------------------- /src/components/StatefulLoader.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | import readerLoaderStyles from "./assets/styles/readerLoader.module.css"; 4 | 5 | import { ThLoader } from "@/core/Components/Reader/ThLoader"; 6 | 7 | import { useI18n } from "@/i18n/useI18n"; 8 | 9 | export const StatefulLoader = ({ isLoading, children }: { isLoading: boolean, children: ReactNode }) => { 10 | const { t } = useI18n(); 11 | 12 | return ( 13 | <> 14 | { t("reader.app.loading") } } 17 | className={ readerLoaderStyles.readerLoaderWrapper } 18 | > 19 | { children } 20 | 21 | 22 | ) 23 | } -------------------------------------------------------------------------------- /src/core/Components/Settings/ThSettingsWrapper/ThSettingsWrapperButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Settings from "./assets/icons/settings.svg"; 4 | 5 | import { ThActionButton, ThActionButtonProps } from "../../Buttons/ThActionButton"; 6 | 7 | export const ThSettingsWrapperButton = ({ 8 | label, 9 | ref, 10 | compounds, 11 | children, 12 | ...props 13 | }: ThActionButtonProps) => { 14 | return ( 15 | 20 | { children 21 | ? children 22 | : <> 23 | 28 | ) 29 | } -------------------------------------------------------------------------------- /src/components/Settings/Spacing/helpers/spacingSettings.ts: -------------------------------------------------------------------------------- 1 | import { ThSpacingSettingsKeys } from "@/preferences/models/enums"; 2 | 3 | /** 4 | * Check if spacing settings are available for customization 5 | * Returns true if subPanel contains at least one spacing setting (excluding spacingPresets and publisherStyles) 6 | */ 7 | export const hasCustomizableSpacingSettings = (subPanelKeys: ThSpacingSettingsKeys[]): boolean => { 8 | const spacingSettingsKeys = [ 9 | ThSpacingSettingsKeys.letterSpacing, 10 | ThSpacingSettingsKeys.lineHeight, 11 | ThSpacingSettingsKeys.paragraphIndent, 12 | ThSpacingSettingsKeys.paragraphSpacing, 13 | ThSpacingSettingsKeys.wordSpacing 14 | ]; 15 | 16 | return spacingSettingsKeys.some(key => subPanelKeys.includes(key)); 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/Sheets/models/sheets.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode, RefObject } from "react"; 2 | import { ThDockingKeys, ThSheetHeaderVariant } from "@/preferences/models/enums"; 3 | import { ActionsStateKeys } from "@/lib/actionsReducer"; 4 | 5 | export interface StatefulSheet { 6 | id: ActionsStateKeys; 7 | triggerRef: RefObject; 8 | heading: string; 9 | headerVariant?: ThSheetHeaderVariant; 10 | className: string; 11 | isOpen: boolean; 12 | onOpenChange: (isOpen: boolean) => void; 13 | onClosePress: () => void; 14 | docker?: ThDockingKeys[]; 15 | children?: ReactNode; 16 | resetFocus?: unknown; 17 | focusWithinRef?: RefObject; 18 | focusSelector?: string; 19 | scrollTopOnFocus?: boolean; 20 | dismissEscapeKeyClose?: boolean; 21 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "target": "ES2015", 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"], 23 | "react": [ "./node_modules/@types/react" ] 24 | } 25 | }, 26 | "include": ["svgr.d.ts", "react.d.ts", "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /src/core/Components/Settings/ThSettingsWrapper/assets/icons/settings.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/assets/styles/backLink.module.css: -------------------------------------------------------------------------------- 1 | .backLink { 2 | display: block; 3 | box-sizing: content-box; 4 | padding: calc(var(--icon-size, 24px) * (1/4)); 5 | text-align: start; 6 | border-radius: var(--layout-radius); 7 | max-width: 100%; 8 | height: var(--icon-size, 24px); 9 | } 10 | 11 | .backLink svg { 12 | fill: var(--theme-text); 13 | max-width: 100%; 14 | height: 100%; 15 | } 16 | 17 | .backLink img { 18 | max-width: 100%; 19 | height: 100%; 20 | } 21 | 22 | .backLink[data-hovered] { 23 | background-color: var(--theme-hover); 24 | } 25 | 26 | .backLink[data-focus-visible] { 27 | outline: 2px solid var(--theme-focus); 28 | } 29 | 30 | .backLink[data-disabled] { 31 | color: var(--theme-disable) 32 | } 33 | 34 | .backLink[data-disabled] svg { 35 | fill: var(--theme-disable); 36 | } -------------------------------------------------------------------------------- /src/core/Components/Settings/ThDropdown/ThDropdownButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import ArrowDropDownIcon from "./assets/icons/arrow_drop_down.svg"; 4 | 5 | import { Button, ButtonProps, SelectValue } from "react-aria-components"; 6 | 7 | export interface ThDropdownButtonProps extends ButtonProps { 8 | ref?: React.ForwardedRef; 9 | } 10 | 11 | export const ThDropdownButton = ({ 12 | ref, 13 | children, 14 | ...props 15 | }: ThDropdownButtonProps) => { 16 | return ( 17 | <> 18 | 30 | 31 | ) 32 | } -------------------------------------------------------------------------------- /src/components/assets/styles/readerLoader.module.css: -------------------------------------------------------------------------------- 1 | .readerLoaderWrapper { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | 6 | .readerLoader { 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | width: 100%; 11 | height: 100%; 12 | color: var(--theme-text); 13 | background-color: var(--theme-background); 14 | font-weight: bold; 15 | } 16 | 17 | .readerLoader::after { 18 | content: "..."; 19 | overflow: hidden; 20 | display: inline-block; 21 | vertical-align: bottom; 22 | animation: ellipsis-dot 1s infinite 300ms; 23 | animation-fill-mode: forwards; 24 | width: 3ch; 25 | } 26 | 27 | @keyframes ellipsis-dot { 28 | 25% { 29 | content: ""; 30 | } 31 | 50% { 32 | content: "."; 33 | } 34 | 75% { 35 | content: ".."; 36 | } 37 | 100% { 38 | content: "..."; 39 | } 40 | } -------------------------------------------------------------------------------- /src/core/Navigator/NavigatorProvider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | 3 | interface NavigatorContextValue { 4 | navigator: any; 5 | } 6 | 7 | const NavigatorContext = createContext(null); 8 | 9 | interface NavigatorProviderProps { 10 | navigator: any; 11 | children: React.ReactNode; 12 | } 13 | 14 | export const NavigatorProvider = ({ navigator, children }: NavigatorProviderProps) => { 15 | return ( 16 | 17 | { children } 18 | 19 | ); 20 | } 21 | 22 | export const useNavigatorContext = () => { 23 | const context = useContext(NavigatorContext); 24 | if (!context) { 25 | throw new Error("Navigator hooks must be used within NavigatorProvider"); 26 | } 27 | return context.navigator; 28 | } -------------------------------------------------------------------------------- /tsconfig.bundle.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "target": "esnext", 5 | "allowJs": true, 6 | "skipLibCheck": false, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "ESNext", 13 | "moduleResolution": "bundler", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "react-jsx", 17 | "outDir": "./dist", 18 | "declaration": true, 19 | "paths": { 20 | "@/*": ["./src/*"], 21 | "react": [ "./node_modules/@types/react" ], 22 | "immer": ["./node_modules/immer"] 23 | } 24 | }, 25 | "include": [ 26 | "svgr.d.ts", 27 | "react.d.ts" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /src/core/Components/Containers/ThTypedComponentRenderer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { ReactNode } from "react"; 4 | 5 | export type ComponentMap = { 6 | [type in T]: React.ComponentType; 7 | } 8 | 9 | export interface TypedComponentRendererProps> { 10 | type: K; 11 | componentMap: ComponentMap; 12 | props?: any; 13 | children?: ReactNode; 14 | } 15 | 16 | export const ThTypedComponentRenderer = >({ 17 | type, 18 | componentMap, 19 | props, 20 | children, 21 | }: TypedComponentRendererProps) => { 22 | const Component = componentMap[type]; 23 | 24 | if (!Component) { 25 | throw new Error(`Unsupported type: ${type}`); 26 | } 27 | 28 | return React.createElement(Component, props, children); 29 | }; -------------------------------------------------------------------------------- /src/core/Helpers/propsToCSSVars.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | /** Converts Object properties to CSSProperties, recursively. If recursive, the prefix will be ignored for objects */ 4 | export const propsToCSSVars = (props: { [x: string]: any; }, prefix?: string) => { 5 | return Object.entries(props) 6 | .reduce((acc: { [key: string]: any }, [key, value]) => { 7 | const cssVar = prefix ? `--${prefix}-${key}` : `--${key}`; 8 | if (typeof props[key] === "object" && props[key] !== null) { 9 | Object.assign(acc, propsToCSSVars(props[key], `${key}`)); 10 | } else { 11 | if (value) { 12 | const cssValue = typeof value === "number" ? `${value}px` : value; 13 | acc[cssVar] = cssValue; 14 | } 15 | } 16 | return acc; 17 | }, {}) 18 | } -------------------------------------------------------------------------------- /src/components/assets/styles/readerHeader.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | box-sizing: border-box; 3 | display: grid; 4 | grid-template-areas: "header-start header-center header-end"; 5 | grid-template-columns: 1fr 3fr 1fr; 6 | padding: 0.25rem 0.5rem 0; 7 | } 8 | 9 | .backLinkWrapper { 10 | grid-area: header-start; 11 | justify-self: start; 12 | align-self: center; 13 | } 14 | 15 | .header h1 { 16 | font-size: 1rem; 17 | color: var(--theme-text); 18 | font-weight: normal; 19 | grid-area: header-center; 20 | justify-self: center; 21 | align-self: center; 22 | max-width: 100%; 23 | white-space: nowrap; 24 | overflow: hidden; 25 | text-overflow: ellipsis; 26 | transition: opacity 200ms ease-in-out; 27 | } 28 | 29 | .actionsWrapper { 30 | grid-area: header-end; 31 | justify-self: end; 32 | display: flex; 33 | align-items: center; 34 | gap: 2px; 35 | } -------------------------------------------------------------------------------- /src/core/Components/Links/ThBackArrow.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import ArrowBackIcon from "../assets/icons/arrow_back.svg"; 4 | import ArrowForwardIcon from "../assets/icons/arrow_forward.svg"; 5 | 6 | import { ThLink, ThLinkIconProps } from "./ThLink"; 7 | 8 | export interface ThBackArrowProps extends ThLinkIconProps { 9 | direction?: "left" | "right"; 10 | } 11 | 12 | export const ThBackArrow = ({ 13 | ref, 14 | href, 15 | "aria-label": ariaLabel, 16 | direction, 17 | ...props 18 | }: ThBackArrowProps) => { 19 | return ( 20 | 26 | { direction === "right" 27 | ? 31 | ); 32 | }; -------------------------------------------------------------------------------- /src/components/Docking/assets/styles/docking.module.css: -------------------------------------------------------------------------------- 1 | .dockerWrapper { 2 | margin-inline-start: auto; 3 | margin-inline-end: calc(var(--icon-size, 24px) * (1 / 4) * -1); /* Optical alingment */ 4 | display: flex; 5 | gap: 2px; 6 | padding-inline-start: var(--layout-spacing); 7 | } 8 | 9 | .docker { 10 | display: flex; 11 | gap: 2px; 12 | } 13 | 14 | .dockResizeHandle { 15 | position: relative; 16 | width: 0; 17 | } 18 | 19 | .dockResizeHandle:focus-visible { 20 | outline: 2px solid var(--theme-focus); 21 | } 22 | 23 | .dockResizeHandleGrab { 24 | position: absolute; 25 | z-index: 1000; 26 | top: 50%; 27 | transform: translateY(-50%); 28 | width: 5px; 29 | height: 50px; 30 | border-radius: 5px; 31 | background-color: var(--theme-subdue); 32 | } 33 | 34 | .dockResizeHandleGrabLeft { 35 | left: 0; 36 | } 37 | 38 | .dockResizeHandleGrabRight { 39 | left: -5px; 40 | } -------------------------------------------------------------------------------- /src/core/Components/Actions/ThActionsBar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useObjectRef } from "react-aria"; 4 | import { Toolbar, ToolbarProps } from "react-aria-components"; 5 | 6 | export enum ThActionsTriggerVariant { 7 | button = "iconButton", 8 | menu = "menuItem" 9 | } 10 | 11 | export interface ThActionEntry { 12 | key: T; 13 | associatedKey?: string; 14 | Trigger: React.ComponentType; 15 | Target?: React.ComponentType; 16 | } 17 | 18 | export interface ThActionsBarProps extends ToolbarProps { 19 | ref?: React.ForwardedRef 20 | }; 21 | 22 | export const ThActionsBar = ({ 23 | ref, 24 | children, 25 | ...props 26 | }: ThActionsBarProps) => { 27 | const resolvedRef = useObjectRef(ref); 28 | 29 | return ( 30 | 34 | { children } 35 | 36 | ) 37 | } -------------------------------------------------------------------------------- /src/components/Epub/Settings/assets/icons/menu_book.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/Components/Containers/ThContainerHeader/ThContainerHeader.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Heading, HeadingProps } from "react-aria-components"; 4 | import { HTMLAttributesWithRef, WithRef } from "../../customTypes"; 5 | 6 | export interface ThContainerHeaderProps extends HTMLAttributesWithRef { 7 | ref?: React.ForwardedRef; 8 | label: string; 9 | compounds?: { 10 | heading?: WithRef; 11 | } 12 | } 13 | 14 | export const ThContainerHeader = ({ 15 | ref, 16 | label, 17 | compounds, 18 | children, 19 | ...props 20 | }: ThContainerHeaderProps) => { 21 | return ( 22 |
26 | 30 | { label } 31 | 32 | { children } 33 |
34 | ) 35 | } -------------------------------------------------------------------------------- /src/i18n/useI18n.ts: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | 3 | /** 4 | * Hook to access the i18n instance and translation functions 5 | * @param ns The namespace to use for translations (defaults to DEFAULT_NAMESPACE) 6 | * @returns Translation functions and i18n instance 7 | */ 8 | export const useI18n = (ns: string = "thorium-web") => { 9 | const { t, i18n, ready } = useTranslation(ns); 10 | 11 | // Helper function to change language 12 | const changeLanguage = (lng: string) => { 13 | return i18n.changeLanguage(lng); 14 | }; 15 | 16 | return { 17 | // Translation function 18 | t, 19 | // i18n instance 20 | i18n, 21 | // Whether translations are loaded 22 | ready, 23 | // Current language 24 | currentLanguage: i18n.language, 25 | // List of available languages 26 | languages: i18n.languages, 27 | // Function to change language 28 | changeLanguage 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /src/core/Components/Reader/ThLoader.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ReactNode } from "react"; 4 | 5 | import { HTMLAttributesWithRef } from "../customTypes"; 6 | 7 | export interface ThLoaderProps extends Omit, "aria-busy" | "aria-live"> { 8 | ref?: React.ForwardedRef; 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 |
30 | { children } 31 | 32 | { compounds?.button && React.isValidElement(compounds.button) 33 | ? compounds.button 34 | : 40 | } 41 |
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 | ? 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 | 42 | { children } 43 | 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 && 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 | 41 | { children 42 | ? children 43 | : <> 44 | { SVGIcon && 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 | 52 | { children } 53 | 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 | 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(undefined); 18 | const [selfLink, setSelfLink] = useState(undefined); 19 | 20 | // Basic URL validation and loading 21 | useEffect(() => { 22 | if (!url) { 23 | setError("Manifest URL is required"); 24 | return; 25 | } 26 | 27 | // Decode URL if needed 28 | const decodedUrl = decodeURIComponent(url); 29 | 30 | const manifestLink = new Link({ href: decodedUrl }); 31 | const fetcher = new HttpFetcher(undefined); 32 | 33 | try { 34 | const fetched = fetcher.get(manifestLink); 35 | 36 | // Get self-link first 37 | fetched.link().then((link) => { 38 | setSelfLink(link.toURL(decodedUrl)); 39 | }); 40 | 41 | // Then get manifest data 42 | fetched.readAsJSON().then((manifestData) => { 43 | setManifest(manifestData as object); 44 | }).catch((error) => { 45 | console.error("Error loading manifest:", error); 46 | setError(`Failed loading manifest ${ decodedUrl }: ${ error instanceof Error ? error.message : "Unknown error" }`); 47 | }); 48 | } catch (error: unknown) { 49 | console.error("Error loading manifest:", error); 50 | setError(`Failed loading manifest ${ decodedUrl }: ${ error instanceof Error ? error.message : "Unknown error" }`); 51 | } 52 | }, [url]); 53 | 54 | // Call onError callback when error changes 55 | useEffect(() => { 56 | if (error) { 57 | onError(error); 58 | } 59 | }, [error, onError]); 60 | 61 | return { 62 | error, 63 | manifest, 64 | selfLink 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /src/app/read/[identifier]/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 { PUBLICATION_MANIFESTS } from "@/config/publications"; 7 | import { usePublication } from "@/hooks/usePublication"; 8 | import { useAppSelector } from "@/lib/hooks"; 9 | import { verifyManifestUrl } from "@/app/api/verify-manifest/verifyDomain"; 10 | 11 | import "@/app/app.css"; 12 | 13 | type Params = { identifier: string }; 14 | 15 | type Props = { 16 | params: Promise; 17 | }; 18 | 19 | export default function BookPage({ params }: Props) { 20 | const [domainError, setDomainError] = useState(null); 21 | const identifier = use(params).identifier; 22 | const isLoading = useAppSelector(state => state.reader.isLoading); 23 | const manifestUrl = identifier ? PUBLICATION_MANIFESTS[identifier as keyof typeof PUBLICATION_MANIFESTS] : ""; 24 | 25 | useEffect(() => { 26 | if (manifestUrl) { 27 | verifyManifestUrl(manifestUrl).then(allowed => { 28 | if (!allowed) { 29 | setDomainError(`Domain not allowed: ${ new URL(manifestUrl).hostname }`); 30 | } 31 | }); 32 | } 33 | }, [manifestUrl]); 34 | 35 | const { error, manifest, selfLink } = usePublication({ 36 | url: manifestUrl, 37 | onError: (error) => { 38 | console.error("Publication loading error:", error); 39 | } 40 | }); 41 | 42 | if (domainError) { 43 | return ( 44 |
45 |

Access Denied

46 |

{ domainError }

47 |
48 | ); 49 | } 50 | 51 | return ( 52 | <> 53 | { error ? ( 54 |
55 |

Error

56 |

{ error }

57 |
58 | ) : ( 59 | 60 | { manifest && selfLink && } 61 | 62 | )} 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/components/Epub/Settings/StatefulLayout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useCallback } from "react"; 4 | 5 | import { ThLayoutOptions } from "@/preferences/models/enums"; 6 | 7 | import ScrollableIcon from "./assets/icons/contract.svg"; 8 | import PaginatedIcon from "./assets/icons/docs.svg"; 9 | 10 | import { StatefulRadioGroup } from "../../Settings/StatefulRadioGroup"; 11 | 12 | import { useEpubNavigator } from "@/core/Hooks/Epub/useEpubNavigator"; 13 | import { useI18n } from "@/i18n/useI18n"; 14 | 15 | import { useAppDispatch, useAppSelector } from "@/lib/hooks"; 16 | import { setScroll } from "@/lib/settingsReducer"; 17 | 18 | export const StatefulLayout = () => { 19 | const { t } = useI18n(); 20 | const scroll = useAppSelector(state => state.settings.scroll); 21 | const isFXL = useAppSelector(state => state.publication.isFXL); 22 | const isScroll = scroll && !isFXL; 23 | 24 | const dispatch = useAppDispatch(); 25 | 26 | const { getSetting, submitPreferences } = useEpubNavigator(); 27 | 28 | const items = [ 29 | { 30 | id: ThLayoutOptions.paginated, 31 | icon: PaginatedIcon, 32 | label: t("reader.settings.layout.paginated"), 33 | value: ThLayoutOptions.paginated 34 | }, 35 | { 36 | id: ThLayoutOptions.scroll, 37 | icon: ScrollableIcon, 38 | label: t("reader.settings.layout.scrolled"), 39 | value: ThLayoutOptions.scroll 40 | } 41 | ]; 42 | 43 | const updatePreference = useCallback(async (value: string) => { 44 | const derivedValue = value === ThLayoutOptions.scroll; 45 | await submitPreferences({ scroll: derivedValue }); 46 | dispatch(setScroll(getSetting("scroll"))); 47 | }, [submitPreferences, getSetting, dispatch]); 48 | 49 | return ( 50 | <> 51 | await updatePreference(val) } 57 | items={ items } 58 | /> 59 | 60 | ) 61 | } -------------------------------------------------------------------------------- /src/components/Settings/Text/StatefulHyphens.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback } from "react"; 4 | 5 | import { StatefulSettingsItemProps } from "../models/settings"; 6 | import { ThTextAlignOptions } from "@/preferences/models/enums"; 7 | 8 | import { StatefulSwitch } from "../StatefulSwitch"; 9 | 10 | import { useNavigator } from "@/core/Navigator"; 11 | import { useI18n } from "@/i18n/useI18n"; 12 | 13 | import { useAppDispatch, useAppSelector } from "@/lib/hooks"; 14 | import { setHyphens } from "@/lib/settingsReducer"; 15 | import { setWebPubHyphens } from "@/lib/webPubSettingsReducer"; 16 | 17 | // TMP Component that is not meant to be implemented AS-IS, for testing purposes 18 | export const StatefulHyphens = ({ standalone = true }: StatefulSettingsItemProps) => { 19 | const { t } = useI18n(); 20 | 21 | const profile = useAppSelector(state => state.reader.profile); 22 | const isWebPub = profile === "webPub"; 23 | 24 | const hyphens = useAppSelector(state => isWebPub ? state.webPubSettings.hyphens : state.settings.hyphens) ?? false; 25 | const textAlign = useAppSelector(state => isWebPub ? state.webPubSettings.textAlign : state.settings.textAlign) ?? ThTextAlignOptions.publisher; 26 | 27 | const dispatch = useAppDispatch(); 28 | 29 | const { getSetting, submitPreferences } = useNavigator(); 30 | 31 | const updatePreference = useCallback(async (value: boolean) => { 32 | await submitPreferences({ hyphens: value }); 33 | const effectiveSetting = getSetting("hyphens"); 34 | 35 | if (isWebPub) { 36 | dispatch(setWebPubHyphens(effectiveSetting)); 37 | } else { 38 | dispatch(setHyphens(effectiveSetting)); 39 | } 40 | }, [isWebPub, submitPreferences, getSetting, dispatch]); 41 | 42 | return( 43 | <> 44 | await updatePreference(isSelected) } 49 | isSelected={ hyphens ?? false } 50 | isDisabled={ textAlign === ThTextAlignOptions.publisher } 51 | /> 52 | 53 | ) 54 | } -------------------------------------------------------------------------------- /src/components/Settings/StatefulDropdown.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | 5 | import settingsStyles from "./assets/styles/settings.module.css"; 6 | 7 | import { ThDropdown, ThDropdownProps } from "@/core/Components/Settings/ThDropdown/ThDropdown"; 8 | 9 | import classNames from "classnames"; 10 | 11 | export interface StatefulDropdownProps extends Omit { 12 | standalone?: boolean; 13 | } 14 | 15 | export const StatefulDropdown = ({ 16 | standalone, 17 | label, 18 | className, 19 | compounds, 20 | ...props 21 | }: StatefulDropdownProps) => { 22 | 23 | return ( 24 | 64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /src/preferences/hooks/usePreferenceKeys.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThSpacingPresetKeys } from "@/preferences/models/enums"; 4 | import { 5 | defaultSpacingSettingsSubpanel, 6 | defaultTextSettingsMain, 7 | defaultTextSettingsSubpanel, 8 | defaultSpacingSettingsMain, 9 | defaultSpacingPresetsOrder 10 | } from "@/preferences/models/const"; 11 | 12 | import { usePreferences } from "./usePreferences"; 13 | 14 | export const usePreferenceKeys = () => { 15 | const { preferences } = usePreferences(); 16 | 17 | const reflowActionKeys = preferences.actions.reflowOrder; 18 | const fxlActionKeys = preferences.actions.fxlOrder; 19 | const webPubActionKeys = preferences.actions.webPubOrder; 20 | 21 | const reflowThemeKeys = preferences.theming.themes.reflowOrder; 22 | const fxlThemeKeys = preferences.theming.themes.fxlOrder; 23 | 24 | const reflowSettingsKeys = preferences.settings.reflowOrder; 25 | const fxlSettingsKeys = preferences.settings.fxlOrder; 26 | const webPubSettingsKeys = preferences.settings.webPubOrder; 27 | 28 | const mainTextSettingsKeys = preferences.settings.text?.main ?? defaultTextSettingsMain; 29 | const subPanelTextSettingsKeys = preferences.settings.text?.subPanel ?? defaultTextSettingsSubpanel; 30 | const mainSpacingSettingsKeys = preferences.settings.spacing?.main ?? defaultSpacingSettingsMain; 31 | const subPanelSpacingSettingsKeys = preferences.settings.spacing?.subPanel ?? defaultSpacingSettingsSubpanel; 32 | 33 | const reflowSpacingPresetKeys = preferences.settings.spacing?.presets?.reflowOrder ?? defaultSpacingPresetsOrder; 34 | const fxlSpacingPresetKeys: ThSpacingPresetKeys[] = []; 35 | const webPubSpacingPresetKeys = preferences.settings.spacing?.presets?.webPubOrder ?? defaultSpacingPresetsOrder; 36 | 37 | return { 38 | reflowActionKeys, 39 | fxlActionKeys, 40 | webPubActionKeys, 41 | reflowThemeKeys, 42 | fxlThemeKeys, 43 | reflowSettingsKeys, 44 | fxlSettingsKeys, 45 | webPubSettingsKeys, 46 | mainTextSettingsKeys, 47 | subPanelTextSettingsKeys, 48 | mainSpacingSettingsKeys, 49 | subPanelSpacingSettingsKeys, 50 | reflowSpacingPresetKeys, 51 | fxlSpacingPresetKeys, 52 | webPubSpacingPresetKeys 53 | }; 54 | } -------------------------------------------------------------------------------- /src/lib/ThReduxPreferencesAdapter.ts: -------------------------------------------------------------------------------- 1 | import { Store } from "@reduxjs/toolkit"; 2 | 3 | import { ThPreferences, CustomizableKeys } from "../preferences/preferences"; 4 | 5 | import { ThPreferencesAdapter } from "../preferences/adapters/ThPreferencesAdapter"; 6 | 7 | import { AppState } from "@/lib/store"; 8 | import { preferencesSlice } from "@/lib/preferencesReducer"; 9 | import { mapStateToPreferences } from "@/lib/helpers/mapPreferences"; 10 | 11 | export class ThReduxPreferencesAdapter implements ThPreferencesAdapter { 12 | private store: Store; 13 | private listeners: Set<(prefs: ThPreferences) => void> = new Set(); 14 | private currentPreferences: ThPreferences; 15 | 16 | constructor(store: Store, initialPreferences: ThPreferences) { 17 | this.store = store; 18 | this.currentPreferences = initialPreferences; 19 | 20 | this.store.subscribe(() => { 21 | const state = this.store.getState(); 22 | const prefs = this.mapStateToPreferences(state); 23 | this.notifyListeners(prefs); 24 | }); 25 | } 26 | 27 | public getPreferences(): ThPreferences { 28 | return { ...this.currentPreferences }; 29 | } 30 | 31 | public setPreferences(prefs: ThPreferences): void { 32 | this.currentPreferences = prefs; 33 | this.store.dispatch(preferencesSlice.actions.updateFromPreferences(prefs as any)); 34 | this.notifyListeners(prefs); 35 | } 36 | 37 | public subscribe(listener: (prefs: ThPreferences) => void): void { 38 | this.listeners.add(listener); 39 | listener(this.getPreferences()); 40 | } 41 | 42 | public unsubscribe(listener: (prefs: ThPreferences) => void): void { 43 | this.listeners.delete(listener); 44 | } 45 | 46 | private mapStateToPreferences(state: AppState): ThPreferences { 47 | if (!state.preferences) return this.currentPreferences; 48 | 49 | const updatedPrefs = mapStateToPreferences(state.preferences, { ...this.currentPreferences }); 50 | return updatedPrefs; 51 | } 52 | 53 | private notifyListeners(prefs: ThPreferences): void { 54 | const prefsCopy = JSON.parse(JSON.stringify(prefs)); 55 | this.listeners.forEach(callback => callback(prefsCopy)); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /docs/packages/Core/ReadMe.md: -------------------------------------------------------------------------------- 1 | # Using the Core Package 2 | 3 | The Core package provides the foundational building blocks for creating reading applications. It includes UI components, custom hooks, helper functions, and utilities for preferences management. 4 | 5 | ## Installation 6 | 7 | Thorium Web relies on peer dependencies to work. You must install them manually. 8 | 9 | ```bash 10 | 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 11 | ``` 12 | 13 | ## Package Structure 14 | 15 | The Core package is organized into several sub-packages: 16 | 17 | - `@edrlab/thorium-web/core/components`: UI components for building interfaces 18 | - `@edrlab/thorium-web/core/hooks`: React hooks for EPUB support, accessibility, and responsive design 19 | - `@edrlab/thorium-web/core/helpers`: Utility functions for common tasks 20 | - `@edrlab/thorium-web/core/lib`: Redux store and related utilities (hooks and reducers) 21 | - `@edrlab/thorium-web/core/preferences`: Preferences management system 22 | 23 | ## Core Components 24 | 25 | Please refer to the [Components documentation](./Components.md) for more information. 26 | 27 | ## Core Hooks 28 | 29 | Please refer to the [Hooks documentation](./Hooks.md) for more information. 30 | 31 | ## Core Helpers 32 | 33 | Please refer to the [Helpers documentation](./Helpers.md) for more information. 34 | 35 | ## Core Lib 36 | 37 | Please refer to the [Lib documentation](./Lib.md) for more information. 38 | 39 | ## Core Preferences 40 | 41 | Please refer to the [Preferences documentation](./Preferences.md) for more information. 42 | 43 | ## Best Practices 44 | 45 | 1. **Use TypeScript**: The Core package is built with TypeScript, so it's recommended to use TypeScript for type safety. 46 | 2. **Follow React Aria guidelines**: The Core package uses React Aria for accessibility, so follow React Aria guidelines when building custom components. 47 | 3. **Test thoroughly**: Test your application on different devices and browsers to ensure compatibility. 48 | 49 | ## Related Documentation 50 | 51 | - [Epub Package](./Epub.md) -------------------------------------------------------------------------------- /src/preferences/CSSValues.ts: -------------------------------------------------------------------------------- 1 | // Length units 2 | // Source: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Values_and_Units 3 | 4 | export type CSSValueUnitless = `${ number }`; 5 | 6 | export type CSSValueWithUnit = `${ number }${ Unit }`; 7 | 8 | export type CSSAbsoluteLength = CSSValueWithUnit<"cm" | "in" | "mm" | "pc" | "pt" | "px" | "Q">; 9 | 10 | export type CSSAngle = CSSValueWithUnit<"deg" | "grad" | "rad" | "turn">; 11 | 12 | export type CSSDefaultViewport = CSSValueWithUnit<"vb" | "vh" | "vi" | "vmax" | "vmin" | "vw">; 13 | 14 | export type CSSDynamicViewport = CSSValueWithUnit<"dvb" |"dvh" | "dvi" | "dvmax" | "dvmin" | "dvw">; 15 | 16 | export type CSSFrequency = CSSValueWithUnit<"Hz" | "kHz">; 17 | 18 | export type CSSFontRelativeLength = CSSValueWithUnit<"cap" | "ch" | "em" | "ex" | "ic" | "lh">; 19 | 20 | export type CSSLargeViewport = CSSValueWithUnit<"lvb" |"lvh" | "lvi" | "lvmax" | "lvmin" | "lvw">; 21 | 22 | export type CSSPhysicalLength = CSSValueWithUnit<"cm" | "in" | "mm" | "pc" | "pt" | "Q">; 23 | 24 | export type CSSRelativeLength = CSSFontRelativeLength | CSSValueWithUnit<"%"> | CSSRootFontRelativeLength; 25 | 26 | export type CSSResolution = CSSValueWithUnit<"dpcm" | "dpi" | "dppx" | "x">; 27 | 28 | export type CSSRootFontRelativeLength = CSSValueWithUnit<"rcap" | "rch" | "rem" | "rex" | "ric" | "rlh">; 29 | 30 | export type CSSSmallViewport = CSSValueWithUnit<"svb" |"svh" | "svi" | "svmax" | "svmin" | "svw">; 31 | 32 | export type CSSTime = CSSValueWithUnit<"ms" | "s">; 33 | 34 | export type CSSViewport = CSSDefaultViewport | CSSDynamicViewport | CSSLargeViewport | CSSSmallViewport; 35 | 36 | // Color 37 | // Source: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_colors 38 | // Possible Improvement: validation 39 | 40 | export type CSSColor = 41 | string // keywords 42 | | `#${string}` // hexadecimal 43 | | `rgb(${string})` // RGB 44 | | `rgba(${string})` // RGBA 45 | | `hsl(${string})` // HSL 46 | | `hsla(${string})` // HSLA 47 | | `hwb(${string})` // HWB 48 | | `lab(${string})` // LAB 49 | | `lch(${string})` // LCH 50 | | `oklab(${string})` // OKLAB 51 | | `oklch(${string})` // OKLCH 52 | | `color(${string})` // color() 53 | | `color-mix(${string})` // color-mix() 54 | | `light-dark(${string})` // light-dark() 55 | ; -------------------------------------------------------------------------------- /src/core/Components/Actions/ThCollapsibleActionsBar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { Fragment } from "react"; 4 | 5 | import { ThActionEntry, ThActionsBar, ThActionsBarProps, ThActionsTriggerVariant } from "./ThActionsBar"; 6 | import { ThMenu, THMenuProps } from "../Menu/ThMenu"; 7 | 8 | import { useObjectRef } from "react-aria"; 9 | import { CollapsiblePref, useCollapsibility } from "./hooks/useCollapsibility"; 10 | 11 | export interface ThCollapsibleActionsBarProps extends ThActionsBarProps { 12 | id: string; 13 | items: ThActionEntry[]; 14 | prefs: CollapsiblePref; 15 | breakpoint?: string; 16 | children?: never; 17 | compounds?: { 18 | menu: THMenuProps | React.ReactElement; 19 | } 20 | } 21 | 22 | export const ThCollapsibleActionsBar = ({ 23 | ref, 24 | id, 25 | items, 26 | prefs, 27 | breakpoint, 28 | compounds, 29 | ...props 30 | }: ThCollapsibleActionsBarProps) => { 31 | const resolvedRef = useObjectRef(ref); 32 | const Actions = useCollapsibility(items, prefs, breakpoint); 33 | 34 | return ( 35 | <> 36 | 40 | { Actions.ActionIcons.map(({ Trigger, Target, key, associatedKey }) => 41 | 42 | 48 | { Target && } 49 | 50 | ) 51 | } 52 | 53 | { React.isValidElement(compounds?.menu) 54 | ? (React.cloneElement(compounds.menu, { 55 | ...compounds.menu.props, 56 | id: id, 57 | triggerRef: resolvedRef, 58 | items: Actions.MenuItems, 59 | dependencies: ["Trigger"], 60 | } as THMenuProps)) 61 | : ( 68 | )} 69 | 70 | 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /docs/packages/Core/API/Components/Menu.md: -------------------------------------------------------------------------------- 1 | # Menu Components API Documentation 2 | 3 | ## ThMenu 4 | 5 | A menu component that supports custom triggers, items, and popovers. 6 | 7 | ### Props 8 | 9 | ```typescript 10 | interface THMenuProps extends MenuProps> { 11 | ref?: React.ForwardedRef; 12 | triggerRef?: React.RefObject; // Reference to trigger element 13 | items?: Iterable>; // Collection of menu items 14 | children?: never; // Children are not allowed 15 | compounds?: { 16 | menuTrigger?: Omit; // Props for menu trigger 17 | button?: ThActionButtonProps | React.ReactElement; // Custom button or props 18 | popover?: PopoverProps; // Props for popover component 19 | } 20 | } 21 | ``` 22 | 23 | ### Features 24 | 25 | - Compound component pattern for flexible configuration 26 | - Support for custom trigger buttons 27 | - Integration with action system through ThActionEntry 28 | - Automatic popover positioning 29 | - Built-in accessibility through react-aria-components 30 | 31 | ## ThMenuItem 32 | 33 | A menu item component that supports icons, labels, and keyboard shortcuts. 34 | 35 | ### Props 36 | 37 | ```typescript 38 | interface ThMenuItemProps extends MenuItemProps { 39 | ref?: React.Ref; 40 | id: string; // Unique identifier 41 | SVGIcon?: React.ComponentType>; // Optional icon 42 | label: string; // Item label 43 | shortcut?: string; // Optional keyboard shortcut 44 | compounds?: { 45 | label: LabelProps; // Props for label component 46 | shortcut: KeyboardProps; // Props for shortcut component 47 | } 48 | } 49 | ``` 50 | 51 | ### Features 52 | 53 | - Support for SVG icons 54 | - Keyboard shortcut display 55 | - Compound props for label and shortcut customization 56 | - Automatic ARIA labeling 57 | 58 | ### Accessibility 59 | 60 | The menu components implement ARIA best practices: 61 | 62 | - Proper menu and menuitem roles 63 | - Keyboard navigation support 64 | - ARIA labels and descriptions 65 | - Focus management 66 | - Screen reader announcements for shortcuts -------------------------------------------------------------------------------- /src/components/Actions/Settings/StatefulSettingsTrigger.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import TuneIcon from "./assets/icons/match_case.svg"; 4 | 5 | import { StatefulActionTriggerProps } from "../models/actions"; 6 | import { ThActionsKeys } from "@/preferences/models/enums"; 7 | import { ThActionsTriggerVariant } from "@/core/Components/Actions/ThActionsBar"; 8 | 9 | import { StatefulActionIcon } from "../Triggers/StatefulActionIcon"; 10 | import { StatefulOverflowMenuItem } from "../Triggers/StatefulOverflowMenuItem"; 11 | 12 | import { usePreferences } from "@/preferences/hooks/usePreferences"; 13 | import { useI18n } from "@/i18n/useI18n"; 14 | 15 | import { setHovering } from "@/lib/readerReducer"; 16 | import { useAppDispatch, useAppSelector } from "@/lib/hooks"; 17 | import { setActionOpen } from "@/lib/actionsReducer"; 18 | 19 | export const StatefulSettingsTrigger = ({ variant }: StatefulActionTriggerProps) => { 20 | const { preferences } = usePreferences(); 21 | const { t } = useI18n(); 22 | const actionState = useAppSelector(state => state.actions.keys[ThActionsKeys.settings]); 23 | const dispatch = useAppDispatch(); 24 | 25 | const setOpen = (value: boolean) => { 26 | dispatch(setActionOpen({ 27 | key: ThActionsKeys.settings, 28 | isOpen: value 29 | })); 30 | 31 | // hover false otherwise it tends to stay on close button press… 32 | if (!value) dispatch(setHovering(false)); 33 | } 34 | 35 | return( 36 | <> 37 | { (variant && variant === ThActionsTriggerVariant.menu) 38 | ? setOpen(!actionState?.isOpen) } 44 | /> 45 | : setOpen(!actionState?.isOpen) } 51 | > 52 | 54 | } 55 | 56 | ) 57 | } -------------------------------------------------------------------------------- /src/components/Settings/Text/StatefulTextGroup.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useCallback } from "react"; 4 | 5 | import { 6 | defaultTextSettingsMain, 7 | defaultTextSettingsSubpanel, 8 | ThSettingsContainerKeys, 9 | ThTextSettingsKeys 10 | } from "@/preferences"; 11 | 12 | import { StatefulGroupWrapper } from "../StatefulGroupWrapper"; 13 | 14 | import { usePreferences } from "@/preferences/hooks/usePreferences"; 15 | import { usePlugins } from "../../Plugins/PluginProvider"; 16 | import { useI18n } from "@/i18n/useI18n"; 17 | 18 | import { useAppDispatch } from "@/lib/hooks"; 19 | import { setSettingsContainer } from "@/lib/readerReducer"; 20 | 21 | export const StatefulTextGroup = () => { 22 | const { preferences } = usePreferences(); 23 | const { t } = useI18n(); 24 | const { textSettingsComponentsMap } = usePlugins(); 25 | 26 | const dispatch = useAppDispatch(); 27 | 28 | const setTextContainer = useCallback(() => { 29 | dispatch(setSettingsContainer(ThSettingsContainerKeys.text)); 30 | }, [dispatch]); 31 | 32 | return( 33 | <> 34 | 35 | label={ t("reader.settings.text.title") } 36 | moreLabel={ t("reader.settings.text.advanced.trigger") } 37 | moreTooltip={ t("reader.settings.text.advanced.tooltip") } 38 | onPressMore={ setTextContainer } 39 | componentsMap={ textSettingsComponentsMap } 40 | prefs={ preferences.settings.text } 41 | defaultPrefs={ { 42 | main: defaultTextSettingsMain, 43 | subPanel: defaultTextSettingsSubpanel 44 | }} 45 | /> 46 | 47 | ) 48 | } 49 | 50 | export const StatefulTextGroupContainer = () => { 51 | const { preferences } = usePreferences(); 52 | const displayOrder = preferences.settings.text?.subPanel as ThTextSettingsKeys[] | null | undefined || defaultTextSettingsSubpanel; 53 | const { textSettingsComponentsMap } = usePlugins(); 54 | 55 | return( 56 | <> 57 | { displayOrder.map((key: ThTextSettingsKeys) => { 58 | const match = textSettingsComponentsMap[key]; 59 | if (!match) { 60 | console.warn(`Action key "${ key }" not found in the plugin registry while present in preferences.`); 61 | return null; 62 | } 63 | return ; 64 | }) } 65 | 66 | ) 67 | } -------------------------------------------------------------------------------- /src/components/assets/styles/readerSharedUI.module.css: -------------------------------------------------------------------------------- 1 | .icon, 2 | .dockerButton, 3 | .closeButton, 4 | .backButton { 5 | box-sizing: border-box; 6 | padding: calc(var(--icon-size, 24px) * (1/4)); 7 | text-align: center; 8 | border-radius: var(--layout-radius); 9 | } 10 | 11 | .backButton { 12 | box-sizing: content-box; 13 | height: var(--icon-size, 24px); 14 | border: 2px solid transparent; 15 | display: flex; 16 | align-items: center; 17 | } 18 | 19 | .closeButton, 20 | .backButton { 21 | margin-inline-start: auto; 22 | } 23 | 24 | .icon svg, 25 | .dockerButton svg, 26 | .closeButton svg, 27 | .backButton svg { 28 | fill: var(--theme-text); 29 | width: var(--icon-size, 24px); 30 | height: var(--icon-size, 24px); 31 | } 32 | 33 | .icon[data-hovered], 34 | .dockerButton[data-hovered], 35 | .closeButton[data-hovered], 36 | .backButton[data-hovered] { 37 | background-color: var(--theme-hover); 38 | } 39 | 40 | .icon[data-focus-visible], 41 | .dockerButton[data-focus-visible], 42 | .closeButton[data-focus-visible], 43 | .backButton[data-focus-visible] { 44 | outline: 2px solid var(--theme-focus); 45 | } 46 | 47 | .backButton[data-disabled] { 48 | color: var(--theme-disable) 49 | } 50 | 51 | .icon[data-disabled] svg, 52 | .dockerButton[data-disabled] svg, 53 | .closeButton[data-disabled] svg, 54 | .backButton[data-disabled] svg { 55 | fill: var(--theme-disable); 56 | } 57 | 58 | .tooltip { 59 | background-color: var(--theme-text); 60 | color: var(--theme-background); 61 | padding: 5px; 62 | border-radius: var(--layout-radius); 63 | } 64 | 65 | /* Visibility + Immersive */ 66 | 67 | .alwaysVisible { 68 | opacity: 1; 69 | } 70 | 71 | :global(.stacked-ui.isImmersive:not(.isHovering)) .partiallyVisible { 72 | opacity: 0; 73 | } 74 | 75 | /* Utils to improve icons’ consistency */ 76 | 77 | .iconCompSm { 78 | padding: calc(var(--icon-size, 24px) * (1/3)); 79 | } 80 | 81 | .iconCompSm svg { 82 | width: calc(var(--icon-size, 24px) * (3/4)); 83 | height: calc(var(--icon-size, 24px) * (3/4)); 84 | stroke: var(--theme-text); 85 | } 86 | 87 | .iconCompLg { 88 | padding: calc(var(--icon-size, 24px) * (1/6)); 89 | } 90 | 91 | .iconCompLg svg { 92 | width: calc(var(--icon-size, 24px) * (4/3)); 93 | height: calc(var(--icon-size, 24px) * (4/3)); 94 | } 95 | 96 | .iconApplyStroke svg { 97 | stroke: var(--theme-text); 98 | } -------------------------------------------------------------------------------- /src/core/Helpers/progressionFormat.ts: -------------------------------------------------------------------------------- 1 | import { ThProgressionFormat } from "@/preferences/models/enums"; 2 | import { TimelineProgression } from "@/core/Hooks/useTimeline"; 3 | 4 | export const getSupportedProgressionFormats = (timeline?: TimelineProgression): ThProgressionFormat[] => { 5 | if (!timeline) { 6 | return [ThProgressionFormat.none]; 7 | } 8 | 9 | const { 10 | currentPositions = [], 11 | totalPositions, 12 | relativeProgression, 13 | totalProgression, 14 | currentIndex, 15 | totalItems, 16 | positionsLeft 17 | } = timeline; 18 | 19 | const supported: ThProgressionFormat[] = [ThProgressionFormat.none]; 20 | 21 | if (currentPositions.length > 0) { 22 | supported.push(ThProgressionFormat.positions); 23 | } 24 | 25 | if (currentPositions.length > 0 && totalPositions) { 26 | supported.push( 27 | ThProgressionFormat.positionsOfTotal, 28 | ThProgressionFormat.positionsPercentOfTotal 29 | ); 30 | } 31 | 32 | if (positionsLeft !== undefined) { 33 | supported.push(ThProgressionFormat.positionsLeft); 34 | } 35 | 36 | if (relativeProgression !== undefined) { 37 | supported.push( 38 | ThProgressionFormat.resourceProgression, 39 | ThProgressionFormat.progressionOfResource 40 | ); 41 | } 42 | 43 | if (totalProgression !== undefined) { 44 | supported.push(ThProgressionFormat.overallProgression); 45 | } 46 | 47 | if (currentIndex !== undefined && totalItems !== undefined) { 48 | supported.push(ThProgressionFormat.readingOrderIndex); 49 | } 50 | 51 | return supported; 52 | }; 53 | 54 | export const canRenderProgressionFormat = ( 55 | format: ThProgressionFormat, 56 | supportedFormats: ThProgressionFormat[] 57 | ): boolean => { 58 | return supportedFormats.includes(format); 59 | }; 60 | 61 | export const getBestMatchingProgressionFormat = ( 62 | preferredFormats: ThProgressionFormat[], 63 | timeline?: TimelineProgression 64 | ): ThProgressionFormat | null => { 65 | if (!timeline) { 66 | return null; 67 | } 68 | 69 | const supportedFormats = getSupportedProgressionFormats(timeline); 70 | 71 | // Find the first preferred format that's supported 72 | const firstSupported = preferredFormats.find(format => 73 | canRenderProgressionFormat(format, supportedFormats) 74 | ); 75 | 76 | return firstSupported || null; 77 | }; 78 | -------------------------------------------------------------------------------- /src/components/Actions/JumpToPosition/StatefulJumpToPositionTrigger.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | 5 | import { ThActionsKeys } from "@/preferences/models/enums"; 6 | import { StatefulActionTriggerProps } from "../models/actions"; 7 | import { ThActionsTriggerVariant } from "@/core/Components/Actions/ThActionsBar"; 8 | 9 | import TargetIcon from "./assets/icons/pin_drop.svg"; 10 | 11 | import { StatefulActionIcon } from "../Triggers/StatefulActionIcon"; 12 | import { StatefulOverflowMenuItem } from "../Triggers/StatefulOverflowMenuItem"; 13 | 14 | import { usePreferences } from "@/preferences/hooks/usePreferences"; 15 | import { useI18n } from "@/i18n/useI18n"; 16 | 17 | import { setActionOpen, useAppDispatch, useAppSelector } from "@/lib"; 18 | 19 | export const StatefulJumpToPositionTrigger = ({ variant }: StatefulActionTriggerProps) => { 20 | const { preferences } = usePreferences(); 21 | const { t } = useI18n(); 22 | const actionState = useAppSelector(state => state.actions.keys[ThActionsKeys.jumpToPosition]); 23 | const positionsList = useAppSelector(state => state.publication.positionsList); 24 | const dispatch = useAppDispatch(); 25 | 26 | const setOpen = (value: boolean) => { 27 | dispatch(setActionOpen({ 28 | key: ThActionsKeys.jumpToPosition, 29 | isOpen: value 30 | })); 31 | } 32 | 33 | // In case there is no positions list we return 34 | if (!positionsList) return null; 35 | 36 | return( 37 | <> 38 | { (variant && variant === ThActionsTriggerVariant.menu) 39 | ? setOpen(!actionState?.isOpen) } 45 | /> 46 | : setOpen(!actionState?.isOpen) } 52 | > 53 | 55 | } 56 | 57 | ) 58 | } -------------------------------------------------------------------------------- /src/components/Settings/StatefulNumberField.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import readerSharedUI from "../assets/styles/readerSharedUI.module.css"; 4 | import settingsStyles from "./assets/styles/settings.module.css"; 5 | 6 | import { ThNumberField, ThNumberFieldProps } from "@/core/Components/Settings/ThNumberField"; 7 | 8 | import { usePreferences } from "@/preferences/hooks/usePreferences"; 9 | import { useI18n } from "@/i18n/useI18n"; 10 | 11 | import classNames from "classnames"; 12 | 13 | export interface StatefulNumberFieldProps extends Omit { 14 | standalone?: boolean; 15 | resetLabel?: string; 16 | placeholder?: string; 17 | } 18 | 19 | export const StatefulNumberField = ({ 20 | standalone, 21 | label, 22 | placeholder, 23 | value, 24 | resetLabel, 25 | ...props 26 | }: StatefulNumberFieldProps) => { 27 | const { t } = useI18n(); 28 | const { preferences } = usePreferences(); 29 | 30 | return ( 31 | <> 32 | 72 | 73 | ); 74 | }; -------------------------------------------------------------------------------- /src/components/Settings/Spacing/StatefulSpacingGroup.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback } from "react"; 4 | 5 | import { 6 | defaultSpacingSettingsMain, 7 | defaultSpacingSettingsSubpanel, 8 | ThSettingsContainerKeys, 9 | ThSpacingSettingsKeys 10 | } from "@/preferences"; 11 | 12 | import { StatefulGroupWrapper } from "../StatefulGroupWrapper"; 13 | 14 | import { usePreferences } from "@/preferences/hooks/usePreferences"; 15 | import { usePlugins } from "../../Plugins/PluginProvider"; 16 | import { useI18n } from "@/i18n/useI18n"; 17 | 18 | import { useAppDispatch } from "@/lib/hooks"; 19 | import { setSettingsContainer } from "@/lib/readerReducer"; 20 | 21 | export const StatefulSpacingGroup = () => { 22 | const { preferences } = usePreferences(); 23 | const { t } = useI18n(); 24 | const { spacingSettingsComponentsMap } = usePlugins(); 25 | 26 | const dispatch = useAppDispatch(); 27 | 28 | const setSpacingContainer = useCallback(() => { 29 | dispatch(setSettingsContainer(ThSettingsContainerKeys.spacing)); 30 | }, [dispatch]); 31 | 32 | return ( 33 | <> 34 | 35 | label={ t("reader.settings.spacing.title") } 36 | moreLabel={ t("reader.settings.spacing.advanced.trigger") } 37 | moreTooltip={ t("reader.settings.spacing.advanced.tooltip") } 38 | onPressMore={ setSpacingContainer } 39 | componentsMap={ spacingSettingsComponentsMap } 40 | prefs={ preferences.settings.spacing } 41 | defaultPrefs={ { 42 | main: defaultSpacingSettingsMain, 43 | subPanel: defaultSpacingSettingsSubpanel 44 | }} 45 | /> 46 | 47 | ); 48 | } 49 | 50 | export const StatefulSpacingGroupContainer = () => { 51 | const { preferences } = usePreferences(); 52 | 53 | const displayOrder = preferences.settings.spacing?.subPanel as ThSpacingSettingsKeys[] | null | undefined || defaultSpacingSettingsSubpanel; 54 | const { spacingSettingsComponentsMap } = usePlugins(); 55 | 56 | return( 57 | <> 58 | { displayOrder.map((key: ThSpacingSettingsKeys) => { 59 | const match = spacingSettingsComponentsMap[key]; 60 | if (!match) { 61 | console.warn(`Setting key "${ key }" not found in the plugin registry while present in preferences.`); 62 | return null; 63 | } 64 | return ; 65 | }) } 66 | 67 | ) 68 | } -------------------------------------------------------------------------------- /src/core/Components/Actions/hooks/useActions.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ActionStateObject } from "@/lib/actionsReducer"; 4 | import { ThDockingKeys } from "@/preferences/models/enums"; 5 | 6 | export interface ThActionMap { 7 | [key: string | number | symbol]: ActionStateObject | undefined; 8 | } 9 | 10 | export const useActions = (actionMap: ThActionMap) => { 11 | const findOpen = () => { 12 | const open: K[] = []; 13 | 14 | Object.entries(actionMap).forEach(([key, value]) => { 15 | if (value?.isOpen) open.push(key as K); 16 | }); 17 | 18 | return open; 19 | }; 20 | 21 | const anyOpen = () => { 22 | return Object.values(actionMap).some((value) => value?.isOpen); 23 | }; 24 | 25 | const isOpen = (key?: K | null) => { 26 | if (key) { 27 | if (actionMap[key]?.isOpen == null) { 28 | return false; 29 | } else { 30 | return actionMap[key]?.isOpen; 31 | } 32 | } 33 | return false; 34 | }; 35 | 36 | const findDocked = () => { 37 | const docked: K[] = []; 38 | 39 | Object.entries(actionMap).forEach(([key, value]) => { 40 | const docking = value?.docking; 41 | if (docking === ThDockingKeys.start || docking === ThDockingKeys.end) { 42 | docked.push(key as K); 43 | } 44 | }); 45 | 46 | return docked; 47 | }; 48 | 49 | const anyDocked = () => { 50 | return Object.values(actionMap).some((value) => { 51 | const docking = value?.docking; 52 | return docking === ThDockingKeys.start || docking === ThDockingKeys.end; 53 | }); 54 | }; 55 | 56 | const isDocked = (key?: K | null) => { 57 | if (!key) return false; 58 | const docking = actionMap[key]?.docking; 59 | return docking === ThDockingKeys.start || docking === ThDockingKeys.end; 60 | }; 61 | 62 | const whichDocked = (key?: K | null) => { 63 | return key ? actionMap[key]?.docking : null; 64 | }; 65 | 66 | const getDockedWidth = (key?: K | null) => { 67 | return key && actionMap[key]?.dockedWidth || undefined; 68 | }; 69 | 70 | const everyOpenDocked = () => { 71 | const opens = findOpen(); 72 | 73 | return opens.every((key) => { 74 | return isDocked(key); 75 | }); 76 | }; 77 | 78 | return { 79 | findOpen, 80 | anyOpen, 81 | isOpen, 82 | findDocked, 83 | anyDocked, 84 | isDocked, 85 | whichDocked, 86 | getDockedWidth, 87 | everyOpenDocked, 88 | }; 89 | }; -------------------------------------------------------------------------------- /src/app/read/experimental/[identifier]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { use, useEffect, useState } from "react"; 4 | import { ExperimentalWebPubStatefulReader } from "@/components/WebPub"; 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 | const WEB_MANIFESTS = { 13 | "readium-css": "https://readium.org/css/docs/manifest.json", 14 | "moby-dick": "https://readium.org/webpub-manifest/examples/MobyDick/manifest.json", 15 | "molly-hopper": "https://publication-server.readium.org/webpub/Z3M6Ly9yZWFkaXVtLXBsYXlncm91bmQtZmlsZXMvZGVtby9tb2xseS1ob3BwZXItdjEuMS53ZWJwdWI/manifest.json" 16 | } 17 | 18 | type Params = { identifier: string }; 19 | 20 | type Props = { 21 | params: Promise; 22 | }; 23 | 24 | export default function WebPubPage({ params }: Props) { 25 | const [domainError, setDomainError] = useState(null); 26 | const identifier = use(params).identifier; 27 | const isLoading = useAppSelector(state => state.reader.isLoading); 28 | 29 | // Determine manifest URL - either from predefined list or treat identifier as URL 30 | const manifestUrl = identifier ? WEB_MANIFESTS[identifier as keyof typeof WEB_MANIFESTS] || identifier : ""; 31 | 32 | useEffect(() => { 33 | if (manifestUrl) { 34 | verifyManifestUrl(manifestUrl).then(allowed => { 35 | if (!allowed) { 36 | setDomainError(`Domain not allowed: ${ new URL(manifestUrl).hostname }`); 37 | } 38 | }); 39 | } 40 | }, [manifestUrl]); 41 | 42 | const { error, manifest, selfLink } = usePublication({ 43 | url: manifestUrl, 44 | onError: (error) => { 45 | console.error("Publication loading error:", error); 46 | } 47 | }); 48 | 49 | if (domainError) { 50 | return ( 51 |
52 |

Access Denied

53 |

{ domainError }

54 |
55 | ); 56 | } 57 | 58 | return ( 59 | <> 60 | { error ? ( 61 |
62 |

Error

63 |

{ error }

64 |
65 | ) : ( 66 | 67 | { manifest && selfLink && } 68 | 69 | )} 70 | 71 | ); 72 | } -------------------------------------------------------------------------------- /src/core/Helpers/breakpointsMap.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThBreakpoints } from "@/preferences/models/enums"; 4 | import { BreakpointsMap } from "@/core/Hooks/useBreakpoints"; 5 | 6 | export const makeBreakpointsMap = ({ 7 | defaultValue, 8 | fromEnum, 9 | pref, 10 | disabledValue, 11 | validateKey 12 | }: { 13 | defaultValue: T; 14 | fromEnum: any; 15 | pref?: BreakpointsMap | boolean; 16 | disabledValue?: T; 17 | validateKey?: string; 18 | }): Required> => { 19 | 20 | const isValidType = (value: any): boolean => { 21 | if (!validateKey) return true; 22 | 23 | // Helper to get nested property 24 | const getNestedValue = (obj: any, path: string) => 25 | path.split(".").reduce((o, p) => o?.[p], obj); 26 | 27 | const valueToCheck = getNestedValue(value, validateKey); 28 | if (valueToCheck === undefined) return false; 29 | 30 | if (Array.isArray(valueToCheck)) { 31 | return valueToCheck.every(v => Object.values(fromEnum).includes(v)); 32 | } 33 | return Object.values(fromEnum).includes(valueToCheck); 34 | }; 35 | 36 | const breakpointsMap: Required> = { 37 | [ThBreakpoints.compact]: defaultValue, 38 | [ThBreakpoints.medium]: defaultValue, 39 | [ThBreakpoints.expanded]: defaultValue, 40 | [ThBreakpoints.large]: defaultValue, 41 | [ThBreakpoints.xLarge]: defaultValue 42 | }; 43 | 44 | if (typeof pref === "boolean" || pref instanceof Boolean) { 45 | if (!pref && disabledValue) { 46 | Object.values(ThBreakpoints).forEach((key) => { 47 | breakpointsMap[key] = disabledValue; 48 | }); 49 | } 50 | } else if (typeof pref === "string" && (!validateKey || isValidType(pref))) { 51 | Object.values(ThBreakpoints).forEach((key) => { 52 | breakpointsMap[key] = pref as unknown as T; 53 | }); 54 | } else if (typeof pref === "object") { 55 | Object.entries(pref).forEach(([key, value]) => { 56 | if (!value) return; 57 | 58 | const isValid = !validateKey || isValidType(value); 59 | 60 | if (isValid) { 61 | // Merge the default value with the breakpoint-specific overrides 62 | if (typeof value === "object" && !Array.isArray(value)) { 63 | breakpointsMap[key as ThBreakpoints] = { 64 | ...defaultValue, 65 | ...value 66 | }; 67 | } else { 68 | breakpointsMap[key as ThBreakpoints] = value as T; 69 | } 70 | } 71 | }); 72 | } 73 | 74 | return breakpointsMap; 75 | }; -------------------------------------------------------------------------------- /src/hooks/useReaderTransitions.ts: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from "@/lib/hooks"; 2 | import { usePrevious } from "@/core/Hooks/usePrevious"; 3 | 4 | export interface ReaderTransitions { 5 | // Current states 6 | isImmersive: boolean; 7 | isFullscreen: boolean; 8 | isScroll: boolean; 9 | hasUserNavigated: boolean; 10 | 11 | // Previous states 12 | wasImmersive: boolean; 13 | wasFullscreen: boolean; 14 | wasScroll: boolean; 15 | wasUserNavigated: boolean; 16 | 17 | // State transitions 18 | fromImmersive: boolean; 19 | toImmersive: boolean; 20 | fromFullscreen: boolean; 21 | toFullscreen: boolean; 22 | fromScroll: boolean; 23 | toScroll: boolean; 24 | fromUserNavigation: boolean; 25 | toUserNavigation: boolean; 26 | } 27 | 28 | export const useReaderTransitions = (): ReaderTransitions => { 29 | // Current states 30 | const isImmersive = useAppSelector(state => state.reader.isImmersive); 31 | const isFullscreen = useAppSelector(state => state.reader.isFullscreen); 32 | const hasUserNavigated = useAppSelector(state => state.reader.hasUserNavigated); 33 | const scroll = useAppSelector(state => state.settings.scroll); 34 | const isFXL = useAppSelector(state => state.publication.isFXL); 35 | 36 | const isScroll = scroll && !isFXL; 37 | 38 | // Previous states 39 | const wasImmersive = usePrevious(isImmersive) ?? false; 40 | const wasFullscreen = usePrevious(isFullscreen) ?? false; 41 | const wasScroll = usePrevious(isScroll) ?? false; 42 | const wasUserNavigated = usePrevious(hasUserNavigated) ?? false; 43 | 44 | // State transitions 45 | const fromImmersive = wasImmersive && !isImmersive; 46 | const toImmersive = !wasImmersive && isImmersive; 47 | const fromFullscreen = wasFullscreen && !isFullscreen; 48 | const toFullscreen = !wasFullscreen && isFullscreen; 49 | const fromScroll = wasScroll && !isScroll; 50 | const toScroll = !wasScroll && isScroll; 51 | const fromUserNavigation = wasUserNavigated && !hasUserNavigated; 52 | const toUserNavigation = !wasUserNavigated && hasUserNavigated; 53 | 54 | return { 55 | // Current states 56 | isImmersive, 57 | isFullscreen, 58 | isScroll, 59 | hasUserNavigated, 60 | 61 | // Previous states 62 | wasImmersive, 63 | wasFullscreen, 64 | wasScroll, 65 | wasUserNavigated, 66 | 67 | // State transitions 68 | fromImmersive, 69 | toImmersive, 70 | fromFullscreen, 71 | toFullscreen, 72 | fromScroll, 73 | toScroll, 74 | fromUserNavigation, 75 | toUserNavigation 76 | }; 77 | }; 78 | -------------------------------------------------------------------------------- /src/lib/themeReducer.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | import { ThColorScheme } from "@/core/Hooks/useColorScheme"; 4 | import { ThContrast } from "@/core/Hooks/useContrast"; 5 | import { ThBreakpoints } from "@/preferences/models/enums"; 6 | 7 | export interface ThemeStateObject { 8 | reflow?: string; 9 | fxl?: string; 10 | } 11 | 12 | export interface ThemeStateChangePayload { 13 | type: string; 14 | payload: { 15 | key: "reflow" | "fxl"; 16 | value?: string; 17 | } 18 | } 19 | 20 | export interface ThemeReducerState { 21 | monochrome: boolean; 22 | colorScheme: ThColorScheme; 23 | theme: ThemeStateObject; 24 | prefersReducedMotion: boolean; 25 | prefersReducedTransparency: boolean; 26 | prefersContrast: ThContrast; 27 | forcedColors: boolean; 28 | breakpoint?: ThBreakpoints; 29 | } 30 | 31 | const initialState: ThemeReducerState = { 32 | monochrome: false, 33 | colorScheme: ThColorScheme.light, 34 | theme: { 35 | reflow: "auto", 36 | fxl: "auto" 37 | }, 38 | prefersReducedMotion: false, 39 | prefersReducedTransparency: false, 40 | prefersContrast: ThContrast.none, 41 | forcedColors: false, 42 | breakpoint: undefined 43 | } 44 | 45 | export const themeSlice = createSlice({ 46 | name: "theming", 47 | initialState, 48 | reducers: { 49 | setMonochrome: (state, action) => { 50 | state.monochrome = action.payload 51 | }, 52 | setColorScheme: (state, action) => { 53 | state.colorScheme = action.payload 54 | }, 55 | setTheme: (state, action: ThemeStateChangePayload) => { 56 | state.theme[action.payload.key] = action.payload.value || "auto" 57 | }, 58 | setReducedMotion: (state, action) => { 59 | state.prefersReducedMotion = action.payload 60 | }, 61 | setReducedTransparency: (state, action) => { 62 | state.prefersReducedTransparency = action.payload 63 | }, 64 | setContrast: (state, action) => { 65 | state.prefersContrast = action.payload 66 | }, 67 | setForcedColors: (state, action) => { 68 | state.forcedColors = action.payload 69 | }, 70 | setBreakpoint: (state, action) => { 71 | state.breakpoint = action.payload 72 | } 73 | } 74 | }) 75 | 76 | // Action creators are generated for each case reducer function 77 | export const { 78 | setMonochrome, 79 | setColorScheme, 80 | setTheme, 81 | setReducedMotion, 82 | setReducedTransparency, 83 | setContrast, 84 | setForcedColors, 85 | setBreakpoint, 86 | } = themeSlice.actions; 87 | 88 | export default themeSlice.reducer; --------------------------------------------------------------------------------