├── .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 ""
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 |
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 |
25 | {value === viewerType && (
26 |
27 | {children}
28 |
29 | )}
30 |
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 |
30 |
31 | );
32 | };
33 |
34 | export default IFrameViewer;
35 |
--------------------------------------------------------------------------------
/src/components/Viewers/ImageViewer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ExifReader from 'exifreader';
3 |
4 | import { Box, SxProps, Typography, useTheme } from '@mui/material';
5 |
6 | import useStore from '@hooks/useStore';
7 | import { ZoomBehaviour } from '@utils/types';
8 | import { log } from '@utils/log';
9 | import ExifInfo from '@components/Viewers/ExifInfo';
10 |
11 | const classes = {
12 | root: {
13 | display: 'flex',
14 | justifyContent: 'center',
15 | alignItems: 'center',
16 | width: '100%',
17 | height: '100%',
18 | overflow: 'hidden',
19 | },
20 | zoomValue: {
21 | position: 'absolute',
22 | top: 8,
23 | right: 8,
24 | },
25 | } satisfies Record;
26 |
27 | const ADJUST_ZOOM_TO = 1; // zoom to fit to occupy 100% of max width|height
28 |
29 | interface TransformSettings {
30 | zoom: number;
31 | minZoom: number;
32 | maxZoom: number;
33 | zoomToFit: number;
34 | translateX: number;
35 | translateY: number;
36 | isDrag: boolean;
37 | }
38 |
39 | const defaultTransform: TransformSettings = {
40 | zoom: 1.5,
41 | minZoom: 1,
42 | maxZoom: 1,
43 | zoomToFit: 1,
44 | translateX: 0,
45 | translateY: 0,
46 | isDrag: false,
47 | };
48 |
49 | const ImageViewer: React.FC = () => {
50 | const theme = useTheme();
51 | const fileName = useStore(state => state.fileName);
52 | const fileUrl = useStore(state => state.fileUrl);
53 | const fileContent = useStore(state => state.fileContent);
54 | const imageRendering = useStore(state => state.imageRendering);
55 | const lastZoom = useStore(state => state.zoom);
56 | const newImageZoomBehaviour = useStore(state => state.newImageZoomBehaviour);
57 | const resizeImageZoomBehaviour = useStore(state => state.resizeImageZoomBehaviour);
58 | const togglePixelated = useStore(state => state.actions.togglePixelated);
59 | const transformRef = React.useRef({ ...defaultTransform });
60 | const containerRef = React.useRef(null);
61 | const imgRef = React.useRef(null);
62 | const [zoom, setZoom] = React.useState(1);
63 | const [translate, setTranslate] = React.useState({ x: 0, y: 0 });
64 | const [tags, setTags] = React.useState(null as ExifReader.Tags | null);
65 |
66 | React.useEffect(() => {
67 | updateZoomSettings(lastZoom);
68 | setZoom(lastZoom);
69 | return () => {
70 | useStore.setState({ zoom: transformRef.current.zoom }); // remember zoom across mounts
71 | };
72 | }, []);
73 |
74 | const updateZoomSettings = (z: number) => {
75 | transformRef.current.zoom = z;
76 | transformRef.current.zoomToFit = z;
77 | transformRef.current.minZoom = Math.min(1, z / 10);
78 | transformRef.current.maxZoom = Math.max(10, z * 10);
79 | };
80 |
81 | const getZoomToFit = () => {
82 | if (!imgRef.current) {
83 | return 0;
84 | }
85 | const iw = imgRef.current.width;
86 | const ih = imgRef.current.height;
87 | if (iw === 0 || ih === 0) {
88 | return 0;
89 | }
90 | const iar = iw / ih;
91 | const ww = window.innerWidth;
92 | const wh = window.innerHeight;
93 | const war = ww / wh;
94 | let startZoom = 1;
95 | if (iar > war) {
96 | startZoom = (ww / iw) * ADJUST_ZOOM_TO;
97 | } else {
98 | startZoom = (wh / ih) * ADJUST_ZOOM_TO;
99 | }
100 | return startZoom;
101 | };
102 |
103 | React.useEffect(() => {
104 | const handleResize = (e: UIEvent) => {
105 | transformRef.current.zoomToFit = getZoomToFit();
106 | updateZoomSettings(transformRef.current.zoomToFit);
107 | if (resizeImageZoomBehaviour === ZoomBehaviour.Zoom1To1) {
108 | transformRef.current.zoom = 1;
109 | setZoom(transformRef.current.zoom);
110 | } else if (resizeImageZoomBehaviour === ZoomBehaviour.ZoomToFit) {
111 | setZoom(transformRef.current.zoomToFit);
112 | }
113 | };
114 |
115 | const handleMouseDown = (e: MouseEvent) => {
116 | transformRef.current.isDrag = true;
117 | // prevent bubbling so it doesn't start a native drag'n drop operation
118 | e.stopPropagation();
119 | e.preventDefault();
120 | return false;
121 | };
122 |
123 | const handleMouseUp = (e: MouseEvent) => {
124 | transformRef.current.isDrag = false;
125 | return false;
126 | };
127 |
128 | const handleMouseMove = (e: MouseEvent) => {
129 | if (transformRef.current.isDrag) {
130 | transformRef.current.translateX += e.movementX;
131 | transformRef.current.translateY += e.movementY;
132 | setTranslate({ x: transformRef.current.translateX, y: transformRef.current.translateY });
133 | }
134 | return false;
135 | };
136 |
137 | const handleScroll = (e: WheelEvent) => {
138 | const { zoom, minZoom, maxZoom, zoomToFit, translateX, translateY } = transformRef.current;
139 | const newZoom = Math.max(minZoom, Math.min(zoom - (e.deltaY * (0.001 * zoomToFit)), maxZoom));
140 |
141 | const wcw = window.innerWidth / 2;
142 | const wch = window.innerHeight / 2;
143 |
144 | // mouse position with origin in lower left
145 | const mx = e.x;
146 | const my = window.innerHeight - e.y;
147 |
148 | // center of image
149 | const icx = translateX + wcw;
150 | const icy = -translateY + wch;
151 |
152 | // vector from center of image to mouse pointer
153 | const dx = mx - icx;
154 | const dy = my - icy;
155 |
156 | // scale the vector to the newZoom
157 | const dx2 = dx / zoom * newZoom;
158 | const dy2 = dy / zoom * newZoom;
159 |
160 | // adjust translate by the difference
161 | const ddx = (dx2 - dx);
162 | const ddy = (dy2 - dy);
163 |
164 | transformRef.current.translateX -= ddx;
165 | transformRef.current.translateY += ddy;
166 | setTranslate({ x: transformRef.current.translateX, y: transformRef.current.translateY });
167 |
168 | transformRef.current.zoom = newZoom;
169 | setZoom(transformRef.current.zoom);
170 |
171 | e.stopPropagation();
172 | e.preventDefault();
173 | return false;
174 | };
175 |
176 | const handleKeydown = (e: KeyboardEvent) => {
177 | const { zoom, minZoom, maxZoom, zoomToFit } = transformRef.current;
178 | let newZoom: number | null = null;
179 | if (e.key === '0') {
180 | newZoom = getZoomToFit();
181 | transformRef.current.translateX = 0;
182 | transformRef.current.translateY = 0;
183 | setTranslate({ x: transformRef.current.translateX, y: transformRef.current.translateY });
184 | } else if (e.key === '1') {
185 | newZoom = 1;
186 | } else if (e.key === '2') {
187 | newZoom = 2;
188 | } else if (e.key === '3') {
189 | newZoom = 3;
190 | } else if (e.key === '4') {
191 | newZoom = 4;
192 | } else if (e.key === '5') {
193 | newZoom = 5;
194 | } else if (e.key === '6') {
195 | newZoom = 6;
196 | } else if (e.key === '7') {
197 | newZoom = 7;
198 | } else if (e.key === '8') {
199 | newZoom = 8;
200 | } else if (e.key === '9') {
201 | newZoom = 9;
202 | } else if (e.key === '+') {
203 | newZoom = Math.max(minZoom, Math.min(zoom + (0.1 * zoomToFit), maxZoom));
204 | } else if (e.key === '-') {
205 | newZoom = Math.max(minZoom, Math.min(zoom - (0.1 * zoomToFit), maxZoom));
206 | }
207 | if (e.key === 'p') {
208 | togglePixelated();
209 | return;
210 | }
211 |
212 | if (newZoom) {
213 | transformRef.current.zoom = newZoom;
214 | setZoom(transformRef.current.zoom);
215 | }
216 | };
217 |
218 | const el = containerRef.current;
219 | if (!el) {
220 | return () => {};
221 | }
222 | // wheel & mousedown on the plugin container element,
223 | // so when the setting dialog is open while this plugin is active, you can
224 | // - select a textbox in the settings
225 | // - scroll in settings without changing zoom
226 | el.addEventListener('wheel', handleScroll, { passive: false });
227 | el.addEventListener('mousedown', handleMouseDown);
228 | window.addEventListener('mouseup', handleMouseUp);
229 | window.addEventListener('mousemove', handleMouseMove);
230 | window.addEventListener('keydown', handleKeydown);
231 | window.addEventListener('resize', handleResize);
232 | return () => {
233 | el.removeEventListener('wheel', handleScroll);
234 | el.removeEventListener('mousedown', handleMouseDown);
235 | window.removeEventListener('mouseup', handleMouseUp);
236 | window.removeEventListener('mousemove', handleMouseMove);
237 | window.removeEventListener('keydown', handleKeydown);
238 | window.removeEventListener('resize', handleResize);
239 | };
240 | }, [containerRef, newImageZoomBehaviour, resizeImageZoomBehaviour]);
241 |
242 | React.useEffect(() => {
243 | if (newImageZoomBehaviour === ZoomBehaviour.Zoom1To1) {
244 | transformRef.current.zoom = 1;
245 | setZoom(transformRef.current.zoom);
246 | } else if (newImageZoomBehaviour === ZoomBehaviour.ZoomToFit) {
247 | transformRef.current = { ...defaultTransform };
248 | setTranslate({ x: transformRef.current.translateX, y: transformRef.current.translateY });
249 |
250 | const zoomToFit = getZoomToFit();
251 | updateZoomSettings(zoomToFit);
252 | setZoom(transformRef.current.zoom);
253 | }
254 | }, [fileUrl]);
255 |
256 | React.useEffect(() => {
257 | setTags(null);
258 | const readTags = async () => {
259 | if (fileContent && fileContent instanceof ArrayBuffer) {
260 | const eTags = await ExifReader.load(fileContent, { async: true });
261 | delete eTags.MakerNote;
262 | delete eTags.Thumbnail;
263 | delete eTags.UserComment;
264 | delete eTags.MPEntry;
265 | setTags(eTags);
266 | }
267 | };
268 | readTags().catch(reason => log(`ExifReader: ${reason}`));
269 | }, [fileContent]);
270 |
271 | const onImageLoad = () => {
272 | if (newImageZoomBehaviour === ZoomBehaviour.Zoom1To1) {
273 | transformRef.current.zoom = 1;
274 | setZoom(transformRef.current.zoom);
275 | } else if (newImageZoomBehaviour === ZoomBehaviour.ZoomToFit) {
276 | const zoomToFit = getZoomToFit();
277 | updateZoomSettings(zoomToFit);
278 | setZoom(transformRef.current.zoom);
279 | }
280 | };
281 |
282 | return (
283 | <>
284 |
285 |
295 |
299 |
305 | {`${Math.round(zoom * 100)}%`}
306 |
307 |
308 |
309 | {tags && (
310 |
311 | )}
312 | >
313 | );
314 | };
315 |
316 | export default ImageViewer;
317 |
--------------------------------------------------------------------------------
/src/components/Viewers/JupyterNBViewer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useTranslation } from 'react-i18next';
3 |
4 | import { Box } from '@mui/material';
5 |
6 | import useStore from '@hooks/useStore';
7 | import * as NB from '@components/Jupyter/JupyterCommon';
8 | import Notebook from '@components/Jupyter/Notebook';
9 |
10 | const JupyterNBViewer: React.FC = () => {
11 | const { t } = useTranslation();
12 | const fileContent = useStore(state => state.fileContent) as string;
13 | const nb: NB.Notebook = JSON.parse(fileContent);
14 |
15 | React.useEffect(() => {
16 | // switching between notebooks, doesn't return to the top of doc
17 | window.scrollTo(0, 0);
18 | }, []);
19 |
20 | const notSupported = nb.nbformat < 4;
21 | return (
22 |
23 | {notSupported ? (
24 | <>{t('UnsupportedVersion')}>
25 | ) : (
26 |
27 | )}
28 |
29 | );
30 | };
31 |
32 | export default JupyterNBViewer;
33 |
--------------------------------------------------------------------------------
/src/components/Viewers/MarkdownViewer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactMarkdown, { ExtraProps } 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 './markdown.css';
9 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
10 | import {
11 | vscDarkPlus,
12 | vs,
13 | } from 'react-syntax-highlighter/dist/esm/styles/prism';
14 |
15 | import { Box, ListItem, List, Typography } from '@mui/material';
16 |
17 | import useStore, { store } from '@hooks/useStore';
18 |
19 | const TOC: React.FC = () => {
20 | const tocItems = useStore(state => state.mdTableOfContentsItems);
21 |
22 | if (tocItems.length === 0) {
23 | return <>>;
24 | }
25 |
26 | const minLevel = tocItems.reduce((l, t) => Math.min(l, t.level), 100);
27 | return (
28 |
39 | Contents
40 |
41 | {tocItems.map(({ level, id, title }) => (
42 |
50 | • {title}
51 |
52 | ))}
53 |
54 |
55 | );
56 | };
57 |
58 | const HeaderItem = ({ node, className, children, ref, ...props }:
59 | React.DetailedHTMLProps, HTMLHeadingElement> & ExtraProps) => {
60 | const addTableOfContentItem = useStore(state => state.actions.addTableOfContentItem);
61 |
62 | // Based on https://github.com/remarkjs/react-markdown/issues/48#issuecomment-1074699244
63 | const level = Number(node!.tagName.match(/h(\d)/)?.slice(1));
64 | if (level && children && typeof children === 'string') {
65 | const id = children.toLowerCase().replace(/[^a-z0-9]+/g, '-');
66 | addTableOfContentItem({
67 | level,
68 | id,
69 | title: children,
70 | });
71 | return React.createElement(
72 | node!.tagName, { id }, children,
73 | );
74 | }
75 |
76 | return React.createElement(node!.tagName, props, children);
77 | };
78 |
79 | const MarkdownViewer: React.FC = () => {
80 | const fileContent = useStore(state => state.fileContent) as string;
81 | const isDark = useStore(state => state.isDark);
82 |
83 | const RM = React.useMemo(() => {
84 | store.getState().actions.clearTableOfContent();
85 | const style = isDark ? vscDarkPlus : vs;
86 |
87 | // This is collecting the TOC data during render (via HeaderItem)
88 | // Memo so it doesn't re-render (and mess with the TOC data).
89 | return (
90 | ERROR;
102 | }
103 | const inline = node.position.start.line === node.position.end.line;
104 | if (inline) {
105 | return (
106 |
107 | {String(children).replace(/\n$/, '')}
108 |
109 | );
110 | }
111 | const match = /language-(\w+)/.exec(className || '');
112 | return (
113 |
133 | {String(children).replace(/\n$/, '')}
134 |
135 | );
136 | },
137 | }}
138 | >
139 | {fileContent}
140 |
141 | );
142 | }, [fileContent, isDark]);
143 |
144 | return (
145 |
152 |
153 |
162 | {RM}
163 |
164 |
165 | );
166 | };
167 |
168 | export default MarkdownViewer;
169 |
--------------------------------------------------------------------------------
/src/components/Viewers/ModelViewer.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-unknown-property */
2 | import React from 'react';
3 | import { Group } from 'three';
4 | import { FBXLoader, OBJLoader } from 'three-stdlib';
5 |
6 | import { OrbitControls, Sky, useGLTF } from '@react-three/drei';
7 | import { Canvas } from '@react-three/fiber';
8 |
9 | import NormalizedScene from '@components/Viewers/NormalizedScene';
10 | import useStore from '@hooks/useStore';
11 | import { log } from '@utils/log';
12 |
13 | // more threejs loaders:
14 | // https://github.com/pmndrs/three-stdlib/tree/main/src/loaders
15 |
16 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
17 | const logProgress = (p: ProgressEvent) => {
18 | const percent = (p.loaded * 100 / p.total).toFixed(1);
19 | log(`Progress: ${percent}%`);
20 | };
21 |
22 | const GLTFScene: React.FC = () => {
23 | const fileUrl = useStore(state => state.fileUrl);
24 | const { scene, animations } = useGLTF(fileUrl);
25 | return (
26 |
27 | {!!scene && }
28 |
29 | );
30 | };
31 |
32 | const FBXScene: React.FC = () => {
33 | const fileName = useStore(state => state.fileName);
34 | const fileUrl = useStore(state => state.fileUrl);
35 | const [scene, setScene] = React.useState(null);
36 |
37 | React.useEffect(() => {
38 | if (fileName && fileUrl) {
39 | const loader = new FBXLoader();
40 | loader.load(
41 | fileUrl,
42 | (s: Group) => { setScene(s); },
43 | undefined, // logProgress,
44 | (e: ErrorEvent) => { log(`Failed to model: ${fileName}`); },
45 | );
46 | }
47 | }, [fileName, fileUrl]);
48 |
49 | return (
50 |
51 | {!!scene && }
52 |
53 | );
54 | };
55 |
56 | const OBJScene: React.FC = () => {
57 | const fileName = useStore(state => state.fileName);
58 | const fileUrl = useStore(state => state.fileUrl);
59 | const [scene, setScene] = React.useState(null);
60 |
61 | React.useEffect(() => {
62 | if (fileName && fileUrl) {
63 | const loader = new OBJLoader();
64 | loader.load(
65 | fileUrl,
66 | (s: Group) => { setScene(s); },
67 | undefined, // logProgress,
68 | (e: ErrorEvent) => { log(`Failed to model: ${fileName}`); },
69 | );
70 | }
71 | }, [fileName, fileUrl]);
72 |
73 | return (
74 |
75 | {!!scene && }
76 |
77 | );
78 | };
79 |
80 | const ModelViewer: React.FC = () => {
81 | const fileExt = useStore(state => state.fileExt);
82 |
83 | return (
84 |
102 | );
103 | };
104 |
105 | export default ModelViewer;
106 |
--------------------------------------------------------------------------------
/src/components/Viewers/NormalizedScene.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-unknown-property */
2 | import React from 'react';
3 | import * as THREE from 'three';
4 |
5 | import { useAnimations } from '@react-three/drei';
6 |
7 | import { log } from '@utils/log';
8 |
9 | interface NormalizedSceneProps {
10 | scene: THREE.Group;
11 | animations?: THREE.AnimationClip[];
12 | }
13 |
14 | const NormalizedScene: React.FC = (props) => {
15 | const [center, setCenter] = React.useState(new THREE.Vector3());
16 | const [scale, setScale] = React.useState(1);
17 | const modelAnimations = useAnimations(props.animations || [], props.scene);
18 |
19 | React.useEffect(() => {
20 | if (modelAnimations.names.length === 0) return;
21 |
22 | const anim = modelAnimations.actions[modelAnimations.names[0]];
23 | if (anim) {
24 | anim
25 | .reset()
26 | .setEffectiveTimeScale(1)
27 | .setEffectiveWeight(1)
28 | .fadeIn(0.5)
29 | .play();
30 | }
31 | }, [modelAnimations]);
32 |
33 | React.useEffect(() => {
34 | if (props.scene) {
35 | const box = new THREE.BoxHelper(props.scene);
36 | box.geometry.computeBoundingBox();
37 |
38 | const bb = box.geometry.boundingBox || new THREE.Box3();
39 | log(`Scene bounding box: ${JSON.stringify(bb, null, 2)}`);
40 |
41 | bb.getCenter(center);
42 | setCenter(center);
43 | log(`Scene center: ${JSON.stringify(center, null, 2)}`);
44 |
45 | const size = new THREE.Vector3();
46 | bb.getSize(size);
47 | const s = 10.0 / Math.max(size.x, size.y, size.z);
48 | setScale(s);
49 | }
50 | }, [props.scene]);
51 |
52 | return (
53 | <>
54 |
55 |
56 |
57 |
58 |
59 |
60 | >
61 | );
62 | };
63 |
64 | export default NormalizedScene;
65 |
--------------------------------------------------------------------------------
/src/components/Viewers/SVGViewer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import svgPanZoom from 'svg-pan-zoom';
3 |
4 | import { Box, SxProps } from '@mui/material';
5 |
6 | import useStore from '@hooks/useStore';
7 |
8 | const classes = {
9 | root: {
10 | width: '100%',
11 | height: '100%',
12 | lineHeight: 0,
13 | '& SVG': {
14 | width: '100%',
15 | height: '100%',
16 | },
17 | },
18 | } satisfies Record;
19 |
20 | const SVGViewer: React.FC = () => {
21 | const container = React.useRef(null);
22 | const fileContent = useStore(state => state.fileContent) as string;
23 |
24 | React.useEffect(() => {
25 | if (container.current && container.current.children.length) {
26 | const svgElement = container.current.children[0] as HTMLElement;
27 | svgPanZoom(svgElement, {
28 | zoomScaleSensitivity: 0.5,
29 | });
30 | }
31 | }, [container]);
32 |
33 | return (
34 |
40 | );
41 | };
42 |
43 | export default SVGViewer;
44 |
--------------------------------------------------------------------------------
/src/components/Viewers/SyntaxViewer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
3 | import {
4 | vscDarkPlus,
5 | vs,
6 | } from 'react-syntax-highlighter/dist/esm/styles/prism';
7 |
8 | import { Box, SxProps } from '@mui/material';
9 |
10 | import useStore from '@hooks/useStore';
11 | import { Ext2Lang } from '@plugins/SyntaxPlugin';
12 |
13 | const SyntaxViewer: React.FC = () => {
14 | const fileContent = useStore(state => state.fileContent) as string;
15 | const fileExt = useStore(state => state.fileExt);
16 | const syntaxShowLineNumbers = useStore(state => state.syntaxShowLineNumbers);
17 | const syntaxWrapLines = useStore(state => state.syntaxWrapLines);
18 | const pluginByShortName = useStore(state => state.pluginByShortName);
19 | const syntaxFontSize = useStore(state => state.syntaxFontSize);
20 | const syntaxCustomFont = useStore(state => state.syntaxCustomFont);
21 | const isDark = useStore(state => state.isDark);
22 | const plugin = pluginByShortName.syntax;
23 |
24 | const matchedExtra = plugin.extraExtensions.filter(e => e.split(':')[0] === fileExt);
25 | let lang = fileExt;
26 | if (matchedExtra.length > 0) {
27 | // e.g. rs:rust
28 | const extAndLang = matchedExtra[0].split(':');
29 | lang = (extAndLang.length === 1) ? extAndLang[0] : extAndLang[1];
30 | } else {
31 | lang = Ext2Lang[fileExt] || fileExt;
32 | }
33 |
34 | const style = isDark ? vscDarkPlus : vs;
35 |
36 | const fontFaceSx: SxProps = syntaxCustomFont
37 | ? {
38 | '@font-face': {
39 | fontFamily: 'syntaxFont',
40 | src: syntaxCustomFont,
41 | },
42 | } : {};
43 |
44 | return (
45 |
49 |
71 | {fileContent}
72 |
73 |
74 | );
75 | };
76 |
77 | export default SyntaxViewer;
78 |
--------------------------------------------------------------------------------
/src/components/Viewers/TabularViewer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDataGrid from 'react-data-grid';
3 | import { read, utils } from 'xlsx';
4 |
5 | import useStore from '@hooks/useStore';
6 | import CSV2RowData from '@utils/CSV2RowData';
7 |
8 | const TabularViewer: React.FC = () => {
9 | const fileContent = useStore(state => state.fileContent);
10 | const type = fileContent instanceof ArrayBuffer ? 'binary' : 'string';
11 | const workbook = read(fileContent, { type });
12 |
13 | // TODO: add a dropdown to select active sheet
14 | const csvContent = utils.sheet_to_csv(
15 | workbook.Sheets[workbook.SheetNames[0]],
16 | );
17 |
18 | const { rows, columns } = CSV2RowData(csvContent);
19 |
20 | return ;
21 | };
22 |
23 | export default TabularViewer;
24 |
--------------------------------------------------------------------------------
/src/components/Viewers/markdown.css:
--------------------------------------------------------------------------------
1 | [data-theme="dark"] .markdown-body {
2 | color-scheme: dark;
3 | --color-prettylights-syntax-comment: #8b949e;
4 | --color-prettylights-syntax-constant: #79c0ff;
5 | --color-prettylights-syntax-entity: #d2a8ff;
6 | --color-prettylights-syntax-storage-modifier-import: #c9d1d9;
7 | --color-prettylights-syntax-entity-tag: #7ee787;
8 | --color-prettylights-syntax-keyword: #ff7b72;
9 | --color-prettylights-syntax-string: #a5d6ff;
10 | --color-prettylights-syntax-variable: #ffa657;
11 | --color-prettylights-syntax-brackethighlighter-unmatched: #f85149;
12 | --color-prettylights-syntax-invalid-illegal-text: #f0f6fc;
13 | --color-prettylights-syntax-invalid-illegal-bg: #8e1519;
14 | --color-prettylights-syntax-carriage-return-text: #f0f6fc;
15 | --color-prettylights-syntax-carriage-return-bg: #b62324;
16 | --color-prettylights-syntax-string-regexp: #7ee787;
17 | --color-prettylights-syntax-markup-list: #f2cc60;
18 | --color-prettylights-syntax-markup-heading: #1f6feb;
19 | --color-prettylights-syntax-markup-italic: #c9d1d9;
20 | --color-prettylights-syntax-markup-bold: #c9d1d9;
21 | --color-prettylights-syntax-markup-deleted-text: #ffdcd7;
22 | --color-prettylights-syntax-markup-deleted-bg: #67060c;
23 | --color-prettylights-syntax-markup-inserted-text: #aff5b4;
24 | --color-prettylights-syntax-markup-inserted-bg: #033a16;
25 | --color-prettylights-syntax-markup-changed-text: #ffdfb6;
26 | --color-prettylights-syntax-markup-changed-bg: #5a1e02;
27 | --color-prettylights-syntax-markup-ignored-text: #c9d1d9;
28 | --color-prettylights-syntax-markup-ignored-bg: #1158c7;
29 | --color-prettylights-syntax-meta-diff-range: #d2a8ff;
30 | --color-prettylights-syntax-brackethighlighter-angle: #8b949e;
31 | --color-prettylights-syntax-sublimelinter-gutter-mark: #484f58;
32 | --color-prettylights-syntax-constant-other-reference-link: #a5d6ff;
33 | --color-fg-default: #c9d1d9;
34 | --color-fg-muted: #8b949e;
35 | --color-fg-subtle: #484f58;
36 | --color-canvas-default: #0d1117;
37 | --color-canvas-subtle: #161b22;
38 | --color-border-default: #30363d;
39 | --color-border-muted: #21262d;
40 | --color-neutral-muted: rgba(110, 118, 129, 0.4);
41 | --color-accent-fg: #58a6ff;
42 | --color-accent-emphasis: #1f6feb;
43 | --color-attention-subtle: rgba(187, 128, 9, 0.15);
44 | --color-danger-fg: #f85149;
45 | }
46 |
47 | [data-theme="light"] .markdown-body {
48 | color-scheme: light;
49 | --color-prettylights-syntax-comment: #6e7781;
50 | --color-prettylights-syntax-constant: #0550ae;
51 | --color-prettylights-syntax-entity: #8250df;
52 | --color-prettylights-syntax-storage-modifier-import: #24292f;
53 | --color-prettylights-syntax-entity-tag: #116329;
54 | --color-prettylights-syntax-keyword: #cf222e;
55 | --color-prettylights-syntax-string: #0a3069;
56 | --color-prettylights-syntax-variable: #953800;
57 | --color-prettylights-syntax-brackethighlighter-unmatched: #82071e;
58 | --color-prettylights-syntax-invalid-illegal-text: #f6f8fa;
59 | --color-prettylights-syntax-invalid-illegal-bg: #82071e;
60 | --color-prettylights-syntax-carriage-return-text: #f6f8fa;
61 | --color-prettylights-syntax-carriage-return-bg: #cf222e;
62 | --color-prettylights-syntax-string-regexp: #116329;
63 | --color-prettylights-syntax-markup-list: #3b2300;
64 | --color-prettylights-syntax-markup-heading: #0550ae;
65 | --color-prettylights-syntax-markup-italic: #24292f;
66 | --color-prettylights-syntax-markup-bold: #24292f;
67 | --color-prettylights-syntax-markup-deleted-text: #82071e;
68 | --color-prettylights-syntax-markup-deleted-bg: #FFEBE9;
69 | --color-prettylights-syntax-markup-inserted-text: #116329;
70 | --color-prettylights-syntax-markup-inserted-bg: #dafbe1;
71 | --color-prettylights-syntax-markup-changed-text: #953800;
72 | --color-prettylights-syntax-markup-changed-bg: #ffd8b5;
73 | --color-prettylights-syntax-markup-ignored-text: #eaeef2;
74 | --color-prettylights-syntax-markup-ignored-bg: #0550ae;
75 | --color-prettylights-syntax-meta-diff-range: #8250df;
76 | --color-prettylights-syntax-brackethighlighter-angle: #57606a;
77 | --color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f;
78 | --color-prettylights-syntax-constant-other-reference-link: #0a3069;
79 | --color-fg-default: #24292f;
80 | --color-fg-muted: #57606a;
81 | --color-fg-subtle: #6e7781;
82 | --color-canvas-default: #ffffff;
83 | --color-canvas-subtle: #f6f8fa;
84 | --color-border-default: #d0d7de;
85 | --color-border-muted: hsla(210, 18%, 87%, 1);
86 | --color-neutral-muted: rgba(175, 184, 193, 0.2);
87 | --color-accent-fg: #0969da;
88 | --color-accent-emphasis: #0969da;
89 | --color-attention-subtle: #fff8c5;
90 | --color-danger-fg: #cf222e;
91 | }
92 |
93 | .markdown-body {
94 | -ms-text-size-adjust: 100%;
95 | -webkit-text-size-adjust: 100%;
96 | margin: 0;
97 | color: var(--color-fg-default);
98 | /* FB: use webviewplus background color
99 | background-color: var(--color-canvas-default);
100 | */
101 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
102 | font-size: 16px;
103 | line-height: 1.5;
104 | word-wrap: break-word;
105 | padding: 0.5rem;
106 | }
107 |
108 | .markdown-body .octicon {
109 | display: inline-block;
110 | fill: currentColor;
111 | vertical-align: text-bottom;
112 | }
113 |
114 | .markdown-body h1:hover .anchor .octicon-link:before,
115 | .markdown-body h2:hover .anchor .octicon-link:before,
116 | .markdown-body h3:hover .anchor .octicon-link:before,
117 | .markdown-body h4:hover .anchor .octicon-link:before,
118 | .markdown-body h5:hover .anchor .octicon-link:before,
119 | .markdown-body h6:hover .anchor .octicon-link:before {
120 | width: 16px;
121 | height: 16px;
122 | content: ' ';
123 | display: inline-block;
124 | background-color: currentColor;
125 | -webkit-mask-image: url("data:image/svg+xml,");
126 | mask-image: url("data:image/svg+xml,");
127 | }
128 |
129 | .markdown-body details,
130 | .markdown-body figcaption,
131 | .markdown-body figure {
132 | display: block;
133 | }
134 |
135 | .markdown-body summary {
136 | display: list-item;
137 | }
138 |
139 | .markdown-body [hidden] {
140 | display: none !important;
141 | }
142 |
143 | .markdown-body a {
144 | background-color: transparent;
145 | color: var(--color-accent-fg);
146 | text-decoration: none;
147 | }
148 |
149 | .markdown-body a:active,
150 | .markdown-body a:hover {
151 | outline-width: 0;
152 | }
153 |
154 | .markdown-body abbr[title] {
155 | border-bottom: none;
156 | text-decoration: underline dotted;
157 | }
158 |
159 | .markdown-body b,
160 | .markdown-body strong {
161 | font-weight: 600;
162 | }
163 |
164 | .markdown-body dfn {
165 | font-style: italic;
166 | }
167 |
168 | .markdown-body h1 {
169 | margin: .67em 0;
170 | font-weight: 600;
171 | padding-bottom: .3em;
172 | font-size: 2em;
173 | border-bottom: 1px solid var(--color-border-muted);
174 | }
175 |
176 | .markdown-body mark {
177 | background-color: var(--color-attention-subtle);
178 | color: var(--color-text-primary);
179 | }
180 |
181 | .markdown-body small {
182 | font-size: 90%;
183 | }
184 |
185 | .markdown-body sub,
186 | .markdown-body sup {
187 | font-size: 75%;
188 | line-height: 0;
189 | position: relative;
190 | vertical-align: baseline;
191 | }
192 |
193 | .markdown-body sub {
194 | bottom: -0.25em;
195 | }
196 |
197 | .markdown-body sup {
198 | top: -0.5em;
199 | }
200 |
201 | .markdown-body img {
202 | border-style: none;
203 | max-width: 100%;
204 | box-sizing: content-box;
205 | background-color: var(--color-canvas-default);
206 | }
207 |
208 | .markdown-body code,
209 | .markdown-body kbd,
210 | .markdown-body pre,
211 | .markdown-body samp {
212 | font-family: monospace, monospace;
213 | font-size: 1em;
214 | }
215 |
216 | .markdown-body figure {
217 | margin: 1em 40px;
218 | }
219 |
220 | .markdown-body hr {
221 | box-sizing: content-box;
222 | overflow: hidden;
223 | background: transparent;
224 | border-bottom: 1px solid var(--color-border-muted);
225 | height: .25em;
226 | padding: 0;
227 | margin: 24px 0;
228 | background-color: var(--color-border-default);
229 | border: 0;
230 | }
231 |
232 | .markdown-body input {
233 | font: inherit;
234 | margin: 0;
235 | overflow: visible;
236 | font-family: inherit;
237 | font-size: inherit;
238 | line-height: inherit;
239 | }
240 |
241 | .markdown-body [type=button],
242 | .markdown-body [type=reset],
243 | .markdown-body [type=submit] {
244 | -webkit-appearance: button;
245 | }
246 |
247 | .markdown-body [type=button]::-moz-focus-inner,
248 | .markdown-body [type=reset]::-moz-focus-inner,
249 | .markdown-body [type=submit]::-moz-focus-inner {
250 | border-style: none;
251 | padding: 0;
252 | }
253 |
254 | .markdown-body [type=button]:-moz-focusring,
255 | .markdown-body [type=reset]:-moz-focusring,
256 | .markdown-body [type=submit]:-moz-focusring {
257 | outline: 1px dotted ButtonText;
258 | }
259 |
260 | .markdown-body [type=checkbox],
261 | .markdown-body [type=radio] {
262 | box-sizing: border-box;
263 | padding: 0;
264 | }
265 |
266 | .markdown-body [type=number]::-webkit-inner-spin-button,
267 | .markdown-body [type=number]::-webkit-outer-spin-button {
268 | height: auto;
269 | }
270 |
271 | .markdown-body [type=search] {
272 | -webkit-appearance: textfield;
273 | outline-offset: -2px;
274 | }
275 |
276 | .markdown-body [type=search]::-webkit-search-cancel-button,
277 | .markdown-body [type=search]::-webkit-search-decoration {
278 | -webkit-appearance: none;
279 | }
280 |
281 | .markdown-body ::-webkit-input-placeholder {
282 | color: inherit;
283 | opacity: .54;
284 | }
285 |
286 | .markdown-body ::-webkit-file-upload-button {
287 | -webkit-appearance: button;
288 | font: inherit;
289 | }
290 |
291 | .markdown-body a:hover {
292 | text-decoration: underline;
293 | }
294 |
295 | .markdown-body hr::before {
296 | display: table;
297 | content: "";
298 | }
299 |
300 | .markdown-body hr::after {
301 | display: table;
302 | clear: both;
303 | content: "";
304 | }
305 |
306 | .markdown-body table {
307 | border-spacing: 0;
308 | border-collapse: collapse;
309 | display: block;
310 | width: max-content;
311 | max-width: 100%;
312 | overflow: auto;
313 | }
314 |
315 | .markdown-body td,
316 | .markdown-body th {
317 | padding: 0;
318 | }
319 |
320 | .markdown-body details summary {
321 | cursor: pointer;
322 | }
323 |
324 | .markdown-body details:not([open])>*:not(summary) {
325 | display: none !important;
326 | }
327 |
328 | .markdown-body kbd {
329 | display: inline-block;
330 | padding: 3px 5px;
331 | font: 11px ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
332 | line-height: 10px;
333 | color: var(--color-fg-default);
334 | vertical-align: middle;
335 | background-color: var(--color-canvas-subtle);
336 | border: solid 1px var(--color-neutral-muted);
337 | border-bottom-color: var(--color-neutral-muted);
338 | border-radius: 6px;
339 | box-shadow: inset 0 -1px 0 var(--color-neutral-muted);
340 | }
341 |
342 | .markdown-body h1,
343 | .markdown-body h2,
344 | .markdown-body h3,
345 | .markdown-body h4,
346 | .markdown-body h5,
347 | .markdown-body h6 {
348 | padding-top: .5em;
349 | margin-top: 24px;
350 | margin-bottom: 16px;
351 | font-weight: 600;
352 | line-height: 1.25;
353 | }
354 |
355 | .markdown-body h2 {
356 | font-weight: 600;
357 | padding-bottom: .3em;
358 | font-size: 1.5em;
359 | border-bottom: 1px solid var(--color-border-muted);
360 | }
361 |
362 | .markdown-body h3 {
363 | font-weight: 600;
364 | font-size: 1.25em;
365 | }
366 |
367 | .markdown-body h4 {
368 | font-weight: 600;
369 | font-size: 1em;
370 | }
371 |
372 | .markdown-body h5 {
373 | font-weight: 600;
374 | font-size: .875em;
375 | }
376 |
377 | .markdown-body h6 {
378 | font-weight: 600;
379 | font-size: .85em;
380 | color: var(--color-fg-muted);
381 | }
382 |
383 | .markdown-body p {
384 | margin-top: 0;
385 | margin-bottom: 10px;
386 | }
387 |
388 | .markdown-body blockquote {
389 | margin: 0;
390 | padding: 0 1em;
391 | color: var(--color-fg-muted);
392 | border-left: .25em solid var(--color-border-default);
393 | }
394 |
395 | .markdown-body ul,
396 | .markdown-body ol {
397 | margin-top: 0;
398 | margin-bottom: 0;
399 | padding-left: 2em;
400 | }
401 |
402 | .markdown-body ol ol,
403 | .markdown-body ul ol {
404 | list-style-type: lower-roman;
405 | }
406 |
407 | .markdown-body ul ul ol,
408 | .markdown-body ul ol ol,
409 | .markdown-body ol ul ol,
410 | .markdown-body ol ol ol {
411 | list-style-type: lower-alpha;
412 | }
413 |
414 | .markdown-body dd {
415 | margin-left: 0;
416 | }
417 |
418 | .markdown-body tt,
419 | .markdown-body code {
420 | font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
421 | font-size: 12px;
422 | }
423 |
424 | .markdown-body pre {
425 | margin-top: 0;
426 | margin-bottom: 0;
427 | font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
428 | font-size: 12px;
429 | word-wrap: normal;
430 | }
431 |
432 | .markdown-body .octicon {
433 | display: inline-block;
434 | overflow: visible !important;
435 | vertical-align: text-bottom;
436 | fill: currentColor;
437 | }
438 |
439 | .markdown-body ::placeholder {
440 | color: var(--color-fg-subtle);
441 | opacity: 1;
442 | }
443 |
444 | .markdown-body input::-webkit-outer-spin-button,
445 | .markdown-body input::-webkit-inner-spin-button {
446 | margin: 0;
447 | -webkit-appearance: none;
448 | appearance: none;
449 | }
450 |
451 | .markdown-body .pl-c {
452 | color: var(--color-prettylights-syntax-comment);
453 | }
454 |
455 | .markdown-body .pl-c1,
456 | .markdown-body .pl-s .pl-v {
457 | color: var(--color-prettylights-syntax-constant);
458 | }
459 |
460 | .markdown-body .pl-e,
461 | .markdown-body .pl-en {
462 | color: var(--color-prettylights-syntax-entity);
463 | }
464 |
465 | .markdown-body .pl-smi,
466 | .markdown-body .pl-s .pl-s1 {
467 | color: var(--color-prettylights-syntax-storage-modifier-import);
468 | }
469 |
470 | .markdown-body .pl-ent {
471 | color: var(--color-prettylights-syntax-entity-tag);
472 | }
473 |
474 | .markdown-body .pl-k {
475 | color: var(--color-prettylights-syntax-keyword);
476 | }
477 |
478 | .markdown-body .pl-s,
479 | .markdown-body .pl-pds,
480 | .markdown-body .pl-s .pl-pse .pl-s1,
481 | .markdown-body .pl-sr,
482 | .markdown-body .pl-sr .pl-cce,
483 | .markdown-body .pl-sr .pl-sre,
484 | .markdown-body .pl-sr .pl-sra {
485 | color: var(--color-prettylights-syntax-string);
486 | }
487 |
488 | .markdown-body .pl-v,
489 | .markdown-body .pl-smw {
490 | color: var(--color-prettylights-syntax-variable);
491 | }
492 |
493 | .markdown-body .pl-bu {
494 | color: var(--color-prettylights-syntax-brackethighlighter-unmatched);
495 | }
496 |
497 | .markdown-body .pl-ii {
498 | color: var(--color-prettylights-syntax-invalid-illegal-text);
499 | background-color: var(--color-prettylights-syntax-invalid-illegal-bg);
500 | }
501 |
502 | .markdown-body .pl-c2 {
503 | color: var(--color-prettylights-syntax-carriage-return-text);
504 | background-color: var(--color-prettylights-syntax-carriage-return-bg);
505 | }
506 |
507 | .markdown-body .pl-sr .pl-cce {
508 | font-weight: bold;
509 | color: var(--color-prettylights-syntax-string-regexp);
510 | }
511 |
512 | .markdown-body .pl-ml {
513 | color: var(--color-prettylights-syntax-markup-list);
514 | }
515 |
516 | .markdown-body .pl-mh,
517 | .markdown-body .pl-mh .pl-en,
518 | .markdown-body .pl-ms {
519 | font-weight: bold;
520 | color: var(--color-prettylights-syntax-markup-heading);
521 | }
522 |
523 | .markdown-body .pl-mi {
524 | font-style: italic;
525 | color: var(--color-prettylights-syntax-markup-italic);
526 | }
527 |
528 | .markdown-body .pl-mb {
529 | font-weight: bold;
530 | color: var(--color-prettylights-syntax-markup-bold);
531 | }
532 |
533 | .markdown-body .pl-md {
534 | color: var(--color-prettylights-syntax-markup-deleted-text);
535 | background-color: var(--color-prettylights-syntax-markup-deleted-bg);
536 | }
537 |
538 | .markdown-body .pl-mi1 {
539 | color: var(--color-prettylights-syntax-markup-inserted-text);
540 | background-color: var(--color-prettylights-syntax-markup-inserted-bg);
541 | }
542 |
543 | .markdown-body .pl-mc {
544 | color: var(--color-prettylights-syntax-markup-changed-text);
545 | background-color: var(--color-prettylights-syntax-markup-changed-bg);
546 | }
547 |
548 | .markdown-body .pl-mi2 {
549 | color: var(--color-prettylights-syntax-markup-ignored-text);
550 | background-color: var(--color-prettylights-syntax-markup-ignored-bg);
551 | }
552 |
553 | .markdown-body .pl-mdr {
554 | font-weight: bold;
555 | color: var(--color-prettylights-syntax-meta-diff-range);
556 | }
557 |
558 | .markdown-body .pl-ba {
559 | color: var(--color-prettylights-syntax-brackethighlighter-angle);
560 | }
561 |
562 | .markdown-body .pl-sg {
563 | color: var(--color-prettylights-syntax-sublimelinter-gutter-mark);
564 | }
565 |
566 | .markdown-body .pl-corl {
567 | text-decoration: underline;
568 | color: var(--color-prettylights-syntax-constant-other-reference-link);
569 | }
570 |
571 | .markdown-body [data-catalyst] {
572 | display: block;
573 | }
574 |
575 | .markdown-body g-emoji {
576 | font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
577 | font-size: 1em;
578 | font-style: normal !important;
579 | font-weight: 400;
580 | line-height: 1;
581 | vertical-align: -0.075em;
582 | }
583 |
584 | .markdown-body g-emoji img {
585 | width: 1em;
586 | height: 1em;
587 | }
588 |
589 | .markdown-body::before {
590 | display: table;
591 | content: "";
592 | }
593 |
594 | .markdown-body::after {
595 | display: table;
596 | clear: both;
597 | content: "";
598 | }
599 |
600 | .markdown-body>*:first-child {
601 | margin-top: 0 !important;
602 | }
603 |
604 | .markdown-body>*:last-child {
605 | margin-bottom: 0 !important;
606 | }
607 |
608 | .markdown-body a:not([href]) {
609 | color: inherit;
610 | text-decoration: none;
611 | }
612 |
613 | .markdown-body .absent {
614 | color: var(--color-danger-fg);
615 | }
616 |
617 | .markdown-body .anchor {
618 | float: left;
619 | padding-right: 4px;
620 | margin-left: -20px;
621 | line-height: 1;
622 | }
623 |
624 | .markdown-body .anchor:focus {
625 | outline: none;
626 | }
627 |
628 | .markdown-body p,
629 | .markdown-body blockquote,
630 | .markdown-body ul,
631 | .markdown-body ol,
632 | .markdown-body dl,
633 | .markdown-body table,
634 | .markdown-body pre,
635 | .markdown-body details {
636 | margin-top: 0;
637 | margin-bottom: 16px;
638 | }
639 |
640 | .markdown-body blockquote>:first-child {
641 | margin-top: 0;
642 | }
643 |
644 | .markdown-body blockquote>:last-child {
645 | margin-bottom: 0;
646 | }
647 |
648 | .markdown-body sup>a::before {
649 | content: "[";
650 | }
651 |
652 | .markdown-body sup>a::after {
653 | content: "]";
654 | }
655 |
656 | .markdown-body h1 .octicon-link,
657 | .markdown-body h2 .octicon-link,
658 | .markdown-body h3 .octicon-link,
659 | .markdown-body h4 .octicon-link,
660 | .markdown-body h5 .octicon-link,
661 | .markdown-body h6 .octicon-link {
662 | color: var(--color-fg-default);
663 | vertical-align: middle;
664 | visibility: hidden;
665 | }
666 |
667 | .markdown-body h1:hover .anchor,
668 | .markdown-body h2:hover .anchor,
669 | .markdown-body h3:hover .anchor,
670 | .markdown-body h4:hover .anchor,
671 | .markdown-body h5:hover .anchor,
672 | .markdown-body h6:hover .anchor {
673 | text-decoration: none;
674 | }
675 |
676 | .markdown-body h1:hover .anchor .octicon-link,
677 | .markdown-body h2:hover .anchor .octicon-link,
678 | .markdown-body h3:hover .anchor .octicon-link,
679 | .markdown-body h4:hover .anchor .octicon-link,
680 | .markdown-body h5:hover .anchor .octicon-link,
681 | .markdown-body h6:hover .anchor .octicon-link {
682 | visibility: visible;
683 | }
684 |
685 | .markdown-body h1 tt,
686 | .markdown-body h1 code,
687 | .markdown-body h2 tt,
688 | .markdown-body h2 code,
689 | .markdown-body h3 tt,
690 | .markdown-body h3 code,
691 | .markdown-body h4 tt,
692 | .markdown-body h4 code,
693 | .markdown-body h5 tt,
694 | .markdown-body h5 code,
695 | .markdown-body h6 tt,
696 | .markdown-body h6 code {
697 | padding: 0 .2em;
698 | font-size: inherit;
699 | }
700 |
701 | .markdown-body ul.no-list,
702 | .markdown-body ol.no-list {
703 | padding: 0;
704 | list-style-type: none;
705 | }
706 |
707 | .markdown-body ol[type="1"] {
708 | list-style-type: decimal;
709 | }
710 |
711 | .markdown-body ol[type=a] {
712 | list-style-type: lower-alpha;
713 | }
714 |
715 | .markdown-body ol[type=i] {
716 | list-style-type: lower-roman;
717 | }
718 |
719 | .markdown-body div>ol:not([type]) {
720 | list-style-type: decimal;
721 | }
722 |
723 | .markdown-body ul ul,
724 | .markdown-body ul ol,
725 | .markdown-body ol ol,
726 | .markdown-body ol ul {
727 | margin-top: 0;
728 | margin-bottom: 0;
729 | }
730 |
731 | .markdown-body li>p {
732 | margin-top: 16px;
733 | }
734 |
735 | .markdown-body li+li {
736 | margin-top: .25em;
737 | }
738 |
739 | .markdown-body dl {
740 | padding: 0;
741 | }
742 |
743 | .markdown-body dl dt {
744 | padding: 0;
745 | margin-top: 16px;
746 | font-size: 1em;
747 | font-style: italic;
748 | font-weight: 600;
749 | }
750 |
751 | .markdown-body dl dd {
752 | padding: 0 16px;
753 | margin-bottom: 16px;
754 | }
755 |
756 | .markdown-body table th {
757 | font-weight: 600;
758 | }
759 |
760 | .markdown-body table th,
761 | .markdown-body table td {
762 | padding: 6px 13px;
763 | border: 1px solid var(--color-border-default);
764 | }
765 |
766 | .markdown-body table tr {
767 | background-color: var(--color-canvas-default);
768 | border-top: 1px solid var(--color-border-muted);
769 | }
770 |
771 | .markdown-body table tr:nth-child(2n) {
772 | background-color: var(--color-canvas-subtle);
773 | }
774 |
775 | .markdown-body table img {
776 | background-color: transparent;
777 | }
778 |
779 | .markdown-body img[align=right] {
780 | padding-left: 20px;
781 | }
782 |
783 | .markdown-body img[align=left] {
784 | padding-right: 20px;
785 | }
786 |
787 | .markdown-body .emoji {
788 | max-width: none;
789 | vertical-align: text-top;
790 | background-color: transparent;
791 | }
792 |
793 | .markdown-body span.frame {
794 | display: block;
795 | overflow: hidden;
796 | }
797 |
798 | .markdown-body span.frame>span {
799 | display: block;
800 | float: left;
801 | width: auto;
802 | padding: 7px;
803 | margin: 13px 0 0;
804 | overflow: hidden;
805 | border: 1px solid var(--color-border-default);
806 | }
807 |
808 | .markdown-body span.frame span img {
809 | display: block;
810 | float: left;
811 | }
812 |
813 | .markdown-body span.frame span span {
814 | display: block;
815 | padding: 5px 0 0;
816 | clear: both;
817 | color: var(--color-fg-default);
818 | }
819 |
820 | .markdown-body span.align-center {
821 | display: block;
822 | overflow: hidden;
823 | clear: both;
824 | }
825 |
826 | .markdown-body span.align-center>span {
827 | display: block;
828 | margin: 13px auto 0;
829 | overflow: hidden;
830 | text-align: center;
831 | }
832 |
833 | .markdown-body span.align-center span img {
834 | margin: 0 auto;
835 | text-align: center;
836 | }
837 |
838 | .markdown-body span.align-right {
839 | display: block;
840 | overflow: hidden;
841 | clear: both;
842 | }
843 |
844 | .markdown-body span.align-right>span {
845 | display: block;
846 | margin: 13px 0 0;
847 | overflow: hidden;
848 | text-align: right;
849 | }
850 |
851 | .markdown-body span.align-right span img {
852 | margin: 0;
853 | text-align: right;
854 | }
855 |
856 | .markdown-body span.float-left {
857 | display: block;
858 | float: left;
859 | margin-right: 13px;
860 | overflow: hidden;
861 | }
862 |
863 | .markdown-body span.float-left span {
864 | margin: 13px 0 0;
865 | }
866 |
867 | .markdown-body span.float-right {
868 | display: block;
869 | float: right;
870 | margin-left: 13px;
871 | overflow: hidden;
872 | }
873 |
874 | .markdown-body span.float-right>span {
875 | display: block;
876 | margin: 13px auto 0;
877 | overflow: hidden;
878 | text-align: right;
879 | }
880 |
881 | .markdown-body code,
882 | .markdown-body tt {
883 | padding: .2em .4em;
884 | margin: 0;
885 | font-size: 85%;
886 | background-color: var(--color-neutral-muted);
887 | border-radius: 6px;
888 | }
889 |
890 | .markdown-body code br,
891 | .markdown-body tt br {
892 | display: none;
893 | }
894 |
895 | .markdown-body del code {
896 | text-decoration: inherit;
897 | }
898 |
899 | .markdown-body pre code {
900 | font-size: 100%;
901 | }
902 |
903 | .markdown-body pre>code {
904 | padding: 0;
905 | margin: 0;
906 | word-break: normal;
907 | white-space: pre;
908 | background: transparent;
909 | border: 0;
910 | }
911 |
912 | .markdown-body .highlight {
913 | margin-bottom: 16px;
914 | }
915 |
916 | .markdown-body .highlight pre {
917 | margin-bottom: 0;
918 | word-break: normal;
919 | }
920 |
921 | .markdown-body .highlight pre,
922 | .markdown-body pre {
923 | padding: 16px;
924 | overflow: auto;
925 | font-size: 85%;
926 | line-height: 1.45;
927 | background-color: var(--color-canvas-subtle);
928 | border-radius: 6px;
929 | }
930 |
931 | .markdown-body pre code,
932 | .markdown-body pre tt {
933 | display: inline;
934 | max-width: auto;
935 | padding: 0;
936 | margin: 0;
937 | overflow: visible;
938 | line-height: inherit;
939 | word-wrap: normal;
940 | background-color: transparent;
941 | border: 0;
942 | }
943 |
944 | .markdown-body .csv-data td,
945 | .markdown-body .csv-data th {
946 | padding: 5px;
947 | overflow: hidden;
948 | font-size: 12px;
949 | line-height: 1;
950 | text-align: left;
951 | white-space: nowrap;
952 | }
953 |
954 | .markdown-body .csv-data .blob-num {
955 | padding: 10px 8px 9px;
956 | text-align: right;
957 | background: var(--color-canvas-default);
958 | border: 0;
959 | }
960 |
961 | .markdown-body .csv-data tr {
962 | border-top: 0;
963 | }
964 |
965 | .markdown-body .csv-data th {
966 | font-weight: 600;
967 | background: var(--color-canvas-subtle);
968 | border-top: 0;
969 | }
970 |
971 | .markdown-body .footnotes {
972 | font-size: 12px;
973 | color: var(--color-fg-muted);
974 | border-top: 1px solid var(--color-border-default);
975 | }
976 |
977 | .markdown-body .footnotes ol {
978 | padding-left: 16px;
979 | }
980 |
981 | .markdown-body .footnotes li {
982 | position: relative;
983 | }
984 |
985 | .markdown-body .footnotes li:target::before {
986 | position: absolute;
987 | top: -8px;
988 | right: -8px;
989 | bottom: -8px;
990 | left: -24px;
991 | pointer-events: none;
992 | content: "";
993 | border: 2px solid var(--color-accent-emphasis);
994 | border-radius: 6px;
995 | }
996 |
997 | .markdown-body .footnotes li:target {
998 | color: var(--color-fg-default);
999 | }
1000 |
1001 | .markdown-body .footnotes .data-footnote-backref g-emoji {
1002 | font-family: monospace;
1003 | }
1004 |
1005 | .markdown-body .task-list-item {
1006 | list-style-type: none;
1007 | }
1008 |
1009 | .markdown-body .task-list-item label {
1010 | font-weight: 400;
1011 | }
1012 |
1013 | .markdown-body .task-list-item.enabled label {
1014 | cursor: pointer;
1015 | }
1016 |
1017 | .markdown-body .task-list-item+.task-list-item {
1018 | margin-top: 3px;
1019 | }
1020 |
1021 | .markdown-body .task-list-item .handle {
1022 | display: none;
1023 | }
1024 |
1025 | .markdown-body .task-list-item-checkbox {
1026 | margin: 0 .2em .25em -1.6em;
1027 | vertical-align: middle;
1028 | }
1029 |
1030 | .markdown-body .contains-task-list:dir(rtl) .task-list-item-checkbox {
1031 | margin: 0 -1.6em .25em .2em;
1032 | }
1033 |
1034 | .markdown-body ::-webkit-calendar-picker-indicator {
1035 | filter: invert(50%);
1036 | }
--------------------------------------------------------------------------------
/src/components/icons/WebViewPlus.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { SvgIcon, SvgIconProps } from '@mui/material';
4 |
5 | const WebViewPlus = (props: SvgIconProps) => {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | );
49 | };
50 |
51 | export default WebViewPlus;
52 |
--------------------------------------------------------------------------------
/src/components/icons/YingYang.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { SvgIcon, SvgIconProps } from '@mui/material';
4 |
5 | const YingYang = (props: SvgIconProps) => {
6 | return (
7 |
8 |
9 |
10 |
11 | );
12 | };
13 |
14 | export default YingYang;
15 |
--------------------------------------------------------------------------------
/src/global.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'comma-separated-values';
2 | declare const APP_VERSION: string;
3 |
--------------------------------------------------------------------------------
/src/hooks/useStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { createStore } from 'zustand/vanilla';
3 | import { combine, persist } from 'zustand/middleware';
4 |
5 | import { IPlugin, ViewerType } from '@plugins/PluginInterface';
6 | import { SyntaxPlugin } from '@plugins/SyntaxPlugin';
7 | import { XLSXPlugin } from '@plugins/XLSXPlugin';
8 | import { SVGPlugin } from '@plugins/SVGPlugin';
9 | import { IFramePlugin } from '@plugins/IFramePlugin';
10 | import { MarkdownPlugin } from '@plugins/MarkdownPlugin';
11 | import { ModelViewerPlugin } from '@plugins/ModelViewerPlugin';
12 | import { ImagePlugin } from '@plugins/ImagePlugin';
13 | import { JupyterNBPlugin } from '@plugins/JupyterNBPlugin';
14 | import { FontPlugin } from '@plugins/FontPlugin';
15 | import { EPubPlugin } from '@plugins/EPubPlugin';
16 | import { IWebView2 } from '@utils/webview2Helpers';
17 | import { ImageRendering, ZoomBehaviour } from '@utils/types';
18 |
19 | const PLUGIN_SETTINGS_KEY = 'pluginSettings';
20 | const PLUGIN_EXTRA_SETTINGS_KEY = 'pluginExtraSettings';
21 |
22 | export const DEFAULT_FONTTEXT = 'The quick brown fox jumps over the lazy dog';
23 | export const DEFAULT_SYNTAX_FONTSIZE = 13;
24 |
25 | const PLUGINS = [
26 | new IFramePlugin(),
27 | new SVGPlugin(),
28 | new ImagePlugin(),
29 | new MarkdownPlugin(),
30 | new XLSXPlugin(),
31 | new ModelViewerPlugin(),
32 | new SyntaxPlugin(),
33 | new JupyterNBPlugin(),
34 | new FontPlugin(),
35 | new EPubPlugin(),
36 | ];
37 |
38 | interface PluginSettings {
39 | shortName: string;
40 | enabled: boolean;
41 | extensions: { [index: string]: boolean };
42 | extraExtensions: string[];
43 | }
44 |
45 | interface TOCItem {
46 | level: number;
47 | id: string;
48 | title: string;
49 | }
50 |
51 | interface FileData {
52 | fileSize: number;
53 | fileName: string;
54 | fileExt: string;
55 | fileUrl: string;
56 | fileContent: string | ArrayBuffer | null;
57 | }
58 |
59 | const getEnabledExtensions = () => {
60 | const { plugins } = store.getState();
61 | const extensions = new Set();
62 | for (const p of plugins) {
63 | if (!p.enabled) continue;
64 |
65 | for (const fileExt in p.extensions) {
66 | if (p.extensions[fileExt]) {
67 | extensions.add(fileExt);
68 | }
69 | }
70 | for (const fileExt of p.extraExtensions) {
71 | // for the syntax highlighter the file extension can
72 | // be followed by the language name. E.g. rs:rust
73 | const rawFileExt = fileExt.split(':')[0];
74 | extensions.add(rawFileExt);
75 | }
76 | }
77 | return [...extensions];
78 | };
79 |
80 | const savePluginSettings = (plugins: IPlugin[]) => {
81 | const pluginSettings: PluginSettings[] = [];
82 | for (const p of plugins) {
83 | pluginSettings.push({
84 | shortName: p.shortName,
85 | enabled: p.enabled,
86 | extraExtensions: p.extraExtensions,
87 | extensions: p.extensions,
88 | });
89 | }
90 | window.localStorage.setItem(
91 | PLUGIN_SETTINGS_KEY,
92 | JSON.stringify(pluginSettings),
93 | );
94 | };
95 |
96 | const loadPluginSettings = () => {
97 | const { pluginByShortName } = store.getState();
98 |
99 | const pluginSettings: PluginSettings[] = JSON.parse(
100 | window.localStorage.getItem(PLUGIN_SETTINGS_KEY) || '[]',
101 | );
102 | for (const ps of pluginSettings) {
103 | const p = pluginByShortName[ps.shortName];
104 | if (p) {
105 | p.enabled = ps.enabled;
106 | p.extraExtensions = ps.extraExtensions;
107 | for (const ext in ps.extensions) {
108 | if (ext in p.extensions) {
109 | p.extensions[ext] = ps.extensions[ext];
110 | }
111 | }
112 | }
113 | }
114 | };
115 |
116 | export const store = createStore(
117 | persist(
118 | combine(
119 | {
120 | webview: (window as any).chrome?.webview as IWebView2 | undefined,
121 |
122 | fileSize: 0,
123 | fileName: '',
124 | fileExt: '',
125 | fileUrl: '',
126 | fileContent: null as string | ArrayBuffer | null,
127 | mdTableOfContentsItems: [] as TOCItem[],
128 |
129 | activeViewer: ViewerType.Unknown,
130 | showSettings: false as boolean,
131 | plugins: PLUGINS as IPlugin[],
132 | pluginByShortName: Object.fromEntries(PLUGINS.map(x => [x.shortName, x])),
133 | yingYang: true as boolean,
134 | isDark: true as boolean,
135 |
136 | openExifPanel: false,
137 | zoom: 1,
138 |
139 | // values below are stored in localstorage (see partialize below)
140 | // image plugin
141 | imageRendering: ImageRendering.Auto,
142 | newImageZoomBehaviour: ZoomBehaviour.ZoomToFit,
143 | resizeImageZoomBehaviour: ZoomBehaviour.KeepZoom,
144 | // font plugin
145 | fontText: DEFAULT_FONTTEXT,
146 | // syntax highlight plugin
147 | syntaxShowLineNumbers: true,
148 | syntaxWrapLines: false,
149 | syntaxFontSize: DEFAULT_SYNTAX_FONTSIZE,
150 | syntaxCustomFont: '',
151 | // detect text file encoding
152 | detectEncoding: false,
153 | // advanced QL settings from https://github.com/QL-Win/QuickLook/wiki/Advanced-configurations
154 | showTrayIcon: true,
155 | useTransparency: true,
156 | // EPub plugin
157 | ePubFontSize: 0,
158 | ePubCustomFont: '',
159 | },
160 | set => ({
161 | actions: {
162 | init: () => {
163 | set((state) => {
164 | loadPluginSettings();
165 | state.webview?.postMessage({ command: 'Extensions', data: getEnabledExtensions() });
166 | return { plugins: [...state.plugins] };
167 | });
168 | },
169 | unload: () => {
170 | set(() => {
171 | return {
172 | fileSize: 0,
173 | fileContent: null,
174 | fileName: '',
175 | fileExt: '',
176 | fileUrl: '',
177 | mdTableOfContentsItems: [],
178 | activeViewer: ViewerType.Unknown,
179 | };
180 | });
181 | },
182 | updateFileData: (fileData: FileData) => {
183 | set((state) => {
184 | return {
185 | ...fileData,
186 | activeViewer: ViewerType.Unknown,
187 | };
188 | });
189 | },
190 | togglePlugin: (p: IPlugin) => {
191 | set((state) => {
192 | p.enabled = !p.enabled;
193 | return { plugins: [...state.plugins] };
194 | });
195 | },
196 | toggleExtension: (ext: string, pluginShortName: string) => {
197 | set((state) => {
198 | const p = state.pluginByShortName[pluginShortName];
199 | if (ext in p.extensions) {
200 | p.extensions[ext] = !p.extensions[ext];
201 | }
202 | return { plugins: [...state.plugins] };
203 | });
204 | },
205 | setExtraExtensions: (extensions: string[], pluginShortName: string) => {
206 | set((state) => {
207 | const p = state.pluginByShortName[pluginShortName];
208 | p.extraExtensions = extensions;
209 | return { plugins: [...state.plugins] };
210 | });
211 | },
212 | setActiveViewer: (viewer: ViewerType) => {
213 | set((state) => {
214 | return { activeViewer: viewer };
215 | });
216 | },
217 | savePluginSettings: () => {
218 | const state = store.getState();
219 | savePluginSettings(state.plugins);
220 | state.webview?.postMessage({ command: 'Extensions', data: getEnabledExtensions() });
221 | return {};
222 | },
223 | clearTableOfContent: () => {
224 | set((state) => {
225 | return { mdTableOfContentsItems: [] };
226 | });
227 | },
228 | addTableOfContentItem: (t: TOCItem) => {
229 | set((state) => {
230 | return { mdTableOfContentsItems: [...state.mdTableOfContentsItems, t] };
231 | });
232 | },
233 | togglePixelated: () => {
234 | set((state) => {
235 | const pixelated = state.imageRendering === ImageRendering.Pixelated;
236 | return { imageRendering: (!pixelated) ? ImageRendering.Pixelated : ImageRendering.Auto };
237 | });
238 | },
239 | setNewImageZoomBehaviour: (newImageZoomBehaviour: ZoomBehaviour) => {
240 | set((state) => {
241 | return { newImageZoomBehaviour };
242 | });
243 | },
244 | setResizeImageZoomBehaviour: (resizeImageZoomBehaviour: ZoomBehaviour) => {
245 | set((state) => {
246 | return { resizeImageZoomBehaviour };
247 | });
248 | },
249 | setFontText: (fontText: string) => {
250 | set((state) => {
251 | return { fontText };
252 | });
253 | },
254 | toggleSyntaxShowLineNumbers: () => {
255 | set((state) => {
256 | return { syntaxShowLineNumbers: !state.syntaxShowLineNumbers };
257 | });
258 | },
259 | toggleSyntaxWrapLines: () => {
260 | set((state) => {
261 | return { syntaxWrapLines: !state.syntaxWrapLines };
262 | });
263 | },
264 | setSyntaxFontSize: (syntaxFontSize: number) => {
265 | set((state) => {
266 | return { syntaxFontSize };
267 | });
268 | },
269 | setSyntaxCustomFont: (syntaxCustomFont: string) => {
270 | set((state) => {
271 | return { syntaxCustomFont };
272 | });
273 | },
274 | setEPubFontSize: (ePubFontSize: number) => {
275 | set((state) => {
276 | return { ePubFontSize };
277 | });
278 | },
279 | setEPubCustomFont: (ePubCustomFont: string) => {
280 | set((state) => {
281 | return { ePubCustomFont };
282 | });
283 | },
284 | setDetectEncoding: (detectEncoding: boolean, options = { init: false }) => {
285 | set((state) => {
286 | if (!options.init) {
287 | state.webview?.postMessage({ command: 'DetectEncoding', boolValue: detectEncoding });
288 | }
289 | return { detectEncoding };
290 | });
291 | },
292 | setShowTrayIcon: (showTrayIcon: boolean, options = { init: false }) => {
293 | set((state) => {
294 | if (!options.init) {
295 | state.webview?.postMessage({ command: 'ShowTrayIcon', boolValue: showTrayIcon });
296 | }
297 | return { showTrayIcon };
298 | });
299 | },
300 | setUseTransparency: (useTransparency: boolean, options = { init: false }) => {
301 | set((state) => {
302 | if (!options.init) {
303 | state.webview?.postMessage({ command: 'UseTransparency', boolValue: useTransparency });
304 | }
305 | return { useTransparency };
306 | });
307 | },
308 | restartQuickLook: () => {
309 | set((state) => {
310 | state.webview?.postMessage({ command: 'Restart', boolValue: true });
311 | return {};
312 | });
313 | },
314 | },
315 | }),
316 | ),
317 | {
318 | name: PLUGIN_EXTRA_SETTINGS_KEY,
319 | partialize: state => ({
320 | imageRendering: state.imageRendering,
321 | newImageZoomBehaviour: state.newImageZoomBehaviour,
322 | resizeImageZoomBehaviour: state.resizeImageZoomBehaviour,
323 | fontText: state.fontText,
324 | syntaxShowLineNumbers: state.syntaxShowLineNumbers,
325 | syntaxWrapLines: state.syntaxWrapLines,
326 | syntaxFontSize: state.syntaxFontSize,
327 | syntaxCustomFont: state.syntaxCustomFont,
328 | detectEncoding: state.detectEncoding,
329 | showTrayIcon: state.showTrayIcon,
330 | useTransparency: state.useTransparency,
331 | ePubFontSize: state.ePubFontSize,
332 | ePubCustomFont: state.ePubCustomFont,
333 | isDark: state.isDark,
334 | yingYang: state.yingYang,
335 | }),
336 | },
337 | ),
338 | );
339 |
340 | const useStore = create(store);
341 |
342 | export default useStore;
343 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { createRoot } from 'react-dom/client';
3 |
4 | import '@utils/i18n';
5 | import App from './App';
6 |
7 | const container = document.getElementById('root');
8 | const root = createRoot(container!);
9 | root.render();
10 |
--------------------------------------------------------------------------------
/src/plugins/EPubPlugin.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import EPubViewerSettings from '@components/Settings/EPubViewerSettings';
4 |
5 | import { IPlugin, ViewerType } from './PluginInterface';
6 |
7 | export class EPubPlugin implements IPlugin {
8 | public shortName = 'epub';
9 | public viewerType = ViewerType.EPub;
10 | public enabled = true;
11 | public extraExtensions: string[] = [];
12 | public extensions: { [index: string]: boolean } = { epub: true };
13 | public customSettings = ;
14 | }
15 |
--------------------------------------------------------------------------------
/src/plugins/FontPlugin.tsx:
--------------------------------------------------------------------------------
1 | import { IPlugin, ViewerType } from './PluginInterface';
2 |
3 | export class FontPlugin implements IPlugin {
4 | public shortName = 'font';
5 | public viewerType = ViewerType.Font;
6 | public enabled = true;
7 | public extraExtensions: string[] = [];
8 | public extensions: { [index: string]: boolean } = {
9 | ttf: true,
10 | otf: true,
11 | woff: true,
12 | woff2: true,
13 | };
14 | }
15 |
--------------------------------------------------------------------------------
/src/plugins/IFramePlugin.tsx:
--------------------------------------------------------------------------------
1 | import { IPlugin, ViewerType } from './PluginInterface';
2 |
3 | export class IFramePlugin implements IPlugin {
4 | public shortName = 'iframe';
5 | public viewerType = ViewerType.IFrame;
6 | public enabled = true;
7 | public extraExtensions: string[] = [];
8 | public extensions: { [index: string]: boolean } = {
9 | htm: true,
10 | html: true,
11 | mht: true,
12 | mhtml: true,
13 | pdf: true,
14 | };
15 | }
16 |
--------------------------------------------------------------------------------
/src/plugins/ImagePlugin.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import ImageViewerSettings from '@components/Settings/ImageViewerSettings';
4 |
5 | import { IPlugin, ViewerType } from './PluginInterface';
6 |
7 | export class ImagePlugin implements IPlugin {
8 | public shortName = 'image';
9 | public viewerType = ViewerType.Image;
10 | public enabled = true;
11 | public extraExtensions: string[] = [];
12 | public extensions: { [index: string]: boolean } = {
13 | png: true,
14 | apng: true,
15 | jpg: true,
16 | jpeg: true,
17 | gif: true,
18 | bmp: true,
19 | webp: true,
20 | avif: true,
21 | };
22 |
23 | public customSettings = ;
24 | }
25 |
--------------------------------------------------------------------------------
/src/plugins/JupyterNBPlugin.tsx:
--------------------------------------------------------------------------------
1 | import { IPlugin, ViewerType } from './PluginInterface';
2 |
3 | export class JupyterNBPlugin implements IPlugin {
4 | public shortName = 'jupyter';
5 | public viewerType = ViewerType.Jupyter;
6 | public enabled = true;
7 | public extraExtensions: string[] = [];
8 | public extensions: { [index: string]: boolean } = {
9 | ipynb: true,
10 | };
11 | }
12 |
--------------------------------------------------------------------------------
/src/plugins/MarkdownPlugin.tsx:
--------------------------------------------------------------------------------
1 | import { IPlugin, ViewerType } from './PluginInterface';
2 |
3 | export class MarkdownPlugin implements IPlugin {
4 | public shortName = 'md';
5 | public viewerType = ViewerType.Markdown;
6 | public enabled = true;
7 | public extraExtensions: string[] = [];
8 | public extensions: { [index: string]: boolean } = {
9 | md: true,
10 | markdown: true,
11 | };
12 | }
13 |
--------------------------------------------------------------------------------
/src/plugins/ModelViewerPlugin.tsx:
--------------------------------------------------------------------------------
1 | import { IPlugin, ViewerType } from './PluginInterface';
2 |
3 | export class ModelViewerPlugin implements IPlugin {
4 | public shortName = 'modelViewer';
5 | public viewerType = ViewerType.Model3D;
6 | public enabled = true;
7 | public extraExtensions: string[] = [];
8 | public extensions: { [index: string]: boolean } = {
9 | gltf: true,
10 | glb: true,
11 | obj: true,
12 | fbx: true,
13 | };
14 | }
15 |
--------------------------------------------------------------------------------
/src/plugins/PluginInterface.ts:
--------------------------------------------------------------------------------
1 | export enum ViewerType {
2 | Unknown,
3 | General,
4 | Tabular,
5 | SVG,
6 | Syntax,
7 | Model3D,
8 | Markdown,
9 | IFrame,
10 | Jupyter,
11 | Image,
12 | Font,
13 | EPub,
14 | }
15 |
16 | export interface IPlugin {
17 | shortName: string;
18 | viewerType: ViewerType;
19 | enabled: boolean;
20 | extensions: { [index: string]: boolean };
21 | extraExtensions: string[];
22 | customSettings?: JSX.Element;
23 | }
24 |
--------------------------------------------------------------------------------
/src/plugins/SVGPlugin.tsx:
--------------------------------------------------------------------------------
1 | import { IPlugin, ViewerType } from './PluginInterface';
2 |
3 | export class SVGPlugin implements IPlugin {
4 | public shortName = 'svg';
5 | public viewerType = ViewerType.SVG;
6 | public enabled = true;
7 | public extraExtensions: string[] = [];
8 | public extensions: { [index: string]: boolean } = {
9 | svg: true,
10 | };
11 | }
12 |
--------------------------------------------------------------------------------
/src/plugins/SyntaxPlugin.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import SyntaxViewerSettings from '@components/Settings/SyntaxViewerSettings';
4 |
5 | import { IPlugin, ViewerType } from './PluginInterface';
6 |
7 | // TODO: add more - see https://prismjs.com/#supported-languages
8 | export const Ext2Lang: { [index: string]: string } = {
9 | 'c++': 'cpp',
10 | 'h++': 'cpp',
11 | bat: 'batch',
12 | c: 'c',
13 | cmake: 'cmake',
14 | cpp: 'cpp',
15 | cs: 'csharp',
16 | css: 'css',
17 | d: 'd',
18 | go: 'go',
19 | h: 'c',
20 | hpp: 'cpp',
21 | html: 'html',
22 | ini: 'ini',
23 | java: 'java',
24 | js: 'javascript',
25 | json: 'json',
26 | jsx: 'jsx',
27 | kt: 'kotlin',
28 | lua: 'lua',
29 | m: 'objectivec',
30 | mm: 'objectivec',
31 | makefile: 'makefile',
32 | md: 'markdown',
33 | pas: 'pascal',
34 | perl: 'perl',
35 | php: 'php',
36 | pl: 'perl',
37 | ps1: 'powershell',
38 | psm1: 'powershell',
39 | py: 'python',
40 | r: 'r',
41 | rb: 'ruby',
42 | rs: 'rust',
43 | sass: 'sass',
44 | scss: 'scss',
45 | sh: 'bash',
46 | scala: 'scala',
47 | sql: 'sql',
48 | svg: 'svg',
49 | swift: 'swift',
50 | tex: 'latex',
51 | ts: 'typescript',
52 | tsx: 'tsx',
53 | txt: 'plain',
54 | xml: 'xml',
55 | yaml: 'yaml',
56 | yml: 'yaml',
57 | };
58 |
59 | export class SyntaxPlugin implements IPlugin {
60 | public shortName = 'syntax';
61 | public viewerType = ViewerType.Syntax;
62 | public enabled = true;
63 | public extraExtensions: string[] = [];
64 | public extensions: { [index: string]: boolean } = {};
65 | public customSettings = ;
66 |
67 | constructor() {
68 | for (const ext in Ext2Lang) {
69 | if ({}.hasOwnProperty.call(Ext2Lang, ext)) {
70 | this.extensions[ext] = true;
71 | }
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/plugins/XLSXPlugin.tsx:
--------------------------------------------------------------------------------
1 | import { IPlugin, ViewerType } from './PluginInterface';
2 |
3 | // supported formats by SheetJs:
4 | // https://docs.sheetjs.com/docs/miscellany/formats
5 |
6 | export class XLSXPlugin implements IPlugin {
7 | public shortName = 'xlsx';
8 | public viewerType = ViewerType.Tabular;
9 | public enabled = true;
10 | public extraExtensions: string[] = [];
11 | public extensions: { [index: string]: boolean } = {
12 | xlsx: true,
13 | xls: true,
14 | ods: true,
15 | csv: true,
16 | tsv: true,
17 | };
18 | }
19 |
--------------------------------------------------------------------------------
/src/theme.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { createTheme, Theme } from '@mui/material';
4 | import { indigo, grey } from '@mui/material/colors';
5 |
6 | import useStore from '@hooks/useStore';
7 |
8 | const useTheme = () => {
9 | const [theme, setTheme] = React.useState(createTheme());
10 | const isDark = useStore(state => state.isDark);
11 |
12 | React.useEffect(() => {
13 | setTheme(createTheme({
14 | palette: {
15 | mode: isDark ? 'dark' : 'light',
16 | ...(isDark
17 | ? {
18 | // palette values for dark mode
19 | primary: indigo,
20 | divider: indigo[700],
21 | background: {
22 | default: grey[900],
23 | paper: grey[800],
24 | },
25 | } : {
26 | // palette values for light mode
27 | background: {
28 | paper: grey[200],
29 | },
30 | }),
31 | },
32 | components: {
33 | MuiCssBaseline: {
34 | styleOverrides: {
35 | html: {
36 | width: '100%',
37 | height: '100%',
38 | margin: 0,
39 | '*::-webkit-scrollbar': {
40 | width: '10px', // width of vertical scrollbar
41 | height: '10px', // height of horizontal scrollbar
42 | },
43 | /* color of the tracking area
44 | '*::-webkit-scrollbar-track': {
45 | backgroundColor: '#888',
46 | },
47 | */
48 | '*::-webkit-scrollbar-thumb': {
49 | backgroundColor: isDark ? '#444' : '#ccc',
50 | borderRadius: '5px',
51 | border: isDark ? '2px solid #222' : '2px solid #fff',
52 | },
53 | '*::-webkit-scrollbar-corner': {
54 | backgroundColor: isDark ? '#222' : '#fff',
55 | },
56 | },
57 | body: {
58 | width: '100%',
59 | height: '100%',
60 | },
61 | img: {
62 | float: 'none',
63 | },
64 | pre: {
65 | fontSize: '0.8rem',
66 | fontFamily: 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace',
67 | color: isDark ? '#aaa !important' : '#333 !important',
68 | },
69 | '#root': {
70 | width: '100%',
71 | height: '100%',
72 | },
73 | // react-syntax-highlighter
74 | 'span[data="textLine"]': {
75 | display: 'block',
76 | },
77 | 'span[data="textLine"]:hover': {
78 | backgroundColor: isDark ? '#444' : '#eee',
79 | },
80 | // react-data-grid custom style
81 | '.rdg': {
82 | fontFamily: 'monospace',
83 | fontSize: '1rem !important',
84 | height: '100% !important',
85 | color: isDark ? '#aaa !important' : '#333 !important',
86 | backgroundColor: isDark ? '#222 !important' : '#fff !important',
87 | '--border-color': isDark ? '#444' : '#ddd',
88 | },
89 | '.rdg-header-row': {
90 | backgroundColor: isDark ? '#115 !important' : '#ccf !important',
91 | },
92 | '.rdg-header-row:hover': {
93 | filter: isDark ? 'brightness(1.2)' : 'brightness(0.8)',
94 | },
95 | '.rdg-row-odd': {
96 | backgroundColor: isDark ? '#222 !important' : '#fff !important',
97 | },
98 | '.rdg-row-odd:hover': {
99 | filter: isDark ? 'brightness(1.2)' : 'brightness(0.8)',
100 | },
101 | '.rdg-row-even': {
102 | backgroundColor: isDark ? '#333 !important' : '#eee !important',
103 | },
104 | '.rdg-row-even:hover': {
105 | filter: isDark ? 'brightness(1.2)' : 'brightness(0.8)',
106 | },
107 | },
108 | },
109 | MuiTab: {
110 | styleOverrides: {
111 | root: {
112 | borderBottom: isDark ? '1px solid #333' : '1px solid #eee',
113 | '&.Mui-selected': {
114 | backgroundColor: isDark ? '#282828' : '#e8e8e8',
115 | },
116 | textAlign: 'left',
117 | },
118 | },
119 | },
120 | MuiDialogTitle: {
121 | styleOverrides: {
122 | root: {
123 | backgroundColor: isDark ? '#282828' : '#ddd',
124 | borderBottom: isDark ? '1px solid #444' : '1px solid #aaa',
125 | },
126 | },
127 | },
128 | MuiTypography: {
129 | styleOverrides: {
130 | caption: {
131 | color: isDark ? '#666' : '#aaa',
132 | },
133 | },
134 | },
135 | },
136 | }));
137 | }, [isDark]);
138 |
139 | return theme;
140 | };
141 |
142 | export default useTheme;
143 |
--------------------------------------------------------------------------------
/src/translations/de/plugin.yaml:
--------------------------------------------------------------------------------
1 | pluginName:
2 | iframe: Einheimisch über iframe
3 | xlsx: Tabellarische Daten
4 | md: Markdown
5 | modelViewer: 3D-Modell-Betrachter
6 | svg: SVG
7 | syntax: Syntax-Highlighter
8 | jupyter: Jupyter Notebook
9 | image: Bild
10 | font: Schriftart
11 | epub: EPub
12 | General: Allgemein
13 | SelectOrDnDFile: Wählen Sie eine Datei aus oder ziehen Sie sie per Drag & Drop hierher.
14 | Settings: Einstellungen
15 | FileExtensions: Dateierweiterungen
16 | NoteExtraExtensions: "Hinweis: Erweiterungen für Binärdateien erfordern Codeänderungen"
17 | ExtraExtensionsLabel: Zusätzliche Dateierweiterungen (durch Komma getrennt; ohne Punkt)
18 | Close: Schließen
19 | Reload: Neu laden
20 | AppError: "Fehler: "
21 | SomethingWentWrong: Etwas ist schief gelaufen :(
22 | NavigationRejected: Sorry, Navigation nicht erlaubt.
23 | UnsupportedDataType: "Nicht unterstützter Datentyp: "
24 | UnsupportedVersion: Nicht unterstützte Version
25 | ExtraSettings: Extra Einstellungen
26 | Pixelated: Verpixelt (nächster Nachbar)
27 | NewImageZoomBehaviour: Zoomverhalten beim Bildwechsel
28 | ResizeZoomBehaviour: Zoomverhalten bei Größenänderung des Fensters
29 | KeepZoom: Zoom beibehalten
30 | ZoomToFit: Zoom anpassen
31 | Zoom1To1: Zoom 1:1
32 | FileTypeNotSupported: Dateityp nicht aktiviert oder nicht unterstützt.
33 | NoFontInfoAvailable: Keine Informationen zur Schriftart verfügbar.
34 | EditText: Text bearbeiten
35 | ResetText: Text zurücksetzen
36 | ShowLineNumbers: Zeilennummern anzeigen
37 | WrapLines: Lange Zeilen umbrechen
38 | FontSize: Schriftgröße
39 | CustomFont: Benutzerdefinierte Schriftart
40 | CustomFontExplain: "Eine css @font-face Schriftart. Z.B.:"
41 | GeneralWebViewPlus: Allgemeines WebViewPlus
42 | DetectEncoding: Kodierung von Textdateien erkennen
43 | DetectEncodingTooltip: Tritt bei der nächsten Vorschau in Kraft
44 | GeneralQuickLook: General Quicklook
45 | ShowTrayIcon: Tray-Symbol anzeigen
46 | ShowTrayIconTooltip: Tritt beim Neustart von Quicklook in Kraft
47 | UseTransparency: Fenster-Transparenz
48 | UseTransparencyTooltip: Tritt beim Neustart von Quicklook in Kraft
49 | RestartQuicklook: Quicklook neu starten
50 | EpubCustomFontExplain: "Der Name einer Schriftart. z.B.:"
--------------------------------------------------------------------------------
/src/translations/en/plugin.yaml:
--------------------------------------------------------------------------------
1 | pluginName:
2 | iframe: Native via iframe
3 | xlsx: Tabular data
4 | md: Markdown
5 | modelViewer: 3D model viewer
6 | svg: SVG
7 | syntax: Syntax highlighter
8 | jupyter: Jupyter Notebook
9 | image: Image
10 | font: Font
11 | epub: EPub
12 | General: General
13 | SelectOrDnDFile: Select or drag and drop file here.
14 | Settings: Settings
15 | FileExtensions: File Extensions
16 | NoteExtraExtensions: "Note: Extensions for binary files require code changes"
17 | ExtraExtensionsLabel: Extra file extensions (comma separated; without dot)
18 | Close: Close
19 | Reload: Reload
20 | AppError: "Error: "
21 | SomethingWentWrong: Something went wrong :(
22 | NavigationRejected: Sorry, navigation not allowed.
23 | UnsupportedDataType: "Unsupported data type: "
24 | UnsupportedVersion: Unsupported version
25 | ExtraSettings: Extra Settings
26 | Pixelated: Pixelated (Nearest Neighbour)
27 | NewImageZoomBehaviour: Zoom behaviour when changing images
28 | ResizeZoomBehaviour: Zoom behaviour when resizing window
29 | KeepZoom: Keep zoom
30 | ZoomToFit: Zoom to fit
31 | Zoom1To1: Zoom 1:1
32 | FileTypeNotSupported: File type not enabled or not supported.
33 | NoFontInfoAvailable: No font info available.
34 | EditText: Edit text
35 | ResetText: Reset text
36 | ShowLineNumbers: Show line numbers
37 | WrapLines: Wrap long lines
38 | FontSize: Font size
39 | CustomFont: Custom font
40 | CustomFontExplain: "A css @font-face font. E.g.:"
41 | GeneralWebViewPlus: General WebViewPlus
42 | DetectEncoding: Detect encoding of text files
43 | DetectEncodingTooltip: Takes effect on next preview
44 | GeneralQuickLook: General Quicklook
45 | ShowTrayIcon: Show tray icon
46 | ShowTrayIconTooltip: Takes effect on Quicklook restart
47 | UseTransparency: Window transparency
48 | UseTransparencyTooltip: Takes effect on Quicklook restart
49 | RestartQuicklook: Restart Quicklook
50 | EpubCustomFontExplain: "A font name. E.g.:"
--------------------------------------------------------------------------------
/src/translations/index.ts:
--------------------------------------------------------------------------------
1 | const langModules = import.meta.glob('./*/*.yaml');
2 |
3 | export const resources: any = {};
4 | for (const filePath in langModules) {
5 | if ({}.hasOwnProperty.call(langModules, filePath)) {
6 | const pathElements = filePath.split('/');
7 | // eslint-disable-next-line no-await-in-loop
8 | const plugin = await langModules[filePath]();
9 | resources[pathElements[1]] = { plugin };
10 | }
11 | }
12 |
13 | // This creates a plugin*.js file for each language module in the release build.
14 | // TODO: load language module on demand. Lang to be passed from QL plugin.
15 |
--------------------------------------------------------------------------------
/src/utils/CSV2RowData.tsx:
--------------------------------------------------------------------------------
1 | import CSV from 'comma-separated-values';
2 |
3 | const CSV2RowData = (data: string) => {
4 | const rows: any = [];
5 | const columns: any = [];
6 |
7 | new CSV(data).forEach((array: Array) => {
8 | if (columns.length < 1) {
9 | array.forEach((cell: any, idx: number) => {
10 | columns.push({
11 | key: `key-${idx}`,
12 | name: cell,
13 | resizable: true,
14 | sortable: true,
15 | filterable: true,
16 | });
17 | });
18 | } else {
19 | const row: any = {};
20 | array.forEach((cell: any, idx: number) => {
21 | row[`key-${idx}`] = cell;
22 | });
23 | rows.push(row);
24 | }
25 | });
26 |
27 | return { rows, columns };
28 | };
29 |
30 | export default CSV2RowData;
31 |
--------------------------------------------------------------------------------
/src/utils/i18n.ts:
--------------------------------------------------------------------------------
1 | import i18n from 'i18next';
2 | import { initReactI18next } from 'react-i18next';
3 | import LanguageDetector from 'i18next-browser-languagedetector';
4 |
5 | import { resources } from '../translations';
6 |
7 | const detection = {
8 | order: ['querystring', 'navigator'],
9 | lookupQuerystring: 'lang',
10 | };
11 |
12 | i18n
13 | .use(initReactI18next)
14 | .use(LanguageDetector)
15 | .init({
16 | resources,
17 | detection,
18 | ns: ['plugin'],
19 | fallbackLng: 'en',
20 | interpolation: {
21 | escapeValue: false,
22 | },
23 | });
24 |
25 | export default i18n;
26 |
--------------------------------------------------------------------------------
/src/utils/log.ts:
--------------------------------------------------------------------------------
1 | function nowWithoutTimezone() {
2 | const d = new Date();
3 | const t = d.toString().substring(0, 24);
4 | const ms = `000${d.getMilliseconds()}`;
5 | return `${t}.${ms.substring(ms.length - 3)}`;
6 | }
7 |
8 | export function log(...args: any) {
9 | Function.prototype.call.call(
10 | // eslint-disable-next-line no-console
11 | console.log,
12 | console,
13 | `${nowWithoutTimezone()} ${args.toString()}`,
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/utils/openFile.ts:
--------------------------------------------------------------------------------
1 | import useStore from '@hooks/useStore';
2 | import { log } from '@utils/log';
3 |
4 | // These should match the ones in the QL plugin WebpagePanel._binExtensions
5 | const BINARY_EXTENSIONS = new Set([
6 | // tabular
7 | 'xlsx',
8 | 'xls',
9 | 'ods',
10 | // 3d model
11 | 'gltf',
12 | 'glb',
13 | 'obj',
14 | 'fbx',
15 | // fonts
16 | 'ttf',
17 | 'otf',
18 | 'woff',
19 | 'woff2',
20 | // pdf & images
21 | 'pdf',
22 | 'jpg',
23 | 'jpeg',
24 | 'apng',
25 | 'png',
26 | 'gif',
27 | 'bmp',
28 | 'webp',
29 | 'avif',
30 | // epub
31 | 'epub',
32 | ]);
33 |
34 | const handledDataLoaded = (e: ProgressEvent) => {
35 | if (e?.target?.result) {
36 | log(`handledDataLoaded: size: ${e.total}`);
37 | const content = e.target.result;
38 | if (typeof content === 'string') {
39 | useStore.setState({
40 | fileContent: content,
41 | fileSize: e.total,
42 | });
43 | } else if (content instanceof ArrayBuffer) {
44 | useStore.setState({
45 | fileContent: content,
46 | fileSize: e.total,
47 | });
48 | } else {
49 | log('File content not supported');
50 | }
51 | }
52 | };
53 |
54 | export const openFile = (file: File) => {
55 | log(`openFile: ${file.name}`);
56 | const ext = file.name.split('.').pop()?.toLocaleLowerCase() || '';
57 |
58 | const url = URL.createObjectURL(file); // returns a blob url - won't expose local file system location
59 | const fileReader = new FileReader();
60 | fileReader.onloadend = handledDataLoaded;
61 | if (BINARY_EXTENSIONS.has(ext)) {
62 | fileReader.readAsArrayBuffer(file);
63 | } else {
64 | fileReader.readAsText(file);
65 | }
66 | useStore.getState().actions.updateFileData({
67 | fileSize: 0,
68 | fileContent: null,
69 | fileName: file.name,
70 | fileExt: ext,
71 | fileUrl: url,
72 | });
73 | };
74 |
--------------------------------------------------------------------------------
/src/utils/types.ts:
--------------------------------------------------------------------------------
1 | export enum ImageRendering {
2 | Auto = 'auto',
3 | Pixelated = 'pixelated',
4 | }
5 |
6 | export enum ZoomBehaviour {
7 | KeepZoom = 'keepZoom',
8 | ZoomToFit = 'zoomToFit',
9 | Zoom1To1 = 'zoom1To1',
10 | }
11 |
12 | export interface InitData
13 | {
14 | langCode: string;
15 | detectEncoding: boolean;
16 | showTrayIcon: boolean;
17 | useTransparency: boolean;
18 | }
19 |
--------------------------------------------------------------------------------
/src/utils/webview2Helpers.ts:
--------------------------------------------------------------------------------
1 | import { store } from '@hooks/useStore';
2 | import { log } from '@utils/log';
3 |
4 | export interface IWebView2 {
5 | postMessage: (obj: any) => void;
6 | releaseBuffer: (buffer: ArrayBuffer) => void;
7 | addEventListener: (type: string, listener: (arg: any) => void) => void;
8 | removeEventListener: (type: string, listener: (arg: any) => void) => void;
9 | // hostObjects
10 | // postMessageWithAdditionalObjects
11 | // dispatchEvent
12 | }
13 |
14 | const ext2Mime: { [index: string]: string } = {
15 | pdf: 'application/pdf',
16 | webp: 'image/webp',
17 | png: 'image/png',
18 | apng: 'image/png',
19 | jpg: 'image/jpeg',
20 | jpeg: 'image/jpeg',
21 | gif: 'image/gif',
22 | bmp: 'image/bmp',
23 | };
24 |
25 | export const handleSharedBufferReceived = (e: MessageEvent & {additionalData: any, getBuffer: any}) => {
26 | const binContent: ArrayBuffer = e.getBuffer();
27 | const fileName = e.additionalData.fileName;
28 | const fileSize = e.additionalData.fileSize;
29 | const isBinary = e.additionalData.isBinary;
30 | const textContent = e.additionalData.textContent;
31 | const fileExt = fileName.split('.').pop()?.toLocaleLowerCase() || '';
32 | log(`Received handleSharedBufferReceived: File=${fileName} Size=${fileSize}`);
33 |
34 | // fileUrl for iframed and img content (pdf, html, etc.)
35 | let fileUrl = '';
36 | if (isBinary) {
37 | const mimeType = ext2Mime[fileExt]
38 | ? ext2Mime[fileExt]
39 | : 'application/octet-stream';
40 | const blob = new Blob([binContent], {
41 | type: mimeType,
42 | });
43 | fileUrl = URL.createObjectURL(blob);
44 | } else {
45 | const blob = new Blob([textContent], { type: 'text/html' });
46 | fileUrl = URL.createObjectURL(blob);
47 | }
48 |
49 | store.getState().actions.updateFileData({
50 | fileSize,
51 | fileContent: isBinary ? binContent : textContent,
52 | fileName,
53 | fileExt,
54 | fileUrl,
55 | });
56 | };
57 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "@plugins/*": ["src/plugins/*"],
6 | "@utils/*": ["src/utils/*"],
7 | "@hooks/*": ["src/hooks/*"],
8 | "@components/*": ["src/components/*"],
9 | },
10 | "target": "ESNext",
11 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
12 | "types": ["vite/client"],
13 | "allowJs": false,
14 | "skipLibCheck": true,
15 | "incremental": true,
16 | "esModuleInterop": false,
17 | "allowSyntheticDefaultImports": true,
18 | "strict": true,
19 | "forceConsistentCasingInFileNames": true,
20 | "module": "ESNext",
21 | "moduleResolution": "Node",
22 | "resolveJsonModule": true,
23 | "isolatedModules": true,
24 | "noEmit": true,
25 | "jsx": "react"
26 | },
27 | "include": ["./src"]
28 | }
29 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import { defineConfig } from 'vite';
3 | import { visualizer } from 'rollup-plugin-visualizer';
4 |
5 | import reactRefresh from '@vitejs/plugin-react-swc';
6 | import yaml from '@rollup/plugin-yaml';
7 |
8 | // eslint-disable-next-line import/extensions
9 | import packageJson from './package.json';
10 |
11 | // https://vitejs.dev/config/
12 | export default defineConfig({
13 | base: './',
14 | define: {
15 | APP_VERSION: JSON.stringify(packageJson.version),
16 | },
17 | plugins: [reactRefresh(), yaml(), visualizer()],
18 | resolve: {
19 | alias: {
20 | '@plugins/': '/src/plugins/',
21 | '@utils/': '/src/utils/',
22 | '@hooks/': '/src/hooks/',
23 | '@components/': '/src/components/',
24 | '@mui/base': '@mui/base/modern',
25 | '@mui/lab': '@mui/lab/modern',
26 | '@mui/material': '@mui/material/modern',
27 | '@mui/styled-engine': '@mui/styled-engine/modern',
28 | '@mui/system': '@mui/system/modern',
29 | '@mui/utils': '@mui/utils/modern',
30 | },
31 | },
32 | build: {
33 | target: 'esnext',
34 | outDir: 'build',
35 | },
36 | server: {
37 | port: 3000,
38 | allowedHosts: true,
39 | fs: {
40 | strict: false,
41 | },
42 | },
43 | });
44 |
--------------------------------------------------------------------------------