├── src-tauri ├── build.rs ├── icons │ ├── 32x32.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── StoreLogo.png │ ├── Square30x30Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ └── Square310x310Logo.png ├── .gitignore ├── desktopTemplate.hbs ├── Cargo.toml └── tauri.conf.json ├── src ├── store │ ├── slices │ │ ├── constants.ts │ │ ├── AppState │ │ │ ├── state │ │ │ │ ├── modals │ │ │ │ │ ├── modalsTypes.d.ts │ │ │ │ │ └── modals.ts │ │ │ │ └── stateManager.ts │ │ │ └── globalThemes.ts │ │ ├── EpubJSBackend │ │ │ ├── state │ │ │ │ ├── stateManager.d.ts │ │ │ │ └── stateManager.ts │ │ │ ├── data │ │ │ │ ├── dataManager.d.ts │ │ │ │ ├── theme │ │ │ │ │ └── themeManager.d.ts │ │ │ │ └── dataManager.ts │ │ │ └── epubjsManager.d.ts │ │ ├── bookStateTypes.d.ts │ │ ├── profileSlice.ts │ │ ├── appStateTypes.d.ts │ │ ├── counterSlice.ts │ │ └── appState.ts │ ├── hooks.ts │ ├── utlity.ts │ ├── syncedActions.ts │ └── store.ts ├── routes │ ├── Reader │ │ ├── SettingsBar │ │ │ ├── ThemesContainer │ │ │ │ ├── ThemesContainer.module.scss │ │ │ │ └── ThemesContainer.tsx │ │ │ ├── DisplayContainer │ │ │ │ ├── DisplayContainer.module.scss │ │ │ │ └── DisplayContainer.tsx │ │ │ ├── SettingsBar.module.scss │ │ │ ├── FontsContainerV2 │ │ │ │ ├── FontsContainer.module.scss │ │ │ │ └── FontsContainer.tsx │ │ │ ├── SpacingContainer │ │ │ │ └── SpacingContainer.module.scss │ │ │ └── SettingsBar.tsx │ │ ├── Components │ │ │ └── BottomMenuContainer │ │ │ │ ├── BottomMenuContainer.tsx │ │ │ │ └── BottomMenuContainer.module.scss │ │ ├── ProgressMenu │ │ │ └── ProgressMenu.module.scss │ │ ├── ReaderView │ │ │ ├── ReaderView.module.scss │ │ │ ├── components │ │ │ │ ├── QuickbarModal │ │ │ │ │ └── QuickbarModal.module.scss │ │ │ │ ├── Dictionary │ │ │ │ │ ├── Dictionary.module.scss │ │ │ │ │ └── Dictionary.tsx │ │ │ │ └── NoteModal │ │ │ │ │ ├── NoteModal.module.scss │ │ │ │ │ └── NoteModal.tsx │ │ │ └── functions │ │ │ │ └── ModalUtility.tsx │ │ ├── SideBar │ │ │ ├── Bookmarks │ │ │ │ ├── Bookmarks.module.scss │ │ │ │ └── Bookmarks.tsx │ │ │ ├── Annotations │ │ │ │ ├── Annotations.module.scss │ │ │ │ └── Annotations.tsx │ │ │ ├── Chapters │ │ │ │ ├── Chapters.module.scss │ │ │ │ └── Chapters.tsx │ │ │ ├── Search │ │ │ │ ├── Search.module.scss │ │ │ │ └── Search.tsx │ │ │ ├── SideBar.module.scss │ │ │ └── SideBar.tsx │ │ ├── SliderNavigator │ │ │ ├── SliderNavigator.module.scss │ │ │ └── SliderNavigator.tsx │ │ ├── FooterBarBottom │ │ │ ├── FooterBar.module.scss │ │ │ └── FooterBar.tsx │ │ └── FooterBar │ │ │ ├── FooterBar.module.scss │ │ │ └── FooterBar.tsx │ ├── Home │ │ ├── FakeCover │ │ │ ├── FakeCover.tsx │ │ │ └── FakeCover.module.scss │ │ └── dynamic.css │ ├── Router.tsx │ ├── Settings │ │ ├── pages │ │ │ ├── About.tsx │ │ │ ├── PreviewWidget │ │ │ │ ├── PreviewWidget.module.scss │ │ │ │ └── PreviewWidget.tsx │ │ │ ├── ReaderTheme.module.scss │ │ │ └── Fonts │ │ │ │ └── Fonts.module.scss │ │ ├── Settings.module.scss │ │ └── Settings.tsx │ └── Info │ │ ├── generator │ │ └── html.ts │ │ └── Info.module.scss ├── shared │ ├── components │ │ ├── TitleBarButtons.module.scss │ │ └── TitleBarButtons.tsx │ ├── styles │ │ └── global │ │ │ ├── stroke.scss │ │ │ └── breakpoints.scss │ └── scripts │ │ ├── Parser │ │ ├── parser.tsx │ │ └── formats │ │ │ └── plaintext.ts │ │ ├── getChapterCfiMap.ts │ │ ├── handleLinkClick.ts │ │ └── TauriActions.ts ├── index.tsx ├── migrations.js └── InitializeApp.tsx ├── .gitignore ├── public ├── resources │ ├── figma │ │ ├── Bookmark.svg │ │ ├── Minimize.svg │ │ ├── Maximize.svg │ │ ├── Exit.svg │ │ └── Unmaximize.svg │ ├── iconmonstr │ │ ├── iconmonstr-copy-9.svg │ │ ├── iconmonstr-save-14.svg │ │ ├── iconmonstr-sort-25.svg │ │ ├── iconmonstr-book-26.svg │ │ ├── iconmonstr-undo-7.svg │ │ ├── iconmonstr-magnifier-2.svg │ │ ├── text-3.svg │ │ ├── iconmonstr-text-align-left-lined.svg │ │ ├── iconmonstr-text-align-right-lined.svg │ │ ├── iconmonstr-computer-10.svg │ │ ├── iconmonstr-text-align-center-lined.svg │ │ ├── iconmonstr-text-description-lined.svg │ │ ├── iconmonstr-cloud-download-thin.svg │ │ └── iconmonstr-script-2.svg │ ├── feathericons │ │ ├── check.svg │ │ ├── chevron-down.svg │ │ ├── chevron-right.svg │ │ ├── bookmark.svg │ │ ├── filter.svg │ │ ├── search.svg │ │ ├── arrow-left.svg │ │ ├── arrow-right.svg │ │ ├── home.svg │ │ ├── check-circle.svg │ │ ├── more-vertical.svg │ │ ├── repeat.svg │ │ ├── maximize-2.svg │ │ ├── minimize-2.svg │ │ ├── folder-plus.svg │ │ ├── file-plus.svg │ │ ├── trash-2.svg │ │ ├── list.svg │ │ └── settings.svg │ └── material │ │ └── article_black_24dp.svg └── template.html ├── .gitmodules ├── .vscode ├── extensions.json └── settings.json ├── scripts ├── Download Assets.sh └── Generate Assets.sh ├── .stylelintrc.yaml ├── tsconfig.json ├── .eslintrc.js ├── globals.d.ts ├── docs ├── Build Instructions.md └── FAQ.md ├── .github └── workflows │ └── build-action.yml └── package.json /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btpf/Alexandria/HEAD/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btpf/Alexandria/HEAD/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btpf/Alexandria/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btpf/Alexandria/HEAD/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btpf/Alexandria/HEAD/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btpf/Alexandria/HEAD/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btpf/Alexandria/HEAD/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btpf/Alexandria/HEAD/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btpf/Alexandria/HEAD/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btpf/Alexandria/HEAD/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btpf/Alexandria/HEAD/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | Alexandria_Data -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btpf/Alexandria/HEAD/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btpf/Alexandria/HEAD/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btpf/Alexandria/HEAD/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btpf/Alexandria/HEAD/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btpf/Alexandria/HEAD/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src/store/slices/constants.ts: -------------------------------------------------------------------------------- 1 | export enum LOADSTATE{ 2 | LOADING, 3 | BOOK_PARSING_COMPLETE, 4 | DATA_PARSING_COMPLETE, 5 | COMPLETE, 6 | CANCELED 7 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.webp 4 | *.jpg 5 | *.gif 6 | *.epub 7 | changelog.txt 8 | public/resources/webfonts.json 9 | public/resources/webfonts.json.bak 10 | public/resources/Fonts/ -------------------------------------------------------------------------------- /public/resources/figma/Bookmark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/resources/iconmonstr/iconmonstr-copy-9.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/figma/Minimize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/resources/figma/Maximize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/resources/iconmonstr/iconmonstr-save-14.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src-tauri/desktopTemplate.hbs: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Version=1.0 3 | Type=Application 4 | Name={{name}} 5 | Comment=A minimalistic eBook reader 6 | Exec={{exec}} 7 | Icon={{icon}} 8 | Categories=Office 9 | Keywords=reader;office;ebook;epub;comicbook;comic;ebook -------------------------------------------------------------------------------- /public/resources/iconmonstr/iconmonstr-sort-25.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "libmobi-rs"] 2 | path = libmobi-rs 3 | url = https://github.com/btpf/libmobi-rs.git 4 | [submodule "public/resources/Alexandria-Assets"] 5 | path = public/resources/Alexandria-Assets 6 | url = https://github.com/btpf/Alexandria-Assets.git 7 | -------------------------------------------------------------------------------- /src/store/slices/AppState/state/modals/modalsTypes.d.ts: -------------------------------------------------------------------------------- 1 | export interface MoveModalAction{ 2 | x: number, 3 | y: number, 4 | visible: boolean 5 | } 6 | export interface MoveModalCFIAction{ 7 | view:number, 8 | selectedCFI: string 9 | } 10 | -------------------------------------------------------------------------------- /src/store/slices/EpubJSBackend/state/stateManager.d.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export interface sideBarUpdate{ 4 | view:number, 5 | state: string|boolean 6 | } 7 | 8 | 9 | 10 | export interface SetDictionaryWordPayload{ 11 | view:number, 12 | word: string 13 | } -------------------------------------------------------------------------------- /public/resources/feathericons/check.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Webpack App 6 | 7 | 8 |
9 | 10 | -------------------------------------------------------------------------------- /public/resources/feathericons/chevron-down.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/feathericons/chevron-right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/iconmonstr/iconmonstr-book-26.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/feathericons/bookmark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/feathericons/filter.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/figma/Exit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/resources/feathericons/search.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "christian-kohler.path-intellisense", 4 | "dbaeumer.vscode-eslint", 5 | "stylelint.vscode-stylelint", 6 | "mrmlnc.vscode-scss", 7 | "clinyong.vscode-css-modules", 8 | "phoenisx.cssvar", 9 | "rust-lang.rust-analyzer" 10 | ] 11 | } -------------------------------------------------------------------------------- /public/resources/feathericons/arrow-left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/feathericons/arrow-right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/feathericons/home.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/feathericons/check-circle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/Download Assets.sh: -------------------------------------------------------------------------------- 1 | # Note to self: DDL Link generated with https://sites.google.com/site/gdocs2direct/ 2 | 3 | # Download assets bundle instead of generating them 4 | wget -O - "https://drive.google.com/uc?export=download&id=1PjRz4N4v5fqEd3wzX9Fh01s7nK5U19X0" > temp.zip 5 | unzip temp.zip 6 | 7 | rm temp.zip 8 | cp -r ./public ../ 9 | rm -rdf ./public 10 | -------------------------------------------------------------------------------- /src/store/hooks.ts: -------------------------------------------------------------------------------- 1 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' 2 | import type { RootState, AppDispatch } 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 -------------------------------------------------------------------------------- /public/resources/feathericons/more-vertical.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/resources/figma/Unmaximize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/resources/material/article_black_24dp.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/feathericons/repeat.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/iconmonstr/iconmonstr-undo-7.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/routes/Reader/SettingsBar/ThemesContainer/ThemesContainer.module.scss: -------------------------------------------------------------------------------- 1 | .themeSelectorContainer{ 2 | width: 100%; 3 | height: 100%; 4 | // padding-top:5px; 5 | display:flex; 6 | align-items: center; 7 | flex-direction: column; 8 | overflow-y: auto; 9 | } 10 | 11 | 12 | .theme{ 13 | font-size: 32px; 14 | background-color:blue; 15 | width: 100%; 16 | text-align: center; 17 | cursor: pointer; 18 | } -------------------------------------------------------------------------------- /public/resources/feathericons/maximize-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/feathericons/minimize-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/feathericons/folder-plus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/feathericons/file-plus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/store/slices/bookStateTypes.d.ts: -------------------------------------------------------------------------------- 1 | import { Rendition } from '@btpf/epubjs' 2 | import { LOADSTATE } from './constants' 3 | import { bookStateStructure } from "./EpubJSBackend/epubjsManager.d.ts" 4 | 5 | interface BackendInstance{ 6 | instance: Rendition 7 | UID: number, 8 | hash: string, 9 | initialLoadState?: LOADSTATE 10 | } 11 | 12 | export interface BookInstances { 13 | [key: string]: bookStateStructure | unknown // some other rendering backend 14 | } -------------------------------------------------------------------------------- /public/resources/feathericons/trash-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/iconmonstr/iconmonstr-magnifier-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/store/slices/EpubJSBackend/data/dataManager.d.ts: -------------------------------------------------------------------------------- 1 | export interface highlightData { 2 | color: string, 3 | note: string 4 | } 5 | 6 | export interface highlightAction extends highlightData { 7 | view: number, 8 | highlightRange: string 9 | } 10 | 11 | export interface progressUpdate{ 12 | view: number, 13 | progress: number, 14 | cfi: string 15 | } 16 | 17 | export interface bookmarkAction { 18 | view: number, 19 | bookmarkLocation: string 20 | } -------------------------------------------------------------------------------- /public/resources/feathericons/list.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/iconmonstr/text-3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/routes/Home/FakeCover/FakeCover.tsx: -------------------------------------------------------------------------------- 1 | 2 | import styles from './FakeCover.module.scss' 3 | import React from 'react' 4 | 5 | type props = { 6 | title: string, 7 | author: string 8 | } 9 | const FakeCover = (props:props)=>{ 10 | 11 | return ( 12 |
13 |
14 |
15 | {props.title} 16 |
17 |
18 | {props.author} 19 |
20 | 21 |
22 | 23 |
24 | ) 25 | } 26 | 27 | 28 | export default FakeCover -------------------------------------------------------------------------------- /public/resources/iconmonstr/iconmonstr-text-align-left-lined.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/iconmonstr/iconmonstr-text-align-right-lined.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/iconmonstr/iconmonstr-computer-10.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/iconmonstr/iconmonstr-text-align-center-lined.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/iconmonstr/iconmonstr-text-description-lined.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/resources/iconmonstr/iconmonstr-cloud-download-thin.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.stylelintrc.yaml: -------------------------------------------------------------------------------- 1 | extends: stylelint-config-recommended-scss 2 | plugins: 3 | stylelint-scss 4 | rules: 5 | selector-id-pattern: .* # Prevent kebab case enforcement 6 | # at-rule-no-unknown - When disabled, this will allow custom variables 7 | # https://github.com/stylelint-scss/stylelint-scss/blob/master/src/rules/at-rule-no-unknown/README.md - Guideline used to disable 8 | at-rule-no-unknown: null # https://stylelint.io/user-guide/rules/list/at-rule-no-unknown/ 9 | scss/at-rule-no-unknown: true 10 | indentation: 2 11 | declaration-block-trailing-semicolon: always 12 | no-descending-specificity: null # https://github.com/stylelint/stylelint/issues/3516 -------------------------------------------------------------------------------- /src/routes/Reader/Components/BottomMenuContainer/BottomMenuContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from './BottomMenuContainer.module.scss' 3 | 4 | 5 | 6 | interface props { 7 | active: boolean 8 | } 9 | 10 | const BottomMenuContainer = (props: React.PropsWithChildren)=>{ 11 | 12 | return ( 13 | // This serves the dual purpose of preventing a flashbang 14 |
15 |
16 | {props.children} 17 |
18 |
19 | ) 20 | } 21 | 22 | export default BottomMenuContainer -------------------------------------------------------------------------------- /src/routes/Home/dynamic.css: -------------------------------------------------------------------------------- 1 | /* @import "breakpoints.css"; */ 2 | 3 | /* 4 | #boxPlaceholder { 5 | border: 1px solid black; 6 | } */ 7 | 8 | /* @value small: (max-width: 599px); 9 | @value medium: (min-width: 600px) and (max-width: 959px); 10 | @value large: (min-width: 960px); */ 11 | 12 | /* :root { 13 | --some-color: salmon; 14 | } */ 15 | 16 | /* @media (max-width: 800px) { 17 | :root { 18 | --some-color: rgb(10 239 255); 19 | } 20 | } */ 21 | 22 | /* @custom-media --medium (max-width: 959px); */ 23 | 24 | /* 25 | @media (--medium) { 26 | #boxPlaceholder { 27 | background-color: var(--some-color) !important; 28 | } 29 | } */ 30 | 31 | .test { 32 | color: red; 33 | } 34 | :root{ 35 | --testEm: green !important; 36 | } -------------------------------------------------------------------------------- /src/routes/Router.tsx: -------------------------------------------------------------------------------- 1 | import {Route, createRoutesFromElements } from "react-router-dom"; 2 | import React from "react"; 3 | 4 | import Home from './Home/Home' 5 | import Reader from "./Reader/Reader"; 6 | import Settings from "./Settings/Settings"; 7 | import Info from "./Info/Info"; 8 | 9 | export default ( 10 | createRoutesFromElements( 11 | <> 12 | } /> 13 | } /> 14 | } /> 15 | } /> 16 | }/> 17 | }/> 18 | 19 | ) 20 | ); -------------------------------------------------------------------------------- /src/store/slices/EpubJSBackend/data/theme/themeManager.d.ts: -------------------------------------------------------------------------------- 1 | export interface SetFontPayload{ 2 | view: number 3 | font?: string 4 | fontSize?: number 5 | } 6 | 7 | 8 | 9 | export interface Theme{ 10 | body: { 11 | background?: string, 12 | color?: string, 13 | }, 14 | '*'?: { 15 | color?: string, 16 | background?: string, 17 | }, 18 | 'a:link'?: { 19 | color?: string, 20 | 'text-decoration'?: string, 21 | }, 22 | 'a:link:hover'?: { 23 | background?: string, 24 | } 25 | } 26 | 27 | // export interface SetThemePayload{ 28 | // view: number 29 | // theme:Theme 30 | // } 31 | 32 | export interface SetThemePayload{ 33 | view: number 34 | themeName: string 35 | } -------------------------------------------------------------------------------- /src/routes/Reader/SettingsBar/DisplayContainer/DisplayContainer.module.scss: -------------------------------------------------------------------------------- 1 | .optionsContainer{ 2 | display:flex; 3 | flex-direction: row; 4 | gap:10px; 5 | justify-content: space-evenly; 6 | width:100%; 7 | } 8 | 9 | .optionContainer{ 10 | height:90px; 11 | width:200px; 12 | display:flex; 13 | flex-direction: column; 14 | align-items: center; 15 | justify-content: space-between; 16 | font-size: 18px; 17 | font-weight: 600; 18 | cursor:pointer; 19 | color:var(--text-primary); 20 | opacity: 0.75; 21 | 22 | transition: 0.15s; 23 | &:hover{ 24 | opacity: 0.85; 25 | } 26 | &:active{ 27 | opacity: 0.5; 28 | } 29 | } 30 | 31 | .active{ 32 | opacity: 1 33 | } 34 | .optionContainer > svg{ 35 | display:block; 36 | } -------------------------------------------------------------------------------- /src/store/slices/profileSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit' 2 | 3 | 4 | interface profileState { 5 | name: string, 6 | age: number 7 | } 8 | 9 | // Define the initial state using that type 10 | const initialState: profileState = { 11 | name: "untitled", 12 | age: -1 13 | } 14 | 15 | export const profile = createSlice({ 16 | name: 'profile', 17 | initialState, 18 | reducers: { 19 | setDetails: (state, action: PayloadAction) =>{ 20 | state.name = action.payload.name 21 | state.age = action.payload.age 22 | } 23 | }, 24 | }) 25 | 26 | // Action creators are generated for each case reducer function 27 | export const { setDetails } = profile.actions 28 | 29 | export default profile.reducer -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/node_modules": true, 4 | "**/libmobi-rs": true 5 | }, 6 | "typescript.tsdk": "node_modules/typescript/lib", 7 | "typescript.suggest.paths": false, 8 | "eslint.format.enable": true, 9 | "editor.codeActionsOnSave": { 10 | "source.fixAll.eslint": "explicit", 11 | "source.fixAll.stylelint": "explicit" 12 | }, 13 | // https://github.com/tailwindlabs/tailwindcss/discussions/5258#discussioncomment-1979394 14 | "css.lint.unknownAtRules": "ignore", 15 | 16 | "path-intellisense.mappings": { 17 | "@resources": "${workspaceRoot}/public/resources", 18 | "@store": "${workspaceRoot}/src/store" 19 | }, 20 | "path-intellisense.autoTriggerNextSuggestion": true, 21 | } -------------------------------------------------------------------------------- /src/shared/components/TitleBarButtons.module.scss: -------------------------------------------------------------------------------- 1 | .titleBarButtonsContainer{ 2 | // width: auto; 3 | height: 100%; 4 | display:inline-flex; 5 | align-items: center; 6 | padding-right: 13px; 7 | padding-left: 30px; 8 | pointer-events:none; 9 | > svg{ 10 | pointer-events: auto; 11 | } 12 | } 13 | 14 | .remove{ 15 | display:none; 16 | } 17 | 18 | .disabled{ 19 | > svg{ 20 | pointer-events:none; 21 | } 22 | 23 | } 24 | .titleBarButton{ 25 | height: 100%; 26 | width: calc(15px + 20px); 27 | opacity: 0.8; 28 | color: var(--text-primary); 29 | cursor: pointer; 30 | user-select: none; 31 | transition: 0.3s; 32 | padding-left:10px; 33 | padding-right:10px; 34 | } 35 | 36 | .titleBarButton:hover{ 37 | opacity: 1; 38 | } 39 | 40 | .titleBarExit:hover{ 41 | color:red; 42 | } -------------------------------------------------------------------------------- /src/store/utlity.ts: -------------------------------------------------------------------------------- 1 | // https://levelup.gitconnected.com/typescript-trick-retrieving-all-keys-of-an-object-c346dacf5369 2 | export type GetAllKeys = T extends object 3 | ? { 4 | [K in keyof T]-?: K extends string | number 5 | ? `${K}` | `${GetAllKeys}` 6 | : never; 7 | }[keyof T] 8 | : never; 9 | 10 | // https://stackoverflow.com/questions/71187691/recursive-partial-with-depth 11 | type Increment = [...A, 0]; 12 | 13 | export type GetKeysAtLevel = T extends object 14 | ? { 15 | [K in keyof T]-?: K extends string | number 16 | ? CurrentDepth["length"] extends Depth?`${K}`:never | `${GetKeysAtLevel>}` 17 | : never; 18 | }[keyof T] 19 | : never; 20 | -------------------------------------------------------------------------------- /src/shared/styles/global/stroke.scss: -------------------------------------------------------------------------------- 1 | /// Stroke font-character 2 | /// @param {Integer} $stroke - Stroke width 3 | /// @param {Color} $color - Stroke color 4 | /// @return {List} - text-shadow list 5 | @function stroke($stroke, $color) { 6 | $shadow: (); 7 | $from: $stroke * -1; 8 | @for $i from $from through $stroke { 9 | @for $j from $from through $stroke { 10 | /* stylelint-disable-next-line scss/no-global-function-names */ 11 | $shadow: append($shadow, $i * 1px $j * 1px 0 $color, comma); 12 | } 13 | } 14 | @return $shadow; 15 | } 16 | /// Stroke font-character 17 | /// @param {Integer} $stroke - Stroke width 18 | /// @param {Color} $color - Stroke color 19 | /// @return {Style} - text-shadow 20 | @mixin stroke($stroke, $color) { 21 | text-shadow: stroke($stroke, $color); 22 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { // https://github.com/mrmckeb/typescript-plugin-css-modules 2 | // Use typescript base https://www.typescriptlang.org/docs/handbook/tsconfig-json.html#tsconfig-bases 3 | "extends": "@tsconfig/node16/tsconfig.json", 4 | "compilerOptions": { 5 | "strict": true, 6 | "baseUrl": "./", 7 | "paths": { 8 | "@resources/*": ["public/resources/*"], 9 | "@store/*": ["src/store/*"], 10 | "@shared/*": ["src/shared/*"] 11 | }, 12 | // https://stackoverflow.com/a/49112420 13 | // https://stackoverflow.com/a/67227887 14 | "lib": ["dom", "ES2021"], 15 | "forceConsistentCasingInFileNames": true, // Forces case sensitive file import linting 16 | "jsx": "react-jsx", // https://stackoverflow.com/a/64969461 17 | "resolveJsonModule": true, // Enables importing JSON 18 | // "esModuleInterop": true 19 | } 20 | } -------------------------------------------------------------------------------- /src/routes/Reader/ProgressMenu/ProgressMenu.module.scss: -------------------------------------------------------------------------------- 1 | .progressContainer{ 2 | display:flex; 3 | height:100%; 4 | width: 100%; 5 | // padding-left:20%; 6 | align-items: center; 7 | justify-content: center; 8 | gap:15px; 9 | flex-direction: column; 10 | font-size:18px; 11 | 12 | input { 13 | border-radius: 10px; 14 | background-color: var(--background-secondary); 15 | color: var(--text-primary); 16 | margin: 0 10px 0 10px; 17 | text-align: center; 18 | border: 1px solid rgba(0, 0, 0, 0.2); 19 | } 20 | input::-webkit-outer-spin-button, 21 | input::-webkit-inner-spin-button { 22 | -webkit-appearance: none; 23 | margin: 0; 24 | } 25 | 26 | input[type=number] { 27 | -moz-appearance: textfield; 28 | } 29 | } 30 | 31 | .chapterBox{ 32 | width:50px; 33 | } 34 | 35 | .cfiBox{ 36 | width: 200px; 37 | } 38 | 39 | .heading{ 40 | display:inline; 41 | opacity: 0.75; 42 | margin-right:15px; 43 | } -------------------------------------------------------------------------------- /src/routes/Reader/ReaderView/ReaderView.module.scss: -------------------------------------------------------------------------------- 1 | .epubContainer{ 2 | // grid-row: 2; 3 | // grid-column: 1; 4 | width:100%; 5 | height:100%; 6 | 7 | // This will fix a bug where the epub child container will try to resize from the size of the parent. 8 | // The issue is when double click resizing (Dblclicking the titlebar of the browser), the epubContainer will resize first, but the 9 | // previous rendition will still be rendered. This causes an overflow. By default the height of the epubContainer will match that 10 | // of what is needed for the child. By setting overflow to hidden, it prevents that bug. However, this may cause issues in the future... 11 | overflow: hidden; 12 | } 13 | 14 | // This will force the rendered epubjs container to be centered no matter the margin 15 | .epubContainer > div{ 16 | margin-left:auto; 17 | margin-right: auto; 18 | } 19 | 20 | .epubContainer ::-webkit-scrollbar{ 21 | display:none !important; 22 | } -------------------------------------------------------------------------------- /src/routes/Reader/Components/BottomMenuContainer/BottomMenuContainer.module.scss: -------------------------------------------------------------------------------- 1 | @use 'breakpoints.scss' as *; 2 | 3 | .overflowContainer{ 4 | grid-area: 2/1/4/1; 5 | overflow-y: hidden; 6 | display:flex; 7 | justify-content: center; 8 | pointer-events: none; 9 | } 10 | 11 | .settingsBarContainer{ 12 | pointer-events: all; 13 | background-color: var(--background-primary); 14 | color: var(--text-primary); 15 | height: 275px; 16 | width: 50%; 17 | display: flex; 18 | flex-direction: column; 19 | align-items: center; 20 | user-select: none; 21 | // background-color:white; 22 | z-index: 1; 23 | box-shadow: 0px -3px 4px rgba(0, 0, 0, 0.25); 24 | align-self: flex-end; 25 | transition: .3s; 26 | border-top-left-radius: 10px; 27 | border-top-right-radius: 10px; 28 | max-width: 650px; 29 | position: relative; 30 | @include lt-md{ 31 | width: 75%; 32 | } 33 | @include lt-sm{ 34 | width: 100%; 35 | } 36 | 37 | // transform: translateY(100%); 38 | } -------------------------------------------------------------------------------- /src/store/slices/EpubJSBackend/state/stateManager.ts: -------------------------------------------------------------------------------- 1 | import { PayloadAction } from "@reduxjs/toolkit" 2 | import { epubjs_reducer } from "@store/slices/EpubJSBackend/epubjsManager.d" 3 | 4 | 5 | type ProgrammaticProgressUpdatePayload = { 6 | view: number, 7 | state: boolean 8 | } 9 | 10 | const setProgrammaticProgressUpdate:epubjs_reducer =(state, action: PayloadAction) =>{ 11 | state[action.payload.view].state.isProgrammaticProgressUpdate = action.payload.state 12 | } 13 | 14 | const SkipMouseEvent:epubjs_reducer = (state, action: PayloadAction) =>{ 15 | console.log("Next event will be skipped") 16 | state[action.payload].state.skipMouseEvent = true; 17 | } 18 | 19 | const AllowMouseEvent:epubjs_reducer = (state, action: PayloadAction) =>{ 20 | state[action.payload].state.skipMouseEvent = false; 21 | } 22 | 23 | 24 | 25 | 26 | 27 | export const actions = { 28 | SkipMouseEvent, 29 | AllowMouseEvent, 30 | setProgrammaticProgressUpdate 31 | } -------------------------------------------------------------------------------- /src/shared/scripts/Parser/parser.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { webpubFromComicBookArchive } from "./formats/comicbook" 3 | import { webpubFromFB2, webpubFromFB2Zip } from "./formats/fb2" 4 | import { webpubFromText } from "./formats/plaintext"; 5 | 6 | export default async (uri:string, checksum:string, filename:string, cbzLayout?:string ) =>{ 7 | const filestem = uri.split('/').pop(); 8 | if(!filestem){ 9 | console.log("filestem parsing error", filestem) 10 | return "error" 11 | } 12 | const fileExtension = filestem.split('.').pop() 13 | 14 | switch(fileExtension){ 15 | case "txt": 16 | return await webpubFromText(uri, filename, checksum) 17 | case "fb2": 18 | return await webpubFromFB2(uri, filename, checksum) 19 | case "fb2.zip": 20 | case "fbz": 21 | return await webpubFromFB2Zip(uri, filename, checksum) 22 | case "cbr": 23 | case "cbt": 24 | case "cb7": 25 | case "cbz": 26 | return await webpubFromComicBookArchive(uri, fileExtension, cbzLayout, filename, checksum) 27 | } 28 | } -------------------------------------------------------------------------------- /public/resources/feathericons/settings.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/routes/Reader/SideBar/Bookmarks/Bookmarks.module.scss: -------------------------------------------------------------------------------- 1 | .annotationContainer{ 2 | display: flex; 3 | width: 100%; 4 | height: auto; 5 | flex-direction: row; 6 | // margin-top: 20px; 7 | transition: 0.15s; 8 | border-radius: 10px; 9 | } 10 | .annotationContainer:hover{ 11 | background-color: rgba(0, 0, 0, 0.2); 12 | 13 | } 14 | .AnnotationLeftSubContainer{ 15 | display: flex; 16 | align-items: center; 17 | min-width: 50px; 18 | justify-content: center; 19 | color: gray; 20 | } 21 | .AnnotationLeftSubContainer:hover{ 22 | color:red; 23 | cursor:pointer; 24 | } 25 | .AnnotationRightSubContainer{ 26 | display: flex; 27 | flex-direction: column; 28 | width: 100%; 29 | cursor: pointer; 30 | user-select: none; 31 | padding:10px 0 10px; 32 | } 33 | 34 | .AnnotationChapter{ 35 | color: var(--text-secondary); 36 | font-size: 12px; 37 | } 38 | 39 | 40 | .noteTextContainer{ 41 | white-space: pre-line; 42 | border-left: 3px solid rgb(0 0 0 / 10%); 43 | padding-left: 5px; 44 | } -------------------------------------------------------------------------------- /src/routes/Reader/SideBar/Annotations/Annotations.module.scss: -------------------------------------------------------------------------------- 1 | .annotationContainer{ 2 | display: flex; 3 | width: 100%; 4 | height: auto; 5 | flex-direction: row; 6 | margin-top: 20px; 7 | transition: 0.15s; 8 | border-radius: 10px; 9 | } 10 | 11 | .annotationContainer:hover{ 12 | background-color: rgba(0, 0, 0, 0.2); 13 | } 14 | 15 | .AnnotationLeftSubContainer{ 16 | display: flex; 17 | align-items: center; 18 | min-width: 50px; 19 | justify-content: center; 20 | color: gray; 21 | } 22 | .AnnotationLeftSubContainer:hover{ 23 | color:red; 24 | cursor:pointer; 25 | } 26 | .AnnotationRightSubContainer{ 27 | display: flex; 28 | flex-direction: column; 29 | width: 100%; 30 | cursor: pointer; 31 | user-select: none; 32 | padding:10px 0 10px; 33 | 34 | } 35 | 36 | .AnnotationChapter{ 37 | color: var(--text-secondary); 38 | } 39 | 40 | .highlightedTextContainer{ 41 | padding-left:10px; 42 | } 43 | 44 | .noteTextContainer{ 45 | white-space: pre-line; 46 | border-left: 3px solid rgb(0 0 0 / 10%); 47 | padding-left: 5px; 48 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:react-hooks/recommended" // This adds linting to react-hooks to prevent errors with debugging and dev tools 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaFeatures": { 16 | "jsx": true 17 | }, 18 | "ecmaVersion": "latest", 19 | "sourceType": "module" 20 | }, 21 | "plugins": [ 22 | "react", 23 | "@typescript-eslint", 24 | "unused-imports" 25 | ], 26 | "rules": { 27 | "@typescript-eslint/no-var-requires": "off", 28 | "indent": ["error", 2], 29 | "no-constant-condition": "off", 30 | "unused-imports/no-unused-imports": "error", 31 | "unused-imports/no-unused-vars": [ 32 | "warn", 33 | { "vars": "all", "varsIgnorePattern": "^_", "args": "after-used", "argsIgnorePattern": "^_" } 34 | ] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/routes/Reader/ReaderView/components/QuickbarModal/QuickbarModal.module.scss: -------------------------------------------------------------------------------- 1 | .container{ 2 | display: flex; 3 | flex-direction: column; 4 | background-color:var(--background-primary); 5 | color: var(--text-primary); 6 | box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25); 7 | border-radius: 6px; 8 | position:fixed; 9 | } 10 | 11 | .actionContainer{ 12 | display:flex; 13 | justify-content: space-evenly; 14 | align-items: center; 15 | flex-grow:1; 16 | } 17 | 18 | .actionContainer > div{ 19 | cursor: pointer; 20 | display:flex; 21 | width: 45px; 22 | // flex-grow:1; 23 | height: 100%; 24 | justify-content: center; 25 | align-items: center; 26 | } 27 | 28 | .divider{ 29 | width: 100%; 30 | margin: 4px 0px 4px 0px; 31 | } 32 | .highlightContainer{ 33 | display:flex; 34 | justify-content: space-evenly; 35 | align-items: center; 36 | flex-grow:1; 37 | } 38 | 39 | .highlightBubble{ 40 | height: 24px; 41 | width: 24px; 42 | border-radius: 100%; 43 | // border: 1px solid black; 44 | cursor:pointer; 45 | } 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/store/slices/appStateTypes.d.ts: -------------------------------------------------------------------------------- 1 | import { ThemeType } from "./AppState/globalThemes"; 2 | 3 | 4 | export interface defaultAppState { 5 | themes: {[themeName:string]: ThemeType}, 6 | selectedTheme: string, 7 | sortDirection: string, 8 | sortBy: string, 9 | readerMargins: number, 10 | state:{ 11 | localSystemFonts: {[fontName: string]: Array}, 12 | maximized: bool, 13 | selectedRendition: number, 14 | dualReaderMode: boolean, 15 | dualReaderReversed: boolean, 16 | dictionaryWord: string, 17 | sidebarMenuSelected: boolean|string, 18 | themeMenuActive: boolean, 19 | menuToggled: boolean, 20 | progressMenuActive:boolean, 21 | footnote:{ 22 | active: boolean, 23 | text:string, 24 | link: string 25 | } 26 | modals:{ 27 | selectedCFI: string, 28 | quickbarModal: {visible: boolean, x:number, y:number}, 29 | noteModal: {visible: boolean, x:number, y:number} 30 | }, 31 | } 32 | 33 | } -------------------------------------------------------------------------------- /public/resources/iconmonstr/iconmonstr-script-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/routes/Reader/SideBar/Chapters/Chapters.module.scss: -------------------------------------------------------------------------------- 1 | 2 | 3 | .rootChapterFlexContainer{ 4 | display:flex; 5 | justify-content: space-between; 6 | } 7 | 8 | $ChapterArrow: 24px; 9 | .tocExpander{ 10 | display: inline-flex; 11 | flex-direction: column; 12 | justify-content: center; 13 | 14 | // order: 2; 15 | 16 | // height: calc($ChapterArrow * 1.5); 17 | width: calc($ChapterArrow * 1.5); 18 | padding-right:40px; 19 | flex-basis: 56px; 20 | } 21 | 22 | .tocExpander > svg{ 23 | transform-origin: 0 0; 24 | transform: scale(1.5); 25 | height: calc(24px * 1.5); 26 | color: var(--text-secondary); 27 | } 28 | 29 | 30 | .tocChapterTitle{ 31 | margin-left:20px; 32 | margin-right:20px; 33 | flex-basis: calc(100% - 56px); 34 | display:flex; 35 | align-items: center; 36 | font-weight: 400; 37 | } 38 | 39 | .TocMapContainer{ 40 | padding-top:10px; 41 | padding-bottom:10px; 42 | cursor: pointer; 43 | transition: 0.15s; 44 | border-radius: 10px; 45 | } 46 | 47 | .TocMapContainer:hover{ 48 | 49 | background-color: rgba(0, 0, 0, 0.2); 50 | 51 | } -------------------------------------------------------------------------------- /src/routes/Home/FakeCover/FakeCover.module.scss: -------------------------------------------------------------------------------- 1 | .bookContainer{ 2 | background-color:#505230; 3 | color:white; 4 | // height: 150px; 5 | // width: 100px; 6 | 7 | aspect-ratio: 170/250; 8 | display: flex; 9 | flex-direction:column; 10 | justify-content:center; 11 | align-items:center; 12 | overflow: hidden; 13 | 14 | align-self: flex-end; 15 | grid-row: 1/3; 16 | grid-column: 1; 17 | 18 | width: var(--book-width); 19 | height: var(--book-height); 20 | 21 | border-radius: var(--book-radius); 22 | } 23 | .title{ 24 | padding:5px 0 5px 5px; 25 | background-color:#404226; 26 | width: 100%; 27 | text-align:left; 28 | font-weight:500; 29 | border-top: 1px solid grey; 30 | border-bottom: 1px solid grey; 31 | font-size: 20px; 32 | 33 | display: -webkit-box; 34 | -webkit-line-clamp: 2; 35 | -webkit-box-orient: vertical; 36 | overflow: hidden; 37 | text-overflow: ellipsis; 38 | } 39 | 40 | .author{ 41 | font-weight: 300; 42 | text-align:right; 43 | padding-right: 5px; 44 | margin-top: 5px; 45 | font-size: 16px; 46 | } 47 | 48 | .content{ 49 | width: 100%; 50 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom/client"; 2 | import React from "react"; 3 | import { createBrowserRouter, RouterProvider } from "react-router-dom"; 4 | import Router from "./routes/Router"; 5 | 6 | import 'sanitize.css'; 7 | import 'sanitize.css/forms.css'; 8 | import 'sanitize.css/typography.css'; 9 | // import './utils/styles/breakpoints.css' 10 | 11 | import store from './store/store' 12 | import { Provider } from 'react-redux' 13 | 14 | import InitializeApp from "./InitializeApp"; 15 | // import bookImport from '@resources/placeholder/courage.epub' 16 | 17 | // https://stackoverflow.com/a/63520782 18 | const portalDiv = document.getElementById("root"); 19 | if(!portalDiv){ 20 | throw new Error("The element #root wasn't found"); 21 | } 22 | 23 | const root = ReactDOM.createRoot( 24 | portalDiv 25 | ); 26 | 27 | function Root() { 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | ) 35 | } 36 | 37 | 38 | root.render( 39 | 40 | // 41 | 42 | ); -------------------------------------------------------------------------------- /src/store/slices/counterSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit' 2 | 3 | 4 | interface CounterState { 5 | value: number 6 | } 7 | 8 | // Define the initial state using that type 9 | const initialState: CounterState = { 10 | value: 0, 11 | } 12 | 13 | export const counterSlice = createSlice({ 14 | name: 'counter', 15 | initialState, 16 | reducers: { 17 | increment: (state) => { 18 | // Redux Toolkit allows us to write "mutating" logic in reducers. It 19 | // doesn't actually mutate the state because it uses the Immer library, 20 | // which detects changes to a "draft state" and produces a brand new 21 | // immutable state based off those changes 22 | state.value += 1 23 | }, 24 | decrement: (state) => { 25 | state.value -= 1 26 | }, 27 | incrementByAmount: (state, action: PayloadAction) => { 28 | state.value += action.payload 29 | }, 30 | }, 31 | }) 32 | 33 | // Action creators are generated for each case reducer function 34 | export const { increment, decrement, incrementByAmount } = counterSlice.actions 35 | 36 | export default counterSlice.reducer -------------------------------------------------------------------------------- /src/routes/Reader/SliderNavigator/SliderNavigator.module.scss: -------------------------------------------------------------------------------- 1 | .slider{ 2 | color:black; 3 | } 4 | 5 | 6 | .slider > div[class=rc-slider-rail]{ 7 | background-color:var(--text-secondary) !important; 8 | height: 6px; 9 | } 10 | 11 | .slider > div[class=rc-slider-track]{ 12 | background-color:var(--text-primary) !important; 13 | // background-color: var(--slider-track-color) !important; 14 | height: 6px; 15 | } 16 | 17 | .slider > div[class=rc-slider-handle]{ 18 | // border-color: var(--text-secondary); 19 | border: 1px solid var(--text-secondary); 20 | background-color:var(--background-primary); 21 | z-index: 1; 22 | height: 22px; 23 | width: 22px; 24 | margin-top: calc( (22 / 2 * 1px) - (14px + 5px)); 25 | opacity: 1; 26 | } 27 | 28 | .slider > div[class="rc-slider-handle rc-slider-handle-dragging"]{ 29 | border-color: gray; 30 | z-index: 1; 31 | } 32 | 33 | // Note: This disables the default bubbles which are used to click. This is replaced by | character 34 | .slider > div[class=rc-slider-step]{ 35 | display:none; 36 | } 37 | 38 | // Container for | characters 39 | .slider > div[class=rc-slider-mark]{ 40 | top:0; 41 | } 42 | 43 | -------------------------------------------------------------------------------- /src/store/slices/EpubJSBackend/epubjsManager.d.ts: -------------------------------------------------------------------------------- 1 | interface dataInterface{ 2 | highlights:{[cfiRange:string]:highlightData}, 3 | bookmarks:Set, 4 | progress: number, 5 | cfi: string, 6 | theme:{ 7 | font: string, 8 | fontCache: string, 9 | fontSize: number, 10 | fontWeight: number, 11 | wordSpacing: number, 12 | lineHeight: number, 13 | renderMode: string, 14 | paragraphSpacing: number, 15 | textAlign: string 16 | } 17 | } 18 | 19 | export interface bookStateHydrationStructure{ 20 | title: string, 21 | author: string, 22 | modified: number, 23 | data:dataInterface 24 | } 25 | 26 | export interface bookStateStructure extends bookStateHydrationStructure{ 27 | instance: Rendition, 28 | UID: number, 29 | hash: string 30 | loadState?: LOADSTATE, 31 | data?: dataInterface, 32 | state:{ 33 | isProgrammaticProgressUpdate: boolean, 34 | skipMouseEvent: boolean, 35 | 36 | }, 37 | } 38 | 39 | 40 | 41 | interface loadProgressUpdate{ 42 | view:number, 43 | state: LOADSTATE 44 | } 45 | 46 | export type epubjs_reducer = (state: WritableDraft, action: PayloadAction) => any -------------------------------------------------------------------------------- /src/shared/scripts/Parser/formats/plaintext.ts: -------------------------------------------------------------------------------- 1 | export const webpubFromText = async (uri:string, filename:string, checksum:string) => { 2 | const res = await fetch(uri) 3 | const blob = await res.blob() 4 | const identifier = checksum 5 | const text = await new Response(blob).text() 6 | const chapters = text.split(/(\r?\n){3,}/g) 7 | .filter(x => !/^\r?\n$/.test(x)) 8 | .map(c => { 9 | const ps = c.split(/(\r?\n){2}/g) 10 | .filter(x => !/^\r?\n$/.test(x)) 11 | const doc = document.implementation.createHTMLDocument() 12 | ps.forEach(p => { 13 | const el = doc.createElement('p') 14 | el.textContent = p 15 | doc.body.appendChild(el) 16 | }) 17 | const blob = new Blob( 18 | [doc.documentElement.outerHTML], 19 | { type: 'text/html' }) 20 | const url = URL.createObjectURL(blob) 21 | return { 22 | href: url, 23 | type: 'text/html', 24 | title: ps[0].replace(/\r?\n/g, '') 25 | } 26 | }) 27 | 28 | return { 29 | metadata: { 30 | title: filename, 31 | identifier 32 | }, 33 | links: [], 34 | readingOrder: chapters, 35 | toc: chapters, 36 | resources: [] 37 | } 38 | } -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "app" 3 | version = "0.1.0" 4 | description = "A Tauri App" 5 | authors = ["you"] 6 | license = "" 7 | repository = "" 8 | default-run = "app" 9 | edition = "2021" 10 | rust-version = "1.59" 11 | 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | 14 | [build-dependencies] 15 | tauri-build = { version = "1.4.0", features = [] } 16 | 17 | [dependencies] 18 | serde_json = "1.0" 19 | serde = { version = "1.0", features = ["derive"] } 20 | tauri = { version = "1.4.1", features = ["api-all", "cli", "devtools"] } 21 | crc32fast = "1.3.2" 22 | reqwest = { version = "0.11", features = ["blocking"] } 23 | epub = "1.2.2" 24 | axum = "0.6.19" 25 | tokio = { version = "1.29.1", features = ["full"] } 26 | tower-http = { version = "0.4.3", features = ["fs", "cors"] } 27 | libmobi-rs = { path = "../libmobi-rs/libmobi-rs" } 28 | font-kit = "0.11.0" 29 | 30 | [features] 31 | # by default Tauri runs in production mode 32 | # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL 33 | default = [ "custom-protocol" ] 34 | # this feature is used for production builds where `devPath` points to the filesystem 35 | # DO NOT remove this 36 | custom-protocol = [ "tauri/custom-protocol" ] 37 | # This will be used for rust 38 | opt_once_cell = [] 39 | -------------------------------------------------------------------------------- /src/store/slices/AppState/state/modals/modals.ts: -------------------------------------------------------------------------------- 1 | import { PayloadAction } from "@reduxjs/toolkit" 2 | import { appStateReducer, appStateReducerSingle } from "@store/slices/appState" 3 | 4 | import { MoveModalAction } from "./modalsTypes" 5 | 6 | 7 | 8 | const MoveQuickbarModal:appStateReducer = (state, action: PayloadAction) =>{ 9 | state.state.modals.quickbarModal = {x: action.payload.x, y:action.payload.y, visible: action.payload.visible} 10 | } 11 | const HideQuickbarModal:appStateReducerSingle = (state) =>{ 12 | state.state.modals.quickbarModal.visible = false 13 | } 14 | const MoveNoteModal:appStateReducer = (state, action: PayloadAction) =>{ 15 | state.state.modals.noteModal = {x: action.payload.x, y:action.payload.y, visible: action.payload.visible} 16 | } 17 | const ShowNoteModal:appStateReducerSingle = (state) =>{ 18 | state.state.modals.noteModal.visible = true 19 | } 20 | const HideNoteModal:appStateReducerSingle = (state) =>{ 21 | state.state.modals.noteModal.visible = false 22 | } 23 | const SetModalCFI:appStateReducer = (state, action: PayloadAction) =>{ 24 | state.state.modals.selectedCFI = action.payload 25 | } 26 | 27 | 28 | export const actions = { 29 | MoveQuickbarModal, 30 | HideQuickbarModal, 31 | MoveNoteModal, 32 | ShowNoteModal, 33 | HideNoteModal, 34 | SetModalCFI 35 | } -------------------------------------------------------------------------------- /src/routes/Reader/ReaderView/components/Dictionary/Dictionary.module.scss: -------------------------------------------------------------------------------- 1 | // This scroll container is necessary because of a visual bug. 2 | // If there is a scrollbar and a border-radius on the container, the blockiness of the scrollbar will stick out 3 | // See for example: https://stackoverflow.com/questions/62789354/how-to-hide-scrollbar-thumb-in-border-radius-element 4 | 5 | .DictionaryScrollContainer{ 6 | background-color:var(--background-secondary); 7 | border: 1px solid rgba(0, 0, 0, 0.233); 8 | border-top-left-radius: 15px; 9 | border-top-right-radius: 15px; 10 | height:300px; 11 | width: 100vw; 12 | max-width: 500px; 13 | z-index: 100; 14 | transition:0.5s; 15 | // Move the default position a few pixels down 16 | // transform: translateY(-40px); 17 | padding-top: 10px; 18 | padding-right: 50px; 19 | 20 | grid-area: 2/1/4/1; 21 | overflow: hidden; 22 | 23 | align-self: flex-end; 24 | justify-self: center; 25 | } 26 | .DictionaryScrollContainerCollapsed{ 27 | height:0px !important; 28 | padding-top:0px !important; 29 | border:0px; 30 | } 31 | 32 | .DictionaryContainer{ 33 | height:289px; // height - (border-top + paddingtop) 34 | width: calc(100vw - 10px); 35 | max-width: 490px; 36 | overflow-y:scroll; 37 | 38 | padding:0 15px 50px 15px; 39 | 40 | 41 | transition: 1s; 42 | padding-bottom: 100px; 43 | } -------------------------------------------------------------------------------- /globals.d.ts: -------------------------------------------------------------------------------- 1 | // Definition for css module imports 2 | 3 | // declare module '*.module.css' { 4 | // const classes: { [key: string]: string }; 5 | // export default classes; 6 | // } 7 | 8 | 9 | // declare module '*.module.scss' { 10 | // const classes: { [key: string]: string }; 11 | // export default classes; 12 | // } 13 | 14 | // declare module '*.module.sass' { 15 | // const classes: { [key: string]: string }; 16 | // export default classes; 17 | // } 18 | 19 | // Simplified typings 20 | declare module "*.module.css"; 21 | declare module "*.module.scss"; 22 | 23 | declare module "*.svg" { 24 | const content: any; 25 | export default content; 26 | } 27 | 28 | declare module "*.jpg" { 29 | const content: any; 30 | export default content; 31 | } 32 | declare module "*.webp" { 33 | const content: any; 34 | export default content; 35 | } 36 | 37 | 38 | declare module "*.epub" { 39 | const content: any; 40 | export default content; 41 | } 42 | 43 | // https://stackoverflow.com/questions/47130406/extending-global-types-e-g-window-inside-a-typescript-module#comment120678060_47130953 44 | // https://stackoverflow.com/questions/47130406/extending-global-types-e-g-window-inside-a-typescript-module#comment125203797_47130953 45 | // We define __TAURI__ since it will be used throughout the project 46 | // declare global { 47 | interface Window { __TAURI__: boolean; } 48 | // } 49 | 50 | // export {} -------------------------------------------------------------------------------- /src/routes/Reader/SideBar/Search/Search.module.scss: -------------------------------------------------------------------------------- 1 | .searchbar{ 2 | width: 80%; 3 | align-self: center; 4 | margin-top:25px; 5 | 6 | border-radius: 10px; 7 | padding-left: 25px; 8 | background-color: var(--background-primary); 9 | color: var(--text-primary); 10 | border: none; 11 | } 12 | .searchbar:focus{ 13 | outline: none; 14 | } 15 | 16 | .searchContainer{ 17 | display:flex; 18 | flex-direction: column; 19 | } 20 | 21 | .resultsContainer{ 22 | margin-top:20px; 23 | width:calc( 100% - 50px); 24 | align-self: center; 25 | display: flex; 26 | flex-direction: column; 27 | margin-left: 50px; 28 | } 29 | 30 | .result{ 31 | border-left: 3px solid rgba(0, 0, 0, 0.1); 32 | margin-left: 50px; 33 | padding-left: 10px; 34 | 35 | } 36 | 37 | 38 | 39 | 40 | 41 | 42 | .resultContainer{ 43 | width: 100%; 44 | height: auto; 45 | flex-direction: row; 46 | transition: 0.15s; 47 | border-radius: 10px; 48 | padding:15px; 49 | cursor:pointer; 50 | } 51 | 52 | .resultContainer:hover{ 53 | background-color:rgba(0, 0, 0, 0.2); 54 | } 55 | 56 | .resultTextContainer{ 57 | display: flex; 58 | flex-direction: column; 59 | width: 100%; 60 | cursor: pointer; 61 | user-select: none; 62 | 63 | white-space: pre-line; 64 | border-left: 3px solid rgb(0 0 0 / 10%); 65 | padding-left: 5px; 66 | } 67 | 68 | .resultChapter{ 69 | color: var(--text-secondary); 70 | font-size: 12px; 71 | } 72 | 73 | -------------------------------------------------------------------------------- /src/shared/scripts/getChapterCfiMap.ts: -------------------------------------------------------------------------------- 1 | import { Book, NavItem } from "@btpf/epubjs" 2 | import Spine from "@btpf/epubjs/types/spine" 3 | 4 | // Gets the cfi for each chapter name and returns it. Used for finding chapter of annotation 5 | export const getChapterCFIMap = (book: Book)=>{ 6 | let allChapters: any[] = [] 7 | 8 | // Recursive function which gets all the chapters and subchapters in order 9 | function traverseTree(node: NavItem[]){ 10 | node.forEach((subNode)=>{ 11 | // href is saved for using spineByHref which returns the ID needed for getting the cfi of the chapter 12 | allChapters.push({href: subNode.href, title:subNode.label}) 13 | if(subNode.subitems){ 14 | traverseTree(subNode.subitems) 15 | } 16 | }) 17 | } 18 | 19 | traverseTree(book.navigation.toc) 20 | allChapters = allChapters.map((item)=>{ 21 | interface fixedSpine extends Spine{ 22 | spineByHref: [value:number], 23 | items: [key:any] 24 | } 25 | 26 | let temp = item.href 27 | if(temp.includes(".xhtml#") || temp.includes(".html#")){ 28 | temp = temp.split("#") 29 | temp.pop() 30 | item.href = temp.join() 31 | } 32 | 33 | // This fixes a bug where the spineByHref returns undefined 34 | const id:number = (book.spine as fixedSpine).spineByHref[item.href] || 0 35 | return {...item, cfi: `epubcfi(${(book.spine as fixedSpine).items[id].cfiBase}!/0)` } 36 | }) 37 | return allChapters 38 | } -------------------------------------------------------------------------------- /src/routes/Settings/pages/About.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | 4 | import { open } from '@tauri-apps/api/shell' 5 | 6 | import packageInfo from '../../../../package.json'; 7 | import Logo from '@resources/logo.svg' 8 | 9 | 10 | const About = ()=>{ 11 | 12 | 13 | 14 | 15 | return ( 16 | 17 |
18 |
19 | 20 |
21 |
Alexandria
22 |
23 |
{open("https://github.com/btpf/Alexandria")}} 25 | >Github
26 | 27 |
Website
28 | 29 |
30 |
Version {packageInfo.version}
31 |
Created By Bret Papkoff
32 |
33 | 34 |
35 | 36 | 37 |
38 | 39 | 40 | 41 | ) 42 | } 43 | 44 | 45 | export default About -------------------------------------------------------------------------------- /src/routes/Reader/SettingsBar/SettingsBar.module.scss: -------------------------------------------------------------------------------- 1 | @use 'breakpoints.scss' as *; 2 | 3 | 4 | 5 | .settingsIconBottomBar{ 6 | color:var(--text-secondary); 7 | margin-right:auto; 8 | margin-left:15px; 9 | transition: 0.15s; 10 | cursor: pointer; 11 | &:hover{ 12 | opacity: 0.8; 13 | } 14 | &:active{ 15 | opacity: 0.5; 16 | } 17 | } 18 | 19 | 20 | .opaqueScreenActive{ 21 | width: 100%; 22 | background-color: rgba(0, 0, 0, 0.4); 23 | pointer-events: auto; 24 | } 25 | .touchBar{ 26 | background-color: #9E9E9E; 27 | height: 8px; 28 | width: 32px; 29 | margin: 10px 0px 20px 0px; 30 | border-radius: 50px; 31 | } 32 | 33 | .currentMenuContainer{ 34 | display:flex; 35 | gap: 25px; 36 | font-weight: 600; 37 | font-size: 18px; 38 | margin: 10px 0px 10px 0px; 39 | width: 100%; 40 | } 41 | .currentMenuContainer > div{ 42 | cursor: pointer; 43 | flex-grow: 0.1; 44 | text-align: center; 45 | } 46 | 47 | .settingsContainer{ 48 | display:flex; 49 | flex-direction: column; 50 | justify-content: space-around; 51 | height: 100%; 52 | align-items: center; 53 | overflow-y: auto; 54 | width: calc(100% - 20px); 55 | // transition: 10s; 56 | 57 | margin-top: 10px; 58 | margin-left: 10px; 59 | margin-right: 5px; 60 | overflow-x:hidden; 61 | } 62 | 63 | 64 | .tabSection{ 65 | color: var(--text-primary); 66 | transition: 0.15s; 67 | &:hover{ 68 | opacity: 0.8 !important; 69 | } 70 | &:active{ 71 | opacity: 0.5 !important; 72 | } 73 | } -------------------------------------------------------------------------------- /src/routes/Reader/ReaderView/components/NoteModal/NoteModal.module.scss: -------------------------------------------------------------------------------- 1 | .noteContentContainer{ 2 | // flex-grow: 1; 3 | height: 200px; 4 | // margin-top:15px; 5 | // border: 1px solid black; 6 | border: none; 7 | resize: none; 8 | margin: 15px 15px 15px 15px; 9 | } 10 | .noteContentContainer:focus { 11 | outline: none !important; 12 | } 13 | 14 | .svgSelect{ 15 | cursor: pointer; 16 | display:flex; 17 | height: 100%; 18 | justify-content: center; 19 | align-items: center; 20 | flex-basis: 15%; 21 | } 22 | .svgSelect:last-of-type > svg{ 23 | color: green; 24 | stroke-width: 4px; 25 | } 26 | .svgSelect:first-of-type > svg{ 27 | color: red; 28 | } 29 | .colorSelector{ 30 | display:flex; 31 | flex-direction: row; 32 | gap: 10px; 33 | // margin-left: 20px; 34 | } 35 | .noteContainer{ 36 | width: 300px; 37 | height: 250px; 38 | box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25); 39 | border: 1px solid rgba(0, 0, 0, 0.25); 40 | // border: 1px solid black; 41 | left: 25px; 42 | top: 25px; 43 | border-radius: 12px; 44 | position:fixed; 45 | background-color: var(--background-primary); 46 | color: var(--text-primary); 47 | display: flex; 48 | flex-direction: column; 49 | } 50 | 51 | .annotationActions{ 52 | display:flex; 53 | justify-content: space-around; 54 | padding-bottom:15px; 55 | padding-top: 10px; 56 | } 57 | 58 | .highlightBubble{ 59 | height: 24px; 60 | width: 24px; 61 | border-radius: 100%; 62 | // border: 1px solid black; 63 | cursor:pointer; 64 | } -------------------------------------------------------------------------------- /src/store/syncedActions.ts: -------------------------------------------------------------------------------- 1 | import {bookState} from "./slices/bookState"; 2 | import {appState} from './slices/appState' 3 | 4 | type AppStateActionNames = (keyof typeof appState.actions) 5 | type completeAppStateActionNames = `appState/${AppStateActionNames}` 6 | 7 | // This line is to generate the valid types which simply populated autocomplete 8 | type bookStateActionNames = keyof typeof bookState.actions | 9 | 'setThemeV2/fulfilled' | 10 | 'setFontV2/fulfilled' | 11 | 'setWordSpacing/fulfilled' | 12 | 'setLineHeight/fulfilled' | 13 | 'setParagraphSpacing/fulfilled'| 14 | 'setTextAlignment/fulfilled' 15 | 16 | type completeBookStateActionNames = `bookState/${bookStateActionNames}` 17 | 18 | // All synced actions, including fulfilled thunks, should be added here 19 | export default new Set([ 20 | "bookState/AddHighlight", 21 | "bookState/ToggleBookmark", 22 | "bookState/ChangeHighlightColor", 23 | "bookState/ChangeHighlightNote", 24 | "bookState/DeleteHighlight", 25 | "bookState/SetProgress", 26 | "bookState/setThemeV2/fulfilled", 27 | "bookState/setFontV2/fulfilled", 28 | "appState/AddTheme", 29 | "appState/DeleteTheme", 30 | "appState/UpdateTheme", 31 | "appState/RenameTheme", 32 | "appState/setSelectedTheme", 33 | 'bookState/setWordSpacing/fulfilled', 34 | 'bookState/setLineHeight/fulfilled', 35 | 'bookState/setParagraphSpacing/fulfilled', 36 | "bookState/setRenderMode", 37 | "appState/SetSortSettings", 38 | "bookState/setTextAlignment/fulfilled" 39 | ]) 40 | -------------------------------------------------------------------------------- /src/routes/Reader/SettingsBar/FontsContainerV2/FontsContainer.module.scss: -------------------------------------------------------------------------------- 1 | .fontContainer{ 2 | display:flex; 3 | width: 100%; 4 | gap: 10px; 5 | overflow-y: auto; 6 | overflow-x:hidden; 7 | // This fixes bug in webkit where once switching to the themes and back to fonts 8 | // There is an overflow 9 | height: 120px; 10 | flex-direction: column; 11 | margin-top: 10px; 12 | flex-grow:1; 13 | padding-bottom:10px; 14 | } 15 | 16 | .font{ 17 | display:flex; 18 | align-items: center; 19 | flex-direction: column; 20 | cursor: pointer; 21 | } 22 | 23 | .fontName{ 24 | // font-family: 'inter'; 25 | font-size: 24px; 26 | width: max-content; 27 | 28 | } 29 | 30 | 31 | 32 | 33 | 34 | .settingsContainer{ 35 | display: flex; 36 | justify-content: space-around; 37 | width: 100%; 38 | margin-top:10px; 39 | text-align: center; 40 | } 41 | 42 | 43 | .fontSizeContainer{ 44 | display: flex; 45 | gap: 10px; 46 | align-items: center; 47 | font-weight: bold; 48 | // flex-grow:1; 49 | margin-top: 10px; 50 | // height: 52px; 51 | // padding: 15px 0px 15px 0px; 52 | overflow:hidden; 53 | } 54 | .resizeContainer{ 55 | display:flex; 56 | width: calc(48px * 0.75); 57 | height: calc(48px * 0.75); 58 | font-weight: bold; 59 | border: 1px solid #9A9A9A; 60 | border-radius: 100%; 61 | justify-content: center; 62 | align-items: center; 63 | font-size: 18px; 64 | cursor:pointer; 65 | transition: 0.15s; 66 | &:hover{ 67 | opacity: 0.8; 68 | } 69 | &:active{ 70 | opacity: 0.5; 71 | } 72 | } 73 | 74 | 75 | .resizeSize{ 76 | width: 50px; 77 | text-align: center; 78 | font-weight: 400; 79 | } -------------------------------------------------------------------------------- /src/routes/Reader/SettingsBar/SpacingContainer/SpacingContainer.module.scss: -------------------------------------------------------------------------------- 1 | @use 'breakpoints.scss' as *; 2 | 3 | .settingContainer{ 4 | // border: 1px solid black; 5 | // flex-direction: row; 6 | // border: 1px solid white; 7 | display: flex; 8 | justify-content: center; 9 | margin-bottom:10px; 10 | } 11 | .settingLabel{ 12 | font-weight: bold; 13 | color: var(--text-primary); 14 | font-size:14px; 15 | margin-bottom: 5px; 16 | // margin-left: 30px; 17 | } 18 | .settingButtonContainer{ 19 | display: flex; 20 | gap: 40px; 21 | align-items: center; 22 | font-weight: bold; 23 | // height: 52px; 24 | // padding: 15px 0px 15px 0px; 25 | overflow:hidden; 26 | 27 | @include lt-md{ 28 | gap: 20px; 29 | } 30 | } 31 | .settingButton{ 32 | display:flex; 33 | width: calc(48px * 0.75); 34 | height: calc(48px * 0.75); 35 | font-weight: bold; 36 | border: 1px solid var(--text-secondary); 37 | border-radius: 100%; 38 | justify-content: center; 39 | align-items: center; 40 | font-size: 18px; 41 | cursor:pointer; 42 | 43 | transition: 0.15s; 44 | &:hover{ 45 | opacity: 0.8; 46 | } 47 | &:active{ 48 | opacity: 0.5; 49 | } 50 | } 51 | 52 | .resizeSize{ 53 | width: 75px; 54 | text-align: center; 55 | } 56 | 57 | .scaleItems{ 58 | display:flex; 59 | flex-direction: row; 60 | flex-wrap: wrap; 61 | // justify-content: space-around; 62 | justify-content: center; 63 | & > div{ 64 | flex-basis: 50%; 65 | } 66 | } 67 | 68 | .alignmentContainer{ 69 | display:flex; 70 | justify-content: center; 71 | gap:20px; 72 | width:100%; 73 | & > svg{ 74 | cursor: pointer; 75 | &:hover{ 76 | opacity: 0.8; 77 | } 78 | &:active{ 79 | opacity: 0.5; 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /src/routes/Reader/SideBar/SideBar.module.scss: -------------------------------------------------------------------------------- 1 | @use 'breakpoints.scss' as *; 2 | 3 | .sideBarContainer{ 4 | height: 100%; 5 | grid-area: 2/1/4/1; 6 | width: 100%; 7 | z-index: 1; 8 | pointer-events: none; 9 | display: grid; 10 | } 11 | 12 | .sideBarContainer > div{ 13 | grid-area: 1/1; 14 | height: 100%; 15 | transition: 0.5s; 16 | @include lt-sm{ 17 | transition: 0s !important; 18 | } 19 | } 20 | .sideBarContainer > div:last-of-type{ 21 | pointer-events: auto; 22 | } 23 | 24 | .sideBar{ 25 | background-color:var(--background-secondary); 26 | color: var(--text-primary); 27 | width: 50%; 28 | max-width: 800px; 29 | min-width: 400px; 30 | // position: fixed; 31 | // left: min(-50%, -400px); 32 | overflow: hidden; 33 | transform: translateX(-100%); 34 | display: flex; 35 | flex-direction: column; 36 | } 37 | .sideBarActive{ 38 | min-width: 400px; 39 | // left: 0%; 40 | transform: translateX(0); 41 | @include lt-sm{ 42 | width: 100%; 43 | max-width: 100%; 44 | } 45 | } 46 | 47 | .tabSelector{ 48 | width: 100%; 49 | display: flex; 50 | justify-content: space-evenly; 51 | color: var(--text-secondary); 52 | &>div { 53 | transition: 0.2s; 54 | } 55 | } 56 | .tabSelector > div{ 57 | // border-bottom: 2px solid rgb(31, 120, 255); 58 | border-bottom: 2px solid rgba(0, 0, 0, 0.171); 59 | flex-grow: 1; 60 | text-align: center; 61 | padding-top:5px; 62 | padding-bottom: 5px; 63 | } 64 | .selectedBookmarkTab{ 65 | border-bottom: 2px solid var(--text-primary) !important; 66 | color: var(--text-primary); 67 | } 68 | .tabSelector > div:hover{ 69 | // border-bottom: 2px solid rgb(31, 120, 255); 70 | background-color:rgba(0, 0, 0, 0.1); 71 | cursor:pointer; 72 | user-select: none; 73 | } 74 | 75 | -------------------------------------------------------------------------------- /src/routes/Reader/FooterBarBottom/FooterBar.module.scss: -------------------------------------------------------------------------------- 1 | @use 'breakpoints.scss' as *; 2 | 3 | .sideBarContainer{ 4 | background-color:var(--background-secondary); 5 | border: 1px solid rgba(0, 0, 0, 0.233); 6 | border-top-left-radius: 15px; 7 | border-top-right-radius: 15px; 8 | height:300px; 9 | width: 100vw; 10 | max-width: 500px; 11 | z-index: 100; 12 | transition:0.5s; 13 | // Move the default position a few pixels down 14 | // transform: translateY(-40px); 15 | padding-top: 10px; 16 | padding-right: 50px; 17 | 18 | grid-area: 2/1/4/1; 19 | overflow: hidden; 20 | 21 | align-self: flex-end; 22 | justify-self: center; 23 | pointer-events: all; 24 | } 25 | 26 | .sideBarContainerInactive{ 27 | height:0px !important; 28 | padding-top:0px !important; 29 | border:0px; 30 | } 31 | .sideBar{ 32 | height:0px; 33 | padding-top:0px ; 34 | border:0px; 35 | } 36 | 37 | 38 | .sideBarNav{ 39 | display:flex; 40 | justify-content:space-between; 41 | // background-color: var(--background-primary); 42 | border-bottom: 1px solid black; 43 | padding: 10px; 44 | } 45 | 46 | .navBack{ 47 | // background-color: var(--background-secondary); 48 | cursor:pointer; 49 | // height:30px; 50 | width:auto; 51 | display: flex; 52 | justify-content: center; 53 | align-items: center; 54 | 55 | margin-left:20px; 56 | > div{ 57 | margin-left:5px; 58 | font-size:18px; 59 | } 60 | } 61 | 62 | .footnoteBody{ 63 | height:289px; // height - (border-top + paddingtop) 64 | width: calc(100vw - 10px); 65 | max-width: 490px; 66 | overflow-y:scroll; 67 | 68 | padding:0 15px 50px 15px; 69 | 70 | 71 | transition: 1s; 72 | padding-bottom: 100px; 73 | } 74 | 75 | .gotoFoot{ 76 | font-size:18px; 77 | margin-right:20px; 78 | cursor:pointer; 79 | } -------------------------------------------------------------------------------- /scripts/Generate Assets.sh: -------------------------------------------------------------------------------- 1 | mkdir public 2 | cd public 3 | mkdir resources 4 | cd resources 5 | 6 | echo "curl \"https://www.googleapis.com/webfonts/v1/webfonts?key=$APIKEY\&sort=POPULARITY\" >> webfonts.json" 7 | 8 | curl "https://www.googleapis.com/webfonts/v1/webfonts?key=$APIKEY&sort=POPULARITY" >> webfonts.json 9 | 10 | 11 | mkdir Fonts 12 | mkdir Fonts/Noto_Sans 13 | cd ./Fonts/Noto_Sans 14 | 15 | URL=$(grep -o '"100": "http://fonts.gstatic.com/s/notosans/.*"' ../../webfonts.json | cut -d '"' -f 4) 16 | curl -o NotoSans-Thin.ttf "$URL" 17 | 18 | 19 | URL=$(grep -o '"200": "http://fonts.gstatic.com/s/notosans/.*"' ../../webfonts.json | cut -d '"' -f 4) 20 | curl -o NotoSans-ExtraLight.ttf "$URL" 21 | 22 | URL=$(grep -o '"300": "http://fonts.gstatic.com/s/notosans/.*"' ../../webfonts.json | cut -d '"' -f 4) 23 | curl -o NotoSans-Light.ttf "$URL" 24 | 25 | URL=$(grep -o '"regular": "http://fonts.gstatic.com/s/notosans/.*"' ../../webfonts.json | cut -d '"' -f 4) 26 | curl -o NotoSans-Regular.ttf "$URL" 27 | 28 | URL=$(grep -o '"500": "http://fonts.gstatic.com/s/notosans/.*"' ../../webfonts.json | cut -d '"' -f 4) 29 | curl -o NotoSans-Medium.ttf "$URL" 30 | 31 | URL=$(grep -o '"600": "http://fonts.gstatic.com/s/notosans/.*"' ../../webfonts.json | cut -d '"' -f 4) 32 | curl -o NotoSans-SemiBold.ttf "$URL" 33 | 34 | URL=$(grep -o '"700": "http://fonts.gstatic.com/s/notosans/.*"' ../../webfonts.json | cut -d '"' -f 4) 35 | curl -o NotoSans-Bold.ttf "$URL" 36 | 37 | URL=$(grep -o '"800": "http://fonts.gstatic.com/s/notosans/.*"' ../../webfonts.json | cut -d '"' -f 4) 38 | curl -o NotoSans-ExtraBold.ttf "$URL" 39 | 40 | URL=$(grep -o '"900": "http://fonts.gstatic.com/s/notosans/.*"' ../../webfonts.json | cut -d '"' -f 4) 41 | curl -o NotoSans-Black.ttf "$URL" 42 | 43 | 44 | cd ../../../../ 45 | dir 46 | 47 | cp -r ./public ../ 48 | rm -rdf ./public 49 | -------------------------------------------------------------------------------- /src/shared/components/TitleBarButtons.tsx: -------------------------------------------------------------------------------- 1 | import styles from './TitleBarButtons.module.scss' 2 | import React from 'react' 3 | 4 | import ExitIcon from '@resources/figma/Exit.svg' 5 | import MaximizeIcon from '@resources/figma/Maximize.svg' 6 | import UnMaximizeIcon from '@resources/figma/Unmaximize.svg' 7 | import MinimizeIcon from '@resources/figma/Minimize.svg' 8 | import { exit } from '@tauri-apps/api/process'; 9 | import { appWindow } from '@tauri-apps/api/window'; 10 | import { useAppSelector } from '@store/hooks' 11 | 12 | interface TitleBarButtonsProps { 13 | disabled: boolean, 14 | remove: boolean 15 | } 16 | 17 | const defaultProps: TitleBarButtonsProps = { 18 | disabled: false, 19 | remove: false 20 | } 21 | 22 | const TitleBarButtons = (props:TitleBarButtonsProps)=>{ 23 | 24 | const maximized = useAppSelector((state)=> state.appState.state.maximized) 25 | 26 | return ( 27 |
28 | { 29 | await appWindow.minimize(); 30 | }} viewBox="10 10 20 20"className={styles.titleBarButton} color="white"/> 31 | 32 | {!maximized? 33 | { 34 | await appWindow.maximize(); 35 | }} viewBox="10 10 20 20" className={styles.titleBarButton}/> 36 | : 37 | { 38 | await appWindow.unmaximize(); 39 | }} viewBox="10 10 20 20" className={styles.titleBarButton}/>} 40 | 41 | { 42 | await exit(1) 43 | }} viewBox="10 10 20 20" className={`${styles.titleBarButton} ${styles.titleBarExit}`}/> 44 |
45 | ) 46 | } 47 | TitleBarButtons.defaultProps = defaultProps 48 | 49 | export default TitleBarButtons -------------------------------------------------------------------------------- /docs/Build Instructions.md: -------------------------------------------------------------------------------- 1 | # Build Instructions 2 | 3 | Follow the instructions below in order to get a dev environment started. 4 | 5 | Please open an issue if the instructions do not work or require tinkering. Thanks. 6 | 7 | 8 | 9 | ### 0. Prerequisites 10 | 11 | 1. Install Tauri Prerequisites https://tauri.app/v1/guides/getting-started/prerequisites/#setting-up-linux 12 | 2. Install LLVM 13 | Windows: 14 | ``` 15 | winget install LLVM.LLVM 16 | ``` 17 | Debian: 18 | ``` 19 | sudo apt-get install clang 20 | ``` 21 | 3. Install Node Version: v18 22 | 4. Clone the repository with submodules through the following command 23 | ``` 24 | git clone --recurse-submodules --remote-submodules https://github.com/btpf/Alexandria.git 25 | 26 | cd Alexandria 27 | ``` 28 | 29 | ### 1. Build libmobi (sometimes not required) 30 | 31 | Alexandria depends on a library called libmobi. This repository was modified to be a static library and only provide to Alexandria a single function which it supports book conversion. While pre-compiled static builds are provided, Sometimes they will need to be re-compiled in order to link successfully in your environment. 32 | 33 | First make sure dependencies are present: 34 | `sudo apt-get install autoconf libtool` 35 | 36 | Then run the install script 37 | 38 | 1. `cd libmobi-rs` 39 | 2. `sh ./build-linux.sh` 40 | 41 | The static library will be tested in a minimal rust environment. If this unit test passes, you are clear to proceed with the final step. 42 | 43 | Return back to the base directory 44 | 45 | ### 2. Run Development Environment 46 | 47 | Run the following for a development environment: 48 | 49 | Install Dependencies 50 | ``` 51 | npm i 52 | ``` 53 | Run Development Environment 54 | ``` 55 | npm run tauri dev 56 | ``` 57 | 58 | For a production build, run 59 | 60 | ``` 61 | npm run tauri build 62 | ``` 63 | 64 | -------------------------------------------------------------------------------- /src/routes/Reader/FooterBar/FooterBar.module.scss: -------------------------------------------------------------------------------- 1 | @use 'breakpoints.scss' as *; 2 | 3 | .sideBarContainer{ 4 | height: 100%; 5 | grid-area: 1/1/4/1; 6 | width: 100%; 7 | z-index: 1; 8 | pointer-events: none; 9 | display: grid; 10 | } 11 | 12 | 13 | .sideBar{ 14 | pointer-events: all; 15 | background-color:var(--background-secondary); 16 | color: var(--text-primary); 17 | width: 50%; 18 | max-width: 500px; 19 | min-width: 400px; 20 | // position: fixed; 21 | // left: min(-50%, -400px); 22 | overflow: hidden; 23 | transform: translateX(100%); 24 | display: flex; 25 | flex-direction: column; 26 | justify-self: flex-end; 27 | 28 | grid-area: 1/1; 29 | height: 100%; 30 | transition: 0.5s; 31 | @include lt-sm{ 32 | transition: 0s !important; 33 | } 34 | } 35 | .sideBarActive{ 36 | min-width: 400px; 37 | // left: 0%; 38 | transform: translateX(0); 39 | @include lt-sm{ 40 | width: 100%; 41 | max-width: 100%; 42 | } 43 | } 44 | 45 | .sideBarNav{ 46 | display:flex; 47 | justify-content:space-between; 48 | // background-color: var(--background-primary); 49 | border-bottom: 1px solid black; 50 | padding: 10px; 51 | } 52 | 53 | .navBack{ 54 | // background-color: var(--background-secondary); 55 | cursor:pointer; 56 | // height:30px; 57 | width:auto; 58 | display: flex; 59 | justify-content: center; 60 | align-items: center; 61 | 62 | margin-left:20px; 63 | > div{ 64 | margin-left:5px; 65 | font-size:18px; 66 | } 67 | } 68 | 69 | .footnoteBody{ 70 | // display:flex; 71 | // background-color:var(--background-tertiary); 72 | user-select:text; 73 | cursor:auto; 74 | padding:20px; 75 | * { 76 | // This will fix issues with the reference href itself not being selectable 77 | user-select: text; 78 | } 79 | } 80 | 81 | .gotoFoot{ 82 | font-size:18px; 83 | margin-right:20px; 84 | cursor:pointer; 85 | } -------------------------------------------------------------------------------- /src/shared/styles/global/breakpoints.scss: -------------------------------------------------------------------------------- 1 | // media aliases and breakpoints 2 | $screen-sm-min: 600px; 3 | $screen-md-min: 960px; 4 | $screen-lg-min: 1280px; 5 | $screen-xl-min: 1920px; 6 | 7 | $screen-xs-max: 599px; 8 | $screen-sm-max: 959px; 9 | $screen-md-max: 1279px; 10 | $screen-lg-max: 1919px; 11 | $screen-xl-max: 5000px; 12 | 13 | // media devices 14 | @mixin xs { 15 | @media screen and (max-width: #{$screen-xs-max}) { 16 | @content; 17 | } 18 | } 19 | 20 | @mixin sm { 21 | @media screen and (min-width: #{$screen-sm-min}) and (max-width: #{$screen-sm-max}) { 22 | @content; 23 | } 24 | } 25 | 26 | @mixin md { 27 | @media screen and (min-width: #{$screen-md-min}) and (max-width: #{$screen-md-max}) { 28 | @content; 29 | } 30 | } 31 | 32 | @mixin lg { 33 | @media screen and (min-width: #{$screen-lg-min}) and (max-width: #{$screen-lg-max}) { 34 | @content; 35 | } 36 | } 37 | 38 | @mixin xl { 39 | @media screen and (min-width: #{$screen-xl-min}) and (max-width: #{$screen-xl-max}) { 40 | @content; 41 | } 42 | } 43 | 44 | // media lt queries 45 | @mixin lt-sm { 46 | @media screen and (max-width: #{$screen-xs-max}) { 47 | @content; 48 | } 49 | } 50 | 51 | @mixin lt-md { 52 | @media screen and (max-width: #{$screen-sm-max}) { 53 | @content; 54 | } 55 | } 56 | 57 | @mixin lt-lg { 58 | @media screen and (max-width: #{$screen-md-max}) { 59 | @content; 60 | } 61 | } 62 | 63 | @mixin lt-xl { 64 | @media screen and (max-width: #{$screen-lg-max}) { 65 | @content; 66 | } 67 | } 68 | 69 | // media gt queries 70 | @mixin gt-xs { 71 | @media screen and (min-width: #{$screen-sm-min}) { 72 | @content; 73 | } 74 | } 75 | 76 | @mixin gt-sm { 77 | @media screen and (min-width: #{$screen-md-min}) { 78 | @content; 79 | } 80 | } 81 | 82 | @mixin gt-md { 83 | @media screen and (min-width: #{$screen-lg-min}) { 84 | @content; 85 | } 86 | } 87 | 88 | @mixin gt-lg { 89 | @media screen and (min-width: #{$screen-xl-min}) { 90 | @content; 91 | } 92 | } -------------------------------------------------------------------------------- /.github/workflows/build-action.yml: -------------------------------------------------------------------------------- 1 | name: Build Alexandria 2 | on: 3 | release: 4 | types: [published] 5 | workflow_dispatch: 6 | jobs: 7 | build_application: 8 | permissions: 9 | contents: write 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | platform: [windows-latest, ubuntu-22.04, macos-latest] # [macos-latest, ubuntu-20.04, windows-latest] 14 | runs-on: ${{ matrix.platform }} 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | submodules: 'recursive' 19 | - name: setup node 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: 18 23 | - name: install Rust stable 24 | uses: dtolnay/rust-toolchain@stable 25 | - name: install dependencies (ubuntu only) 26 | if: matrix.platform == 'ubuntu-22.04' 27 | run: | 28 | sudo apt-get update 29 | sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf 30 | sudo apt-get install -y clang autoconf libtool 31 | cd ./libmobi-rs && sh build-linux.sh 32 | - name: install dependencies (macOS only) 33 | if: matrix.platform == 'macos-latest' 34 | run: | 35 | brew install automake autoconf libtool 36 | cd ./libmobi-rs && sh build-mac.sh 37 | - name: install frontend dependencies 38 | run: npm install # change this to npm or pnpm depending on which one you use 39 | - uses: tauri-apps/tauri-action@v0 40 | - name: Archive production artifacts 41 | uses: actions/upload-artifact@v4 42 | with: 43 | name: Upload Binaries [${{ matrix.platform }}] 44 | path: | 45 | src-tauri/target/release/**/*.exe 46 | src-tauri/target/release/**/*.msi 47 | src-tauri/target/release/**/*.deb 48 | src-tauri/target/release/**/*.AppImage 49 | src-tauri/target/release/**/*.dmg 50 | !src-tauri/target/release/deps 51 | !src-tauri/target/release/build 52 | -------------------------------------------------------------------------------- /src/routes/Reader/SettingsBar/ThemesContainer/ThemesContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './ThemesContainer.module.scss' 4 | 5 | import { useAppDispatch, useAppSelector } from '@store/hooks' 6 | import { Theme } from '@store/slices/EpubJSBackend/data/theme/themeManager.d'; 7 | import { setThemeThunk } from '@store/slices/EpubJSBackend/data/theme/themeManager'; 8 | import { setSelectedTheme } from '@store/slices/appState'; 9 | 10 | interface ThemeInterface{ 11 | [name: string]: Theme 12 | } 13 | 14 | 15 | const ThemesContainer = ()=>{ 16 | const dispatch = useAppDispatch() 17 | const selectedRendition = useAppSelector((state) => state.appState.state.selectedRendition) 18 | const isDualReaderMode = useAppSelector((state) => state.appState.state.dualReaderMode) 19 | 20 | const appThemes = useAppSelector((state) => state.appState.themes) 21 | 22 | const OrderedAppThemeKeys = Object.keys(appThemes); 23 | const idxoflight = OrderedAppThemeKeys.indexOf("Default Light"); 24 | [ OrderedAppThemeKeys[0], OrderedAppThemeKeys[idxoflight] ] = [ OrderedAppThemeKeys[idxoflight],OrderedAppThemeKeys[0] ]; 25 | const idxofdark = OrderedAppThemeKeys.indexOf("Default Dark"); 26 | [ OrderedAppThemeKeys[1], OrderedAppThemeKeys[idxofdark] ] = [ OrderedAppThemeKeys[idxofdark], OrderedAppThemeKeys[1] ]; 27 | return ( 28 | 29 |
30 | {OrderedAppThemeKeys.map((item)=>{ 31 | const {background, color} = (appThemes[item].reader).body 32 | return ( 33 |
{ 34 | // This will select and save the global theme. 35 | dispatch(setSelectedTheme(item)) 36 | 37 | // Below will simply update the rendition themes. 38 | if(isDualReaderMode){ 39 | dispatch(setThemeThunk({themeName: item, view:0})) 40 | dispatch(setThemeThunk({themeName: item, view:1})) 41 | return 42 | } 43 | dispatch(setThemeThunk({themeName: item, view:selectedRendition})) 44 | }} style={{backgroundColor: background, color}} className={styles.theme}> 45 | {item} 46 |
47 | ) 48 | })} 49 |
50 | ) 51 | } 52 | 53 | 54 | export default ThemesContainer -------------------------------------------------------------------------------- /src/store/slices/EpubJSBackend/data/dataManager.ts: -------------------------------------------------------------------------------- 1 | import { PayloadAction } from "@reduxjs/toolkit" 2 | import { epubjs_reducer } from "@store/slices/EpubJSBackend/epubjsManager.d" 3 | import { highlightAction, bookmarkAction, progressUpdate } from "./dataManager.d" 4 | 5 | 6 | 7 | const AddHighlight:epubjs_reducer = (state, action: PayloadAction) =>{ 8 | state[action.payload.view].data.highlights[action.payload.highlightRange] = {color:action.payload.color, note:action.payload.note} 9 | } 10 | const ToggleBookmark:epubjs_reducer = (state, action: PayloadAction) =>{ 11 | if(state[action.payload.view].data.bookmarks.has(action.payload.bookmarkLocation)){ 12 | state[action.payload.view].data.bookmarks.delete(action.payload.bookmarkLocation) 13 | }else{ 14 | state[action.payload.view].data.bookmarks.add(action.payload.bookmarkLocation) 15 | } 16 | } 17 | const DeleteHighlight:epubjs_reducer = (state, action: PayloadAction) =>{ 18 | delete state[action.payload.view].data.highlights[action.payload.highlightRange] 19 | } 20 | const ChangeHighlightColor:epubjs_reducer = (state, action: PayloadAction) =>{ 21 | console.log(action.payload.highlightRange) 22 | console.log(JSON.stringify(state[action.payload.view].data.highlights[action.payload.highlightRange])) 23 | console.log(state[action.payload.view].data.highlights[action.payload.highlightRange]) 24 | state[action.payload.view].data.highlights[action.payload.highlightRange] = {color:action.payload.color, note:state[action.payload.view].data.highlights[action.payload.highlightRange].note} 25 | } 26 | const ChangeHighlightNote:epubjs_reducer = (state, action: PayloadAction) =>{ 27 | state[action.payload.view].data.highlights[action.payload.highlightRange] = {color:state[action.payload.view].data.highlights[action.payload.highlightRange].color, note:action.payload.note} 28 | } 29 | const SetProgress:epubjs_reducer = (state, action: PayloadAction) =>{ 30 | state[action.payload.view].data.progress = action.payload.progress 31 | state[action.payload.view].data.cfi = action.payload.cfi 32 | 33 | } 34 | 35 | 36 | export const actions = { 37 | AddHighlight, 38 | ToggleBookmark, 39 | DeleteHighlight, 40 | ChangeHighlightColor, 41 | ChangeHighlightNote, 42 | SetProgress 43 | } -------------------------------------------------------------------------------- /docs/FAQ.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ### Will there be pdf support? 4 | Currently, there are no plans to support PDF, or formats other than the ones already planned. 5 | The app is built around EpubJS and is designed for responsive book formats. Including PDF support would mean incorporating something like pdf.js and would require tons of effort and rewrite which is completely off course at this moment. 6 | 7 | ### When will mobile support be released? 8 | Currently my objective is to finish up all 1.0 features and deliver a completed stable and reliable desktop application. Afterwards my focus will shift to mobile support 9 | 10 | ### Where is the MacOS Build? 11 | Currently there is no ETA on MacOS builds. While the app is ready for desktop, I would need access to an Apple machine in order to produce builds. 12 | 13 | ### How will syncing work 14 | I am aiming to have ALL books, data, and app settings sync so everything is as you left off. 15 | 16 | **TLDR**; I have not decided on an approach yet, 17 | 18 | Syncing 19 | Cloud services are convenient and very user friendly. I really like this approach because it is accessible to non technical people. But it comes with some drawbacks. 20 | 21 | Merge conflicts 22 | If the app first force overrides the local data when it's opened with the cloud data, you could accidentally override with outdated data. 23 | For example: 24 | If you read for an extended period on your phone while it's offline, and then you open accidentally the app on your desktop, the cloud would show that there is new data (from the desktop), and override your phone's local data. 25 | Unless a custom conflict strategy is devised, using the most recent cloud copy could be a bad user experience. 26 | 27 | Privacy 28 | Cloud services do maintain a database of bad file hashes. A book that you backed up could trigger this system, and cause issues with syncing as the file will be automatically deleted off the cloud. 29 | While encrypting data (Like your annotations) is quick, encrypting and decrypting a library of books can be time consuming on some devices. 30 | Alternatively, I can investigate supporting self hosted solutions like couchbase or pouchdb. 31 | This conflict resolution strategy really sold me on it: https://www.couchbase.com/blog/conflict-resolution-couchbase-mobile/ 32 | The downside of this is that self hosting is not accessible to casual users. 33 | Syncing is still a fair bit down the roadmap so there is plenty of time to decide on the approach. 34 | 35 | -------------------------------------------------------------------------------- /src/migrations.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-fallthrough */ 2 | import {readTextFile, writeTextFile, readDir } from '@tauri-apps/api/fs'; 3 | import {invoke} from '@tauri-apps/api' 4 | let config_path = undefined 5 | export default async function performMigrations(){ 6 | config_path = await invoke("get_config_path_js") 7 | await migrateFonts() 8 | await migrateBooks() 9 | 10 | } 11 | 12 | const migrateBooks = async ()=>{ 13 | const json_path = config_path + "/books/" 14 | const entries = await readDir(json_path); 15 | let bookConfigs = entries.map((entry) => `${entry.path}/${entry.name}.json`) 16 | 17 | for(let bookConfig of bookConfigs){ 18 | 19 | let jsonText = await readTextFile(bookConfig) 20 | 21 | 22 | //Handle case where we have only empty brackets 23 | if(jsonText.length < 2) continue 24 | 25 | let json = JSON.parse(jsonText); 26 | const version = json?.version 27 | 28 | switch(version){ 29 | 30 | case undefined: 31 | // This is initial case where the version is undefined 32 | 33 | if(json?.data?.theme?.lineHeight){ 34 | json.data.theme.lineHeight = json.data.theme.lineHeight/100 35 | } 36 | json.version = "0.13" 37 | console.log("Migrating Book: ", bookConfig) 38 | case "0.13": 39 | // case 0.11 means we are catching this case, and converting it to the next version. 40 | // json.version = undefined 41 | console.log("json is up to date") 42 | 43 | } 44 | 45 | await writeTextFile(bookConfig, JSON.stringify(json, null, 2)) 46 | } 47 | 48 | } 49 | 50 | const migrateFonts = async ()=>{ 51 | const json_path = config_path + "/fonts/fonts.json" 52 | let jsonText = await readTextFile(json_path) 53 | 54 | //Handle case where we have only empty brackets 55 | if(jsonText.length < 2) return 56 | let json = JSON.parse(jsonText); 57 | console.log(jsonText) 58 | const version = json?.version 59 | 60 | switch(version){ 61 | 62 | case undefined: 63 | // This is initial case where the version is undefined 64 | { 65 | let jsonNew = {fonts:{}} 66 | 67 | if(json.fonts && json.fonts.fontMap){ 68 | jsonNew.fonts = json.fonts.fontMap 69 | } 70 | 71 | jsonNew.version = "0.11" 72 | json = jsonNew 73 | } 74 | console.log("Migrating fonts.json: 0.10 -> 0.11") 75 | case "0.11": 76 | // case 0.11 means we are catching this case, and converting it to the next version. 77 | console.log("json is up to date") 78 | 79 | } 80 | 81 | // If no changes were made, return 82 | if(json.version == version){ 83 | return 84 | } 85 | await writeTextFile(json_path, JSON.stringify(json, null, 2)) 86 | } -------------------------------------------------------------------------------- /src/routes/Settings/Settings.module.scss: -------------------------------------------------------------------------------- 1 | @use 'breakpoints.scss' as *; 2 | 3 | 4 | .titleBar{ 5 | height: 50px; 6 | width: 100%; 7 | display:flex; 8 | justify-content: flex-start; 9 | align-items: center; 10 | gap:25px; 11 | background-color: var(--background-primary); 12 | color: var(--text-primary) 13 | // margin-bottom:25px; 14 | } 15 | 16 | 17 | .backButtonContainer{ 18 | margin-left:20px; 19 | padding:0 5px 0 5px; 20 | cursor:pointer; 21 | width: 50px; 22 | height:50px; 23 | display:flex; 24 | align-items: center; 25 | justify-content: center; 26 | 27 | } 28 | 29 | 30 | .titleText{ 31 | font-size:25px; 32 | pointer-events: none; 33 | } 34 | 35 | 36 | 37 | .responsiveSettingsGrid{ 38 | display:grid; 39 | 40 | grid-template-columns: 1fr 4fr; 41 | width: 100%; 42 | /* This is important because otherwise the grid and subitems will not know how to overflow properly */ 43 | height: calc(100vh - 50px); 44 | border-top: 1px solid rgba(0, 0, 0, 0.2); 45 | } 46 | 47 | 48 | .settingsPageContainer{ 49 | height: 100vh; 50 | display:flex; 51 | flex-direction: column; 52 | overflow: hidden; 53 | } 54 | 55 | 56 | // This class name is only appended when subPageActive is true in the react component 57 | // Then these conditions will only apply if the screen size is small 58 | .navbarActive{ 59 | @include lt-sm{ 60 | > .navbar{ 61 | display:none; 62 | } 63 | // Content will span the entire page 64 | > .contentContainer{ 65 | @include lt-sm{ 66 | grid-column: 1/3; 67 | } 68 | } 69 | 70 | } 71 | } 72 | 73 | .titleBarButtonsContainer{ 74 | margin-left:auto; 75 | height:100%; 76 | pointer-events: none; 77 | @include lt-sm{ 78 | display:none; 79 | } 80 | } 81 | 82 | 83 | .hidesm{ 84 | @include lt-sm{ 85 | display:none; 86 | } 87 | } 88 | 89 | .hidegtsm{ 90 | @include gt-xs{ 91 | display:none; 92 | } 93 | } 94 | 95 | 96 | .navbar{ 97 | min-width: 150px; 98 | font-size: 18px; 99 | text-align: center; 100 | grid-row: 1/1; 101 | z-index: 3; 102 | @include lt-sm{ 103 | grid-column: 1/3; 104 | } 105 | // box-shadow: 4px 4px 4px rgba(0, 0, 0, 0.25); 106 | border-right: 1px solid rgba(0,0,0,0.2); 107 | background-color: var(--background-secondary); 108 | color: var(--text-primary); 109 | 110 | display:flex; 111 | flex-direction: column; 112 | } 113 | 114 | 115 | .navbar > div{ 116 | margin: 30px 0 15px 0; 117 | cursor:pointer; 118 | } 119 | 120 | 121 | .contentContainer{ 122 | grid-row: 1/1; 123 | grid-column: 2/3; 124 | overflow:auto; 125 | } -------------------------------------------------------------------------------- /src/routes/Reader/SettingsBar/SettingsBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import styles from './SettingsBar.module.scss' 3 | 4 | 5 | import { useAppDispatch, useAppSelector } from '@store/hooks' 6 | import FontsContainer from './FontsContainerV2/FontsContainer' 7 | import ThemesContainer from './ThemesContainer/ThemesContainer' 8 | import SpacingContainer from './SpacingContainer/SpacingContainer' 9 | import DisplayContainer from './DisplayContainer/DisplayContainer' 10 | 11 | import SettingsIcon from '@resources/feathericons/settings.svg' 12 | import { useNavigate } from 'react-router-dom' 13 | import BottomMenuContainer from '../Components/BottomMenuContainer/BottomMenuContainer' 14 | 15 | const menuExpanded = { 16 | transform: `translateY(100%)`, 17 | // width: "0%" 18 | } 19 | 20 | const SettingsBar = ()=>{ 21 | const dispatch = useAppDispatch() 22 | const [menu, setMenu] = useState("Fonts") 23 | const ThemeMenuActive = useAppSelector((state) => state?.appState?.state?.themeMenuActive) 24 | const navigate = useNavigate(); 25 | 26 | const showQuickSettingsIcon = menu == "Fonts" || menu == "Themes" 27 | return ( 28 | 29 |
30 | {DisplaySubpage(menu)} 31 |
32 | 33 |
34 | { 38 | console.log(window.location.pathname) 39 | navigate("/settings/" + menu, {state:{backPath:window.location.pathname}}) 40 | }} 41 | /> 42 | {['Fonts', 'Themes', "Spacing", "Display"].map((item,i)=>{ 43 | return ( 44 | //
setMenu(item)}> {item}
45 |
setMenu(item)}> {item}
46 | ) 47 | })} 48 | {/* This line is simply just used as a spacer. It is invisible */} 49 | 50 |
51 |
52 | 53 | ) 54 | } 55 | 56 | const DisplaySubpage = (pageName:string)=>{ 57 | switch (pageName) { 58 | case "Fonts": 59 | return 60 | case "Themes": 61 | return 62 | case "Spacing": 63 | return 64 | case "Display": 65 | return 66 | default: 67 | break; 68 | } 69 | } 70 | 71 | export default SettingsBar -------------------------------------------------------------------------------- /src/routes/Reader/SideBar/SideBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from './SideBar.module.scss' 3 | 4 | 5 | import { Rendition } from '@btpf/epubjs' 6 | import Chapters from './Chapters/Chapters' 7 | import { useAppDispatch, useAppSelector } from '@store/hooks' 8 | import Annotations from './Annotations/Annotations' 9 | import Bookmarks from './Bookmarks/Bookmarks' 10 | import Search from './Search/Search' 11 | import { SelectSidebarMenu } from '@store/slices/appState' 12 | 13 | 14 | const Sidebar = ()=>{ 15 | const selectedRendition = useAppSelector((state) => state.appState.state.selectedRendition) 16 | const sidebarOpen = useAppSelector((state) => state?.appState?.state?.sidebarMenuSelected) 17 | const renditionInstance = useAppSelector((state) => state.bookState[selectedRendition]?.instance) 18 | const dispatch = useAppDispatch() 19 | 20 | return ( 21 |
22 |
23 |
24 | {["Chapters", "Bookmarks", "Annotations", "Search"].map((item)=>{ 25 | return ( 26 |
dispatch(SelectSidebarMenu(item))} className={`${sidebarOpen == item && styles.selectedBookmarkTab}`}> 27 | {item} 28 |
29 | ) 30 | })} 31 |
32 | 33 |
34 | 35 |
36 | 37 |
38 |
39 | ) 40 | } 41 | 42 | export default Sidebar 43 | 44 | type SidebarContentTypes = { 45 | selection: string| boolean, 46 | renditionInstance: Rendition|undefined 47 | }; 48 | 49 | const SidebarContent = React.memo((props: SidebarContentTypes)=>{ 50 | 51 | 52 | 53 | 54 | 55 | if(props.selection == "Chapters" && props.renditionInstance?.book?.navigation){ 56 | return ( 57 | 58 | ) 59 | } 60 | if(props.selection == "Annotations"){ 61 | return ( 62 | 63 | ) 64 | } 65 | if(props.selection == "Bookmarks"){ 66 | return 67 | } 68 | let query = "" 69 | if(typeof props.selection == typeof query){ 70 | if((props.selection as string).includes("#")){ 71 | query = (props.selection as string).split("#")[1] 72 | } 73 | } 74 | 75 | return 76 | 77 | 78 | 79 | return (
) 80 | 81 | }, (_, nextProps)=> nextProps.selection == false) 82 | 83 | SidebarContent.displayName = 'SidebarContent'; -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/schema.json", 3 | "build": { 4 | "beforeBuildCommand": "npm run build", 5 | "beforeDevCommand": "npm run dev", 6 | "devPath": "http://localhost:9000", 7 | "distDir": "../dist" 8 | }, 9 | "package": { 10 | "productName": "Alexandria", 11 | "version": "../package.json" 12 | }, 13 | "tauri": { 14 | "security": { 15 | "csp": "default-src 'self' blob: https://asset.localhost/ http://127.0.0.1:16780/ https://en.wiktionary.org https://fonts.gstatic.com/ https://fonts.googleapis.com/ data:; style-src 'self' 'unsafe-inline' blob: https://asset.localhost/ https://fonts.googleapis.com/;" 16 | }, 17 | "cli": { 18 | "args": [{ 19 | "name": "source", 20 | "index": 1, 21 | "takesValue": true 22 | }] 23 | }, 24 | "allowlist": { 25 | "protocol":{ 26 | "asset": true, 27 | "assetScope": ["**"] 28 | }, 29 | "http": { 30 | "all": true, 31 | "scope": ["https://fonts.gstatic.com/*","https://fonts.googleapis.com/*"], 32 | "request": true 33 | }, 34 | "all": true 35 | }, 36 | "bundle": { 37 | "active": true, 38 | "category": "DeveloperTool", 39 | "copyright": "", 40 | "deb": { 41 | "depends": [], 42 | "desktopTemplate":"desktopTemplate.hbs" 43 | }, 44 | "externalBin": [], 45 | "icon": [ 46 | "icons/32x32.png", 47 | "icons/128x128.png", 48 | "icons/128x128@2x.png", 49 | "icons/icon.icns", 50 | "icons/icon.ico" 51 | ], 52 | "identifier": "com.btpf.alexandria", 53 | "longDescription": "", 54 | "macOS": { 55 | "entitlements": null, 56 | "exceptionDomain": "", 57 | "frameworks": [], 58 | "providerShortName": null, 59 | "signingIdentity": null 60 | }, 61 | "resources": [], 62 | "shortDescription": "", 63 | "targets": "all", 64 | "windows": { 65 | "certificateThumbprint": null, 66 | "digestAlgorithm": "sha256", 67 | "timestampUrl": "" 68 | } 69 | }, 70 | "updater": { 71 | "active": false 72 | }, 73 | "windows": [ 74 | { 75 | "fullscreen": false, 76 | "height": 600, 77 | "resizable": true, 78 | "title": "Alexandria", 79 | "width": 800, 80 | "fileDropEnabled": true, 81 | "decorations": false, 82 | "transparent": true, 83 | "additionalBrowserArgs": "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection,ElasticOverscroll --enable-features=msWebView2EnableDraggableRegions" 84 | } 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/routes/Info/generator/html.ts: -------------------------------------------------------------------------------- 1 | import store from "@store/store"; 2 | 3 | 4 | export default (bookMeta, annotations)=>{ 5 | const storeSnap = store.getState() 6 | const selectedTheme = storeSnap.appState.selectedTheme 7 | const currentTheme = storeSnap.appState.themes[selectedTheme] 8 | 9 | const bg = currentTheme.ui.tertiaryBackground 10 | const tc = currentTheme.ui.primaryText 11 | const tc2 = currentTheme.ui.secondaryText 12 | 13 | const exportHtml = 14 | ` 15 | 16 | 17 | 82 | 83 | 84 | Annotation Export 85 | 86 | 87 |
88 |

