├── .github └── workflows │ └── vitejs.yml ├── .gitignore ├── LICENSE-MIT ├── Makefile ├── README.md ├── eslint.config.mjs ├── index.html ├── package.json ├── src ├── App.tsx ├── components │ ├── FilePicker.tsx │ ├── Jupyter │ │ ├── CodeCell.tsx │ │ ├── JupyterCommon.ts │ │ ├── MarkdownCell.tsx │ │ ├── Notebook.tsx │ │ └── RawCell.tsx │ ├── Settings │ │ ├── EPubViewerSettings.tsx │ │ ├── GeneralSettings.tsx │ │ ├── ImageViewerSettings.tsx │ │ ├── PluginPanel.tsx │ │ ├── PluginWithSwitch.tsx │ │ ├── SettingsDialog.tsx │ │ ├── SyntaxViewerSettings.tsx │ │ └── TabPanelContainer.tsx │ ├── Viewers │ │ ├── EPubViewer.tsx │ │ ├── ExifInfo.tsx │ │ ├── FileViewer.tsx │ │ ├── FontViewer.tsx │ │ ├── IFrameViewer.tsx │ │ ├── ImageViewer.tsx │ │ ├── JupyterNBViewer.tsx │ │ ├── MarkdownViewer.tsx │ │ ├── ModelViewer.tsx │ │ ├── NormalizedScene.tsx │ │ ├── SVGViewer.tsx │ │ ├── SyntaxViewer.tsx │ │ ├── TabularViewer.tsx │ │ └── markdown.css │ └── icons │ │ ├── WebViewPlus.tsx │ │ └── YingYang.tsx ├── global.d.ts ├── hooks │ └── useStore.ts ├── index.tsx ├── plugins │ ├── EPubPlugin.tsx │ ├── FontPlugin.tsx │ ├── IFramePlugin.tsx │ ├── ImagePlugin.tsx │ ├── JupyterNBPlugin.tsx │ ├── MarkdownPlugin.tsx │ ├── ModelViewerPlugin.tsx │ ├── PluginInterface.ts │ ├── SVGPlugin.tsx │ ├── SyntaxPlugin.tsx │ └── XLSXPlugin.tsx ├── theme.tsx ├── translations │ ├── de │ │ └── plugin.yaml │ ├── en │ │ └── plugin.yaml │ └── index.ts └── utils │ ├── CSV2RowData.tsx │ ├── i18n.ts │ ├── log.ts │ ├── openFile.ts │ ├── types.ts │ └── webview2Helpers.ts ├── tsconfig.json ├── vite.config.ts └── yarn.lock /.github/workflows/vitejs.yml: -------------------------------------------------------------------------------- 1 | # Workflow for building and deploying a vitejs site to GitHub Pages 2 | # Derived from nextjs.yml 3 | name: Deploy vitejs site to Pages 4 | 5 | on: 6 | # Runs on pushes targeting the default branch 7 | push: 8 | branches: ["master"] 9 | 10 | # Allows you to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 14 | permissions: 15 | contents: read 16 | pages: write 17 | id-token: write 18 | 19 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 20 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 21 | concurrency: 22 | group: "pages" 23 | cancel-in-progress: false 24 | 25 | jobs: 26 | # Build job 27 | build: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | - name: Detect package manager 33 | id: detect-package-manager 34 | run: | 35 | if [ -f "${{ github.workspace }}/yarn.lock" ]; then 36 | echo "manager=yarn" >> $GITHUB_OUTPUT 37 | echo "command=install" >> $GITHUB_OUTPUT 38 | echo "runner=yarn" >> $GITHUB_OUTPUT 39 | exit 0 40 | elif [ -f "${{ github.workspace }}/package.json" ]; then 41 | echo "manager=npm" >> $GITHUB_OUTPUT 42 | echo "command=ci" >> $GITHUB_OUTPUT 43 | echo "runner=npx --no-install" >> $GITHUB_OUTPUT 44 | exit 0 45 | else 46 | echo "Unable to determine package manager" 47 | exit 1 48 | fi 49 | - name: Setup Node 50 | uses: actions/setup-node@v4 51 | with: 52 | node-version: "20" 53 | cache: ${{ steps.detect-package-manager.outputs.manager }} 54 | - name: Setup Pages 55 | uses: actions/configure-pages@v4 56 | - name: Restore cache 57 | uses: actions/cache@v4 58 | with: 59 | path: | 60 | .vitejs/cache 61 | # Generate a new cache whenever packages or source files change. 62 | key: ${{ runner.os }}-vitejs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} 63 | # If source files changed but packages didn't, rebuild from a prior cache. 64 | restore-keys: | 65 | ${{ runner.os }}-vitejs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}- 66 | - name: Install dependencies 67 | run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }} 68 | - name: Build with vitejs 69 | run: ${{ steps.detect-package-manager.outputs.runner }} vite build 70 | - name: Upload artifact 71 | uses: actions/upload-pages-artifact@v3 72 | with: 73 | path: ./build 74 | 75 | # Deployment job 76 | deploy: 77 | environment: 78 | name: github-pages 79 | url: ${{ steps.deployment.outputs.page_url }} 80 | runs-on: ubuntu-latest 81 | needs: build 82 | steps: 83 | - name: Deploy to GitHub Pages 84 | id: deployment 85 | uses: actions/deploy-pages@v4 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .vscode 4 | dist 5 | dist-ssr 6 | *.local 7 | build 8 | stats.html 9 | tsconfig.tsbuildinfo 10 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021-2022 Frank Becker 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | 3 | dev: 4 | yarn vite --clearScreen false --host 5 | 6 | build: 7 | yarn 8 | rm -rf build 9 | yarn vite build 10 | 11 | lint: 12 | node ./node_modules/eslint/bin/eslint.js . 13 | 14 | check: 15 | node ./node_modules/typescript/bin/tsc --noEmit -p tsconfig.json 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebViewPlus 2 | 3 | Browser file viewer 4 | (meant to be used with this [QuickLook plugin](https://github.com/mooflu/QuickLook.Plugin.WebViewPlus)) 5 | 6 | Demo: https://mooflu.github.io/WebViewPlus/ 7 | 8 | ## Supported file formats: 9 | 10 | ### Native via iframe 11 | 12 | html, pdf 13 | 14 | ### Scalable Vector Graphics (pan & zoom) 15 | 16 | svg 17 | 18 | ### Images (including animated) 19 | 20 | png, apng, jpeg, gif, bmp, webp, avif 21 | 22 | ### Markdown (with math plugins) 23 | 24 | md 25 | 26 | ### Jupyter notebook 27 | 28 | ipynb 29 | 30 | ### Tabular data 31 | 32 | xlsx, xls, ods, csv, tsv 33 | 34 | ### 3D model viewer 35 | 36 | gltf, glb, fbx, obj 37 | 38 | ### Syntax Highlighter 39 | 40 | See list here: https://github.com/mooflu/WebViewPlus/blob/master/src/plugins/SyntaxPlugin.tsx 41 | 42 | More file extensions can be added in settings. 43 | Where the language doesn't match the extension, specify both separated by a colon: 44 | E.g. "rs:rust" 45 | Any language supported by https://prismjs.com/#supported-languages should work. 46 | 47 | ### Fonts 48 | 49 | ttf, otf, woff2 (without font info), woff 50 | 51 | ### e-books 52 | 53 | epub 54 | 55 | ## Develop 56 | 57 | make dev 58 | 59 | make build 60 | 61 | make lint 62 | 63 | make check 64 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { fixupConfigRules, fixupPluginRules } from "@eslint/compat"; 2 | import react from "eslint-plugin-react"; 3 | import reactHooks from "eslint-plugin-react-hooks"; 4 | import jsxA11Y from "eslint-plugin-jsx-a11y"; 5 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 6 | import globals from "globals"; 7 | import tsParser from "@typescript-eslint/parser"; 8 | import path from "node:path"; 9 | import { fileURLToPath } from "node:url"; 10 | import js from "@eslint/js"; 11 | import { FlatCompat } from "@eslint/eslintrc"; 12 | 13 | const __filename = fileURLToPath(import.meta.url); 14 | const __dirname = path.dirname(__filename); 15 | const compat = new FlatCompat({ 16 | baseDirectory: __dirname, 17 | recommendedConfig: js.configs.recommended, 18 | allConfig: js.configs.all 19 | }); 20 | 21 | export default [{ 22 | ignores: [ 23 | "eslint.config.mjs", 24 | "**/build/*", 25 | "**/config/*", 26 | "**/node_modules/*", 27 | "**/scripts/*", 28 | "**/publicPathSetup.ts", 29 | "**/*test.js", 30 | "**/*test.ts", 31 | "**/*test.tsx", 32 | ], 33 | }, ...fixupConfigRules(compat.extends( 34 | "eslint:recommended", 35 | "plugin:@typescript-eslint/recommended", 36 | "plugin:@typescript-eslint/eslint-recommended", 37 | "plugin:react/recommended", 38 | "plugin:react-hooks/recommended", 39 | "plugin:import/warnings", 40 | "airbnb", 41 | )), { 42 | plugins: { 43 | react: fixupPluginRules(react), 44 | "react-hooks": fixupPluginRules(reactHooks), 45 | "jsx-a11y": fixupPluginRules(jsxA11Y), 46 | "@typescript-eslint": fixupPluginRules(typescriptEslint), 47 | }, 48 | 49 | languageOptions: { 50 | globals: { 51 | ...globals.browser, 52 | }, 53 | 54 | parser: tsParser, 55 | ecmaVersion: 6, 56 | sourceType: "module", 57 | 58 | parserOptions: { 59 | ecmaFeatures: { 60 | jsx: true, 61 | }, 62 | }, 63 | }, 64 | 65 | settings: { 66 | react: { 67 | version: "18.0", 68 | }, 69 | 70 | "import/internal-regex": "^src/", 71 | }, 72 | 73 | rules: { 74 | "arrow-body-style": 0, 75 | 76 | "arrow-parens": ["warn", "as-needed", { 77 | requireForBlockBody: true, 78 | }], 79 | 80 | "class-methods-use-this": 0, 81 | "function-call-argument-newline": ["warn", "consistent"], 82 | "function-paren-newline": ["warn", "consistent"], 83 | indent: ["warn", 4], 84 | 85 | "lines-between-class-members": ["warn", "always", { 86 | exceptAfterSingleLine: true, 87 | }], 88 | 89 | "max-classes-per-file": 0, 90 | 91 | "max-len": ["warn", 120, { 92 | ignoreComments: true, 93 | ignoreUrls: true, 94 | ignoreStrings: true, 95 | ignoreTemplateLiterals: true, 96 | ignoreRegExpLiterals: true, 97 | ignorePattern: "^import .+ from '", 98 | }], 99 | 100 | "operator-linebreak": 0, 101 | "no-continue": 0, 102 | "no-empty-function": 0, 103 | "no-mixed-operators": 0, 104 | 105 | "no-param-reassign": ["error", { 106 | props: false, 107 | }], 108 | 109 | "no-plusplus": ["warn", { 110 | allowForLoopAfterthoughts: true, 111 | }], 112 | 113 | "no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"], 114 | "no-shadow": 0, 115 | "no-undef": 0, 116 | "no-unused-vars": 0, 117 | "no-use-before-define": 0, 118 | "no-useless-constructor": 0, 119 | "no-restricted-exports": 0, 120 | 121 | "object-curly-newline": ["warn", { 122 | consistent: true, 123 | }], 124 | 125 | "prefer-destructuring": 0, 126 | 127 | "import/extensions": ["error", "never", { 128 | ico: "always", 129 | scss: "always", 130 | png: "always", 131 | jpg: "always", 132 | svg: "always", 133 | }], 134 | 135 | "import/no-import-module-exports": 0, 136 | "import/no-unresolved": 0, 137 | "import/prefer-default-export": 0, 138 | 139 | "import/order": ["error", { 140 | groups: ["builtin", "external", "internal", "parent", "sibling", "index"], 141 | 142 | pathGroups: [{ 143 | pattern: "react|react*", 144 | group: "external", 145 | position: "before", 146 | }, { 147 | pattern: "zustand?*", 148 | group: "external", 149 | position: "after", 150 | }, { 151 | pattern: "@mui/**", 152 | group: "external", 153 | position: "after", 154 | }, { 155 | pattern: "@react*/**", 156 | group: "external", 157 | position: "after", 158 | }, { 159 | pattern: "three-stdlib/**", 160 | group: "external", 161 | position: "after", 162 | }, { 163 | pattern: "@*/**", 164 | group: "internal", 165 | position: "after", 166 | }], 167 | 168 | pathGroupsExcludedImportTypes: ["builtin", "object"], 169 | "newlines-between": "always", 170 | }], 171 | 172 | "@typescript-eslint/ban-ts-comment": 0, 173 | "@typescript-eslint/explicit-module-boundary-types": 0, 174 | "@typescript-eslint/no-empty-interface": 0, 175 | "@typescript-eslint/no-explicit-any": 0, 176 | 177 | "@typescript-eslint/no-unused-vars": ["warn", { 178 | vars: "all", 179 | args: "none", 180 | }], 181 | 182 | "@typescript-eslint/no-useless-constructor": ["error"], 183 | "@typescript-eslint/triple-slash-reference": 0, 184 | "@typescript-eslint/no-non-null-assertion": 0, 185 | "react/destructuring-assignment": 0, 186 | "react/function-component-definition": 0, 187 | 188 | "react/jsx-filename-extension": ["error", { 189 | extensions: [".jsx", ".tsx"], 190 | }], 191 | 192 | "react/jsx-indent": ["warn", 4], 193 | "react/jsx-indent-props": ["warn", 4], 194 | "react/jsx-no-useless-fragment": 0, 195 | "react/jsx-one-expression-per-line": 0, 196 | "react/jsx-props-no-spreading": 0, 197 | "react/no-danger": 0, 198 | 199 | "react/no-unstable-nested-components": ["error", { 200 | allowAsProps: true, 201 | }], 202 | 203 | "react/prop-types": 0, 204 | "react/require-default-props": 0, 205 | 206 | "react/sort-comp": ["warn", { 207 | order: ["instance-variables", "lifecycle", "everything-else", "rendering"], 208 | 209 | groups: { 210 | lifecycle: [ 211 | "defaultProps", 212 | "constructor", 213 | "getDerivedStateFromProps", 214 | "componentWillMount", 215 | "componentDidMount", 216 | "componentWillReceiveProps", 217 | "shouldComponentUpdate", 218 | "componentWillUpdate", 219 | "getSnapshotBeforeUpdate", 220 | "componentDidUpdate", 221 | "componentDidCatch", 222 | "componentWillUnmount", 223 | ], 224 | 225 | rendering: ["/^render.+$/", "render"], 226 | }, 227 | }], 228 | 229 | "react/static-property-placement": 0, 230 | "react-hooks/exhaustive-deps": 0, 231 | "jsx-a11y/media-has-caption": 0, 232 | }, 233 | }]; -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | Webview Plus 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webviewplus", 3 | "version": "1.6.1", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "tsc && vite build", 7 | "serve": "vite preview" 8 | }, 9 | "dependencies": { 10 | "@emotion/react": "^11.13.0", 11 | "@emotion/styled": "^11.13.0", 12 | "@mui/icons-material": "^5.16.4", 13 | "@mui/material": "^5.16.4", 14 | "@mui/styles": "^5.16.4", 15 | "@react-three/drei": "^9.109.2", 16 | "@react-three/fiber": "^8.16.8", 17 | "comma-separated-values": "^3.6.4", 18 | "exifreader": "^4.20.0", 19 | "i18next": "^23.12.2", 20 | "i18next-browser-languagedetector": "^8.0.0", 21 | "katex": "^0.16.4", 22 | "opentype.js": "^1.3.4", 23 | "react": "^18.2.0", 24 | "react-app-polyfill": "^3.0.0", 25 | "react-data-grid": "7.0.0-canary.36", 26 | "react-dom": "^18.2.0", 27 | "react-error-boundary": "^4.0.12", 28 | "react-i18next": "^15.0.0", 29 | "react-markdown": "^9.0.1", 30 | "react-reader": "^2.0.10", 31 | "react-syntax-highlighter": "^15.5.0", 32 | "rehype-katex": "^7.0.0", 33 | "rehype-raw": "^7.0.0", 34 | "remark-gfm": "^4.0.0", 35 | "remark-math": "^6.0.0", 36 | "svg-pan-zoom": "^3.6.1", 37 | "three": "^0.173.0", 38 | "three-stdlib": "^2.30.5", 39 | "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", 40 | "zustand": "^4.1.5" 41 | }, 42 | "devDependencies": { 43 | "@eslint/compat": "^1.1.1", 44 | "@eslint/eslintrc": "^3.1.0", 45 | "@eslint/js": "^9.8.0", 46 | "@rollup/plugin-yaml": "^4.0.1", 47 | "@types/katex": "^0.16.2", 48 | "@types/node": "^20.14.12", 49 | "@types/opentype.js": "^1.3.4", 50 | "@types/react": "^18.0.26", 51 | "@types/react-dom": "^18.0.9", 52 | "@types/react-syntax-highlighter": "^15.5.5", 53 | "@types/three": "^0.173.0", 54 | "@typescript-eslint/eslint-plugin": "^8.0.0", 55 | "@typescript-eslint/parser": "^8.0.0", 56 | "@vitejs/plugin-react-swc": "^3.0.0", 57 | "eslint": "^9.8.0", 58 | "eslint-config-airbnb": "^19.0.4", 59 | "eslint-plugin-import": "^2.26.0", 60 | "eslint-plugin-jsx-a11y": "^6.6.1", 61 | "eslint-plugin-react": "^7.33.2", 62 | "eslint-plugin-react-hooks": "^4.6.0", 63 | "globals": "^15.9.0", 64 | "rollup-plugin-visualizer": "^5.8.3", 65 | "typescript": "^5.5.4", 66 | "vite": "^6.1.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; 4 | import i18n from 'i18next'; 5 | 6 | import { 7 | Alert, 8 | CssBaseline, 9 | Box, 10 | Button, 11 | CircularProgress, 12 | IconButton, 13 | Snackbar, 14 | SxProps, 15 | ThemeProvider, 16 | Typography, 17 | useMediaQuery, 18 | } from '@mui/material'; 19 | import { 20 | Settings as SettingsIcon, 21 | Block as NotAllowedIcon, 22 | Undo as UndoIcon, 23 | } from '@mui/icons-material'; 24 | 25 | import SettingsDialog from '@components/Settings/SettingsDialog'; 26 | import FilePicker from '@components/FilePicker'; 27 | import FileViewer from '@components/Viewers/FileViewer'; 28 | import YingYangIcon from '@components/icons/YingYang'; 29 | import useStore from '@hooks/useStore'; 30 | import { 31 | handleSharedBufferReceived, 32 | } from '@utils/webview2Helpers'; 33 | import { log } from '@utils/log'; 34 | import { openFile } from '@utils/openFile'; 35 | import { InitData } from '@utils/types'; 36 | 37 | import useTheme from './theme'; 38 | 39 | const classes = { 40 | root: { 41 | display: 'flex', 42 | height: '100%', 43 | justifyContent: 'center', 44 | alignItems: 'center', 45 | }, 46 | floatButtonsHidden: { 47 | opacity: '0', 48 | transition: 'opacity 0.5s linear', 49 | }, 50 | floatButtons: { 51 | display: 'flex', 52 | flexDirection: 'row', 53 | position: 'fixed', 54 | bottom: '0.5rem', 55 | right: '0.5rem', 56 | }, 57 | floatButton: { 58 | ml: '0.5rem', 59 | opacity: 0.7, 60 | }, 61 | errorComponent: { 62 | display: 'flex', 63 | height: '100%', 64 | flexDirection: 'column', 65 | justifyContent: 'center', 66 | alignItems: 'center', 67 | }, 68 | } satisfies Record; 69 | 70 | const CatchGlobalError: React.FC = () => { 71 | const [globalError, setGlobalError] = React.useState(null); 72 | if (globalError) { 73 | throw globalError; // re-throw error in react context so ErrorBoundary can catch and handle 74 | } 75 | 76 | const handleError = (e: ErrorEvent) => { 77 | setGlobalError(e.error); 78 | }; 79 | 80 | React.useEffect(() => { 81 | window.addEventListener('error', handleError); 82 | return () => { 83 | window.removeEventListener('error', handleError); 84 | }; 85 | }, []); 86 | 87 | return <>; 88 | }; 89 | 90 | const ErrorFallback: React.FC = (props) => { 91 | const { t } = useTranslation(); 92 | const [disabled, setDisabled] = React.useState(false); 93 | 94 | const onReload = () => { 95 | setDisabled(true); 96 | window.location.reload(); 97 | }; 98 | 99 | return ( 100 | 101 | {t('SomethingWentWrong')} 102 | {t('AppError')} {props.error.message} 103 | 104 | 105 | ); 106 | }; 107 | 108 | const App: React.FC = () => { 109 | const { t } = useTranslation(); 110 | const theme = useTheme(); 111 | const webview = useStore(state => state.webview); 112 | const fileContent = useStore(state => state.fileContent); 113 | const fileName = useStore(state => state.fileName); 114 | const showSettings = useStore(state => state.showSettings); 115 | const yingYang = useStore(state => state.yingYang); 116 | const initState = useStore(state => state.actions.init); 117 | const unload = useStore(state => state.actions.unload); 118 | const setDetectEncoding = useStore(state => state.actions.setDetectEncoding); 119 | const setShowTrayIcon = useStore(state => state.actions.setShowTrayIcon); 120 | const setUseTransparency = useStore(state => state.actions.setUseTransparency); 121 | const [snackbarOpen, setSnackbarOpen] = React.useState(false); 122 | const [dragInProgress, setDragInProgress] = React.useState(false); 123 | const [settingsButtonsVisible, setSettingsButtonsVisible] = React.useState(true); 124 | const systemDark = useMediaQuery('(prefers-color-scheme: dark)'); 125 | const isDark = yingYang ? systemDark : !systemDark; 126 | const floatButtonContainer = React.useRef(null); 127 | 128 | React.useEffect(() => { 129 | initState(); 130 | 131 | const handleWebMessage = (e: MessageEvent) => { 132 | log(`Received handleWebMessage: ${e.data}`); 133 | if (e.data === 'unload') { 134 | unload(); 135 | } else if (e.data.startsWith('initData:')) { 136 | const initDataStr = e.data.substring(9); 137 | const initData: InitData = JSON.parse(initDataStr); 138 | i18n.changeLanguage(initData.langCode); 139 | setDetectEncoding(initData.detectEncoding, { init: true }); 140 | setShowTrayIcon(initData.showTrayIcon, { init: true }); 141 | setUseTransparency(initData.useTransparency, { init: true }); 142 | } else if (e.data === 'newWindowRejected') { 143 | setSnackbarOpen(true); 144 | } else if (e.data === 'frameNavigationRejected') { 145 | setSnackbarOpen(true); 146 | } 147 | }; 148 | 149 | const body = document.getElementsByTagName('body')[0]; 150 | if (webview) { 151 | webview.addEventListener('sharedbufferreceived', handleSharedBufferReceived); 152 | webview.addEventListener('message', handleWebMessage); 153 | webview.postMessage({ command: 'AppReadyForData', data: null }); 154 | } else { 155 | body.addEventListener('dragenter', handleDragEnter); 156 | body.addEventListener('dragover', handleDragOver); 157 | body.addEventListener('dragleave', handleDragExit); 158 | body.addEventListener('drop', handleDrop); 159 | } 160 | document.addEventListener('pointerenter', makeSettingsButtonsVisible); 161 | document.addEventListener('keydown', makeSettingsButtonsVisible); 162 | 163 | return () => { 164 | if (webview) { 165 | webview.removeEventListener('sharedbufferreceived', handleSharedBufferReceived); 166 | webview.removeEventListener('message', handleWebMessage); 167 | } else { 168 | body.removeEventListener('dragenter', handleDragEnter); 169 | body.removeEventListener('dragover', handleDragOver); 170 | body.removeEventListener('dragleave', handleDragExit); 171 | body.removeEventListener('drop', handleDrop); 172 | } 173 | if (floatButtonContainer.current) { 174 | floatButtonContainer.current.removeEventListener('pointermove', makeSettingsButtonsVisible); 175 | floatButtonContainer.current.removeEventListener('pointerdown', makeSettingsButtonsVisible); 176 | floatButtonContainer.current.removeEventListener('keydown', makeSettingsButtonsVisible); 177 | } 178 | document.addEventListener('pointerenter', makeSettingsButtonsVisible); 179 | document.addEventListener('keydown', makeSettingsButtonsVisible); 180 | }; 181 | }, []); 182 | 183 | React.useEffect(() => { 184 | if (floatButtonContainer.current) { 185 | floatButtonContainer.current.addEventListener('pointermove', makeSettingsButtonsVisible); 186 | floatButtonContainer.current.addEventListener('pointerdown', makeSettingsButtonsVisible); 187 | floatButtonContainer.current.addEventListener('keydown', makeSettingsButtonsVisible); 188 | } 189 | }, [floatButtonContainer]); 190 | 191 | React.useEffect(() => { 192 | document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light'); 193 | useStore.setState({ isDark }); 194 | }, [isDark]); 195 | 196 | React.useEffect(() => { 197 | if (showSettings) { 198 | // if a new preview comes in, turn settings dialog off 199 | useStore.setState({ showSettings: !showSettings }); 200 | } 201 | }, [fileContent]); 202 | 203 | React.useEffect(() => { 204 | if (settingsButtonsVisible) { 205 | const timerId = window.setTimeout(() => { 206 | setSettingsButtonsVisible(false); 207 | }, 3000); 208 | return () => { 209 | window.clearTimeout(timerId); 210 | }; 211 | } 212 | return () => {}; 213 | }, [settingsButtonsVisible]); 214 | 215 | const toggleSettings = () => { 216 | useStore.setState({ showSettings: !showSettings }); 217 | }; 218 | 219 | const toggleYingYang = () => { 220 | // toggle light/dark mode - useful for items with transparent background 221 | // where the forground color is the similar to light/dark theme. 222 | useStore.setState({ yingYang: !yingYang }); 223 | }; 224 | 225 | const resetFile = () => { 226 | useStore.getState().actions.unload(); 227 | }; 228 | 229 | const onCloseSnackbar = () => { 230 | setSnackbarOpen(false); 231 | }; 232 | 233 | const handleDrop = (e: DragEvent) => { 234 | setDragInProgress(false); 235 | unload(); 236 | if (e.dataTransfer?.items) { 237 | for (const item of e.dataTransfer.items) { 238 | if (item.kind === 'file') { 239 | const file = item.getAsFile(); 240 | if (file) { 241 | e.preventDefault(); 242 | openFile(file); 243 | } 244 | } 245 | } 246 | } 247 | }; 248 | 249 | const handleDragEnter = (e: DragEvent) => { 250 | setDragInProgress(true); 251 | e.preventDefault(); 252 | }; 253 | 254 | const handleDragOver = (e: DragEvent) => { 255 | setDragInProgress(true); 256 | e.preventDefault(); 257 | }; 258 | 259 | const handleDragExit = (e: DragEvent) => { 260 | setDragInProgress(false); 261 | e.preventDefault(); 262 | }; 263 | 264 | const makeSettingsButtonsVisible = () => { 265 | setSettingsButtonsVisible(true); 266 | }; 267 | 268 | const floatButtonsStyles: SxProps[] = [classes.floatButtons]; 269 | if (!settingsButtonsVisible) { 270 | floatButtonsStyles.push(classes.floatButtonsHidden); 271 | } 272 | 273 | const floatButton = { 274 | ...classes.floatButton, 275 | color: isDark ? '#222' : '#888', 276 | backgroundColor: isDark ? '#ccc' : '#333', 277 | }; 278 | 279 | return ( 280 | 281 | 282 | 283 | 284 | {fileContent === null && ( 285 | <> 286 | {(webview || fileName) ? ( 287 | 288 | 289 | 290 | ) : ( 291 | 292 | )} 293 | 294 | )} 295 | {fileContent !== null && ( 296 | 297 | )} 298 | 299 | 300 | {!webview && fileName && ( 301 | 302 | 303 | 304 | )} 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 320 | }> 321 | {t('NavigationRejected')} 322 | 323 | 324 | 336 | 337 | 338 | ); 339 | }; 340 | 341 | export default App; 342 | -------------------------------------------------------------------------------- /src/components/FilePicker.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | import { Box, Button, Input, SxProps } from '@mui/material'; 5 | 6 | import { openFile } from '@utils/openFile'; 7 | 8 | const classes = { 9 | root: { 10 | display: 'flex', 11 | height: '100%', 12 | justifyContent: 'center', 13 | alignItems: 'center', 14 | }, 15 | button: { 16 | margin: '4rem', 17 | padding: '4rem', 18 | height: '20rem', 19 | borderRadius: '1rem', 20 | border: '1px dashed #888', 21 | textTransform: 'none', 22 | fontSize: '2rem', 23 | }, 24 | filePicker: { 25 | position: 'absolute', 26 | left: 0, 27 | top: 0, 28 | width: '100%', 29 | height: '100%', 30 | opacity: 0, 31 | '& .MuiInput-input': { 32 | position: 'absolute', 33 | height: '100%', 34 | cursor: 'pointer', 35 | }, 36 | }, 37 | } satisfies Record; 38 | 39 | const FILE_BUTTON_ID = 'raised-button-file'; 40 | 41 | const FilePicker: React.FC = () => { 42 | const { t } = useTranslation(); 43 | 44 | const handleOpen = (e: React.ChangeEvent) => { 45 | if (e?.target?.files) { 46 | const file = e.target.files[0]; 47 | openFile(file); 48 | } 49 | }; 50 | 51 | return ( 52 | 53 | 67 | 68 | ); 69 | }; 70 | 71 | export default FilePicker; 72 | -------------------------------------------------------------------------------- /src/components/Jupyter/CodeCell.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; 4 | import { 5 | vscDarkPlus, 6 | vs, 7 | } from 'react-syntax-highlighter/dist/esm/styles/prism'; 8 | 9 | import { Box, useTheme } from '@mui/material'; 10 | 11 | import useStore from '@hooks/useStore'; 12 | import * as NB from '@components/Jupyter/JupyterCommon'; 13 | import MarkdownCell from '@components/Jupyter/MarkdownCell'; 14 | 15 | interface DataOutputProps { 16 | data: NB.Data; 17 | } 18 | 19 | const ImageMimeType = [ 20 | 'image/png', 21 | 'image/webp', 22 | 'image/jpeg', 23 | 'image/gif', 24 | 'image/bmp', 25 | ]; 26 | 27 | const DataOutput: React.FC = (props) => { 28 | const { t } = useTranslation(); 29 | const { data } = props; 30 | const isDark = useStore(state => state.isDark); 31 | const style = isDark ? vscDarkPlus : vs; 32 | 33 | for (const mimeType of ImageMimeType) { 34 | const imageData = data[mimeType] as string; 35 | if (imageData) { 36 | const src = `data:${mimeType};base64,${imageData}`; 37 | return ( 38 | 39 | ); 40 | } 41 | } 42 | if (data['image/svg+xml']) { 43 | const html = NB.joinData(data['image/svg+xml']); 44 | return ( 45 | 46 | ); 47 | } 48 | if (data['text/html']) { 49 | const html = NB.joinData(data['text/html']); 50 | return ( 51 | 62 | ); 63 | } 64 | if (data['text/latex']) { 65 | const latex = NB.joinData(data['text/latex']); 66 | const cell: NB.MarkdownCell = { 67 | cell_type: NB.CellType.Markdown, 68 | source: latex, 69 | metadata: {}, 70 | }; 71 | return ( 72 | 73 | ); 74 | } 75 | if (data['application/javascript']) { 76 | const code = NB.joinData(data['application/javascript']); 77 | return ( 78 | 89 | {code} 90 | 91 | ); 92 | } 93 | if (data['text/plain']) { 94 | const text = NB.joinData(data['text/plain']); 95 | return
{text}
; 96 | } 97 | 98 | return ( 99 |
100 |             {t('UnsupportedDataType')}
101 |             {JSON.stringify(data, null, 2)}
102 |         
103 | ); 104 | }; 105 | 106 | interface StreamOutputProps { 107 | output: NB.StreamOutput; 108 | } 109 | 110 | const StreamOutput: React.FC = (props) => { 111 | const { output } = props; 112 | return ( 113 |
{NB.joinData(output.text)}
114 | ); 115 | }; 116 | 117 | interface ErrorOutputProps { 118 | output: NB.ErrorOutput; 119 | } 120 | 121 | const ErrorOutput: React.FC = (props) => { 122 | const { output } = props; 123 | const theme = useTheme(); 124 | // eslint-disable-next-line no-control-regex 125 | const stripAnsiSeq = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g; 126 | return ( 127 | 128 | {output.traceback.join('\n').replace(stripAnsiSeq, '')} 129 | 130 | ); 131 | }; 132 | 133 | interface ExecuteResultOutputProps { 134 | output: NB.ExecuteResultOutput; 135 | } 136 | 137 | const ExecuteResultOutput: React.FC = (props) => { 138 | const { output } = props; 139 | return ( 140 | 141 | ); 142 | }; 143 | 144 | interface DisplayDataOutputProps { 145 | output: NB.DisplayDataOutput; 146 | } 147 | 148 | const DisplayDataOutput: React.FC = (props) => { 149 | const { output } = props; 150 | return ( 151 | 152 | ); 153 | }; 154 | 155 | interface CodeCellProps { 156 | cell: NB.CodeCell; 157 | lang: string; 158 | } 159 | 160 | const CodeCell: React.FC = (props) => { 161 | const { cell, lang } = props; 162 | const theme = useTheme(); 163 | const isDark = useStore(state => state.isDark); 164 | const style = isDark ? vscDarkPlus : vs; 165 | const code = NB.joinData(cell.source); 166 | 167 | return ( 168 | 169 | 185 | {code} 186 | 187 | {cell.outputs && cell.outputs.map((output, i) => { 188 | return ( 189 | // eslint-disable-next-line react/no-array-index-key 190 | 191 | <> 192 | {{ 193 | [NB.OutputType.Stream]: , 196 | [NB.OutputType.Error]: , 199 | [NB.OutputType.ExecuteResult]: , 202 | [NB.OutputType.DisplayData]: , 205 | }[output.output_type]} 206 | 207 | 208 | ); 209 | })} 210 | 211 | ); 212 | }; 213 | 214 | export default CodeCell; 215 | -------------------------------------------------------------------------------- /src/components/Jupyter/JupyterCommon.ts: -------------------------------------------------------------------------------- 1 | // https://nbformat.readthedocs.io/en/latest/format_description.html 2 | export type Notebook = { 3 | cells: Cell[]; 4 | metadata: NotebookMetadata; 5 | nbformat: number; 6 | nbformat_minor: number; 7 | } 8 | 9 | export type NotebookMetadata = { 10 | kernelspec: Kernelspec; 11 | authors: { name: string }[]; 12 | } 13 | 14 | export type Kernelspec = { 15 | name: string; 16 | display_name?: string; 17 | language?: string; 18 | } 19 | 20 | // https://nbformat.readthedocs.io/en/latest/format_description.html#cell-types 21 | export enum CellType { 22 | Code = 'code', 23 | Markdown = 'markdown', 24 | Raw = 'raw', 25 | } 26 | 27 | export type BaseCell = { 28 | execution_count: number | null; 29 | } 30 | 31 | export type CodeCell = BaseCell & { 32 | cell_type: CellType.Code; 33 | source: string[]; 34 | execution_count: number | null; 35 | metadata: { 36 | collapsed: boolean; 37 | scrolled: boolean | 'auto'; 38 | }; 39 | outputs?: Output[]; 40 | } 41 | 42 | export type MarkdownCell = { 43 | cell_type: CellType.Markdown; 44 | source: string | string[]; 45 | metadata: Record; 46 | attachments?: Record>[]; 47 | // 'test.png: { 'image/png': 'base64 encoded data' } 48 | // in markdown ref'd as "![inline image](attachment:test.png)" 49 | } 50 | 51 | export type RawCell = { 52 | cell_type: CellType.Raw; 53 | metadata: { 54 | format: string; // mime/type 55 | }; 56 | source: string[]; 57 | } 58 | 59 | export type Cell = CodeCell | MarkdownCell | RawCell; 60 | 61 | // https://nbformat.readthedocs.io/en/latest/format_description.html#cell-metadata 62 | export type CellMetadata = { 63 | collapsed?: boolean; 64 | scrolled?: boolean | 'auto'; 65 | format?: string; // mime/type 66 | name?: string; 67 | tags?: string[]; 68 | // ... 69 | } 70 | 71 | export enum OutputType { 72 | Stream = 'stream', 73 | Error = 'error', 74 | ExecuteResult = 'execute_result', 75 | DisplayData = 'display_data', 76 | } 77 | 78 | export type Output = StreamOutput | ExecuteResultOutput | DisplayDataOutput | ErrorOutput; 79 | 80 | export type StreamOutput = { 81 | output_type: OutputType.Stream; 82 | name: 'stdout' | 'stderr' 83 | text: string[]; 84 | } 85 | 86 | export type ErrorOutput = { 87 | output_type: OutputType.Error; 88 | ename: string; 89 | evalue: string; 90 | traceback: string[]; 91 | } 92 | 93 | export type ExecuteResultOutput = { 94 | output_type: OutputType.ExecuteResult; 95 | execution_count: number; 96 | data: Data; 97 | metadata: OutputMetadata; 98 | } 99 | 100 | export type DisplayDataOutput = { 101 | output_type: OutputType.DisplayData; 102 | data: Data; 103 | metadata: OutputMetadata; 104 | } 105 | 106 | export type Data = Record; 107 | 108 | export type OutputMetadata = { 109 | 'image/png': { 110 | width: number; 111 | height: number; 112 | }; 113 | } 114 | 115 | // Helpers 116 | export const joinData = (data: string | string[]) => { 117 | return Array.isArray(data) ? data.join('') : data; 118 | }; 119 | -------------------------------------------------------------------------------- /src/components/Jupyter/MarkdownCell.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactMarkdown from 'react-markdown'; 3 | import gfm from 'remark-gfm'; 4 | import math from 'remark-math'; 5 | import rehypeKatex from 'rehype-katex'; 6 | import rehypeRaw from 'rehype-raw'; 7 | import 'katex/dist/katex.min.css'; 8 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; 9 | import { 10 | vscDarkPlus, 11 | vs, 12 | } from 'react-syntax-highlighter/dist/esm/styles/prism'; 13 | 14 | import { Box } from '@mui/material'; 15 | 16 | import useStore from '@hooks/useStore'; 17 | import * as NB from '@components/Jupyter/JupyterCommon'; 18 | 19 | interface MarkdownCellProps { 20 | cell: NB.MarkdownCell; 21 | } 22 | 23 | const MarkdownCell: React.FC = (props) => { 24 | const { cell } = props; 25 | const isDark = useStore(state => state.isDark); 26 | const style = isDark ? vscDarkPlus : vs; 27 | const cellContent = NB.joinData(cell.source); 28 | 29 | return ( 30 | 31 | ERROR; 38 | } 39 | const inline = node.position.start.line === node.position.end.line; 40 | if (inline) { 41 | return ( 42 | 43 | {String(children).replace(/\n$/, '')} 44 | 45 | ); 46 | } 47 | const match = /language-(\w+)/.exec(className || ''); 48 | return ( 49 | 68 | {String(children).replace(/\n$/, '')} 69 | 70 | ); 71 | }, 72 | }} 73 | > 74 | {cellContent} 75 | 76 |
77 | ); 78 | }; 79 | 80 | export default MarkdownCell; 81 | -------------------------------------------------------------------------------- /src/components/Jupyter/Notebook.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | import { Box, useTheme } from '@mui/material'; 5 | 6 | import * as NB from '@components/Jupyter/JupyterCommon'; 7 | import CodeCell from '@components/Jupyter/CodeCell'; 8 | import MarkdownCell from '@components/Jupyter/MarkdownCell'; 9 | import RawCell from '@components/Jupyter/RawCell'; 10 | 11 | interface NotebookProps { 12 | nb: NB.Notebook; 13 | } 14 | 15 | const Notebook: React.FC = (props) => { 16 | const { nb } = props; 17 | const { t } = useTranslation(); 18 | const theme = useTheme(); 19 | 20 | if (nb.nbformat < 4) { 21 | return <>{t('UnsupportedVersion')}; 22 | } 23 | 24 | const lang = nb.metadata.kernelspec.language || nb.metadata.kernelspec.name; 25 | 26 | return ( 27 | 28 | {nb.cells.map((c, i) => { 29 | const baseCell = c as NB.BaseCell; 30 | const cellBlockLabel = baseCell.execution_count ? `[${baseCell.execution_count}]` : ''; 31 | return ( 32 | // eslint-disable-next-line react/no-array-index-key 33 | 34 | 48 | {cellBlockLabel} 49 | 50 | 51 | {{ 52 | [NB.CellType.Code]: , 53 | [NB.CellType.Markdown]: , 54 | [NB.CellType.Raw]: , 55 | }[c.cell_type]} 56 | 57 | 58 | ); 59 | })} 60 | 61 | ); 62 | }; 63 | 64 | export default Notebook; 65 | -------------------------------------------------------------------------------- /src/components/Jupyter/RawCell.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Box, useTheme } from '@mui/material'; 4 | 5 | import * as NB from '@components/Jupyter/JupyterCommon'; 6 | 7 | interface RawCellProps { 8 | cell: NB.RawCell; 9 | } 10 | 11 | const RawCell: React.FC = (props) => { 12 | const { cell } = props; 13 | const theme = useTheme(); 14 | const rawText = NB.joinData(cell.source); 15 | 16 | return ( 17 | 26 | {rawText} 27 | 28 | ); 29 | }; 30 | 31 | export default RawCell; 32 | -------------------------------------------------------------------------------- /src/components/Settings/EPubViewerSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | import { 5 | Box, 6 | Grid, 7 | Input, 8 | Slider, 9 | TextField, 10 | Tooltip, 11 | Typography, 12 | } from '@mui/material'; 13 | 14 | import useStore from '@hooks/useStore'; 15 | 16 | const MIN_EPUB_FONT_SIZE = 0; 17 | const MAX_EPUB_FONT_SIZE = 72; 18 | const CustomFontExample1 = 'Helvetica'; 19 | const CustomFontExample2 = 'system-ui'; 20 | 21 | const EPubViewerSettings: React.FC = () => { 22 | const { t } = useTranslation(); 23 | const ePubFontSize = useStore(state => state.ePubFontSize); 24 | const setEPubFontSize = useStore(state => state.actions.setEPubFontSize); 25 | const ePubCustomFont = useStore(state => state.ePubCustomFont); 26 | const setEPubCustomFont = useStore(state => state.actions.setEPubCustomFont); 27 | 28 | const handleFontSizeChange = (event: Event, newValue: number | number[]) => { 29 | setEPubFontSize(newValue as number); 30 | }; 31 | 32 | const handleInputChange = (event: React.ChangeEvent) => { 33 | setEPubFontSize(event.target.value === '' ? 0 : Number(event.target.value)); 34 | }; 35 | 36 | const handleBlur = () => { 37 | if (ePubFontSize < MIN_EPUB_FONT_SIZE) { 38 | setEPubFontSize(MIN_EPUB_FONT_SIZE); 39 | } else if (ePubFontSize > MAX_EPUB_FONT_SIZE) { 40 | setEPubFontSize(MAX_EPUB_FONT_SIZE); 41 | } 42 | }; 43 | 44 | return ( 45 | 46 | 47 | {t('ExtraSettings')} 48 | 49 | 50 | 51 | 52 | {t('FontSize')} 53 | 54 | 55 | 56 | 57 | 64 | 65 | 66 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | {t('CustomFont')} 86 | 87 | 88 | 92 | 93 | {t('EpubCustomFontExplain')} 94 | 95 | 96 | {CustomFontExample1} 97 | 98 | 99 | {CustomFontExample2} 100 | 101 | 102 | )} 103 | componentsProps={{ 104 | tooltip: { sx: { maxWidth: 'none' } }, 105 | }} 106 | > 107 | ) => { 113 | setEPubCustomFont(event.target.value); 114 | }} 115 | /> 116 | 117 | 118 |
119 | ); 120 | }; 121 | 122 | export default EPubViewerSettings; 123 | -------------------------------------------------------------------------------- /src/components/Settings/GeneralSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | import { 5 | Box, 6 | Button, 7 | FormControlLabel, 8 | Switch, 9 | Typography, 10 | } from '@mui/material'; 11 | 12 | import useStore from '@hooks/useStore'; 13 | 14 | const GeneralSettings: React.FC = () => { 15 | const { t } = useTranslation(); 16 | const detectEncoding = useStore(state => state.detectEncoding); 17 | const setDetectEncoding = useStore(state => state.actions.setDetectEncoding); 18 | const showTrayIcon = useStore(state => state.showTrayIcon); 19 | const setShowTrayIcon = useStore(state => state.actions.setShowTrayIcon); 20 | const useTransparency = useStore(state => state.useTransparency); 21 | const setUseTransparency = useStore(state => state.actions.setUseTransparency); 22 | const restartQuickLook = useStore(state => state.actions.restartQuickLook); 23 | const [restartInProgress, setRestartInProgress] = React.useState(false); 24 | 25 | const toggleDetectEncoding = (event: React.ChangeEvent, checked: boolean) => { 26 | setDetectEncoding(checked); 27 | }; 28 | 29 | const toggleShowTrayIcon = (event: React.ChangeEvent, checked: boolean) => { 30 | setShowTrayIcon(checked); 31 | }; 32 | 33 | const toggleUseTransparency = (event: React.ChangeEvent, checked: boolean) => { 34 | setUseTransparency(checked); 35 | }; 36 | 37 | const sendRestart = () => { 38 | setRestartInProgress(true); 39 | restartQuickLook(); 40 | }; 41 | 42 | return ( 43 | 44 | 45 | {t('GeneralWebViewPlus')} 46 | 47 | 48 | 49 | 52 | } 53 | label={t('DetectEncoding')} 54 | title={t('DetectEncodingTooltip')} 55 | /> 56 | 57 | 58 | 59 | {t('GeneralQuickLook')} 60 | 61 | 62 | 63 | 66 | } 67 | label={t('ShowTrayIcon')} 68 | title={t('ShowTrayIconTooltip')} 69 | /> 70 | 71 | 72 | 73 | 76 | } 77 | label={t('UseTransparency')} 78 | title={t('UseTransparencyTooltip')} 79 | /> 80 | 81 | 82 | 83 | 86 | 87 | 88 | ); 89 | }; 90 | 91 | export default GeneralSettings; 92 | -------------------------------------------------------------------------------- /src/components/Settings/ImageViewerSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | import { 5 | Box, 6 | FormControl, 7 | FormControlLabel, 8 | InputLabel, 9 | MenuItem, 10 | Select, 11 | SelectChangeEvent, 12 | Switch, 13 | Typography, 14 | } from '@mui/material'; 15 | 16 | import useStore from '@hooks/useStore'; 17 | import { ImageRendering, ZoomBehaviour } from '@utils/types'; 18 | 19 | const ImageViewerSettings: React.FC = () => { 20 | const { t } = useTranslation(); 21 | const imageRendering = useStore(state => state.imageRendering); 22 | const pixelated = imageRendering === ImageRendering.Pixelated; 23 | const togglePixelated = useStore(state => state.actions.togglePixelated); 24 | const newImageZoomBehaviour = useStore(state => state.newImageZoomBehaviour); 25 | const setNewImageZoomBehaviour = useStore(state => state.actions.setNewImageZoomBehaviour); 26 | const resizeImageZoomBehaviour = useStore(state => state.resizeImageZoomBehaviour); 27 | const setResizeImageZoomBehaviour = useStore(state => state.actions.setResizeImageZoomBehaviour); 28 | 29 | const onNewImageZoomBehaviour = (e: SelectChangeEvent) => { 30 | setNewImageZoomBehaviour(e.target.value as ZoomBehaviour); 31 | }; 32 | 33 | const onResizeImageZoomBehaviour = (e: SelectChangeEvent) => { 34 | setResizeImageZoomBehaviour(e.target.value as ZoomBehaviour); 35 | }; 36 | 37 | return ( 38 | 39 | 40 | {t('ExtraSettings')} 41 | 42 | 43 | 44 | 47 | } 48 | label={t('Pixelated')} 49 | /> 50 | 51 | 52 | 53 | {t('NewImageZoomBehaviour')} 54 | 65 | 66 | 67 | 68 | 69 | {t('ResizeZoomBehaviour')} 70 | 81 | 82 | 83 | 84 | ); 85 | }; 86 | 87 | export default ImageViewerSettings; 88 | -------------------------------------------------------------------------------- /src/components/Settings/PluginPanel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | import { 5 | Box, 6 | Checkbox, 7 | FormGroup, 8 | FormControlLabel, 9 | TextField, 10 | Typography, 11 | } from '@mui/material'; 12 | 13 | import useStore from '@hooks/useStore'; 14 | import { IPlugin, ViewerType } from '@plugins/PluginInterface'; 15 | 16 | interface PluginPanelProps { 17 | p: IPlugin; 18 | } 19 | 20 | const PluginPanel: React.FC = (props) => { 21 | const { t } = useTranslation(); 22 | const { p } = props; 23 | const toggleExtension = useStore(state => state.actions.toggleExtension); 24 | const setExtraExtensions = useStore(state => state.actions.setExtraExtensions); 25 | const [extensionsStr, setExtensionsStr] = React.useState(p.extraExtensions.join(',')); 26 | 27 | React.useEffect(() => { 28 | setExtensionsStr(p.extraExtensions.join(',')); 29 | }, [p]); 30 | 31 | const extensionItems = Object.keys(p.extensions).map((ext: string) => { 32 | const checked = p.extensions[ext]; 33 | const enabled = p.enabled && !(p.viewerType === ViewerType.IFrame && ext === 'htm'); 34 | const handleChange = (event: React.ChangeEvent) => { 35 | toggleExtension(ext, p.shortName); 36 | }; 37 | return ( 38 | } 42 | label={ext} 43 | /> 44 | ); 45 | }); 46 | 47 | const handleChange = (event: React.ChangeEvent) => { 48 | const extraExtensions = event.target.value.split(',').map(e => e.trim().replace('.', '').toLocaleLowerCase()); 49 | setExtraExtensions(extraExtensions.filter(ext => ext.length > 0), p.shortName); 50 | setExtensionsStr(event.target.value); 51 | }; 52 | 53 | return ( 54 | <> 55 | 56 | {t('FileExtensions')} 57 | 58 | 59 | 60 | {extensionItems} 61 | 62 | 63 | 73 | 74 | {t('NoteExtraExtensions')} 75 | 76 | 77 | {p.customSettings && ( 78 | 79 | {p.customSettings} 80 | 81 | )} 82 | 83 | ); 84 | }; 85 | 86 | export default PluginPanel; 87 | -------------------------------------------------------------------------------- /src/components/Settings/PluginWithSwitch.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | import { 5 | Box, 6 | Typography, 7 | Switch, 8 | } from '@mui/material'; 9 | 10 | import useStore from '@hooks/useStore'; 11 | import { IPlugin } from '@plugins/PluginInterface'; 12 | 13 | interface PluginWithSwitchProps { 14 | p: IPlugin; 15 | withSwitch: boolean; 16 | } 17 | 18 | const PluginWithSwitch: React.FC = (props) => { 19 | const { t } = useTranslation(); 20 | const { p, withSwitch } = props; 21 | const togglePlugin = useStore(state => state.actions.togglePlugin); 22 | const name = t(p.shortName, { keyPrefix: 'pluginName' }); 23 | 24 | const handleChange = (event: React.ChangeEvent) => { 25 | togglePlugin(p); 26 | }; 27 | 28 | return ( 29 | 39 | 40 | {name} 41 | 42 | {withSwitch && } 43 | 44 | ); 45 | }; 46 | 47 | export default PluginWithSwitch; 48 | -------------------------------------------------------------------------------- /src/components/Settings/SettingsDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | import { 5 | Box, 6 | Button, 7 | Dialog, 8 | Slide, 9 | IconButton, 10 | Tabs, 11 | Tab, 12 | Typography, 13 | DialogTitle, 14 | DialogContent, 15 | } from '@mui/material'; 16 | import { 17 | Close as CloseIcon, 18 | } from '@mui/icons-material'; 19 | import { TransitionProps } from '@mui/material/transitions'; 20 | 21 | import useStore from '@hooks/useStore'; 22 | import { ViewerType } from '@plugins/PluginInterface'; 23 | import WebViewPlus from '@components/icons/WebViewPlus'; 24 | 25 | import PluginPanel from './PluginPanel'; 26 | import PluginWithSwitch from './PluginWithSwitch'; 27 | import TabPanelContainer from './TabPanelContainer'; 28 | import GeneralSettings from './GeneralSettings'; 29 | 30 | const Transition = React.forwardRef(( 31 | props: TransitionProps & { children: React.ReactElement; }, 32 | ref: React.Ref, 33 | ) => { 34 | return ; 35 | }); 36 | 37 | const SettingsDialog: React.FC = () => { 38 | const { t } = useTranslation(); 39 | const showSettings = useStore(state => state.showSettings); 40 | const plugins = useStore(state => state.plugins); 41 | const activeViewerType = useStore(state => state.activeViewer); 42 | const savePluginSettings = useStore(state => state.actions.savePluginSettings); 43 | const webview = useStore(state => state.webview); 44 | const [viewerType, setViewerType] = React.useState(activeViewerType); 45 | 46 | React.useEffect(() => { 47 | // activate the tab for the file type currently being viewed 48 | setViewerType(activeViewerType); 49 | }, [activeViewerType]); 50 | 51 | const closeSettings = () => { 52 | savePluginSettings(); 53 | useStore.setState({ showSettings: false }); 54 | }; 55 | 56 | const handleChange = (event: React.SyntheticEvent, newViewerType: ViewerType) => { 57 | setViewerType(newViewerType); 58 | }; 59 | 60 | const pluginTabs = plugins.map((p) => { 61 | const name = t(p.shortName, { keyPrefix: 'pluginName' }); 62 | const withSwitch = p.viewerType !== ViewerType.IFrame; 63 | return ( 64 | } 74 | /> 75 | ); 76 | }); 77 | 78 | const pluginTabPanelContainers = plugins.map((p, i) => { 79 | return ( 80 | 81 | 82 | 83 | ); 84 | }); 85 | 86 | if (webview) { 87 | pluginTabs.unshift(( 88 | 108 | 109 | {t('General')} 110 | 111 | 112 | )} 113 | /> 114 | )); 115 | pluginTabPanelContainers.unshift(( 116 | 117 | 118 | 119 | )); 120 | } 121 | 122 | return ( 123 | 129 | 130 | 131 | 132 | {t('Settings')} 133 | 134 | 138 | 139 | 140 | 147 | 148 | 149 | 150 | 154 | 161 | {pluginTabs} 162 | 163 | {pluginTabPanelContainers} 164 | 165 | 169 | {APP_VERSION} 170 | 171 | 172 | 173 | ); 174 | }; 175 | 176 | export default SettingsDialog; 177 | -------------------------------------------------------------------------------- /src/components/Settings/SyntaxViewerSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | import { 5 | Box, 6 | FormControlLabel, 7 | Grid, 8 | Input, 9 | Switch, 10 | Slider, 11 | TextField, 12 | Tooltip, 13 | Typography, 14 | } from '@mui/material'; 15 | 16 | import useStore from '@hooks/useStore'; 17 | 18 | const MIN_SYNTAX_FONT_SIZE = 5; 19 | const MAX_SYNTAX_FONT_SIZE = 72; 20 | const CustomFontExample1 = 'local("Cascadia Mono")'; 21 | const CustomFontExample2 = 'url("https://fonts.gstatic.com/s/novamono/v18/Cn-0JtiGWQ5Ajb--MRKvZ2ZZ.woff2")'; 22 | 23 | const SyntaxViewerSettings: React.FC = () => { 24 | const { t } = useTranslation(); 25 | const syntaxShowLineNumbers = useStore(state => state.syntaxShowLineNumbers); 26 | const toggleSyntaxShowLineNumbers = useStore(state => state.actions.toggleSyntaxShowLineNumbers); 27 | const syntaxWrapLines = useStore(state => state.syntaxWrapLines); 28 | const toggleSyntaxWrapLines = useStore(state => state.actions.toggleSyntaxWrapLines); 29 | const syntaxFontSize = useStore(state => state.syntaxFontSize); 30 | const setSyntaxFontSize = useStore(state => state.actions.setSyntaxFontSize); 31 | const syntaxCustomFont = useStore(state => state.syntaxCustomFont); 32 | const setSyntaxCustomFont = useStore(state => state.actions.setSyntaxCustomFont); 33 | 34 | const handleFontSizeChange = (event: Event, newValue: number | number[]) => { 35 | setSyntaxFontSize(newValue as number); 36 | }; 37 | 38 | const handleInputChange = (event: React.ChangeEvent) => { 39 | setSyntaxFontSize(event.target.value === '' ? 0 : Number(event.target.value)); 40 | }; 41 | 42 | const handleBlur = () => { 43 | if (syntaxFontSize < MIN_SYNTAX_FONT_SIZE) { 44 | setSyntaxFontSize(MIN_SYNTAX_FONT_SIZE); 45 | } else if (syntaxFontSize > MAX_SYNTAX_FONT_SIZE) { 46 | setSyntaxFontSize(MAX_SYNTAX_FONT_SIZE); 47 | } 48 | }; 49 | 50 | return ( 51 | 52 | 53 | {t('ExtraSettings')} 54 | 55 | 56 | 57 | 60 | } 61 | label={t('ShowLineNumbers')} 62 | /> 63 | 64 | 65 | 66 | 69 | } 70 | label={t('WrapLines')} 71 | /> 72 | 73 | 74 | 75 | 76 | {t('FontSize')} 77 | 78 | 79 | 80 | 81 | 88 | 89 | 90 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | {t('CustomFont')} 110 | 111 | 112 | 116 | 117 | {t('CustomFontExplain')} 118 | 119 | 120 | {CustomFontExample1} 121 | 122 | 123 | {CustomFontExample2} 124 | 125 | 126 | )} 127 | componentsProps={{ 128 | tooltip: { sx: { maxWidth: 'none' } }, 129 | }} 130 | > 131 | ) => { 137 | setSyntaxCustomFont(event.target.value); 138 | }} 139 | /> 140 | 141 | 142 | 143 | ); 144 | }; 145 | 146 | export default SyntaxViewerSettings; 147 | -------------------------------------------------------------------------------- /src/components/Settings/TabPanelContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { 4 | Box, 5 | } from '@mui/material'; 6 | 7 | interface TabPanelContainerProps { 8 | children?: React.ReactNode; 9 | viewerType: number; 10 | value: number; 11 | } 12 | 13 | const TabPanelContainer: React.FC = (props) => { 14 | const { children, value, viewerType } = props; 15 | 16 | return ( 17 | 31 | ); 32 | }; 33 | 34 | export default TabPanelContainer; 35 | -------------------------------------------------------------------------------- /src/components/Viewers/EPubViewer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { type Rendition, type Contents } from 'epubjs'; 3 | import { ReactReader, ReactReaderStyle, type IReactReaderStyle } from 'react-reader'; 4 | 5 | import { Box } from '@mui/material'; 6 | import { grey } from '@mui/material/colors'; 7 | 8 | import useStore from '@hooks/useStore'; 9 | 10 | const lightTheme: IReactReaderStyle = { 11 | ...ReactReaderStyle, 12 | container: { 13 | ...ReactReaderStyle.container, 14 | zIndex: '0', 15 | }, 16 | tocArea: { 17 | ...ReactReaderStyle.tocArea, 18 | backgroundColor: grey[400], // toc panel background color 19 | }, 20 | tocAreaButton: { 21 | ...ReactReaderStyle.tocAreaButton, 22 | color: grey[900], // toc entry color 23 | }, 24 | tocButtonBar: { 25 | ...ReactReaderStyle.tocButtonBar, 26 | background: grey[900], // button to open toc (bars foreground) 27 | }, 28 | tocButtonExpanded: { 29 | ...ReactReaderStyle.tocButton, 30 | background: 'none', // button to close toc (bars background) 31 | }, 32 | arrow: { 33 | ...ReactReaderStyle.arrow, 34 | color: grey[900], 35 | }, 36 | readerArea: { 37 | ...ReactReaderStyle.readerArea, 38 | // setting font color here doesn't work -> doing it in updateTheme 39 | backgroundColor: grey[200], 40 | textDecoration: 'none', 41 | }, 42 | }; 43 | 44 | const darkTheme: IReactReaderStyle = { 45 | ...ReactReaderStyle, 46 | container: { 47 | ...ReactReaderStyle.container, 48 | zIndex: '0', 49 | }, 50 | tocArea: { 51 | ...ReactReaderStyle.tocArea, 52 | backgroundColor: grey[800], // toc panel background color 53 | }, 54 | tocAreaButton: { 55 | ...ReactReaderStyle.tocAreaButton, 56 | color: grey[200], // toc entry color 57 | }, 58 | tocButtonBar: { 59 | ...ReactReaderStyle.tocButtonBar, 60 | background: grey[200], // button to open toc (bars foreground) 61 | }, 62 | tocButtonExpanded: { 63 | ...ReactReaderStyle.tocButton, 64 | background: 'none', // button to close toc (bars background) 65 | }, 66 | arrow: { 67 | ...ReactReaderStyle.arrow, 68 | color: grey[200], 69 | }, 70 | readerArea: { 71 | ...ReactReaderStyle.readerArea, 72 | // setting font color here doesn't work -> doing it in updateTheme 73 | backgroundColor: grey[900], 74 | textDecoration: 'none', 75 | }, 76 | }; 77 | 78 | function updateTheme(rendition: Rendition, isDark: boolean, ePubFontSize: number, ePubCustomFont: string) { 79 | const themes = rendition.themes; 80 | let themeSettings = {}; 81 | const fontSize = ePubFontSize ? `${ePubFontSize}px !important` : 'initial !important'; 82 | const fontFamily = ePubCustomFont ? `${ePubCustomFont} !important` : 'dummy-default !important'; 83 | 84 | if (isDark) { 85 | themeSettings = { 86 | a: { 87 | color: grey[200], 88 | 'text-decoration': 'none', 89 | }, 90 | 'a:link': { 91 | color: grey[200], 92 | 'text-decoration': 'none', 93 | }, 94 | 'a:visited ': { 95 | color: grey[200], 96 | 'text-decoration': 'none', 97 | }, 98 | 'a:active ': { 99 | color: grey[500], 100 | 'text-decoration': 'none', 101 | }, 102 | 'a:hover': { 103 | color: grey[500], 104 | 'text-decoration': 'none', 105 | }, 106 | }; 107 | themes.override('color', grey[400]); 108 | } else { 109 | themeSettings = { 110 | a: { 111 | color: grey[900], 112 | 'text-decoration': 'none', 113 | }, 114 | 'a:link': { 115 | color: grey[900], 116 | 'text-decoration': 'none', 117 | }, 118 | 'a:visited ': { 119 | color: grey[900], 120 | 'text-decoration': 'none', 121 | }, 122 | 'a:active ': { 123 | color: grey[700], 124 | 'text-decoration': 'none', 125 | }, 126 | 'a:hover': { 127 | color: grey[700], 128 | 'text-decoration': 'none', 129 | }, 130 | }; 131 | themes.override('color', grey[900]); 132 | } 133 | themeSettings = { 134 | ...themeSettings, 135 | span: { 136 | 'font-size': fontSize, 137 | 'font-family': fontFamily, 138 | }, 139 | div: { 140 | 'font-size': fontSize, 141 | 'font-family': fontFamily, 142 | }, 143 | p: { 144 | 'font-size': fontSize, 145 | 'font-family': fontFamily, 146 | 'font-weight': 'normal', 147 | }, 148 | body: { 149 | 'font-size': fontSize, 150 | 'font-family': fontFamily, 151 | 'font-weight': 'normal', 152 | 'background-color': 'transparent !important', 153 | }, 154 | }; 155 | themes.default(themeSettings); 156 | } 157 | 158 | const EPubViewer: React.FC = () => { 159 | const rendition = React.useRef(undefined); 160 | const fileContent = useStore(state => state.fileContent as ArrayBuffer); 161 | const ePubFontSize = useStore(state => state.ePubFontSize); 162 | const ePubCustomFont = useStore(state => state.ePubCustomFont); 163 | const isDark = useStore(state => state.isDark); 164 | const [location, setLocation] = React.useState(0); 165 | 166 | React.useEffect(() => { 167 | if (rendition.current) { 168 | updateTheme(rendition.current, isDark, ePubFontSize, ePubCustomFont); 169 | } 170 | }, [ePubFontSize, ePubCustomFont]); 171 | 172 | React.useEffect(() => { 173 | if (rendition.current) { 174 | updateTheme(rendition.current, isDark, ePubFontSize, ePubCustomFont); 175 | } 176 | }, [isDark]); 177 | 178 | return ( 179 | 180 | setLocation(epubcfi)} 184 | readerStyles={isDark ? darkTheme : lightTheme} 185 | epubOptions={{ spread: 'none' }} // single column 186 | getRendition={(_rendition) => { 187 | updateTheme(_rendition, isDark, ePubFontSize, ePubCustomFont); 188 | _rendition.hooks.content.register((content: Contents) => { 189 | content.document.addEventListener('wheel', (e: WheelEvent) => { 190 | if (e.deltaY > 0) { 191 | _rendition.next(); 192 | } else { 193 | _rendition.prev(); 194 | } 195 | }); 196 | }); 197 | rendition.current = _rendition; 198 | }} 199 | /> 200 | 201 | ); 202 | }; 203 | 204 | export default EPubViewer; 205 | -------------------------------------------------------------------------------- /src/components/Viewers/ExifInfo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ExifReader from 'exifreader'; 3 | 4 | import { 5 | IconButton, 6 | Paper, 7 | SxProps, 8 | Table, 9 | TableBody, 10 | TableRow, 11 | TableCell, 12 | Tooltip, 13 | } from '@mui/material'; 14 | import { 15 | Info as InfoIcon, 16 | Close as CloseIcon, 17 | } from '@mui/icons-material'; 18 | 19 | import useStore from '@hooks/useStore'; 20 | 21 | interface ValueTagWithName extends ExifReader.ValueTag { 22 | name: string; 23 | } 24 | 25 | const getDescription = (tag: ValueTagWithName) => { 26 | if (Array.isArray(tag)) { 27 | return tag.map(item => item.description).join(', '); 28 | } 29 | return tag.description; 30 | }; 31 | 32 | const classes = { 33 | infoContainer: { 34 | position: 'absolute', 35 | right: '0.3rem', 36 | top: '2.8rem', 37 | zIndex: 10, 38 | opacity: 0.6, 39 | p: 0.5, 40 | overflow: 'auto', 41 | maxWidth: '20rem', 42 | maxHeight: 'calc(100% - 6.4rem)', 43 | }, 44 | } satisfies Record; 45 | 46 | interface ExifInfoProps { 47 | tags: ExifReader.Tags; 48 | } 49 | 50 | const ExifInfo: React.FC = (props) => { 51 | const openExifPanel = useStore(state => state.openExifPanel); 52 | 53 | const tagList: ValueTagWithName[] = []; 54 | for (const [key, value] of Object.entries(props.tags)) { 55 | tagList.push({ ...value, name: key }); 56 | } 57 | 58 | const toggleInfoPanel = () => { 59 | useStore.setState({ openExifPanel: !openExifPanel }); 60 | }; 61 | 62 | const infoTable = ( 63 | 64 | 65 | {tagList.map((tag) => { 66 | return ( 67 | 68 | {tag.name} 69 | {getDescription(tag)} 70 | 71 | ); 72 | })} 73 | 74 |
75 | ); 76 | 77 | if (!openExifPanel) { 78 | return ( 79 | 87 | 88 | 89 | 90 | 91 | ); 92 | } 93 | 94 | return ( 95 | <> 96 | 100 | {openExifPanel && infoTable} 101 | 102 | 103 | 104 | 105 | 106 | ); 107 | }; 108 | 109 | export default ExifInfo; 110 | -------------------------------------------------------------------------------- /src/components/Viewers/FileViewer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | import { 5 | Box, 6 | Typography, 7 | } from '@mui/material'; 8 | 9 | import IFrameViewer from '@components/Viewers/IFrameViewer'; 10 | import MarkdownViewer from '@components/Viewers/MarkdownViewer'; 11 | import ModelViewer from '@components/Viewers/ModelViewer'; 12 | import SVGViewer from '@components/Viewers/SVGViewer'; 13 | import SyntaxViewer from '@components/Viewers/SyntaxViewer'; 14 | import TabularViewer from '@components/Viewers/TabularViewer'; 15 | import ImageViewer from '@components/Viewers/ImageViewer'; 16 | import JupyterNBViewer from '@components/Viewers/JupyterNBViewer'; 17 | import FontViewer from '@components/Viewers/FontViewer'; 18 | import EPubViewer from '@components/Viewers/EPubViewer'; 19 | import { ViewerType } from '@plugins/PluginInterface'; 20 | import useStore from '@hooks/useStore'; 21 | 22 | const FileTypeNotSupported: React.FC = () => { 23 | const { t } = useTranslation(); 24 | return ( 25 | 36 | 37 | {t('FileTypeNotSupported')} 38 | 39 | 40 | ); 41 | }; 42 | 43 | const FileViewer: React.FC = () => { 44 | const fileExt = useStore(state => state.fileExt); 45 | const plugins = useStore(state => state.plugins); 46 | const activeViewer = useStore(state => state.activeViewer); 47 | const setActiveViewer = useStore(state => state.actions.setActiveViewer); 48 | 49 | React.useEffect(() => { 50 | for (const p of plugins) { 51 | if (!p.enabled) continue; 52 | 53 | if (p.extensions[fileExt]) { 54 | setActiveViewer(p.viewerType); 55 | return; 56 | } 57 | 58 | if (p.extraExtensions.filter(e => e.split(':')[0] === fileExt).length > 0) { 59 | setActiveViewer(p.viewerType); 60 | return; 61 | } 62 | } 63 | setActiveViewer(ViewerType.Unknown); 64 | }, [fileExt, plugins, activeViewer]); 65 | 66 | return ( 67 | <> 68 | {{ 69 | [ViewerType.IFrame]: , 70 | [ViewerType.Markdown]: , 71 | [ViewerType.Model3D]: , 72 | [ViewerType.SVG]: , 73 | [ViewerType.Syntax]: , 74 | [ViewerType.Tabular]: , 75 | [ViewerType.Image]: , 76 | [ViewerType.Jupyter]: , 77 | [ViewerType.Font]: , 78 | [ViewerType.EPub]: , 79 | [ViewerType.General]: , 80 | [ViewerType.Unknown]: , 81 | }[activeViewer]} 82 | 83 | ); 84 | }; 85 | 86 | export default FileViewer; 87 | -------------------------------------------------------------------------------- /src/components/Viewers/FontViewer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import * as opentype from 'opentype.js'; 4 | 5 | import { 6 | Box, 7 | IconButton, 8 | Paper, 9 | Table, 10 | TableBody, 11 | TableRow, 12 | TableCell, 13 | TextField, 14 | Typography, 15 | SxProps, 16 | } from '@mui/material'; 17 | import { 18 | Close as CloseIcon, 19 | Edit as EditIcon, 20 | SettingsBackupRestore as RestoreIcon, 21 | } from '@mui/icons-material'; 22 | 23 | import useStore, { DEFAULT_FONTTEXT } from '@hooks/useStore'; 24 | 25 | const classes = { 26 | infoContainer: { 27 | position: 'absolute', 28 | right: '0.3rem', 29 | top: '0.5rem', 30 | zIndex: 10, 31 | opacity: 0.6, 32 | p: 0.5, 33 | overflow: 'auto', 34 | maxWidth: '30rem', 35 | maxHeight: 'calc(100% - 4rem)', 36 | }, 37 | } satisfies Record; 38 | 39 | interface NameProp { 40 | name: string; 41 | value: any; 42 | } 43 | 44 | const FontViewer: React.FC = () => { 45 | const { t } = useTranslation(); 46 | const fileUrl = useStore(state => state.fileUrl); 47 | const fileContent = useStore(state => state.fileContent); 48 | const fontText = useStore(state => state.fontText); 49 | const setFontText = useStore(state => state.actions.setFontText); 50 | const [fontInfo, setFontInfo] = React.useState(null); 51 | const [showEdit, setShowEdit] = React.useState(false); 52 | 53 | React.useEffect(() => { 54 | try { 55 | // Note: opentype.js does not support woff2 directly. Needs decompression... 56 | const font = opentype.parse(fileContent); 57 | const nameTable = font.tables.name; 58 | const properties = Object.keys(nameTable).sort(); 59 | const nameProps: NameProp[] = []; 60 | properties.forEach(name => nameProps.push({ 61 | name, 62 | value: nameTable[name], 63 | })); 64 | setFontInfo(nameProps); 65 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 66 | } catch (ex) { 67 | setFontInfo(null); 68 | } 69 | }, [fileContent]); 70 | 71 | const updateFontText = (fontText: string) => { 72 | setFontText(fontText); 73 | }; 74 | 75 | const restoreDefaultText = () => { 76 | setFontText(DEFAULT_FONTTEXT); 77 | }; 78 | 79 | const toggleEdit = () => { 80 | setShowEdit(!showEdit); 81 | }; 82 | 83 | return ( 84 | 94 | {!showEdit && ( 95 | 96 | 97 | 98 | )} 99 | {showEdit && ( 100 | 101 | ) => { 106 | updateFontText(event.target.value); 107 | }} 108 | onBlur={toggleEdit} 109 | /> 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | )} 120 | 121 | {[0.5, 0.7, 1, 1.2, 1.5, 2, 3, 5].map(fs => ( 122 | 123 | {fontText} 124 | 125 | ))} 126 | 130 | {fontInfo ? ( 131 | 132 | 133 | {fontInfo.map((field) => { 134 | // opentype name table values are per language 135 | // try en and fallback to first language found 136 | const fieldValue = field.value.en 137 | ? field.value.en 138 | : field.value[Object.keys(field.value)[0]]; 139 | return ( 140 | 141 | {field.name} 142 | {fieldValue} 143 | 144 | ); 145 | })} 146 | 147 |
148 | ) : ( 149 | {t('NoFontInfoAvailable')} 150 | )} 151 |
152 |
153 | ); 154 | }; 155 | 156 | export default FontViewer; 157 | -------------------------------------------------------------------------------- /src/components/Viewers/IFrameViewer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Box, SxProps } from '@mui/material'; 4 | 5 | import useStore from '@hooks/useStore'; 6 | import { log } from '@utils/log'; 7 | 8 | const classes = { 9 | root: { 10 | width: '100%', 11 | height: '100%', 12 | iframe: { 13 | border: 'none', 14 | }, 15 | }, 16 | } satisfies Record; 17 | 18 | const IFrameViewer: React.FC = () => { 19 | const fileUrl = useStore(state => state.fileUrl); 20 | log(`IFrameViewer url:${fileUrl}`); 21 | return ( 22 | 23 |