${bookMeta["title"]}

89 |

By ${bookMeta["creator"]}

90 |
91 | 92 | ${annotations.map((item)=>{ 93 | return ` 94 |
95 |
96 |
${item.title} - ${item.AnnotationCFI}
97 | 98 |
${item.highlightedText}
99 |
${item.annotation}
100 | 101 |
102 |
103 | ` 104 | 105 | }).join("\n")} 106 |
107 | 108 | ` 109 | 110 | 111 | return exportHtml 112 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alexandria", 3 | "version": "0.13.2", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "webpack", 9 | "build": "cross-env NODE_ENV=production webpack", 10 | "dev": "webpack-dev-server", 11 | "tauri": "tauri", 12 | "lint": "eslint --fix --ext .ts,.tsx ." 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/btpf/Alexandria.git" 17 | }, 18 | "author": "", 19 | "license": "ISC", 20 | "bugs": { 21 | "url": "https://github.com/btpf/Alexandria/issues" 22 | }, 23 | "homepage": "https://github.com/btpf/Alexandria#readme", 24 | "devDependencies": { 25 | "@babel/core": "^7.18.6", 26 | "@babel/preset-env": "^7.18.6", 27 | "@babel/preset-react": "^7.18.6", 28 | "@babel/preset-typescript": "^7.18.6", 29 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", 30 | "@svgr/webpack": "^6.3.1", 31 | "@tauri-apps/cli": "^1.4.0", 32 | "@tsconfig/node16": "^1.0.3", 33 | "@types/react": "^18.0.15", 34 | "@types/react-dom": "^18.0.6", 35 | "@typescript-eslint/eslint-plugin": "^5.30.6", 36 | "@typescript-eslint/parser": "^5.30.6", 37 | "autoprefixer": "^10.4.7", 38 | "babel-loader": "^8.2.5", 39 | "babel-plugin-tsconfig-paths-module-resolver": "^1.0.3", 40 | "cross-env": "^7.0.3", 41 | "css-loader": "^6.7.1", 42 | "eslint": "^8.20.0", 43 | "eslint-plugin-react": "^7.30.1", 44 | "eslint-plugin-react-hooks": "^4.6.0", 45 | "eslint-plugin-unused-imports": "^2.0.0", 46 | "html-webpack-plugin": "^5.5.0", 47 | "postcss": "^8.4.14", 48 | "postcss-loader": "^7.0.1", 49 | "react-refresh": "^0.14.0", 50 | "sass": "^1.54.2", 51 | "sass-loader": "^13.0.2", 52 | "style-loader": "^3.3.1", 53 | "stylelint": "^14.9.1", 54 | "stylelint-config-recommended-scss": "^7.0.0", 55 | "stylelint-config-standard": "^26.0.0", 56 | "webpack": "^5.73.0", 57 | "webpack-cli": "^4.10.0", 58 | "webpack-dev-server": "^4.9.3" 59 | }, 60 | "dependencies": { 61 | "@btpf/epubjs": "^0.3.95", 62 | "@github/mini-throttle": "^2.1.1", 63 | "@reduxjs/toolkit": "^1.8.3", 64 | "@tauri-apps/api": "^1.4.0", 65 | "@types/css-font-loading-module": "^0.0.8", 66 | "@types/node": "^18.0.5", 67 | "immer": "^9.0.15", 68 | "jszip": "^3.10.1", 69 | "rc-slider": "^10.1.0", 70 | "react": "^18.2.0", 71 | "react-colorful": "^5.6.1", 72 | "react-dom": "^18.2.0", 73 | "react-hot-toast": "^2.4.1", 74 | "react-redux": "^8.0.2", 75 | "react-router-dom": "^6.6.1", 76 | "react-virtuoso": "^4.1.0", 77 | "sanitize.css": "^13.0.0", 78 | "stylelint-scss": "^4.3.0" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/routes/Reader/SideBar/Chapters/Chapters.tsx: -------------------------------------------------------------------------------- 1 | import styles from './Chapters.module.scss' 2 | import React, { useState } from 'react' 3 | import { NavItem, Rendition } from '@btpf/epubjs'; 4 | import produce from 'immer'; 5 | import ChevronRight from '@resources/feathericons/chevron-right.svg' 6 | import ChevronDown from '@resources/feathericons/chevron-down.svg' 7 | 8 | import { useAppDispatch } from '@store/hooks' 9 | import { SelectSidebarMenu } from '@store/slices/appState'; 10 | 11 | type SidebarTypes = { 12 | renditionInstance: Rendition 13 | }; 14 | type ExpandingTree = { [member: string]: any|null } 15 | 16 | 17 | const Sidebar = (props:SidebarTypes)=>{ 18 | const dispatch = useAppDispatch() 19 | 20 | //https://stackoverflow.com/a/59370530 21 | const [expandableTree, setExpandableTree] = useState({}); 22 | 23 | const toggleLevel = (level:any)=>{ 24 | const nextState = produce(expandableTree, (draftState:ExpandingTree) => { 25 | let currentState = draftState 26 | 27 | let index = 0 28 | for(const chapter of level){ 29 | if (currentState[chapter]){ 30 | if(index + 1 == level.length){ 31 | delete currentState[chapter] 32 | break 33 | } 34 | currentState = currentState[chapter] 35 | }else{ 36 | currentState[chapter] = {} 37 | break 38 | } 39 | index += 1 40 | } 41 | }) 42 | setExpandableTree(nextState) 43 | } 44 | 45 | const recursiveMap = (chapterList:NavItem[], level:Array, currentTree:ExpandingTree=expandableTree) =>{ 46 | return chapterList.map((item)=>{ 47 | return ( 48 |
{ 50 | e.stopPropagation() 51 | e.preventDefault() 52 | props.renditionInstance?.display(item.href) 53 | dispatch(SelectSidebarMenu(false)) 54 | }} 55 | > 56 |
57 | 58 |
{item.label}
59 | 60 |
{ 61 | e.stopPropagation(); 62 | e.preventDefault() 63 | if(item.subitems){ 64 | toggleLevel([...level, item.id]) 65 | } 66 | }}> 67 | {item?.subitems?.length ?currentTree[item.id]?::""} 68 |
69 | 70 | 71 | 72 |
73 |
{item.subitems && currentTree[item.id]? recursiveMap(item.subitems, [...level, item.id], currentTree[item.id]):""}
74 |
75 | ) 76 | }) 77 | } 78 | 79 | return ( 80 |
81 | {recursiveMap(props.renditionInstance?.book.navigation.toc, [])} 82 |
83 | ) 84 | } 85 | 86 | export default Sidebar -------------------------------------------------------------------------------- /src/routes/Settings/pages/PreviewWidget/PreviewWidget.module.scss: -------------------------------------------------------------------------------- 1 | /* --- Widget Theme --- */ 2 | @use 'breakpoints.scss' as *; 3 | 4 | $widgetBorderRadius: 5px; 5 | 6 | .widgetContainer{ 7 | width: 250px; 8 | height: 175px; 9 | // Fixes bug on windows which causes widget to be crushed when there is not enough vertical space 10 | min-height: 175px; 11 | border: 1px solid black; 12 | margin-top:25px; 13 | border-radius: $widgetBorderRadius; 14 | // https://css-tricks.com/absolute-positioning-inside-relative-positioning/ 15 | position:relative; 16 | user-select: none; /* standard syntax */ 17 | display: table; 18 | 19 | @include gt-sm{ 20 | transform-origin: top; 21 | transform: scale(1.5); 22 | margin-bottom:calc(175px / 2); 23 | } 24 | @include gt-md{ 25 | transform-origin: top; 26 | transform: scale(2); 27 | margin-bottom:175px; 28 | } 29 | } 30 | 31 | .widgetTopBar{ 32 | width: 248px; 33 | background-color: var(--background-primary); 34 | height: 25px; 35 | position: absolute; 36 | z-index: 2; 37 | border-top-right-radius: $widgetBorderRadius; 38 | border-top-left-radius: $widgetBorderRadius; 39 | } 40 | .widgetSideBar{ 41 | background-color: var(--background-secondary); 42 | width: 50px; 43 | height: 173px; 44 | position: absolute; 45 | z-index: 1; 46 | border-bottom-left-radius: $widgetBorderRadius; 47 | border-top-left-radius: $widgetBorderRadius; 48 | box-shadow: 4px 0 10px -2px #0000006c; 49 | font-size:8px; 50 | padding-top:30px; 51 | display:flex; 52 | flex-direction: column; 53 | padding-left:2px; 54 | } 55 | .widgetContentContainer{ 56 | height: 173px; 57 | width: 248px; 58 | padding-top: 30px; 59 | padding-left: calc(40px + 20px); 60 | padding-right: 20px; 61 | font-size:8px; 62 | position: absolute; 63 | border-radius: $widgetBorderRadius; 64 | } 65 | .widgetIconContainer{ 66 | display:inline; 67 | cursor:pointer; 68 | 69 | } 70 | .topBarLeft{ 71 | margin-left: 5px; 72 | display:inline; 73 | > .widgetIconContainer{ 74 | margin-left: 2px; 75 | color: var(--text-secondary); 76 | 77 | :hover{ 78 | color:var(--text-primary); 79 | } 80 | } 81 | } 82 | .topBarRight{ 83 | margin-left: 175px; 84 | display:inline; 85 | > .widgetIconContainer{ 86 | margin-left: 2px; 87 | color: var(--text-secondary); 88 | 89 | :hover{ 90 | color:var(--text-primary); 91 | } 92 | } 93 | } 94 | 95 | .topBarTitle{ 96 | color:var(--text-primary); 97 | position: absolute; 98 | margin-left: 112px; 99 | margin-top: 8px; 100 | font-size:8px; 101 | } 102 | /* --- End Widget Theme --- */ 103 | 104 | 105 | .imageExample{ 106 | // position:"absolute"; 107 | margin-top:15px; 108 | margin-left:calc((168px / 2) - 20px); 109 | } 110 | 111 | .noteContainer{ 112 | box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.05); 113 | border: 1px solid rgba(0, 0, 0, 0.25); 114 | border-radius: 2px; 115 | } -------------------------------------------------------------------------------- /src/routes/Reader/SettingsBar/DisplayContainer/DisplayContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './DisplayContainer.module.scss' 4 | 5 | import { useAppDispatch, useAppSelector } from '@store/hooks' 6 | 7 | import { setRenderMode } from '@store/slices/bookState' 8 | import ScriptIcon from '@resources/iconmonstr/iconmonstr-script-2.svg' 9 | import SinglePageIcon from '@resources/material/article_black_24dp.svg' 10 | import Swap from '@resources/feathericons/repeat.svg' 11 | import { SetDualReaderReversed } from '@store/slices/appState' 12 | import { bookStateStructure } from '@store/slices/EpubJSBackend/epubjsManager.d' 13 | 14 | const DisplayContainer = ()=>{ 15 | const dispatch = useAppDispatch() 16 | const selectedRendition = useAppSelector((state) => state.appState.state.selectedRendition) 17 | const dualReaderReversed = useAppSelector((state) => state.appState.state.dualReaderReversed) 18 | const isDualReaderMode = useAppSelector((state) => state.appState.state.dualReaderMode) 19 | const readerMode = useAppSelector((state) => (state.bookState[selectedRendition] as bookStateStructure)?.data?.theme?.renderMode) 20 | 21 | const renderModeDispatcher = (renderMode:string) =>{ 22 | dispatch(setRenderMode({view:0, renderMode:renderMode})) 23 | if(isDualReaderMode){ 24 | dispatch(setRenderMode({view:1, renderMode:renderMode})) 25 | } 26 | } 27 | return ( 28 | <> 29 |
30 |
{ 31 | renderModeDispatcher("single") 32 | }} className={`${styles.optionContainer} ${readerMode=="single"? styles.active:""}`}>Single Column
33 |
{ 34 | // TODO: Possible to optimize with the below methods 35 | // renditionInstance.flow("paginated") 36 | // renditionInstance.spread("none") 37 | renderModeDispatcher("auto") 38 | }} className={`${styles.optionContainer} ${readerMode=="auto"? styles.active:""}`}> 39 |
40 | 41 | 42 |
43 | Double Column 44 |
45 |
{ 46 | renderModeDispatcher("continuous") 47 | }} className={`${styles.optionContainer} ${readerMode=="continuous"? styles.active:""}`}>Scrolled
48 | 49 | 50 | 51 | 52 | 53 |
54 | 55 |
{ 56 | dispatch(SetDualReaderReversed(!dualReaderReversed)) 57 | 58 | }} style={isDualReaderMode?{height:"auto", position:"absolute", marginTop:160}:{display:"none"}} className={`${styles.optionContainer}`}> 59 | 60 | Dual Panel Swap 61 |
62 | 63 | ) 64 | } 65 | 66 | 67 | export default DisplayContainer -------------------------------------------------------------------------------- /src/store/slices/AppState/state/stateManager.ts: -------------------------------------------------------------------------------- 1 | import { PayloadAction } from "@reduxjs/toolkit" 2 | import { appStateReducer, appStateReducerSingle, initialAppState } from "../../appState" 3 | 4 | 5 | const SetMaximized:appStateReducer = (state, action: PayloadAction) =>{ 6 | state.state.maximized = action.payload 7 | } 8 | 9 | const SetSelectedRendition:appStateReducer = (state, action: PayloadAction) =>{ 10 | state.state.selectedRendition = action.payload 11 | } 12 | 13 | const SetDualReaderMode:appStateReducer = (state, action: PayloadAction) =>{ 14 | state.state.dualReaderMode = action.payload 15 | } 16 | 17 | const SetDualReaderReversed:appStateReducer = (state, action: PayloadAction) =>{ 18 | state.state.dualReaderReversed = action.payload 19 | } 20 | 21 | const resetBookAppState:appStateReducerSingle = (state) =>{ 22 | const myState = {...initialAppState.state} 23 | myState.localSystemFonts = state.state.localSystemFonts 24 | myState["maximized"] = state.state.maximized 25 | 26 | state.state = myState 27 | } 28 | 29 | const SelectSidebarMenu:appStateReducer = (state, action: PayloadAction) =>{ 30 | state.state.sidebarMenuSelected = action.payload 31 | } 32 | 33 | const CloseSidebarMenu:appStateReducerSingle = (state) =>{ 34 | state.state.sidebarMenuSelected = false 35 | } 36 | 37 | const ToggleMenu:appStateReducerSingle = (state) =>{ 38 | state.state.menuToggled = !state.state.menuToggled 39 | } 40 | 41 | const SetDictionaryWord:appStateReducer = (state, action: PayloadAction) =>{ 42 | state.state.dictionaryWord = action.payload 43 | } 44 | 45 | const ToggleThemeMenu:appStateReducerSingle =(state) =>{ 46 | state.state.themeMenuActive = !state.state.themeMenuActive 47 | } 48 | 49 | const ToggleProgressMenu:appStateReducerSingle =(state) =>{ 50 | state.state.progressMenuActive = !state.state.progressMenuActive 51 | } 52 | 53 | const setReaderMargins:appStateReducer = (state, action: PayloadAction) =>{ 54 | state.readerMargins = action.payload 55 | } 56 | 57 | export interface footnoteUpdate{ 58 | link: string, 59 | text:string 60 | } 61 | 62 | const SetFootnoteActive:appStateReducer = (state, action: PayloadAction) =>{ 63 | state.state.footnote.link = action.payload.link 64 | state.state.footnote.text = action.payload.text 65 | state.state.footnote.active = true; 66 | } 67 | 68 | const HideFootnote:appStateReducerSingle =(state) =>{ 69 | state.state.footnote.active = false; 70 | } 71 | 72 | export interface locaFontsListPayload{ 73 | fonts: {[fontName: string]: Array} 74 | } 75 | 76 | const SetLocalFontsList:appStateReducer = (state, action: PayloadAction) =>{ 77 | state.state.localSystemFonts = action.payload.fonts 78 | } 79 | 80 | export const actions = { 81 | SetMaximized, 82 | SetSelectedRendition, 83 | SelectSidebarMenu, 84 | CloseSidebarMenu, 85 | ToggleMenu, 86 | SetDictionaryWord, 87 | ToggleThemeMenu, 88 | setReaderMargins, 89 | SetDualReaderMode, 90 | resetBookAppState, 91 | SetDualReaderReversed, 92 | ToggleProgressMenu, 93 | SetFootnoteActive, 94 | HideFootnote, 95 | SetLocalFontsList 96 | } 97 | -------------------------------------------------------------------------------- /src/routes/Settings/Settings.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import styles from './Settings.module.scss' 3 | 4 | import BackArrow from '@resources/feathericons/arrow-left.svg' 5 | import { Route, Routes, useLocation, useNavigate } from "react-router-dom" 6 | // import ReaderTheme from "./pages/ReaderTheme" 7 | import GlobalTheme from "./pages/GlobalTheme" 8 | import Fonts from "./pages/Fonts/Fonts" 9 | import TitleBarButtons from "@shared/components/TitleBarButtons" 10 | import About from "./pages/About" 11 | 12 | const Settings = (props:any)=>{ 13 | const navigate = useNavigate() 14 | const location = useLocation() 15 | 16 | let mobileTitle = location.pathname 17 | const subpaths = mobileTitle.split('/') 18 | 19 | if (subpaths.length == 2){ 20 | mobileTitle = "Settings" 21 | }else{ 22 | mobileTitle = subpaths[2] 23 | } 24 | const {backPath} =location.state 25 | return ( 26 |
27 |
28 | {/* This is the titlebar for desktop screens */} 29 |
backPath?navigate(backPath):navigate("/")} className={styles.backButtonContainer + " " + styles.hidesm}> 30 | 31 |
32 |
Settings
33 | 34 | 35 | {/* This is the titlebar for mobile screens */} 36 |
mobileTitle=="Settings"? backPath?navigate(backPath):navigate("/"): navigate("/settings",{state:{backPath}})} className={styles.backButtonContainer + " " + styles.hidegtsm}> 37 | 38 |
39 |
{mobileTitle.replace("%20", " ")}
40 |
41 | 42 | 43 |
44 |
45 | 46 |
47 |
48 |
navigate("Themes", {state:{backPath}})}>Themes
49 | {/*
navigate("Reader Theme")}>Reader Theme
*/} 50 |
navigate("Fonts", {state:{backPath}})}>Fonts
51 |
navigate("About", {state:{backPath}})}>About
52 |
53 | 54 |
55 | 56 | } /> 57 | } /> 58 | {/* } /> */} 59 | } /> 60 | } /> 61 | 62 |
63 |
64 | 65 |
66 | 67 | ) 68 | } 69 | 70 | 71 | export default Settings -------------------------------------------------------------------------------- /src/routes/Reader/FooterBarBottom/FooterBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react' 2 | import styles from './FooterBar.module.scss' 3 | 4 | 5 | import { useAppDispatch, useAppSelector } from '@store/hooks' 6 | 7 | import { useNavigate } from 'react-router-dom' 8 | import LeftArrow from '@resources/feathericons/arrow-left.svg' 9 | import { HideFootnote } from '@store/slices/appState' 10 | import { handleLinkClick } from '@shared/scripts/handleLinkClick' 11 | 12 | 13 | const FooterBar = ()=>{ 14 | const dispatch = useAppDispatch() 15 | const [menu, setMenu] = useState("Fonts") 16 | const footnoteActive = useAppSelector((state) => state?.appState?.state?.footnote.active) 17 | const footnoteLink = useAppSelector((state) => state?.appState?.state?.footnote.link) 18 | const footnoteText = useAppSelector((state) => state?.appState?.state?.footnote.text) 19 | const selectedRendition = useAppSelector((state) => state.appState.state.selectedRendition) 20 | const renditionInstance = useAppSelector((state) => state.bookState[selectedRendition]?.instance) 21 | const footerContentRef = useRef(null); 22 | const navigate = useNavigate(); 23 | 24 | useEffect(()=>{ 25 | console.log("FOOTNOTE LINK") 26 | }, [footnoteLink]) 27 | 28 | 29 | useEffect(()=>{ 30 | console.log("Dictionary Mounted") 31 | const t = footerContentRef.current 32 | if(t == null){ 33 | console.log("dictionaryContainerRef does not exist") 34 | return 35 | } 36 | t.scrollTop = 0; 37 | const query = t.querySelectorAll("*") 38 | /* console.log(query) */ 39 | query.forEach((item)=>{ 40 | /* console.log(item.tagName) */ 41 | if(item.tagName == "A"){ 42 | 43 | const replacement = document.createElement("div"); 44 | // replacement.style = "display:inline; color:red; text-decoration:underline; cursor:pointer;" 45 | // lightblue for dark theme, blue for light theme 46 | replacement.style.cssText = "display:inline; color:var(--link); cursor:pointer;" 47 | replacement.innerHTML = item.innerHTML 48 | const href = (item as HTMLAnchorElement).getAttribute("href") 49 | replacement.onclick = () =>{ 50 | 51 | handleLinkClick(renditionInstance, href) 52 | 53 | } 54 | 55 | item.replaceWith(replacement) 56 | 57 | } 58 | }) 59 | },[footnoteLink]) 60 | 61 | 62 | return ( 63 |
64 | 65 | 66 |
67 |
dispatch(HideFootnote())}> 68 | 69 |
70 | Return 71 |
72 |
73 | 74 |
{ 75 | renditionInstance.display(footnoteLink) 76 | dispatch(HideFootnote()) 77 | }} className={styles.gotoFoot}> 78 | Navigate To Footnote 79 |
80 | 81 |
82 | 83 |
87 | 88 |
89 | 90 | ) 91 | } 92 | 93 | 94 | export default FooterBar -------------------------------------------------------------------------------- /src/routes/Reader/SideBar/Bookmarks/Bookmarks.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | 3 | import { useAppDispatch, useAppSelector } from '@store/hooks' 4 | import Trash from '@resources/feathericons/trash-2.svg' 5 | import { ToggleBookmark } from '@store/slices/bookState' 6 | 7 | import styles from './Bookmarks.module.scss' 8 | import { CloseSidebarMenu } from '@store/slices/appState' 9 | import { getChapterCFIMap } from '@shared/scripts/getChapterCfiMap' 10 | 11 | 12 | interface Bookmark{ 13 | cfi: string, 14 | title: string 15 | } 16 | 17 | const Bookmarks = ()=>{ 18 | const dispatch = useAppDispatch() 19 | const selectedRendition = useAppSelector((state) => state.appState.state.selectedRendition) 20 | 21 | const renditionInstance = useAppSelector((state) => state.bookState[selectedRendition]?.instance) 22 | const bookmarks = useAppSelector((state) => state.bookState[selectedRendition]?.data.bookmarks) 23 | const [orderedBookmarks, setOrderedBookmarks] = useState(Array) 24 | 25 | 26 | useEffect(()=>{ 27 | 28 | console.log(bookmarks) 29 | if(!bookmarks || !renditionInstance.book?.spine || !renditionInstance.book?.navigation?.toc){ 30 | return 31 | } 32 | const workingBookmarks = Array.from(bookmarks) 33 | 34 | 35 | 36 | const allChapters = getChapterCFIMap(renditionInstance.book) 37 | 38 | 39 | const newOrderedBookmarks = workingBookmarks.map((cfi)=>{ 40 | let titlename; 41 | for(const item in allChapters){ 42 | if(!allChapters[item].cfi){ 43 | continue 44 | } 45 | const comparison = renditionInstance.epubcfi.compare(cfi, allChapters[item].cfi) 46 | // In the case where the current chapter is ahead of our annotation, break before setting the title 47 | if (comparison < 0){ 48 | break 49 | } 50 | titlename = allChapters[item].title.trim() 51 | } 52 | return {cfi, title:titlename} 53 | 54 | }) 55 | 56 | 57 | // Sort annotations by location in book 58 | newOrderedBookmarks.sort((a, b)=>{ 59 | return renditionInstance.epubcfi.compare(b.cfi, a.cfi) 60 | } 61 | ) 62 | 63 | setOrderedBookmarks(newOrderedBookmarks) 64 | }, [bookmarks]) 65 | 66 | return ( 67 |
68 | {orderedBookmarks.map((item)=>{ 69 | return ( 70 |
71 |
{ 72 | renditionInstance.annotations.remove(item.cfi, "highlight") 73 | dispatch(ToggleBookmark({view: selectedRendition,bookmarkLocation:item.cfi})) 74 | 75 | }}> 76 | 77 |
78 |
{ 79 | renditionInstance.display(item.cfi).then(()=>{ 80 | renditionInstance.display(item.cfi) 81 | }) 82 | dispatch(CloseSidebarMenu()) 83 | }}> 84 |
{item.cfi}
85 | 86 |
{item.title}
87 | 88 |
89 |
90 | ) 91 | })} 92 |
93 | ) 94 | } 95 | 96 | export default Bookmarks -------------------------------------------------------------------------------- /src/routes/Reader/FooterBar/FooterBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react' 2 | import styles from './FooterBar.module.scss' 3 | 4 | 5 | import { useAppDispatch, useAppSelector } from '@store/hooks' 6 | 7 | import { useNavigate } from 'react-router-dom' 8 | import LeftArrow from '@resources/feathericons/arrow-left.svg' 9 | import { HideFootnote } from '@store/slices/appState' 10 | import { handleLinkClick } from '@shared/scripts/handleLinkClick' 11 | 12 | 13 | const FooterBar = ()=>{ 14 | const dispatch = useAppDispatch() 15 | const [menu, setMenu] = useState("Fonts") 16 | const footnoteActive = useAppSelector((state) => state?.appState?.state?.footnote.active) 17 | const footnoteLink = useAppSelector((state) => state?.appState?.state?.footnote.link) 18 | const footnoteText = useAppSelector((state) => state?.appState?.state?.footnote.text) 19 | const selectedRendition = useAppSelector((state) => state.appState.state.selectedRendition) 20 | const renditionInstance = useAppSelector((state) => state.bookState[selectedRendition]?.instance) 21 | const footerContentRef = useRef(null); 22 | const navigate = useNavigate(); 23 | 24 | useEffect(()=>{ 25 | console.log("FOOTNOTE LINK") 26 | }, [footnoteLink]) 27 | 28 | 29 | useEffect(()=>{ 30 | console.log("Dictionary Mounted") 31 | const t = footerContentRef.current 32 | if(t == null){ 33 | console.log("dictionaryContainerRef does not exist") 34 | return 35 | } 36 | t.scrollTop = 0; 37 | const query = t.querySelectorAll("*") 38 | /* console.log(query) */ 39 | query.forEach((item)=>{ 40 | /* console.log(item.tagName) */ 41 | if(item.tagName == "A"){ 42 | 43 | const replacement = document.createElement("div"); 44 | // replacement.style = "display:inline; color:red; text-decoration:underline; cursor:pointer;" 45 | // lightblue for dark theme, blue for light theme 46 | replacement.style.cssText = "display:inline; color:var(--link); cursor:pointer;" 47 | replacement.innerHTML = item.innerHTML 48 | const href = (item as HTMLAnchorElement).getAttribute("href") 49 | replacement.onclick = () =>{ 50 | 51 | handleLinkClick(renditionInstance, href) 52 | 53 | } 54 | 55 | item.replaceWith(replacement) 56 | 57 | } 58 | }) 59 | },[footnoteLink]) 60 | 61 | 62 | return ( 63 |
64 |
65 |
66 | 67 |
dispatch(HideFootnote())}> 68 | 69 |
70 | Return 71 |
72 |
73 | 74 |
{ 75 | renditionInstance.display(footnoteLink) 76 | dispatch(HideFootnote()) 77 | }} className={styles.gotoFoot}> 78 | Navigate To Footnote 79 |
80 | 81 |
82 | 83 |
87 | 88 |
89 |
90 | 91 | ) 92 | } 93 | 94 | 95 | export default FooterBar -------------------------------------------------------------------------------- /src/routes/Settings/pages/ReaderTheme.module.scss: -------------------------------------------------------------------------------- 1 | 2 | 3 | .themeContainer{ 4 | width: 100%; 5 | min-height: 99%; 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | } 10 | 11 | 12 | .comboBox{ 13 | width: 200px; 14 | border-radius: 5px; 15 | border-color:rgba(0, 0, 0, 0.2); 16 | display:inline; 17 | background-color: var(--background-primary); 18 | color: var(--text-primary); 19 | } 20 | 21 | .comboTextBox{ 22 | width: 200px; 23 | border-radius: 5px; 24 | border-color:rgba(0, 0, 0, 0.2); 25 | background-color: var(--background-primary) !important; 26 | color: var(--text-primary) !important; 27 | } 28 | 29 | .comboContainer{ 30 | margin-top:15px; 31 | } 32 | 33 | .comboContainerText{ 34 | font-size:14px; 35 | } 36 | 37 | .newCombo{ 38 | display: inline-block; 39 | margin-left: 15px; 40 | background-color: var(--background-primary); 41 | border: 1px solid rgba(0, 0, 0, 0.2); 42 | padding: 8px; 43 | border-radius: 10px; 44 | font-weight: 600; 45 | color: var(--text-primary); 46 | cursor:pointer; 47 | width:55px; 48 | text-align: center; 49 | } 50 | 51 | 52 | .themePropertyContainer{ 53 | margin-top:50px; 54 | display:flex; 55 | flex-direction: row; 56 | gap:25px; 57 | width: clamp(0px, 100%, 1000px); 58 | justify-content: space-around; 59 | flex-wrap: wrap-reverse; 60 | padding-bottom:25px; 61 | } 62 | 63 | .themeTargetContainer{ 64 | display:flex; 65 | flex-direction: column; 66 | gap:8px; 67 | } 68 | 69 | .themePropertyRow{ 70 | margin-left:25px; 71 | display:flex; 72 | align-items: center; 73 | } 74 | 75 | .themePropertyName{ 76 | margin-right: 50px; 77 | width:200px; 78 | 79 | } 80 | 81 | .themeColor{ 82 | width: 25px; 83 | height: 25px; 84 | border-radius: 100%; 85 | background-color:black; 86 | margin-right:15px; 87 | border: 1px solid rgba(0, 0, 0, 0.3); 88 | } 89 | .themeColor:hover:enabled{ 90 | cursor:pointer; 91 | } 92 | 93 | .resetButton{ 94 | transform: scale(0.9); 95 | cursor:pointer; 96 | } 97 | 98 | .deleteButton{ 99 | 100 | color:red; 101 | // background-color: #FFEBEB; 102 | // border:1px solid red; 103 | 104 | background-color: var(--background-primary); 105 | border: 1px solid rgba(0, 0, 0, 0.2); 106 | 107 | display: inline-block; 108 | // margin-left: 15px; 109 | 110 | padding: 8px; 111 | border-radius: 10px; 112 | font-weight: 600; 113 | // color: var(--text-primary); 114 | cursor:pointer; 115 | width:55px; 116 | text-align: center; 117 | margin-right:15px; 118 | 119 | // This is 55px wide + 8 padding + 2px border on each side 120 | // calc(55px + 8px + 2px) 121 | } 122 | 123 | .themeTarget{ 124 | font-weight: bold; 125 | } 126 | 127 | .colorPickerContainer{ 128 | z-index: 100; 129 | background-color:var(--background-primary); 130 | border: 1px solid rgba(0, 0, 0, 0.2); 131 | position:absolute; 132 | border-radius: 10px; 133 | & > input{ 134 | display: block; 135 | box-sizing: border-box; 136 | width: 90px; 137 | margin: 5px 55px 5px; 138 | padding: 5px; 139 | border: 1px solid rgba(0, 0, 0, 0.2); 140 | border-radius: 4px; 141 | outline: none; 142 | font: inherit; 143 | text-transform: uppercase; 144 | text-align: center; 145 | background-color:var(--background-secondary) !important; 146 | 147 | 148 | 149 | } 150 | } -------------------------------------------------------------------------------- /src/routes/Reader/ReaderView/functions/ModalUtility.tsx: -------------------------------------------------------------------------------- 1 | import { EpubCFI, Rendition } from '@btpf/epubjs'; 2 | 3 | 4 | export const QUICKBAR_MODAL_WIDTH = 200 5 | export const QUICKBAR_MODAL_HEIGHT = 100 6 | 7 | export const NOTE_MODAL_WIDTH = 300; 8 | export const NOTE_MODAL_HEIGHT = 250; 9 | 10 | 11 | export const CalculateBoxPosition = (renditionInstance:Rendition, cfiRange:string,boxWidth:number, boxHeight:number)=>{ 12 | // const wrapperBounds = this.props.renditionInstance?.manager?.container?.getBoundingClientRect(); 13 | const wrapperBounds = renditionInstance?.manager?.container?.getBoundingClientRect() 14 | const rangeBoxBounds = renditionInstance.getRange(cfiRange).getBoundingClientRect(); 15 | 16 | if (!wrapperBounds){ 17 | console.log("Wrapper not found") 18 | return {x:0, y:0} 19 | } 20 | 21 | // Since the position is absolute inside the container, by getting the wrapper and the bounding rect, 22 | // We can add how far from the top the actual render of the ReaderView is 23 | // We also subtract wrapperBounds.top for the continuous scroll since our offset 24 | // is our y position - how far from the top we are 25 | const offsetY = wrapperBounds?.y - wrapperBounds.top 26 | 27 | 28 | 29 | // The bottom limit is the wrappers distance from the top + the height of the render wrapper 30 | const yBottomLimit = wrapperBounds?.y + wrapperBounds?.height 31 | // The default position will be the position of the highlight + the offsetY + the height of the highlight 32 | let ypos = rangeBoxBounds.y + offsetY + rangeBoxBounds.height 33 | 34 | if (ypos + boxHeight > yBottomLimit){ 35 | // Subtract the height of the highlight and the height of the rendered dialog 36 | ypos -= (boxHeight + rangeBoxBounds.height) 37 | } 38 | ypos = Math.max(ypos, 0) 39 | 40 | 41 | const cfi = new EpubCFI(cfiRange); 42 | const sectionIndex = cfi.spinePos 43 | const views = renditionInstance.views(); 44 | // We are finding the current view we are on in the case there are multiple on screen (Continuous) 45 | let correctView; 46 | views.forEach( (view) => { 47 | if (sectionIndex === view.index) { 48 | correctView = view; 49 | } 50 | }); 51 | 52 | const correctViewBounds = correctView.iframe.getBoundingClientRect(); 53 | // We are adding the amount from the top we are in the current selected view 54 | ypos += correctViewBounds.top 55 | 56 | const xRightLimit = wrapperBounds?.x + wrapperBounds?.width 57 | 58 | // Fixed bug where once resized, epubjs keep the previously rendered content off screen, 59 | // Causing the position calculations to mess up. This will mod the x position by the width 60 | // ensuring the box stays on the screen. 61 | const trueX = rangeBoxBounds.x % wrapperBounds.width 62 | 63 | let xpos = trueX + rangeBoxBounds.width /2 - boxWidth/2 64 | 65 | // This will handle the case where Reader Margins are set. 66 | // In this case, the xposition will need to be offset by the amount that there is margins 67 | // on the left side of the parent container 68 | const element = renditionInstance?.manager?.container?.parentElement 69 | if(element){ 70 | // For some reason this does not work on chromium based engines 71 | // const marginLeftValue = window.getComputedStyle(element).marginLeft 72 | // xpos += Number(marginLeftValue.replace("px", "")) 73 | 74 | const marginLeftValue = element.getBoundingClientRect().x 75 | xpos += marginLeftValue 76 | } 77 | 78 | 79 | 80 | xpos = Math.min(xpos, xRightLimit - boxWidth) 81 | xpos = Math.max(xpos, 0) 82 | 83 | return {x:xpos, y:ypos} 84 | } -------------------------------------------------------------------------------- /src/store/slices/appState.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit' 2 | import { WritableDraft } from 'immer/dist/internal'; 3 | import {actions as globalThemeActions} from './AppState/globalThemes' 4 | import {actions as stateActions} from './AppState/state/stateManager' 5 | import { defaultAppState } from './appStateTypes'; 6 | import { BaseThemeDark, BaseThemeLight } from './AppState/globalThemes'; 7 | import { setThemeThunk } from './EpubJSBackend/data/theme/themeManager'; 8 | import {actions as modalActions} from './AppState/state/modals/modals'; 9 | 10 | 11 | export type appStateReducer = (state: WritableDraft, action: PayloadAction) => any 12 | export type appStateReducerSingle = (state: WritableDraft) => any 13 | 14 | 15 | const initialState: defaultAppState = { 16 | themes:{ 17 | "Default Dark":BaseThemeDark, 18 | "Default Light":BaseThemeLight 19 | }, 20 | selectedTheme: "Default Light", 21 | sortBy:"title", 22 | sortDirection:"ASC", 23 | readerMargins: 75, 24 | state:{ 25 | localSystemFonts: {}, 26 | maximized: false, 27 | selectedRendition: 0, 28 | dualReaderMode: false, 29 | dualReaderReversed: false, 30 | dictionaryWord: "", 31 | sidebarMenuSelected: false, 32 | menuToggled: true, 33 | themeMenuActive: false, 34 | progressMenuActive: false, 35 | footnote:{ 36 | active: false, 37 | text: "", 38 | link: "" 39 | }, 40 | modals:{ 41 | selectedCFI: "", 42 | quickbarModal: {visible: false, x:0, y:0}, 43 | noteModal: {visible: false, x:0, y:0} 44 | } 45 | } 46 | } 47 | 48 | export const initialAppState = initialState 49 | 50 | type SortPayload = { 51 | sortDirection:string, 52 | sortBy:string 53 | } 54 | 55 | export const appState = createSlice({ 56 | name: 'appState', 57 | initialState, 58 | reducers: { 59 | // ...readerThemeActions, 60 | ...globalThemeActions, 61 | ...stateActions, 62 | ...modalActions, 63 | SetSortSettings:(state, action: PayloadAction) =>{ 64 | state.sortDirection = action.payload.sortDirection 65 | state.sortBy = action.payload.sortBy 66 | } 67 | 68 | 69 | }, 70 | extraReducers(builder) { 71 | builder.addCase(setThemeThunk.pending, (state, action)=>{ 72 | // We will comment this out for consistency, since the bookstate setThemeThunk should not set the global app theme. 73 | // state.selectedTheme = action.meta.arg.themeName 74 | }) 75 | }, 76 | }) 77 | 78 | // Action creators are generated for each case reducer function 79 | export const { 80 | // AddReaderTheme, 81 | // RenameReaderTheme, 82 | // LoadReaderThemes, 83 | // DeleteReaderTheme, 84 | // UpdateReaderTheme, 85 | /* Global Theme Actions */ 86 | AddTheme, 87 | RenameTheme, 88 | DeleteTheme, 89 | UpdateTheme, 90 | setSelectedTheme, 91 | LoadThemes, 92 | 93 | SetMaximized, 94 | SetSortSettings, 95 | SetSelectedRendition, 96 | setReaderMargins, 97 | 98 | /* State */ 99 | SelectSidebarMenu, 100 | CloseSidebarMenu, 101 | ToggleMenu, 102 | SetDictionaryWord, 103 | ToggleThemeMenu, 104 | SetDualReaderMode, 105 | resetBookAppState, 106 | SetDualReaderReversed, 107 | ToggleProgressMenu, 108 | SetFootnoteActive, 109 | HideFootnote, 110 | SetLocalFontsList, 111 | 112 | /* Modals */ 113 | MoveQuickbarModal, 114 | HideQuickbarModal, 115 | MoveNoteModal, 116 | ShowNoteModal, 117 | HideNoteModal, 118 | SetModalCFI, 119 | } = appState.actions 120 | 121 | export default appState.reducer -------------------------------------------------------------------------------- /src/routes/Info/Info.module.scss: -------------------------------------------------------------------------------- 1 | @use 'breakpoints.scss' as *; 2 | 3 | 4 | .titleBar{ 5 | height: 50px; 6 | width: 100%; 7 | display:flex; 8 | justify-content: flex-start; 9 | align-items: center; 10 | gap:25px; 11 | background-color: var(--background-primary); 12 | color: var(--text-primary) 13 | // margin-bottom:25px; 14 | } 15 | 16 | 17 | .titleBarButtonsContainer{ 18 | margin-left:auto; 19 | height:100%; 20 | pointer-events: none; 21 | @include lt-sm{ 22 | display:none; 23 | } 24 | } 25 | 26 | .titleText{ 27 | font-size:25px; 28 | pointer-events: none; 29 | } 30 | 31 | 32 | .backButtonContainer{ 33 | margin-left:20px; 34 | padding:0 5px 0 5px; 35 | cursor:pointer; 36 | width: 50px; 37 | height:50px; 38 | display:flex; 39 | align-items: center; 40 | justify-content: center; 41 | 42 | } 43 | 44 | .infoPageContainer{ 45 | display:flex; 46 | flex-direction: column; 47 | height:100vh; 48 | overflow:hidden; 49 | } 50 | 51 | .coverStyles{ 52 | max-width: 100%; 53 | max-height: 50vh; 54 | height: auto; 55 | object-fit: contain; 56 | } 57 | 58 | .bookTitle{ 59 | text-align: center; 60 | } 61 | 62 | .overflowContainer{ 63 | height:calc(100vh - 50px); 64 | width: 100%; 65 | overflow:auto; 66 | } 67 | 68 | .contentContainer{ 69 | display:flex; 70 | justify-content: center; 71 | align-items: center; 72 | overflow-y:visible; 73 | height:100%; 74 | flex-wrap: wrap; 75 | } 76 | 77 | .leftSide{ 78 | flex-basis: 50%; 79 | display:flex; 80 | flex-direction: column; 81 | justify-content: center; 82 | align-items: center; 83 | overflow-y:visible; 84 | 85 | 86 | @include lt-md{ 87 | flex-basis: 100vw; 88 | } 89 | } 90 | .rightSide{ 91 | flex-basis: 50%; 92 | justify-content: flex-start; 93 | height:100%; 94 | overflow:visible; 95 | display:flex; 96 | flex-direction: column; 97 | align-items: center; 98 | @include lt-md{ 99 | flex-basis: 100vw; 100 | } 101 | } 102 | 103 | 104 | .bookDescription{ 105 | padding: 0 40px 0 40px; 106 | max-width: 800px; 107 | } 108 | .highlightContainer{ 109 | // height: 600px; 110 | max-width: 500px; 111 | max-height:600px; 112 | width:100%; 113 | border-top: 1px solid rgba(0,0,0,0.2); 114 | overflow:auto; 115 | padding-bottom: 100px; 116 | } 117 | 118 | 119 | 120 | 121 | .annotationContainer{ 122 | display: flex; 123 | width: 100%; 124 | height: auto; 125 | flex-direction: row; 126 | margin-top: 20px; 127 | } 128 | 129 | .AnnotationLeftSubContainer{ 130 | display: flex; 131 | align-items: center; 132 | min-width: 50px; 133 | justify-content: center; 134 | color: gray; 135 | } 136 | .AnnotationLeftSubContainer:hover{ 137 | color:red; 138 | cursor:pointer; 139 | } 140 | .AnnotationRightSubContainer{ 141 | display: flex; 142 | flex-direction: column; 143 | width: 100%; 144 | cursor: pointer; 145 | user-select: none; 146 | } 147 | 148 | .AnnotationChapter{ 149 | color: var(--text-secondary); 150 | } 151 | 152 | .highlightedTextContainer{ 153 | padding-left:10px; 154 | } 155 | 156 | .noteTextContainer{ 157 | white-space: pre-line; 158 | border-left: 3px solid rgb(0 0 0 / 10%); 159 | padding-left: 5px; 160 | } 161 | 162 | .annotationTitleContainer{ 163 | display: flex; 164 | width: 50%; 165 | justify-content: space-around; 166 | align-items: center; 167 | > div{ 168 | flex-basis: 33%; 169 | } 170 | 171 | } 172 | .exportButton{ 173 | background-color: var(--background-primary); 174 | border-radius: 10px; 175 | display: flex; 176 | justify-content: center; 177 | align-items: center; 178 | margin-left: 50px; 179 | height: 30px; 180 | padding: 5px; 181 | cursor: pointer; 182 | max-width: 100px; 183 | } -------------------------------------------------------------------------------- /src/routes/Reader/ReaderView/components/NoteModal/NoteModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; // we need this to make JSX compile 2 | 3 | 4 | import { 5 | ChangeHighlightColor, 6 | DeleteHighlight, 7 | ChangeHighlightNote, 8 | SkipMouseEvent, 9 | } from '@store/slices/bookState' 10 | 11 | // Transferred Imports 12 | import styles from './NoteModal.module.scss' 13 | 14 | 15 | import Check from '@resources/feathericons/check.svg' 16 | import Trash from '@resources/feathericons/trash-2.svg' 17 | import { useAppSelector, useAppDispatch } from '@store/hooks'; 18 | import { CalculateBoxPosition } from '../../functions/ModalUtility'; 19 | import { HideNoteModal, MoveNoteModal, SetModalCFI } from '@store/slices/appState'; 20 | 21 | 22 | 23 | const COLORS = ['#FFD600', 'red', 'orange','#00FF29', 'cyan'] 24 | 25 | 26 | const NoteModal = () =>{ 27 | const selectedRendition = useAppSelector((state) => state.appState.state.selectedRendition) 28 | const noteModalVisible = useAppSelector((state) => state?.appState?.state?.modals.noteModal.visible) 29 | const modalX = useAppSelector((state) => state?.appState?.state?.modals.noteModal.x) 30 | const modalY = useAppSelector((state) => state?.appState?.state?.modals.noteModal.y) 31 | const selectedCFI = useAppSelector((state) => state?.appState?.state?.modals.selectedCFI) 32 | const renditionInstance = useAppSelector((state) => state.bookState[selectedRendition]?.instance) 33 | const annotations = useAppSelector((state) => state.bookState[selectedRendition]?.data.highlights) 34 | 35 | 36 | 37 | const dispatch = useAppDispatch() 38 | if(noteModalVisible){ 39 | return( 40 |
41 | 47 | 48 |
49 |
50 | { 51 | renditionInstance.annotations.remove(selectedCFI, "highlight") 52 | dispatch(DeleteHighlight({highlightRange:selectedCFI, color:"any", note:"", view:selectedRendition})) 53 | dispatch(SetModalCFI("")) 54 | dispatch(HideNoteModal()) 55 | }}/> 56 | 57 |
58 |
59 | {COLORS.map((item)=>{ 60 | return
{ 61 | renditionInstance.annotations.remove(selectedCFI, "highlight") 62 | dispatch(ChangeHighlightColor({highlightRange:selectedCFI, color:item, note:"", view:selectedRendition})) 63 | const cfiRangeClosure = selectedCFI 64 | renditionInstance.annotations.highlight(selectedCFI,{}, (e:MouseEvent) => { 65 | // This will prevent page turning when clicking on highlight 66 | dispatch(SkipMouseEvent(0)) 67 | 68 | const {x, y} = CalculateBoxPosition(renditionInstance,cfiRangeClosure, 300, 250) 69 | 70 | 71 | dispatch(SetModalCFI(cfiRangeClosure)) 72 | dispatch(MoveNoteModal({ 73 | x, 74 | y, 75 | visible: true 76 | })) 77 | 78 | }, '', {fill:item}); 79 | 80 | }}/> 81 | 82 | })} 83 |
84 |
85 | { 86 | dispatch(SetModalCFI('')) 87 | 88 | dispatch(HideNoteModal()) 89 | }}/> 90 | 91 |
92 | 93 |
94 | 95 |
96 | ) 97 | } 98 | 99 | return null 100 | } 101 | 102 | export default NoteModal -------------------------------------------------------------------------------- /src/routes/Reader/ReaderView/components/Dictionary/Dictionary.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react' 2 | import styles from './Dictionary.module.scss' 3 | 4 | 5 | import { useAppDispatch, useAppSelector } from '@store/hooks' 6 | 7 | 8 | 9 | const Dictionary = ()=>{ 10 | const selectedRendition = useAppSelector((state) => state.appState.state.selectedRendition) 11 | const DictionaryWord = useAppSelector((state) => state.appState.state.dictionaryWord) 12 | const dispatch = useAppDispatch() 13 | const dictionaryContainerRef = useRef(null); 14 | 15 | const displayString = `
present participle of test` 16 | const [response, setResponse] = useState(displayString) 17 | 18 | const requestWord = (redirect:string)=>{ 19 | const template = "https://en.wiktionary.org/api/rest_v1/page/definition/" 20 | fetch(template + redirect.toLowerCase()).then((response)=>{ 21 | response.json().then((js)=>{ 22 | if(!Object.keys(js).includes("en")){ 23 | console.log("Error Caught, no definition found") 24 | setResponse("

Definition Not Found

") 25 | return 26 | } 27 | const capitalized = redirect.charAt(0).toUpperCase() + redirect.slice(1) 28 | let finalStr = `
${capitalized}
`; 29 | for(let i = 0; i < js.en.length; i++){ 30 | finalStr += `
${js.en[i].partOfSpeech}
` 31 | for(let j = 0; j < js.en[i].definitions.length; j++){ 32 | if(js.en[i].definitions[j].definition == ""){ 33 | continue 34 | } 35 | finalStr += `-
` + js.en[i].definitions[j].definition + `
` 36 | } 37 | } 38 | 39 | setResponse(finalStr) 40 | }) 41 | }); 42 | } 43 | useEffect(()=>{ 44 | console.log("Dictionary Mounted") 45 | const t = dictionaryContainerRef.current 46 | if(t == null){ 47 | console.log("dictionaryContainerRef does not exist") 48 | return 49 | } 50 | t.scrollTop = 0; 51 | const query = t.querySelectorAll("*") 52 | /* console.log(query) */ 53 | query.forEach((item)=>{ 54 | /* console.log(item.tagName) */ 55 | if(item.tagName == "A"){ 56 | 57 | const replacement = document.createElement("div"); 58 | // replacement.style = "display:inline; color:red; text-decoration:underline; cursor:pointer;" 59 | // lightblue for dark theme, blue for light theme 60 | replacement.style.cssText = "display:inline; color:var(--link); cursor:pointer;" 61 | replacement.innerHTML = item.innerHTML 62 | replacement.onclick = () =>{ 63 | let redirect = item.title 64 | if(redirect == "Appendix:Glossary"){ 65 | redirect = (item as HTMLAnchorElement).href.split("#")[1] 66 | } 67 | console.log(redirect) 68 | requestWord(redirect) 69 | } 70 | 71 | item.replaceWith(replacement) 72 | 73 | } 74 | }) 75 | },[response]) 76 | 77 | useEffect(()=>{ 78 | console.log("New dictionary word detected") 79 | if(!DictionaryWord){ 80 | return 81 | } 82 | 83 | requestWord(DictionaryWord) 84 | },[DictionaryWord]) 85 | 86 | return ( 87 |
88 |
89 |
90 | ) 91 | } 92 | 93 | export default Dictionary -------------------------------------------------------------------------------- /src/routes/Settings/pages/PreviewWidget/PreviewWidget.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | import styles from './PreviewWidget.module.scss' 4 | 5 | import { useAppSelector } from "@store/hooks"; 6 | 7 | import { ThemeType } from "@store/slices/AppState/globalThemes"; 8 | import { GetAllKeys } from "@store/utlity"; 9 | 10 | import Bookmark from '@resources/feathericons/bookmark.svg' 11 | import List from '@resources/feathericons/list.svg' 12 | import Search from '@resources/feathericons/search.svg' 13 | import Font from '@resources/iconmonstr/text-3.svg' 14 | import HomeIcon from '@resources/feathericons/home.svg' 15 | 16 | 17 | 18 | 19 | const PreviewWidget = (props:{readerOptions:any})=>{ 20 | 21 | 22 | const appThemes = useAppSelector((state) => state.appState.themes) 23 | const selectedTheme = useAppSelector((state) => state.appState.selectedTheme) 24 | 25 | const readerBackgroundColor = (props.readerOptions[1].path as GetAllKeys[]).reduce((themeObjLevel:any, pathNavigate) => themeObjLevel[pathNavigate], appThemes[selectedTheme]) 26 | const readerColor = (props.readerOptions[0].path as GetAllKeys[]).reduce((themeObjLevel:any, pathNavigate) => themeObjLevel[pathNavigate], appThemes[selectedTheme]) 27 | 28 | return ( 29 | 30 | 31 |
32 |
33 |
34 | Book Title 35 |
36 |
37 |
38 | 39 |
40 | 41 |
42 | 43 |
44 |
45 |
46 |
47 | 48 |
49 |
50 | 51 |
52 |
53 | 54 |
55 | 56 | 57 | 58 |
59 | 60 |
61 |
62 |
Chapter 1 63 |
Chapter 2 64 |
Chapter 3 65 |
Chapter 4 66 |
67 |
68 |
69 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
hyperlink
veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip
75 |
Note text
78 | ex ea commodo consequat. 79 |
80 |
81 |
86 | 94 |
95 |
96 | 97 | 98 |
99 |
100 | ) 101 | } 102 | 103 | 104 | export default PreviewWidget -------------------------------------------------------------------------------- /src/routes/Settings/pages/Fonts/Fonts.module.scss: -------------------------------------------------------------------------------- 1 | .themeContainer{ 2 | width: 100%; 3 | height: 100%; 4 | // background-color: blue; 5 | display: flex; 6 | flex-direction: column; 7 | 8 | align-items: center; 9 | } 10 | 11 | .listContainer{ 12 | width: calc(100% - 50px); 13 | max-width: 500px; 14 | height: 100%; 15 | margin-bottom:15px; 16 | 17 | background-color: var(--background-primary); 18 | border-radius: 8px; 19 | padding-right:5px; 20 | } 21 | 22 | .comboContainer{ 23 | margin-top:15px; 24 | margin-bottom: 15px; 25 | } 26 | 27 | .comboContainerText{ 28 | font-size:14px; 29 | } 30 | 31 | .comboTextBox{ 32 | width: 200px; 33 | height: 30px; 34 | border-radius: 5px; 35 | border-color: rgba(0, 0, 0, 0.2); 36 | background-color: var(--background-primary); 37 | color: var(--text-primary); 38 | // margin-right: calc(50px + (8px * 2)); 39 | } 40 | .comboTextBox:focus{ 41 | outline: none; 42 | } 43 | .sliderContainer{ 44 | width:400px; 45 | margin-bottom:25px; 46 | } 47 | 48 | 49 | .fontRow{ 50 | width: 400px; 51 | display:flex; 52 | justify-content: space-between; 53 | align-items: flex-end; 54 | margin-bottom:10px; 55 | :first-child{ 56 | justify-self: flex-end; 57 | align-self: flex-end; 58 | // margin-left:50px; 59 | } 60 | :last-child{ 61 | // margin-right:50px; 62 | filter: drop-shadow(0px 4px 4px rgba(0, 10, 255, 0.25)); 63 | scale:0.9; 64 | cursor:pointer; 65 | } 66 | } 67 | 68 | 69 | .trash{ 70 | color:red; 71 | margin-left:20px; 72 | cursor:pointer; 73 | } 74 | .sourceIcon{ 75 | margin-left:20px; 76 | opacity: 0.5; 77 | } 78 | 79 | .selector{ 80 | padding-left:50px; 81 | height:20px; 82 | width:20px; 83 | margin-left:20px; 84 | margin-right:10px; 85 | cursor:pointer; 86 | } 87 | 88 | .localThemeContainer{ 89 | display:flex; 90 | height:60px; 91 | // border: 1px solid black; 92 | align-items: center; 93 | justify-content: space-between; 94 | transition: 0.2s; 95 | cursor:pointer; 96 | 97 | // &:hover{ 98 | // opacity: 0.75; 99 | // } 100 | } 101 | 102 | .label{ 103 | font-size:18px; 104 | } 105 | 106 | 107 | .fontPreviewText{ 108 | font-size:14px; 109 | text-align: left; 110 | width:410px; 111 | color: var(--text-secondary); 112 | margin-top:15px; 113 | } 114 | 115 | .localButtonsContainer{ 116 | display:flex; 117 | align-items: center; 118 | flex-basis: 33%; 119 | } 120 | 121 | .remoteButtonsContainer{ 122 | flex-basis: 33%; 123 | align-items: center; 124 | 125 | filter: drop-shadow(0px 4px 4px rgba(0, 10, 255, 0.25)); 126 | scale:0.9; 127 | cursor:pointer; 128 | display:flex; 129 | justify-content: flex-end; 130 | } 131 | 132 | .fontNameBox{ 133 | flex-basis: 33%; 134 | text-align: center; 135 | font-size:18px; 136 | } 137 | 138 | 139 | .slider{ 140 | color:black; 141 | } 142 | 143 | 144 | .slider > div[class=rc-slider-rail]{ 145 | background-color:var(--text-secondary) !important; 146 | height: 6px; 147 | } 148 | 149 | .slider > div[class=rc-slider-track]{ 150 | background-color:var(--text-secondary) !important; 151 | // background-color: var(--slider-track-color) !important; 152 | height: 6px; 153 | } 154 | 155 | .slider > div[class=rc-slider-handle]{ 156 | // border-color: var(--text-secondary); 157 | border: 1px solid var(--text-secondary); 158 | background-color:var(--background-primary); 159 | z-index: 1; 160 | height: 18px; 161 | width: 18px; 162 | margin-top: calc( -6px); 163 | opacity: 1; 164 | } 165 | 166 | .slider > div[class="rc-slider-handle rc-slider-handle-dragging"]{ 167 | box-shadow: none !important; 168 | border:none; 169 | border-color: gray; 170 | z-index: 1; 171 | } 172 | .slider span[class=rc-slider-mark-text]{ 173 | color: var(--text-primary) !important; 174 | opacity: 1; 175 | } 176 | .slider span[class="rc-slider-mark-text rc-slider-mark-text-active"]{ 177 | color: var(--text-primary) !important; 178 | } 179 | 180 | // Note: This disables the default bubbles which are used to click. This is replaced by | character 181 | .slider > div[class=rc-slider-step]{ 182 | display:none; 183 | } 184 | 185 | // Container for | characters 186 | .slider > div[class=rc-slider-mark]{ 187 | top:24px; 188 | } 189 | -------------------------------------------------------------------------------- /src/shared/scripts/handleLinkClick.ts: -------------------------------------------------------------------------------- 1 | import { Rendition } from "@btpf/epubjs"; 2 | import { SetFootnoteActive } from "@store/slices/appState"; 3 | import store from "@store/store"; 4 | 5 | 6 | const EPUB_NS = 'http://www.idpf.org/2007/ops'; 7 | const resolveURL = (url, relativeTo) => { 8 | // HACK-ish: abuse the URL API a little to resolve the path 9 | // the base needs to be a valid URL, or it will throw a TypeError, 10 | // so we just set a random base URI and remove it later 11 | const base = 'https://example.invalid/' 12 | return new URL(url, base + relativeTo).href.replace(base, '') 13 | } 14 | 15 | const isExternalURL = href => { 16 | if (href.startsWith('blob:')) return false 17 | return href.startsWith('mailto:') || href.includes('://') 18 | } 19 | 20 | 21 | 22 | const refTypes = [ 23 | 'annoref', // deprecated 24 | 'biblioref', 25 | 'glossref', 26 | 'noteref', 27 | ] 28 | const forbidRefTypes = [ 29 | 'backlink', 30 | 'referrer' 31 | ] 32 | const noteTypes = [ 33 | 'annotation', // deprecated 34 | 'note', // deprecated 35 | 'footnote', 36 | 'endnote', 37 | 'rearnote' // deprecated 38 | ] 39 | 40 | 41 | export const handleLinkClick = async (renditionInstance: Rendition, href:string)=>{ 42 | 43 | // const type = link.getAttributeNS(EPUB_NS, 'type') 44 | // const types = type ? type.split(' ') : [] 45 | // const isRefLink = refTypes.some(x => types.includes(x)) 46 | const book = renditionInstance.book; 47 | const id = href.split('#')[1] 48 | // const pageHref = "part0012.html#id_119"; 49 | console.log("LOGGING PAGEREDF") 50 | console.log(href) 51 | const pageHref = resolveURL(href, 52 | // From contents.sectionIndex -> id 53 | book.spine.spineItems[renditionInstance.location.start.index].href) 54 | 55 | const followLink = () => { 56 | renditionInstance.display(pageHref) 57 | return false 58 | } 59 | 60 | if (isExternalURL(href)){ 61 | // e.stopPropagation() 62 | // e.preventDefault() 63 | return followLink()// DO NOTHING, allow event to pass 64 | // } 65 | // else if (!isRefLink || forbidRefTypes.some(x => types.includes(x))){ 66 | // e.stopPropagation() 67 | // e.preventDefault() 68 | // // console.log("SHOULD BYPASS") 69 | // return 70 | } 71 | else { 72 | 73 | const item = book.spine.get(pageHref) 74 | if (item) await item.load(book.load.bind(book)) 75 | console.log(item) 76 | if(!item || !item.document) return followLink() 77 | let el = item.document 78 | .getElementById(id) 79 | if (!el) return followLink() 80 | 81 | let dt 82 | if (el.nodeName.toLowerCase() === 'dt') { 83 | const dfn = el.querySelector('dfn') 84 | if (dfn) dt = dfn 85 | else dt = el 86 | el = el.nextElementSibling 87 | } 88 | 89 | // this bit deals with situations like 90 | //

1 My footnote

91 | // where simply getting the ID or its parent would not suffice 92 | // although it would still fail to extract useful texts for some books 93 | const isFootnote = el => { 94 | const nodeName = el.nodeName.toLowerCase() 95 | return [ 96 | 'a', 'span', 'sup', 'sub', 97 | 'em', 'strong', 'i', 'b', 98 | 'small', 'big' 99 | ].every(x => x !== nodeName) 100 | } 101 | if (!isFootnote(el)) { 102 | while (true) { 103 | const parent = el.parentElement 104 | if (!parent) break 105 | el = parent 106 | if (isFootnote(parent)) break 107 | } 108 | } 109 | if (item) item.unload() 110 | if (el.innerText.trim()) { 111 | 112 | const elType = el.getAttributeNS(EPUB_NS, 'type') 113 | const elTypes = elType ? elType.split(' ') : [] 114 | 115 | // footnotes not matching this would be hidden (see above) 116 | // and so one cannot navigate to them 117 | const canLink = !(el.nodeName === 'aside' 118 | && noteTypes.some(x => elTypes.includes(x))) 119 | 120 | // console.log("ARRIVED TO END") 121 | 122 | const text = (dt ? `${dt.innerHTML}
` : '') + el.innerHTML 123 | 124 | store.dispatch(SetFootnoteActive({ 125 | text: text, 126 | link: pageHref 127 | })) 128 | 129 | 130 | } else return followLink() 131 | } 132 | } -------------------------------------------------------------------------------- /src/routes/Reader/SideBar/Annotations/Annotations.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | 3 | import { useAppDispatch, useAppSelector } from '@store/hooks' 4 | import Trash from '@resources/feathericons/trash-2.svg' 5 | import styles from './Annotations.module.scss' 6 | import { DeleteHighlight } from '@store/slices/bookState' 7 | import { CloseSidebarMenu } from '@store/slices/appState' 8 | import { getChapterCFIMap } from '@shared/scripts/getChapterCfiMap' 9 | 10 | 11 | interface AnnotationData{ 12 | title?: string, 13 | href?: string, 14 | annotation?: string, 15 | AnnotationCFI: string, 16 | color?: string, 17 | highlightedText?: string, 18 | } 19 | 20 | const Annotations = ()=>{ 21 | const dispatch = useAppDispatch() 22 | const selectedRendition = useAppSelector((state) => state.appState.state.selectedRendition) 23 | const renditionInstance = useAppSelector((state) => state.bookState[selectedRendition]?.instance) 24 | const annotations = useAppSelector((state) => state.bookState[selectedRendition]?.data.highlights) 25 | const [data, updateData] = useState>([]) 26 | 27 | // Handles case where new annotation is made 28 | useEffect(()=>{ 29 | 30 | const promiseArray:Array> = [] 31 | if(!annotations){ 32 | return 33 | } 34 | 35 | // This will create the promises which fetch the annotated text across the book 36 | Object.keys(annotations).forEach((item)=>{ 37 | const myPromise:Promise = new Promise(function(myResolve, myReject) { 38 | // "Producing Code" (May take some time) 39 | ((renditionInstance.book.getRange(item) as unknown) as Promise).then((rangeData:Range)=>{ 40 | const documentFragement = rangeData.cloneContents() 41 | const text = documentFragement.textContent || "" 42 | const myData:AnnotationData = {AnnotationCFI: item, annotation: annotations[item].note, color: annotations[item].color, highlightedText: text} 43 | myResolve(myData); // when successful 44 | }) 45 | 46 | // myReject(); // when error 47 | }); 48 | promiseArray.push(myPromise) 49 | }) 50 | 51 | 52 | 53 | const finalState:Array = [] 54 | const allChapters = getChapterCFIMap(renditionInstance.book) 55 | Promise.all(promiseArray).then((values) => { 56 | for (const value of values){ 57 | let titlename; 58 | for(const item in allChapters){ 59 | if(!allChapters[item].cfi){ 60 | continue 61 | } 62 | const comparison = renditionInstance.epubcfi.compare(value.AnnotationCFI, allChapters[item].cfi) 63 | // In the case where the current chapter is ahead of our annotation, break before setting the title 64 | if (comparison < 0){ 65 | break 66 | } 67 | titlename = allChapters[item].title 68 | } 69 | finalState.push({...value, title:titlename}) 70 | 71 | } 72 | 73 | // Sort annotations by location in book 74 | finalState.sort((a, b)=>{ 75 | return renditionInstance.epubcfi.compare( a.AnnotationCFI, b.AnnotationCFI) 76 | } 77 | ) 78 | updateData(finalState) 79 | }); 80 | 81 | 82 | }, [annotations]) 83 | 84 | return ( 85 |
86 | {data.map((item)=>{ 87 | return ( 88 |
89 |
{ 90 | renditionInstance.annotations.remove(item.AnnotationCFI, "highlight") 91 | dispatch(DeleteHighlight({highlightRange:item.AnnotationCFI, color:"any", note:"", view:selectedRendition})) 92 | 93 | }}> 94 | 95 |
96 |
{ 97 | renditionInstance.display(item.AnnotationCFI) 98 | dispatch(CloseSidebarMenu()) 99 | }}> 100 |
{item.title}
101 | 102 |
{item.highlightedText}
103 |
{item.annotation}
104 | 105 |
106 |
107 | ) 108 | })} 109 |
110 | ) 111 | } 112 | 113 | export default Annotations -------------------------------------------------------------------------------- /src/routes/Reader/SettingsBar/FontsContainerV2/FontsContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | 3 | import styles from './FontsContainer.module.scss' 4 | 5 | import { useAppDispatch, useAppSelector } from '@store/hooks' 6 | import { invoke } from '@tauri-apps/api' 7 | import { convertFileSrc } from '@tauri-apps/api/tauri' 8 | import { setFontThunk } from '@store/slices/EpubJSBackend/data/theme/themeManager' 9 | import { platform } from '@tauri-apps/api/os'; 10 | 11 | const FontsContainer = ()=>{ 12 | const dispatch = useAppDispatch() 13 | const selectedRendition = useAppSelector((state) => state.appState.state.selectedRendition) 14 | const fontSize = useAppSelector((state) => state.bookState[selectedRendition]?.data.theme.fontSize) 15 | const fontWeight = useAppSelector((state) => state.bookState[selectedRendition]?.data.theme.fontWeight) 16 | const [fontsList, setFontList] = useState([]) 17 | type ListFontsType = {[key: string]: boolean} ; 18 | 19 | 20 | useEffect(()=>{ 21 | platform().then((result)=>{ 22 | let IS_LINUX = false 23 | if(result == "linux"){ 24 | IS_LINUX = true 25 | } 26 | invoke("list_fonts").then((payload)=>{ 27 | const typedPayload = (payload as ListFontsType) 28 | const tempList:Array = [] 29 | Object.keys(typedPayload).forEach((item)=>{ 30 | 31 | 32 | // if true, meaning the font was downloaded 33 | 34 | tempList.push(item) 35 | if(typedPayload[item]){ 36 | invoke("get_font_url", {name: item}).then((path)=>{ 37 | console.log("This should be my path::::") 38 | console.log(path) 39 | const typedPath = path as string 40 | if(path == null){ 41 | return 42 | } 43 | // this means if the name has an extension like .ttf 44 | const fontName = item.replaceAll(" ", "_") 45 | const font = new FontFace(fontName, `url("${IS_LINUX?encodeURI("http://127.0.0.1:16780/" + typedPath.split('/').slice(-4).join("/")):convertFileSrc(typedPath)}")`); 46 | // wait for font to be loaded 47 | font.load().then(()=>{ 48 | document.fonts.add(font); 49 | console.log() 50 | }); 51 | 52 | 53 | }) 54 | } 55 | }) 56 | setFontList(tempList) 57 | }) 58 | }) 59 | 60 | },[]) 61 | return ( 62 | <> 63 |
64 | {["Default",...fontsList].map((item)=>{ 65 | return ( 66 |
{ 67 | 68 | dispatch(setFontThunk({view:selectedRendition, font:item})) 69 | 70 | }} style={{fontFamily:item.replaceAll(" ", "_") + ', ' + item}} className={styles.font}> 71 |
{item}
72 |
73 | ) 74 | })} 75 |
76 | 77 |
78 |
79 |
Font Size
80 |
81 | 82 |
{ 83 | // dispatch(SetFont({view: 0, fontSize: fontSize-5})) 84 | dispatch(setFontThunk({view: selectedRendition, fontSize: fontSize-5})) 85 | }}>-
86 |
{fontSize}%
87 |
{ 88 | dispatch(setFontThunk({view: selectedRendition, fontSize: fontSize+5})) 89 | 90 | }}>+
91 |
92 |
93 | 94 |
95 |
Font Weight
96 |
97 |
{ 98 | // dispatch(SetFont({view: 0, fontSize: fontSize-5})) 99 | dispatch(setFontThunk({view: selectedRendition, fontWeight: fontWeight-100})) 100 | }}>-
101 |
{fontWeight}
102 |
{ 103 | dispatch(setFontThunk({view: selectedRendition, fontWeight: Math.min(fontWeight+100, 900)})) 104 | 105 | }}>+
106 |
107 |
108 | 109 |
110 | 111 | 112 | ) 113 | } 114 | 115 | 116 | export default FontsContainer -------------------------------------------------------------------------------- /src/shared/scripts/TauriActions.ts: -------------------------------------------------------------------------------- 1 | import { fs, invoke } from "@tauri-apps/api" 2 | import { platform } from "@tauri-apps/api/os" 3 | import { convertFileSrc } from "@tauri-apps/api/tauri" 4 | import parser from "@shared/scripts/Parser/parser" 5 | import epubjs from '@btpf/epubjs' 6 | 7 | export const getBookUrlByHash = async (bookHash:string)=>{ 8 | let bookUrl:string = await invoke("get_book_by_hash",{bookHash}) 9 | if(await platform() == "linux"){ 10 | const splitPath = bookUrl.split('/').slice(-4) 11 | // Main Issue:https://github.com/tauri-apps/tauri/issues/3725 12 | bookUrl = "http://127.0.0.1:16780/" + splitPath.join("/") 13 | }else{ 14 | bookUrl = convertFileSrc(bookUrl) 15 | } 16 | return bookUrl 17 | } 18 | 19 | export const createBookInstance = async (bookUrl:string, bookHash:string, cbzLayout?:string)=>{ 20 | 21 | if(bookUrl.endsWith("epub3") || bookUrl.endsWith("epub")){ 22 | return epubjs((bookUrl as any)) 23 | 24 | }else{ 25 | 26 | const book = epubjs() 27 | 28 | // Decodes in the case that the convertFileSrc was used 29 | // But also works otherwise 30 | const fileName = (decodeURI((new URL(bookUrl.startsWith("http")? bookUrl:"file://" + bookUrl).pathname)) 31 | .replaceAll("\\","/") // make windows paths work 32 | .split("/").pop() as string) // Pop the file stem 33 | .split(".")[0] // Get the file name without the extension 34 | 35 | const convertedValue = await parser(bookUrl, bookHash, fileName, cbzLayout) 36 | if(convertedValue == "error"){ 37 | console.log("Book loading cancelled") 38 | return 39 | } 40 | console.log(convertedValue) 41 | // @ts-expect-error need to add typings 42 | book.openJSON(convertedValue) 43 | return book 44 | 45 | } 46 | } 47 | export const SUPPORTED_FORMATS = [ 48 | 'epub','epub3', 'azw3', "azw", "mobi", 'pdb', 'prc', 49 | "fb2", "fbz", 50 | "cbz", "cbr", "cb7", "cbt", 51 | "txt" 52 | ] 53 | const BACKEND_MANAGED = [ 54 | 'epub','epub3', 'azw3', "azw", "mobi", 'pdb', 'prc' 55 | ] 56 | export const importBook = async (file:string)=>{ 57 | const filetype = file.split(".").slice(-1)[0] 58 | if(!SUPPORTED_FORMATS.includes(filetype)){ 59 | throw "Unsupported Filetype: " + file.split("/").slice(-1)[0] 60 | return 61 | } 62 | 63 | try { 64 | const response:any = await invoke('import_book', {payload:file}) 65 | 66 | if(response){ 67 | const returnData = {title: response.title, modified: response.modified, author: response.author, cover_url: response.cover_url || "", progress: 0, hash:response.hash} 68 | 69 | 70 | // If the file is not converted to an epub, we will need to do parsing on the client side 71 | // Here we will extract any metadata and create the cover if one exists 72 | if(!BACKEND_MANAGED.includes(filetype) ){ 73 | 74 | 75 | 76 | const bookValue = await getBookUrlByHash(response.hash); 77 | const book = await createBookInstance(bookValue, response.hash) 78 | if(book == undefined){ 79 | console.log("Book load cancelled during import") 80 | return 81 | } 82 | const bookData = await book.ready 83 | const cover = await book.coverUrl() 84 | 85 | const config_path = await invoke("get_config_path_js") 86 | const book_path = config_path + "/books/" + response.hash + "/" 87 | const coverpath = book_path + "cover.jpg"; 88 | let author = (book.packaging.metadata.creator as unknown as string) 89 | author = author? author: "unknown author" 90 | // console.log( book.packaging.manifest.metadata) 91 | const newData = { 92 | "author": author, 93 | "data": { 94 | "cfi": "", 95 | "progress": 0 96 | }, 97 | "modified": Date.now(), 98 | "title": book.packaging.metadata.title 99 | } 100 | await fs.writeTextFile({ path:book_path + response.hash + ".json", contents:JSON.stringify(newData) }); 101 | 102 | 103 | if(cover != null){ 104 | const blob = await fetch(cover).then(r => r.blob()); 105 | const contents = await blob.arrayBuffer(); 106 | console.log("printing cover path", coverpath) 107 | await fs.writeBinaryFile({ path:coverpath, contents }); 108 | } 109 | 110 | // Update library before destroying book instance 111 | if(cover != null) returnData.cover_url = coverpath 112 | book.destroy() 113 | 114 | } 115 | 116 | return returnData 117 | 118 | } 119 | 120 | throw "No Response" 121 | } catch (error: any) { 122 | 123 | console.log(error) 124 | throw error 125 | // toast.error(error) 126 | } 127 | } -------------------------------------------------------------------------------- /src/store/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | import bookState from './slices/bookState' 3 | import SyncedDataActions from './syncedActions' 4 | import counterSlice from './slices/counterSlice' 5 | import appState from './slices/appState' 6 | 7 | import {enableMapSet} from "immer" 8 | import { invoke } from '@tauri-apps/api' 9 | import { LOADSTATE } from './slices/constants' 10 | import { bookStateStructure } from './slices/EpubJSBackend/epubjsManager.d' 11 | import {debounce} from '@github/mini-throttle' 12 | 13 | enableMapSet() 14 | 15 | 16 | const saveAppStateLocally = debounce((currentState:any)=>{ 17 | invoke("set_global_themes", {payload:currentState.appState.themes}) 18 | 19 | 20 | invoke("set_settings", {payload:{ 21 | 22 | selectedTheme: currentState.appState.selectedTheme, 23 | sortBy: currentState.appState.sortBy, 24 | sortDirection: currentState.appState.sortDirection 25 | 26 | }}) 27 | 28 | }, 500) 29 | const store = configureStore({ 30 | reducer: { 31 | counter: counterSlice, 32 | appState, 33 | bookState 34 | }, 35 | middleware: (getDefaultMiddleware) => 36 | getDefaultMiddleware({ 37 | serializableCheck: { 38 | // Ignore these action types 39 | // This is done since the redux state will only be set once with the rendition and is an 'isolated app' 40 | // Isolated since state and react does not directly influence it's rendering. Only library calls do. 41 | // See: 42 | // https://redux.js.org/style-guide/#do-not-put-non-serializable-values-in-state-or-actions 43 | // https://redux-toolkit.js.org/usage/usage-guide#working-with-non-serializable-data 44 | // https://stackoverflow.com/questions/66733221/how-should-react-redux-work-with-non-serializable-data 45 | // Although it will break dev tools, and is against the recommendation of markerikson, I believe this approach 46 | // is "correct" enough 47 | ignoredActions: ['bookState/AddRendition', 'bookState/AddBookmark'], 48 | ignoredPaths: ['bookState.0.instance', 'bookState.1.instance', 'bookState.0.data.bookmarks', 'bookState.1.data.bookmarks'] 49 | }, 50 | }).concat(storeAPI => next => action => { 51 | 52 | next(action) 53 | if(SyncedDataActions.has(action.type)){ 54 | const currentState:RootState = storeAPI.getState() 55 | if(action.type.includes("bookState")){ 56 | 57 | console.log("Synced bookState Action:", action) 58 | const renditionToHandle = action.payload.view 59 | if(renditionToHandle === undefined){ 60 | console.log("Error: Could not save information for following payload") 61 | console.log(action.payload) 62 | return 63 | } 64 | const currentBook:bookStateStructure = currentState.bookState[renditionToHandle] 65 | const bookUID = currentBook.hash 66 | 67 | 68 | // Only save the data if the book is done with it's loading phase 69 | // During the loading phase, all sorts of synced actions will get called, but this is only the initial population, 70 | // And nothing here should be saved. 71 | if(window.__TAURI__ && currentBook.loadState == LOADSTATE.COMPLETE){ 72 | if(!currentBook.data){ 73 | return 74 | } 75 | if(currentBook.data.progress == null){ 76 | console.log("Current progress null, returning") 77 | return 78 | } 79 | const saveData = { 80 | title: currentBook.title, 81 | author: currentBook.author, 82 | modified: Date.now(), 83 | data:{ 84 | progress: currentBook.data.progress, 85 | cfi: currentBook.data.cfi, 86 | bookmarks: Array.from(currentBook.data.bookmarks), 87 | highlights: currentBook.data.highlights, 88 | theme:{...currentBook.data.theme} 89 | } 90 | } 91 | console.log("This is the save data: ") 92 | console.log(saveData) 93 | invoke("update_data_by_hash", {payload:saveData, hash: currentBook.hash}) 94 | } 95 | 96 | }else if (action.type.includes("appState")){ 97 | saveAppStateLocally(currentState) 98 | } 99 | 100 | 101 | }else{ 102 | console.log("Warning: Action Unsaved: ", action.type) 103 | } 104 | 105 | 106 | 107 | }), 108 | }) 109 | 110 | // Infer the `RootState` and `AppDispatch` types from the store itself 111 | export type RootState = ReturnType 112 | // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} 113 | export type AppDispatch = typeof store.dispatch 114 | 115 | export default store -------------------------------------------------------------------------------- /src/store/slices/AppState/globalThemes.ts: -------------------------------------------------------------------------------- 1 | import { PayloadAction } from "@reduxjs/toolkit" 2 | // import { current, WritableDraft } from "immer/dist/internal"; 3 | import { appStateReducer, appStateReducerSingle } from "../appState"; 4 | import { defaultAppState } from "../appStateTypes"; 5 | 6 | interface RenameThemePayload{ 7 | oldThemeName: string, 8 | newThemeName: string 9 | } 10 | export type uiTheme = { 11 | primaryBackground: string, 12 | secondaryBackground: string, 13 | tertiaryBackground: string, 14 | primaryText : string, 15 | secondaryText: string 16 | } 17 | 18 | export type ThemeType = { 19 | ui:uiTheme, 20 | reader:{ 21 | body: { 22 | background: string, 23 | color: string, 24 | link: string 25 | }, 26 | image:{ 27 | mixBlendMode: string, 28 | invert:boolean 29 | } 30 | } 31 | } 32 | 33 | 34 | export const BaseThemeDark = { 35 | ui:{ 36 | primaryBackground: "#111111", 37 | secondaryBackground: "#252525", 38 | tertiaryBackground: "#252525", 39 | primaryText: "white", 40 | secondaryText: "grey" 41 | }, 42 | reader:{ 43 | body: { 44 | background: `#181818`, 45 | color: `#fff`, 46 | link: "lightblue" 47 | }, 48 | image:{ 49 | mixBlendMode: '', 50 | invert:false 51 | } 52 | } 53 | 54 | } 55 | export const BaseThemeLight = { 56 | ui:{ 57 | primaryBackground: "#fef3e7", 58 | secondaryBackground: "#ffffff", 59 | tertiaryBackground: "#ffffff", 60 | primaryText: "rgba(0, 0, 0, 0.8)", 61 | secondaryText: "rgba(0, 0, 0, 0.6)" 62 | }, 63 | reader:{ 64 | body: { 65 | background: `white`, 66 | color: `black`, 67 | link: "blue" 68 | }, 69 | image:{ 70 | mixBlendMode: '', 71 | invert:false 72 | } 73 | 74 | } 75 | } 76 | 77 | const AddTheme:appStateReducerSingle = (state) =>{ 78 | 79 | let i = 0 80 | 81 | while(true){ 82 | if(i == 0){ 83 | if(state.themes[`${state.selectedTheme}`] == undefined){ 84 | 85 | break 86 | } 87 | 88 | }else{ 89 | if(state.themes[`${state.selectedTheme} (${i})`] == undefined){ 90 | break 91 | } 92 | 93 | } 94 | i++ 95 | } 96 | const themeCopy = JSON.parse(JSON.stringify(state.themes[state.selectedTheme])) 97 | if(i==0){ 98 | state.themes[`${state.selectedTheme}`] = themeCopy 99 | }else{ 100 | state.themes[`${state.selectedTheme} (${i})`] = themeCopy 101 | } 102 | 103 | } 104 | 105 | const RenameTheme:appStateReducer = (state, action: PayloadAction) =>{ 106 | if(state.themes[action.payload.newThemeName] == undefined){ 107 | state.themes[action.payload.newThemeName] = state.themes[action.payload.oldThemeName] 108 | delete state.themes[action.payload.oldThemeName] 109 | } 110 | } 111 | const DeleteTheme:appStateReducer = (state, action) =>{ 112 | delete state.themes[action.payload] 113 | } 114 | 115 | type GetAllKeys = T extends object 116 | ? { 117 | [K in keyof T]-?: K extends string | number 118 | ? `${K}` | `${GetAllKeys}` 119 | : never; 120 | }[keyof T] 121 | : never; 122 | 123 | 124 | type UpdateThemePayload = { 125 | themeName: string, 126 | newColor: ThemeType, 127 | path: GetAllKeys 128 | } 129 | 130 | // import { createReducer, createAction, current } from '@reduxjs/toolkit' 131 | 132 | const UpdateTheme:appStateReducer = (state, action: PayloadAction) =>{ 133 | 134 | if(state.themes[action.payload.themeName] !== undefined){ 135 | 136 | let currentObject:any = state.themes[action.payload.themeName] 137 | const path = action.payload.path 138 | for (let index = 0; index < path.length - 1; index++) { 139 | currentObject = currentObject[path[index]] 140 | } 141 | 142 | currentObject[path[path.length - 1]] = action.payload.newColor 143 | 144 | 145 | 146 | 147 | } 148 | } 149 | 150 | const setSelectedTheme:appStateReducer = (state, action: PayloadAction) =>{ 151 | // Return in the case where the config file is empty 152 | if(action.payload == ""){ 153 | return 154 | } 155 | 156 | // In the case we are setting the theme to one which doesn't exists, Do not crash the application. 157 | if(!Object.keys(state.themes).includes(action.payload)){ 158 | return 159 | } 160 | state.selectedTheme = action.payload 161 | console.log(state) 162 | } 163 | 164 | const LoadThemes:appStateReducer = (state, action: PayloadAction) =>{ 165 | if(Object.keys(action.payload.themes).length == 0){ 166 | return 167 | } 168 | state.themes = action.payload.themes 169 | } 170 | 171 | 172 | export const actions = { 173 | AddTheme, 174 | RenameTheme, 175 | DeleteTheme, 176 | UpdateTheme, 177 | setSelectedTheme, 178 | LoadThemes 179 | } 180 | -------------------------------------------------------------------------------- /src/InitializeApp.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 2 | 3 | import { useAppSelector } from "@store/hooks" 4 | import { LoadThemes, SetLocalFontsList, SetMaximized, setSelectedTheme, SetSortSettings } from "@store/slices/appState" 5 | import { invoke } from "@tauri-apps/api" 6 | import { appWindow } from "@tauri-apps/api/window" 7 | import React, { useEffect, useLayoutEffect, useState } from "react" 8 | import { useDispatch } from "react-redux" 9 | import styles from './InitializeStyles.module.scss' 10 | import toast, { Toaster } from 'react-hot-toast' 11 | import { getMatches } from '@tauri-apps/api/cli' 12 | import { importBook} from '@shared/scripts/TauriActions' 13 | // @ts-expect-error Migrations should have flexible datatypes, so JS will be easier. 14 | import performMigrations from './migrations.js' 15 | 16 | const InitializeApp = ({children}: JSX.ElementChildrenAttribute) =>{ 17 | const themes = useAppSelector((state)=> state.appState.themes) 18 | const selectedTheme = useAppSelector((state)=> state.appState.selectedTheme) 19 | const isMaximized = useAppSelector((state)=> state.appState.state.maximized) 20 | const [isFullScreen, setIsFullScreen] = useState(false) 21 | const dispatch = useDispatch() 22 | useEffect(()=>{ 23 | 24 | performMigrations().then(()=>{ 25 | // Handles case where application gets launch parameters 26 | getMatches().then(async (matches) => { 27 | let bookHash = null; 28 | 29 | // Prevent infinite loop by only running code if on homescreen 30 | if(window.location.pathname == "/"){ 31 | if(matches.args.source.value && typeof matches.args.source.value == "string"){ 32 | try { 33 | const response = await importBook(matches.args.source.value) 34 | console.log("Bookhash imported") 35 | if(!response){ 36 | toast.error("Error: No importBook response") 37 | return 38 | } 39 | bookHash = response.hash 40 | 41 | } catch (error:any) { 42 | // const error = error as string; 43 | console.log(error) 44 | if(!error.startsWith("Error: Book is duplicate")){ 45 | toast.error(error) 46 | return 47 | }else{ 48 | bookHash = error.split(" - ")[1] 49 | console.log(bookHash) 50 | } 51 | } 52 | 53 | window.location.pathname = ("/reader/" + bookHash) 54 | } 55 | } 56 | }) 57 | console.log("App Loading") 58 | // invoke("get_reader_themes").then((response:any)=>{ 59 | // dispatch(LoadReaderThemes(response)) 60 | // }) 61 | invoke("get_global_themes").then((response:any)=>{ 62 | dispatch(LoadThemes(response)) 63 | }) 64 | invoke("get_settings").then((response:any)=>{ 65 | dispatch(setSelectedTheme(response.selectedTheme)) 66 | dispatch(SetSortSettings({sortBy: response.sortBy, sortDirection:response.sortDirection})) 67 | // dispatch(LoadGlobalThemes(response)) 68 | }) 69 | 70 | invoke("list_system_fonts").then((response)=>{ 71 | dispatch(SetLocalFontsList({fonts:response})); 72 | }) 73 | 74 | }) 75 | 76 | 77 | 78 | }, []) 79 | 80 | 81 | useLayoutEffect(() => { 82 | async function updateSize() { 83 | const currentlyMaximized = await appWindow.isMaximized() 84 | const currentlyFullscreen = await appWindow.isFullscreen() 85 | 86 | if(currentlyMaximized !== isMaximized){ 87 | dispatch(SetMaximized(currentlyMaximized)) 88 | } 89 | 90 | if(currentlyFullscreen != isFullScreen){ 91 | setIsFullScreen(currentlyFullscreen) 92 | } 93 | } 94 | window.addEventListener('resize', updateSize); 95 | return () => window.removeEventListener('resize', updateSize); 96 | }, [isMaximized, isFullScreen]); 97 | 98 | 99 | return ( 100 |
108 | {/*
*/} 109 | 115 | {children} 116 |
117 | ) 118 | } 119 | 120 | export default InitializeApp -------------------------------------------------------------------------------- /src/routes/Reader/SideBar/Search/Search.tsx: -------------------------------------------------------------------------------- 1 | import styles from './Search.module.scss' 2 | import React, { useEffect, useState } from 'react' 3 | 4 | import { useAppDispatch, useAppSelector } from '@store/hooks' 5 | import { FindResults } from 'epubjs/types/section'; 6 | import { CloseSidebarMenu } from '@store/slices/appState'; 7 | 8 | 9 | const Search = (props:{query:string})=>{ 10 | const dispatch = useAppDispatch() 11 | const selectedRendition = useAppSelector((state) => state.appState.state.selectedRendition) 12 | 13 | const renditionInstance = useAppSelector((state) => state.bookState[selectedRendition]?.instance) 14 | 15 | const [searchText, setSearchText] = useState(props.query) 16 | 17 | const [results, setResults] = useState([]) 18 | const search = async (query: string)=>{ 19 | // return Promise.all(renditionInstance.book.spine.spineItems.map(item => { 20 | // return item.load(renditionInstance.book.load.bind(renditionInstance.book)).then(doc => { 21 | // const results = item.find(query); 22 | // item.unload(); 23 | // return Promise.resolve(results); 24 | // }); 25 | // })).then(results => results.reduce((resultsArray, currentItem)=>{ 26 | // return [...resultsArray, ...currentItem] 27 | // })); 28 | 29 | 30 | const results = [] 31 | for (const spineSection of renditionInstance.book.spine.spineItems){ 32 | await spineSection.load(renditionInstance.book.load.bind(renditionInstance.book)) 33 | results.push(...spineSection.find(query)) 34 | spineSection.unload() 35 | // Attempt to limit to 50 results 36 | if(results.length >= 50){ 37 | break 38 | } 39 | 40 | } 41 | return results 42 | 43 | 44 | 45 | } 46 | 47 | useEffect(()=>{ 48 | if(props.query){ 49 | setSearchText(props.query) 50 | search(props.query).then((results)=>{ 51 | if(results.length == 0){ 52 | return setResults([{cfi:"", excerpt:"No results found"}]) 53 | } 54 | setResults(results) 55 | }) 56 | } 57 | },[props.query]) 58 | 59 | 60 | return ( 61 |
62 | 63 | 64 | setSearchText(e.target.value)} onKeyDown={(event)=>{ 65 | if (event.key === 'Enter') { 66 | if(searchText == ""){ 67 | setResults([]) 68 | return 69 | } 70 | search(searchText).then((results)=>{ 71 | if(results.length == 0){ 72 | return setResults([{cfi:"", excerpt:"No results found"}]) 73 | } 74 | setResults(results) 75 | }) 76 | } 77 | }}/> 78 | 79 |
80 | {results.map((result)=>{ return ( 81 |
{ 82 | renditionInstance.display(result.cfi).then(()=>{ 83 | // This is a hacky workaround to an issue that is as follows 84 | // If the user in is chapter 1, they make a search, and the result is in chapter 3 85 | // Additionally, this user also has font-size or line-spacing set, 86 | // they will initially simply open chapter 3, and be navigated to the incorrect place. 87 | // Calling display on a chapter which is opened will navigate to the text accurately. 88 | // Therefore, calling it twice ensures the chapter is open first, then we are "scrolled" to the text 89 | renditionInstance.display(result.cfi) 90 | }) 91 | 92 | const highlighter =()=>{ 93 | 94 | const increments = (Math.PI/2)/10 95 | let currentVal = 0 96 | const totalFrames = 40 97 | let currentFrame = 0 98 | 99 | const spotlight = setInterval(()=>{ 100 | currentFrame += 1 101 | renditionInstance.annotations.remove(result.cfi, "highlight") 102 | if(currentFrame == totalFrames){ 103 | clearInterval(spotlight) 104 | } 105 | currentVal += increments 106 | renditionInstance.annotations.highlight(result.cfi, {}, (e:MouseEvent) => { 107 | console.log("Skip event id: 3") 108 | 109 | // store.dispatch(SkipMouseEvent(0)) 110 | 111 | }, '', {fill:`rgba(0,255,0,${Math.abs(Math.sin(currentVal))})`}); 112 | }, 50) 113 | 114 | } 115 | 116 | highlighter() 117 | 118 | dispatch(CloseSidebarMenu()) 119 | }}> 120 |
{result.cfi}
121 |
{result.excerpt}
122 |
) 123 | })} 124 | 125 | 126 |
127 |
128 | ) 129 | } 130 | 131 | export default Search -------------------------------------------------------------------------------- /src/routes/Reader/SliderNavigator/SliderNavigator.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import styles from './SliderNavigator.module.scss' 3 | 4 | import { useAppDispatch, useAppSelector } from '@store/hooks' 5 | 6 | 7 | import Slider from 'rc-slider'; 8 | import 'rc-slider/assets/index.css'; 9 | 10 | import { Rendition } from '@btpf/epubjs' 11 | import { setProgrammaticProgressUpdate, SetProgress } from '@store/slices/bookState'; 12 | import { LOADSTATE } from '@store/slices/constants'; 13 | import { getChapterCFIMap } from '@shared/scripts/getChapterCfiMap'; 14 | 15 | 16 | 17 | interface MarkObj { 18 | style?: React.CSSProperties; 19 | label?: React.ReactNode; 20 | } 21 | 22 | type MarkType = Record 23 | 24 | 25 | const defaultMarks = { 26 | 500: { 27 | style: { 28 | top: 10, 29 | }, 30 | label: loading... 31 | }, 32 | }; 33 | 34 | 35 | const SliderNavigator = ()=>{ 36 | const selectedRendition = useAppSelector((state) => state.appState.state.selectedRendition) 37 | const renditionInstance:Rendition = useAppSelector((state) => state.bookState[selectedRendition]?.instance) 38 | const renditionState = useAppSelector((state) => state.bookState[selectedRendition]?.loadState) 39 | const currentPercent = useAppSelector((state) => state.bookState[selectedRendition]?.data.progress) 40 | const currentCfi = useAppSelector((state) => state.bookState[selectedRendition]?.data.cfi) 41 | const isProgrammaticProgressUpdate = useAppSelector((state) => state.bookState[selectedRendition]?.state.isProgrammaticProgressUpdate) 42 | 43 | 44 | const dispatch = useAppDispatch() 45 | 46 | 47 | 48 | 49 | const [markers, setMarkers] = useState(defaultMarks) 50 | const [mouseOnSlider, setMouseOnSlider] = useState(false) 51 | 52 | // This is used to animate the mouse if scrolling 53 | const [placeholderProgress, setPlaceholderProgress] = useState(0) 54 | 55 | 56 | useEffect(() => { 57 | if(renditionState != LOADSTATE.COMPLETE && renditionState != LOADSTATE.BOOK_PARSING_COMPLETE){ 58 | return 59 | } 60 | // If the previous update event was because of the epub reader 61 | // cancel the event 62 | if(isProgrammaticProgressUpdate){ 63 | dispatch(setProgrammaticProgressUpdate({view:selectedRendition, state:false})) 64 | return 65 | } 66 | const handler = setTimeout(() =>{ 67 | dispatch(setProgrammaticProgressUpdate({view:selectedRendition, state:true})) 68 | if(currentCfi) 69 | renditionInstance.display(currentCfi) 70 | }, 100); 71 | 72 | return () => clearTimeout(handler); 73 | }, [currentCfi]); 74 | 75 | 76 | 77 | useEffect(()=>{ 78 | 79 | 80 | if(renditionState != LOADSTATE.COMPLETE){ 81 | return 82 | } 83 | 84 | 85 | const chapterCFIMap = getChapterCFIMap(renditionInstance.book) 86 | 87 | const markerObject: MarkType = {} 88 | 89 | chapterCFIMap.forEach((item)=>{ 90 | const myPercentage = renditionInstance.book.locations.percentageFromCfi(item.cfi) 91 | // Alternative method of finding the percentage from CFI, Commented out as it seems unnecessary 92 | // if(myPercentage == 0){ 93 | // const sectionLocation = renditionInstance.book.locations.locationFromCfi(item.cfi) 94 | // myPercentage = renditionInstance.book.locations.percentageFromLocation(sectionLocation) 95 | // } 96 | markerObject[myPercentage * 1000] = | 97 | }) 98 | 99 | setMarkers(markerObject) 100 | 101 | }, [renditionState, selectedRendition]) 102 | 103 | return ( 104 | { 112 | if(typeof e !== "number"){ 113 | return 114 | } 115 | 116 | if(mouseOnSlider){ 117 | setPlaceholderProgress(e) 118 | return 119 | } 120 | 121 | // This complicates logic and can likely be removed. 122 | // dispatch(SetProgress({view: 0, progress: e/1000})) 123 | 124 | 125 | }} 126 | 127 | 128 | onBeforeChange={(e)=>{ 129 | setMouseOnSlider(true) 130 | }} 131 | onAfterChange={(e)=>{ 132 | if(typeof e !== "number"){ 133 | return 134 | } 135 | 136 | // Refocus onto a different element so that when using the arrow keys, 137 | // You will not be controlling the slider, but rather using window button events. 138 | // Controlling the slider with arrow keys leads to poor navigation experience 139 | window?.document?.getElementById("reader-background")?.focus() 140 | setMouseOnSlider(false) 141 | dispatch(SetProgress({view: selectedRendition, progress: e/1000, cfi: renditionInstance.book.locations.cfiFromPercentage(e/1000)})) 142 | }} 143 | value={mouseOnSlider? placeholderProgress: currentPercent * 1000} 144 | max={1000}/> 145 | ) 146 | } 147 | 148 | export default SliderNavigator --------------------------------------------------------------------------------