├── .babelrc ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── ci-linux.yml │ ├── ci-windows.yml │ ├── publish-linux.yml │ └── publish-windows.yml ├── .gitignore ├── DESIGN.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── api │ ├── files.ts │ ├── index.ts │ ├── ipc.ts │ ├── main-proxy.ts │ └── preferences.ts ├── application │ ├── browsing.ts │ ├── builtin.ts │ ├── component.tsx │ ├── esimport.d.ts │ ├── esimport.js │ ├── index.html │ ├── index.sass │ ├── index.ts │ ├── preference-service.ts │ └── react-loader.ts ├── contextual-menu.sass ├── contextual-menu.tsx ├── debounce.ts ├── dialog.ts ├── elements.sass ├── error.ts ├── event.ts ├── extension.ts ├── gallery │ ├── constants.ts │ ├── gallery.sass │ ├── index.ts │ ├── menu.tsx │ └── mode.tsx ├── global.sass ├── help.sass ├── help.tsx ├── index.d.ts ├── interface.ts ├── ipc.contract.ts ├── main │ ├── attrs.d.ts │ ├── attrs.linux.ts │ ├── attrs.win32.ts │ ├── dbus-native.d.ts │ ├── file.ts │ ├── index.ts │ ├── thumbnail.d.ts │ ├── thumbnail.linux.ts │ ├── thumbnail.win32.ts │ └── window.ts ├── menu.sass ├── notice.sass ├── notice.tsx ├── number-input.sass ├── number-input.tsx ├── ordering │ ├── comparer.ts │ ├── index.ts │ ├── menu.tsx │ └── service.ts ├── pipeline.ts ├── progress.ts ├── radio-buttons.sass ├── radio-buttons.tsx ├── scope-toggle.tsx ├── scroll-pane.sass ├── scroll-pane.tsx ├── shell │ ├── extras.tsx │ ├── index.tsx │ ├── menu-button.tsx │ ├── menu.sass │ ├── menu.tsx │ ├── modes.tsx │ ├── selection.tsx │ ├── shell.sass │ └── system-buttons.tsx ├── stage │ ├── center.tsx │ ├── constants.ts │ ├── index.ts │ ├── lineup.sass │ ├── lineup.tsx │ ├── menu.tsx │ ├── mode.tsx │ ├── stage.sass │ └── transition-service.ts ├── tag │ ├── filter.ts │ ├── index.ts │ ├── list │ │ ├── index.sass │ │ ├── index.tsx │ │ ├── input.tsx │ │ └── item.tsx │ ├── menu.tsx │ └── service.ts └── thumbnail │ ├── image.test.tsx │ ├── image.tsx │ ├── index.tsx │ └── thumbnail.sass ├── tsconfig.json ├── tslint.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "targets": { 5 | "chrome": "70" 6 | } 7 | }], 8 | "@babel/preset-typescript", 9 | "@babel/preset-react" 10 | ], 11 | "plugins": [ 12 | "@babel/proposal-class-properties", 13 | "@babel/proposal-object-rest-spread", 14 | "@babel/plugin-transform-runtime" 15 | ] 16 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "rules": { 13 | // dumb rule for people with uncontrolled ADHD or too lazy to read 14 | "no-fallthrough": "off", 15 | 16 | // dumb rule for gullible idiots that trusts implicit conversions 17 | "@typescript-eslint/no-inferrable-types": "off" 18 | } 19 | } -------------------------------------------------------------------------------- /.github/workflows/ci-linux.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Linux-CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | runs-on: [ubuntu-latest] 15 | 16 | strategy: 17 | matrix: 18 | node-version: [14.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | 23 | - name: Cache node modules 24 | uses: actions/cache@v2 25 | env: 26 | cache-name: cache-node-modules 27 | with: 28 | path: ~/.npm 29 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 30 | restore-keys: | 31 | ${{ runner.os }}-build-${{ env.cache-name }}- 32 | ${{ runner.os }}-build- 33 | ${{ runner.os }}- 34 | 35 | - name: Use Node.js ${{ matrix.node-version }} 36 | uses: actions/setup-node@v1 37 | with: 38 | node-version: ${{ matrix.node-version }} 39 | 40 | - run: npm install 41 | 42 | - run: npm run build 43 | -------------------------------------------------------------------------------- /.github/workflows/ci-windows.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Windows-CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | runs-on: [windows-latest] 15 | 16 | strategy: 17 | matrix: 18 | node-version: [14.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | 23 | - name: Cache node modules 24 | uses: actions/cache@v2 25 | env: 26 | cache-name: cache-node-modules 27 | with: 28 | path: '%userprofile%\.npm' 29 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**\package-lock.json') }} 30 | restore-keys: | 31 | ${{ runner.os }}-build-${{ env.cache-name }}- 32 | ${{ runner.os }}-build- 33 | ${{ runner.os }}- 34 | 35 | - name: setup-msbuild 36 | uses: microsoft/setup-msbuild@v1 37 | 38 | - name: Use Node.js ${{ matrix.node-version }} 39 | uses: actions/setup-node@v1 40 | with: 41 | node-version: ${{ matrix.node-version }} 42 | 43 | - run: npm install 44 | 45 | - run: npm run build 46 | -------------------------------------------------------------------------------- /.github/workflows/publish-linux.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Linux-Publish 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | jobs: 11 | build: 12 | runs-on: [ubuntu-latest] 13 | 14 | strategy: 15 | matrix: 16 | node-version: [14.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - run: npm install 27 | 28 | - run: npm run release 29 | 30 | - run: zip --junk-paths build/ATTRIBUTION.linux.zip build/ATTRIBUTION.*.json 31 | 32 | - name: Get release 33 | id: get_release 34 | uses: bruceadams/get-release@v1.2.2 35 | env: 36 | GITHUB_TOKEN: ${{ github.token }} 37 | 38 | - name: Upload debian installer 39 | uses: actions/upload-release-asset@v1 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | with: 43 | upload_url: ${{ steps.get_release.outputs.upload_url }} 44 | asset_name: fs-viewer-linux-${{ github.event.release.tag_name }}.deb 45 | asset_path: dist/fs-viewer_${{ github.event.release.tag_name }}_amd64.deb 46 | asset_content_type: application/octlet-stream 47 | 48 | - name: Upload RPM installer 49 | uses: actions/upload-release-asset@v1 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | with: 53 | upload_url: ${{ steps.get_release.outputs.upload_url }} 54 | asset_name: fs-viewer-linux-${{ github.event.release.tag_name }}.rpm 55 | asset_path: dist/fs-viewer-${{ github.event.release.tag_name }}.x86_64.rpm 56 | asset_content_type: application/octlet-stream 57 | 58 | - name: Upload ASAR archive 59 | uses: actions/upload-release-asset@v1 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | with: 63 | upload_url: ${{ steps.get_release.outputs.upload_url }} 64 | asset_name: fs-viewer-linux-${{ github.event.release.tag_name }}.app.asar 65 | asset_path: dist/linux-unpacked/resources/app.asar 66 | asset_content_type: application/octlet-stream 67 | 68 | - name: Upload attribution files 69 | uses: actions/upload-release-asset@v1 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | with: 73 | upload_url: ${{ steps.get_release.outputs.upload_url }} 74 | asset_name: fs-viewer-linux-attributions-${{ github.event.release.tag_name }}.zip 75 | asset_path: build/ATTRIBUTION.linux.zip 76 | asset_content_type: application/zip 77 | -------------------------------------------------------------------------------- /.github/workflows/publish-windows.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Windows-Publish 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | jobs: 11 | build: 12 | runs-on: [windows-latest] 13 | 14 | strategy: 15 | matrix: 16 | node-version: [14.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | - name: setup-msbuild 22 | uses: microsoft/setup-msbuild@v1 23 | 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | 29 | - run: npm install 30 | 31 | - run: npm run release 32 | 33 | - run: Compress-Archive build/ATTRIBUTION.*.json build/ATTRIBUTION.win.zip 34 | 35 | - name: Get release 36 | id: get_release 37 | uses: bruceadams/get-release@v1.2.2 38 | env: 39 | GITHUB_TOKEN: ${{ github.token }} 40 | 41 | - name: Upload windows installer 42 | uses: actions/upload-release-asset@v1 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | with: 46 | upload_url: ${{ steps.get_release.outputs.upload_url }} 47 | asset_name: fs-viewer-win-${{ github.event.release.tag_name }}.exe 48 | asset_path: dist/fs-viewer Setup ${{ github.event.release.tag_name }}.exe 49 | asset_content_type: application/octet-stream 50 | 51 | - name: Upload ASAR archive 52 | uses: actions/upload-release-asset@v1 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | with: 56 | upload_url: ${{ steps.get_release.outputs.upload_url }} 57 | asset_name: fs-viewer-win-${{ github.event.release.tag_name }}.app.asar 58 | asset_path: dist/win-unpacked/resources/app.asar 59 | asset_content_type: application/octet-stream 60 | 61 | - name: Upload attribution files 62 | uses: actions/upload-release-asset@v1 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | with: 66 | upload_url: ${{ steps.get_release.outputs.upload_url }} 67 | asset_name: fs-viewer-win-attributions-${{ github.event.release.tag_name }}.zip 68 | asset_path: build/ATTRIBUTION.win.zip 69 | asset_content_type: application/zip 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | index.js 5 | *.txt -------------------------------------------------------------------------------- /DESIGN.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unreadablewxy/fs-viewer/d4093045697fd6a50b8db427828452a72f591777/DESIGN.md -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Linux-CI](https://github.com/unreadablewxy/fs-viewer/workflows/Linux-CI/badge.svg) 2 | ![Windows-CI](https://github.com/unreadablewxy/fs-viewer/workflows/Windows-CI/badge.svg) 3 | 4 | # fs-viewer 5 | 6 | Created to solve one person's image organization woes, this program is for those who wants a tagging image browser like those of booru sites. But isn't a big fan of brittle database files other viewers like to keep. 7 | 8 | What is unique about this program? 9 | * Tag data is kept separate, but securely attached, to your files. Guaranteed by your Operating System 10 | * Moving or editing your files won't affect your tags 11 | * Changing your tags won't affect your file contents or checksum 12 | * See [xattrs](https://man7.org/linux/man-pages/man7/xattr.7.html) or [alternate streams](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-fscc/c54dec26-1551-4d3a-a0ea-4fa40f848eb3) for those interested 13 | * Have a Windows tablet or Linux storage server? Tag data auto follows your files even over SMB and NFS file shares 14 | * Designed to work with giant `mono-collections` that contains thousands of files without noticable performance issues 15 | * Supports all popular animated image & short video clip formats (built on electron, so everything Chrome supports, "just works") 16 | * Integrated with your OS' native thumbnailer for maximum compatibility out of the box 17 | * Can also hook up a custom thumbnailer and load thumbnails from wherever you like, even animated ones 18 | * Extensibility! Built in [extensions support](https://github.com/unreadablewxy/fs-viewer/wiki/Extension-Development) 19 | 20 |

21 | 22 | https://user-images.githubusercontent.com/18103838/127989462-6ca40f90-5351-4b77-8153-1456dd2c439e.mp4 23 | 24 | https://user-images.githubusercontent.com/18103838/127989469-67e7d66a-7ebe-426f-a89c-247ee17a9ca3.mp4 25 | 26 |

27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fs-viewer", 3 | "version": "1.3.1", 4 | "description": "A tagging small media browser", 5 | "main": "build/index.js", 6 | "types": "src/index.d.ts", 7 | "scripts": { 8 | "test-types": "tsc", 9 | "build": "webpack --mode=production", 10 | "watch": "webpack --mode=development --watch", 11 | "postinstall": "electron-builder install-app-deps", 12 | "release": "npm run clean && npm run build && electron-builder", 13 | "clean": "rm -rf build dist", 14 | "start": "electron .", 15 | "test": "jest", 16 | "test-watch": "jest --watch" 17 | }, 18 | "build": { 19 | "appId": "de.unreadableco.freakshow.viewer", 20 | "copyright": "Copyright © 2020 ${author}", 21 | "linux": { 22 | "target": [ 23 | "deb", 24 | "rpm" 25 | ], 26 | "category": "Graphics" 27 | }, 28 | "files": [ 29 | "build/*", 30 | "!build/*.txt", 31 | "!build/*.json", 32 | "!node_modules/**/*" 33 | ], 34 | "extraFiles": [ 35 | { 36 | "from": "build", 37 | "filter": [ 38 | "*.txt", 39 | "*.json" 40 | ] 41 | } 42 | ], 43 | "publish": null 44 | }, 45 | "repository": "https://github.com/unreadablewxy/fs-viewer", 46 | "keywords": [], 47 | "author": { 48 | "name": "UnreadableCode", 49 | "email": "nobody@unreadableco.de", 50 | "url": "https://github.com/unreadable-code" 51 | }, 52 | "homepage": "https://github.com/unreadablewxy/fs-viewer", 53 | "license": "GPL-2.0", 54 | "devDependencies": { 55 | "@babel/core": "^7.15.5", 56 | "@babel/plugin-proposal-class-properties": "^7.14.5", 57 | "@babel/plugin-proposal-object-rest-spread": "^7.14.7", 58 | "@babel/plugin-transform-runtime": "^7.15.0", 59 | "@babel/preset-env": "^7.15.4", 60 | "@babel/preset-react": "^7.14.5", 61 | "@babel/preset-typescript": "^7.15.0", 62 | "@types/enzyme": "^3.10.9", 63 | "@types/jest": "^27.0.1", 64 | "@types/mime": "^2.0.3", 65 | "@types/node": "^14.17.14", 66 | "@types/react": "^17.0.19", 67 | "@types/react-dom": "^17.0.9", 68 | "@types/react-router-dom": "^5.1.8", 69 | "@types/reselect": "^2.2.0", 70 | "@typescript-eslint/eslint-plugin": "^4.30.0", 71 | "@typescript-eslint/parser": "^4.30.0", 72 | "babel-loader": "^8.2.2", 73 | "copy-webpack-plugin": "^9.0.1", 74 | "css-loader": "^6.2.0", 75 | "electron": "^13.2.3", 76 | "electron-builder": "^22.11.7", 77 | "enzyme": "^3.11.0", 78 | "enzyme-adapter-react-16": "^1.15.6", 79 | "eslint": "^7.32.0", 80 | "eslint-loader": "^4.0.2", 81 | "eslint-webpack-plugin": "^3.0.1", 82 | "html-webpack-plugin": "^5.3.2", 83 | "jest": "^27.1.0", 84 | "license-webpack-plugin": "^2.3.21", 85 | "mini-css-extract-plugin": "^2.2.2", 86 | "native-ext-loader": "^2.3.0", 87 | "sass": "^1.39.0", 88 | "sass-loader": "^12.1.0", 89 | "source-map-loader": "^3.0.0", 90 | "style-loader": "^3.2.1", 91 | "terser-webpack-plugin": "^5.2.3", 92 | "typescript": "^4.4.2", 93 | "webpack": "5.45.1", 94 | "webpack-cli": "^4.8.0", 95 | "webpack-config-builder": "github:unreadable-code/webpack-config-builder#breaking-changes" 96 | }, 97 | "optionalDependencies": { 98 | "fs-xattr": "^0.3.1", 99 | "shell-image-win": "github:unreadable-code/shell-image-win#upgrade-nan" 100 | }, 101 | "dependencies": { 102 | "@mdi/js": "^5.9.55", 103 | "@mdi/react": "^1.5.0", 104 | "dbus-native": "^0.4.0", 105 | "fuse.js": "^6.4.6", 106 | "inconel": "github:unreadable-code/inconel", 107 | "mime": "^2.5.2", 108 | "react": "^17.0.2", 109 | "react-dom": "^17.0.2", 110 | "react-draggable": "^4.4.4", 111 | "react-router-dom": "^5.3.0", 112 | "reselect": "^4.0.0" 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/api/files.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createReadStream, 3 | existsSync, 4 | Dirent, 5 | Stats, 6 | promises as afs, 7 | } from "fs"; 8 | import {dirname, join as joinPath, normalize} from "path"; 9 | import readline from "readline"; 10 | 11 | import {getHomePath, loadPreferenceFile, rcFileName as preferencesFile} from "./preferences"; 12 | 13 | import type {preference} from ".."; 14 | import type {OpenDirectoryResult} from "../ipc.contract"; 15 | 16 | export {joinPath}; 17 | 18 | export function readDirectory(path: string): Promise { 19 | return afs.readdir(path, {withFileTypes: true}); 20 | } 21 | 22 | export function getFileStat(path: string): Promise { 23 | return afs.stat(path); 24 | } 25 | 26 | /** 27 | * The equivlent of Array.prototype.reduce that operates on a lines of a file 28 | * @param path 29 | * @param visitor 30 | * @param initial 31 | * @returns 32 | */ 33 | export function reduceTextFile( 34 | path: string, 35 | visitor: (value: T, line: string) => boolean, 36 | initial: T, 37 | ): Promise { 38 | return new Promise((resolve, reject) => { 39 | const lineReader = readline.createInterface({ 40 | input: createReadStream(path, {encoding: "utf8"}), 41 | }); 42 | 43 | lineReader.on("close", () => { 44 | resolve(initial); 45 | }); 46 | 47 | lineReader.on("line", line => { 48 | try { 49 | if (!visitor(initial, line)) 50 | lineReader.close(); 51 | } catch (e) { 52 | lineReader.removeAllListeners(); 53 | lineReader.close(); 54 | reject(e); 55 | } 56 | }); 57 | }); 58 | } 59 | 60 | const tagsNSFile = ".tagnames"; 61 | 62 | /** 63 | * @param directory a directory in which to begin 64 | * @returns `directory` or a parent, that contains a namespace file 65 | */ 66 | export function findTagNamespaceFile(directory: string): string { 67 | const homePath = normalize(getHomePath()); 68 | 69 | let p = normalize(directory); 70 | do { 71 | const tryPath = joinPath(p, tagsNSFile); 72 | if (existsSync(tryPath)) 73 | return tryPath; 74 | 75 | const parent = dirname(p); 76 | if (parent === p) break; 77 | p = parent; 78 | } while (p !== homePath); 79 | 80 | return joinPath(directory, tagsNSFile); 81 | } 82 | 83 | /** 84 | * A specialized function meant to load all data necessary when changing the 85 | * working directory of the program 86 | * 87 | * @param path the path the user is intending to open 88 | * @returns relevant information about the new working directory 89 | */ 90 | export async function openDirectory(path: string): Promise { 91 | const files = await readDirectory(path); 92 | 93 | const result: OpenDirectoryResult = { 94 | files: { 95 | path, 96 | names: [], 97 | }, 98 | preferences: {}, 99 | }; 100 | 101 | let loadPrefsTask: Promise> | null = null; 102 | 103 | for (let n = 0; n < files.length; ++n) { 104 | const entry = files[n]; 105 | if (entry.isFile()) { 106 | const fileName = entry.name; 107 | switch (fileName) { 108 | case preferencesFile: 109 | loadPrefsTask = loadPreferenceFile(joinPath(path, fileName)); 110 | break; 111 | 112 | default: 113 | if (fileName[0] !== ".") 114 | result.files.names.push(fileName); 115 | break; 116 | } 117 | } 118 | } 119 | 120 | if (loadPrefsTask) 121 | result.preferences = await loadPrefsTask; 122 | 123 | return result; 124 | } 125 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import {contextBridge} from "electron"; 2 | 3 | import { 4 | joinPath, 5 | 6 | getFileStat, 7 | openDirectory, 8 | readDirectory, 9 | reduceTextFile, 10 | 11 | findTagNamespaceFile, 12 | } from "./files"; 13 | 14 | import { 15 | createIPCConnection, 16 | createWorkerProcess, 17 | executeProgram, 18 | } from "./ipc"; 19 | 20 | import {fs, window} from "./main-proxy"; 21 | 22 | import { 23 | loadPreferences, 24 | savePreferences, 25 | getExtensionRoot, 26 | getExtensions, 27 | } from "./preferences"; 28 | 29 | function create() { 30 | return Object.freeze({ 31 | joinPath, 32 | 33 | getFileStat, 34 | openDirectory, 35 | readDirectory, 36 | reduceTextFile, 37 | 38 | findTagNamespaceFile, 39 | 40 | fs, 41 | window, 42 | 43 | createIPCConnection, 44 | createWorkerProcess, 45 | executeProgram, 46 | 47 | loadPreferences, 48 | savePreferences, 49 | getExtensionRoot, 50 | getExtensions, 51 | }); 52 | } 53 | 54 | let api: API | null = create(); 55 | 56 | function acquire(): API { 57 | const acquired = api; 58 | if (!acquired) { 59 | throw new Error("API already acquired by privileged code"); 60 | } 61 | 62 | api = null 63 | return acquired; 64 | } 65 | 66 | declare global { 67 | type API = ReturnType; 68 | 69 | interface Window { 70 | api: { 71 | acquire(): API, 72 | }, 73 | } 74 | } 75 | 76 | contextBridge.exposeInMainWorld("api", {acquire}); 77 | -------------------------------------------------------------------------------- /src/api/ipc.ts: -------------------------------------------------------------------------------- 1 | import {execFile} from "child_process"; 2 | import {existsSync} from "fs"; 3 | import {createConnection as createNetConnection} from "net"; 4 | import {normalize, isAbsolute} from "path"; 5 | 6 | import type {Readable, Writable} from "stream"; 7 | import type {ipc} from ".."; 8 | 9 | function assertPath(maybePath: string): string { 10 | maybePath = normalize(maybePath); 11 | if (!isAbsolute(maybePath) || !existsSync(maybePath)) 12 | throw new Error("A file system path is required"); 13 | 14 | return maybePath; 15 | } 16 | 17 | const EnvelopeSize = 8; 18 | 19 | /** 20 | * Implements request multiplexing for multiple RPC proxies. 21 | * 22 | * The protocol is pretty simple, every message is enveloped in 2 fields: 23 | * UInt32 callID 24 | * UInt32 payloadBytesSize 25 | * 26 | * Call IDs allows multiple overlapped requests to be dispatched and serviced 27 | * simultaneously. Both the request and response that comprises a RPC Call must 28 | * be identified by the same call ID. 29 | * 30 | * To simplify synchronization on both sides, odd call IDs always denote a call 31 | * that was initiated by this program. Even call IDs denotes the alternative. 32 | */ 33 | class Connection { 34 | private refCount = 0; 35 | 36 | // All client sent IDs are odd, all remote IDs are even 37 | private nextCallID = 1; 38 | private responseHandlers = new Map void>(); 39 | private readonly envelope = new Uint32Array(2); 40 | 41 | // The parts of a multi-part message 42 | private receivedParts: Uint8Array[] = []; 43 | 44 | // How many more bytes are needed before the next message is fully received 45 | private receiveDue: number = 0; 46 | 47 | constructor( 48 | private readonly input: Readable, 49 | private readonly output: Writable, 50 | private readonly close: () => void, 51 | private readonly listener?: ipc.Listener, 52 | ) { 53 | this.input.on("data", this.onData.bind(this)); 54 | } 55 | 56 | public createProxy(): ipc.RPCProxy { 57 | const proxy: ipc.RPCProxy = { 58 | call: this.sendAndReceive.bind(this), 59 | close: this.release.bind(this), 60 | }; 61 | 62 | ++this.refCount; 63 | return proxy; 64 | } 65 | 66 | private async onData(data: Uint8Array): Promise { 67 | if (this.receiveDue) { 68 | this.receivedParts.push(data); 69 | 70 | if (this.receiveDue > data.byteLength) { 71 | // Still need to read more, add this to the pile and wait 72 | this.receiveDue -= data.byteLength; 73 | return; 74 | } else { 75 | data = Buffer.concat(this.receivedParts); 76 | this.receiveDue = 0; 77 | } 78 | } 79 | 80 | // The data received might be multiple messages concatenated 81 | do { 82 | const view = new DataView(data.buffer); 83 | const messageLength = view.getUint32(4, true); 84 | 85 | // Handle incomplete message 86 | if (messageLength > data.byteLength) { 87 | this.receiveDue = messageLength - data.byteLength; 88 | this.receivedParts = [data]; 89 | return; 90 | } 91 | 92 | const messageID = view.getUint32(0, true); 93 | if (messageID & 1) { 94 | // Handle a call response 95 | const handler = this.responseHandlers.get(messageID); 96 | if (handler) { 97 | handler(data.subarray(EnvelopeSize)); 98 | this.responseHandlers.delete(messageID); 99 | } else 100 | console.warn(`No handler registered for response message ${messageID}`); 101 | } else if (this.listener) { 102 | const reply = await this.listener(data); 103 | if (reply) { 104 | this.envelope[0] = messageID; 105 | this.envelope[1] = EnvelopeSize + reply.length; 106 | this.output.write(new Uint8Array(this.envelope.buffer)); 107 | this.output.write(reply); 108 | } 109 | } 110 | 111 | data = data.subarray(messageLength); 112 | } while (data.byteLength); 113 | } 114 | 115 | private sendAndReceive(payload: Uint8Array): Promise { 116 | const callID = this.nextCallID; 117 | this.nextCallID = callID > (1 << 31) ? 1 : callID + 2; 118 | this.envelope[0] = callID; 119 | this.envelope[1] = EnvelopeSize + payload.length; 120 | this.output.write(new Uint8Array(this.envelope.buffer)); 121 | this.output.write(payload); 122 | return new Promise((resolve) => { 123 | // TODO: Implement timeouts 124 | this.responseHandlers.set(callID, resolve); 125 | }); 126 | } 127 | 128 | private release(): void { 129 | if (--this.refCount === 0) 130 | this.close(); 131 | } 132 | } 133 | 134 | const connections = new Map(); 135 | 136 | export function createIPCConnection( 137 | socketPath: string, 138 | disconnect?: () => void, 139 | listener?: ipc.Listener, 140 | ): Promise { 141 | socketPath = assertPath(socketPath); 142 | 143 | const existing = connections.get(socketPath); 144 | if (existing) 145 | return Promise.resolve(existing.createProxy()); 146 | 147 | return new Promise((resolve, reject) => { 148 | const socket = createNetConnection(socketPath); 149 | 150 | function close(): void { 151 | socket.destroy(); 152 | connections.delete(socketPath); 153 | } 154 | 155 | socket.once("error", reject); 156 | 157 | socket.once("connect", () => { 158 | socket.removeListener("error", reject); 159 | 160 | const connection = new Connection(socket, socket, close, listener); 161 | 162 | if (disconnect) 163 | socket.on("close", disconnect); 164 | 165 | connections.set(socketPath, connection); 166 | resolve(connection.createProxy()); 167 | }); 168 | }); 169 | } 170 | 171 | export function createWorkerProcess( 172 | executablePath: string, 173 | listener?: ipc.Listener, 174 | ...args: string[] 175 | ): Promise { 176 | executablePath = assertPath(executablePath); 177 | 178 | const process = execFile(executablePath, args); 179 | const {stdout, stdin} = process; 180 | if (!stdout || !stdin) 181 | throw new Error("Unable to communicate with spawned process"); 182 | 183 | function close(): void { 184 | stdin && stdin.destroy(); 185 | } 186 | 187 | const connection = new Connection(stdout, stdin, close, listener); 188 | return Promise.resolve(connection.createProxy()); 189 | } 190 | 191 | export function executeProgram( 192 | executablePath: string, 193 | ...args: string[] 194 | ): Promise { 195 | executablePath = assertPath(executablePath); 196 | 197 | return new Promise((resolve, reject) => { 198 | const process = execFile(executablePath, args, (err, stdout, stderr) => { 199 | if (err) 200 | return reject(err); 201 | 202 | resolve({ 203 | status: process.exitCode || 0, 204 | out: stdout, 205 | err: stderr, 206 | }); 207 | }); 208 | }); 209 | } 210 | -------------------------------------------------------------------------------- /src/api/main-proxy.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from "electron"; 2 | import type {FileFilter} from "electron/main"; 3 | 4 | import {WindowStatus} from "../main"; 5 | import {ChannelName, isFault, RequestType} from "../ipc.contract"; 6 | 7 | function createHandler( 8 | type: RequestType, 9 | ): (...args: A) => Promise { 10 | return (...args: A) => ipcRenderer.invoke(ChannelName, type, ...args). 11 | then(r => isFault(r) ? Promise.reject(r) : r); 12 | } 13 | 14 | export const window = Object.seal({ 15 | show: createHandler(RequestType.WindowShow), 16 | close: createHandler(RequestType.WindowClose), 17 | maximize: createHandler(RequestType.WindowMaximize), 18 | minimize: createHandler(RequestType.WindowMinimize), 19 | unmaximize: createHandler(RequestType.WindowUnmaximize), 20 | getStatus: createHandler(RequestType.WindowGetStatus), 21 | promptDirectory: createHandler(RequestType.WindowPromptDirectory), 22 | promptFile: createHandler(RequestType.WindowPromptFile), 23 | }); 24 | 25 | export const fs = Object.seal({ 26 | getAttr: createHandler(RequestType.FileGetAttr), 27 | setAttr: createHandler(RequestType.FileSetAttr), 28 | removeAttr: createHandler(RequestType.FileRemoveAttr), 29 | 30 | loadObject: createHandler, [directory: string, file: string]>(RequestType.FileLoadObject), 31 | patchObject: createHandler]>(RequestType.FilePatchObject), 32 | 33 | loadText: createHandler(RequestType.FileLoadText), 34 | patchText: createHandler]>(RequestType.FilePatchText), 35 | 36 | flush: createHandler(RequestType.FileFlush), 37 | }); -------------------------------------------------------------------------------- /src/api/preferences.ts: -------------------------------------------------------------------------------- 1 | import {existsSync, promises as afs} from "fs"; 2 | import {join as joinPath, sep as pathSeparator} from "path"; 3 | 4 | import type {preference} from ".."; 5 | import type {RendererArguments} from "../ipc.contract"; 6 | 7 | export const defaultPreferences: preference.Set = { 8 | columns: 6, 9 | order: 0, 10 | thumbnail: "system", 11 | thumbnailSizing: "cover", 12 | thumbnailLabel: "full", 13 | preload: 1, 14 | extensions: [], 15 | lineupPosition: "bottom", 16 | lineupEntries: 3, 17 | }; 18 | 19 | export const rcFileName = ".viewerrc"; 20 | 21 | const configEncoding = "utf8"; 22 | 23 | const [homePath, statePath] = process.argv.slice(process.argv.length - 2) as RendererArguments; 24 | const configRoot = joinPath(statePath, "fs-viewer"); 25 | const configFilePath = joinPath(configRoot, "config.json"); 26 | const extensionRoot = joinPath(homePath, ".fs-viewer-extensions"); 27 | 28 | function writePreferences( 29 | value: Partial, 30 | path: string, 31 | ): Promise { 32 | return afs.writeFile(path, JSON.stringify(value), configEncoding); 33 | } 34 | 35 | export function loadPreferenceFile( 36 | path: string, 37 | ): Promise> { 38 | return afs.readFile(path, configEncoding).then(JSON.parse); 39 | } 40 | 41 | export async function savePreferences(value: preference.Set): Promise; 42 | export async function savePreferences(value: Partial, directory: string): Promise; 43 | export async function savePreferences( 44 | value: preference.Set | Partial, 45 | directory?: string, 46 | ): Promise { 47 | let path: string; 48 | if (directory) { 49 | path = joinPath(directory, rcFileName); 50 | } else { 51 | directory = configRoot; 52 | path = configFilePath; 53 | } 54 | 55 | if (!existsSync(directory)) 56 | await afs.mkdir(directory, {recursive: true}); 57 | 58 | return writePreferences(value, path); 59 | } 60 | 61 | export function loadPreferences(): Promise { 62 | return loadPreferenceFile(configFilePath) 63 | .then(d => Object.assign({}, defaultPreferences, d)) 64 | .catch(() => defaultPreferences); 65 | } 66 | 67 | export function getExtensionRoot(): string { 68 | return extensionRoot + pathSeparator; 69 | } 70 | 71 | export function getHomePath(): string { 72 | return homePath; 73 | } 74 | 75 | export async function getExtensions(): Promise { 76 | const paths = await afs.readdir(getExtensionRoot(), {withFileTypes: true}); 77 | const result = new Array(); 78 | for (let n = 0; n < paths.length; ++n) 79 | if (paths[n].isDirectory()) 80 | result.push(paths[n].name); 81 | 82 | return result; 83 | } -------------------------------------------------------------------------------- /src/application/browsing.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from "events"; 2 | 3 | import {Debounce} from "../debounce"; 4 | import {method} from "../interface"; 5 | import {Pipeline} from "../pipeline"; 6 | 7 | import type {browsing} from ".."; 8 | 9 | class FilterPipeline extends Pipeline { 10 | public apply(files: browsing.FilesView): browsing.FilesView { 11 | for (let n = 0; n < this.stages.length; ++n) { 12 | const filter = this.stages[n]; 13 | filter.begin && filter.begin(); 14 | 15 | try { 16 | files = filter.filter(files); 17 | } finally { 18 | filter.end && filter.end(); 19 | } 20 | } 21 | 22 | return files; 23 | } 24 | } 25 | 26 | class ComparerPipeline extends Pipeline { 27 | public apply({path, names: fileNames}: browsing.FilesView): browsing.FilesView { 28 | for (let n = this.stages.length; n --> 0;) { 29 | const stage = this.stages[n]; 30 | stage.begin && stage.begin(); 31 | } 32 | 33 | try { 34 | const names = fileNames.slice(0).sort((a, b) => { 35 | for (let c = this.stages.length; c --> 0;) { 36 | const diff = this.stages[c].compare(path, a, b); 37 | if (diff !== 0) 38 | return diff; 39 | } 40 | 41 | return 0; 42 | }); 43 | 44 | return {path, names}; 45 | } finally { 46 | for (let n = this.stages.length; n --> 0;) { 47 | const stage = this.stages[n]; 48 | stage.end && stage.end(); 49 | } 50 | } 51 | } 52 | } 53 | 54 | const NoFiles: browsing.FilesView = Object.seal({ 55 | path: "", 56 | names: [], 57 | }); 58 | 59 | export function create(): [browsing.Service, (files: browsing.FilesView) => void] { 60 | let files = NoFiles; 61 | 62 | const filters = new FilterPipeline(); 63 | let filtersChanged = false; 64 | let filteredFiles: browsing.FilesView = NoFiles; 65 | 66 | const comparers = new ComparerPipeline(); 67 | let comparersChanged = false; 68 | let comparedFiles: browsing.FilesView = NoFiles; 69 | 70 | let selected = new Set(); 71 | 72 | let focusedFile: number | null = null; 73 | 74 | function setSelected(newVal: Set): void { 75 | selected = newVal; 76 | service.emit("selectchange"); 77 | } 78 | 79 | const updateFilesList = new Debounce(() => { 80 | setSelected(new Set()); 81 | service.setFocus(null); 82 | 83 | if (filtersChanged) 84 | filteredFiles = filters.stages.length > 0 85 | ? filters.apply(files) 86 | : files; 87 | 88 | if (filtersChanged || comparersChanged) 89 | comparedFiles = comparers.stages.length > 0 90 | ? comparers.apply(filteredFiles) 91 | : filteredFiles; 92 | 93 | filtersChanged = comparersChanged = false; 94 | service.emit("fileschange"); 95 | }); 96 | 97 | function setFiles(f: browsing.FilesView): void { 98 | files = f; 99 | filtersChanged = comparersChanged = true; 100 | filters.clear(); 101 | comparers.clear(); 102 | updateFilesList.schedule(); 103 | } 104 | 105 | const service = Object.defineProperties(new EventEmitter(), { 106 | files: { 107 | configurable: false, 108 | get: () => comparedFiles, 109 | }, 110 | selected: { 111 | configurable: false, 112 | get: () => selected, 113 | }, 114 | filters: { 115 | configurable: false, 116 | get: () => filters.stages, 117 | }, 118 | comparers: { 119 | configurable: false, 120 | get: () => comparers.stages, 121 | }, 122 | focusedFile: { 123 | configurable: false, 124 | get: () => focusedFile, 125 | }, 126 | addFilter: { ...method, value: addFilter }, 127 | removeFilter: { ...method, value: removeFilter }, 128 | registerFilterProvider: { ...method, value: registerFilterProvider }, 129 | addComparer: { ...method, value: addComparer }, 130 | removeComparer: { ...method, value: removeComparer }, 131 | registerComparerProvider: { ...method, value: registerComparerProvider }, 132 | addSelection: { ...method, value: addSelection }, 133 | clearSelection: { ...method, value: clearSelection }, 134 | removeSelection: { ...method, value: removeSelection }, 135 | setFocus: { ...method, value: setFocus }, 136 | getSelectedNames: { ...method, value: getSelectedNames }, 137 | }) as browsing.Service; 138 | 139 | async function addFilter(config: browsing.FilterConfig): Promise { 140 | const id = await filters.add(config); 141 | filtersChanged = true; 142 | updateFilesList.schedule(); 143 | return id; 144 | } 145 | 146 | function removeFilter(id: number): void { 147 | filters.remove(id); 148 | filtersChanged = true; 149 | updateFilesList.schedule(); 150 | } 151 | 152 | function registerFilterProvider(type: string, provider: browsing.FilterProvider): void { 153 | filters.register(type, provider); 154 | } 155 | 156 | async function addComparer(config: browsing.ComparerConfig): Promise { 157 | const id = await comparers.add(config); 158 | comparersChanged = true; 159 | updateFilesList.schedule(); 160 | return id; 161 | } 162 | 163 | function removeComparer(id: number): void { 164 | comparers.remove(id); 165 | comparersChanged = true; 166 | updateFilesList.schedule(); 167 | } 168 | 169 | function registerComparerProvider(type: string, provider: browsing.ComparerProvider): void { 170 | comparers.register(type, provider); 171 | } 172 | 173 | function addSelection(start: number, end: number): void { 174 | const newSelection = new Set(selected); 175 | while (start < end) 176 | newSelection.add(start++); 177 | 178 | setSelected(newSelection); 179 | } 180 | 181 | function clearSelection() { 182 | setSelected(new Set()); 183 | } 184 | 185 | function removeSelection(start: number, end: number): void { 186 | const newSelection = new Set(selected); 187 | while (start < end) 188 | newSelection.delete(start++); 189 | 190 | setSelected(newSelection); 191 | } 192 | 193 | function setFocus(index: number | null): void { 194 | if (focusedFile !== index) { 195 | focusedFile = index; 196 | service.emit("filefocus", index); 197 | } 198 | } 199 | 200 | function getSelectedNames(): string[] | null { 201 | if (selected.size < 1) 202 | return null; 203 | 204 | const result = new Array(selected.size); 205 | 206 | let n = 0; 207 | for (const v of selected) 208 | result[n++] = comparedFiles.names[v]; 209 | 210 | return result; 211 | } 212 | 213 | return [service, setFiles]; 214 | } -------------------------------------------------------------------------------- /src/application/builtin.ts: -------------------------------------------------------------------------------- 1 | import {MenuDefinition as Tagging} from "../tag"; 2 | import {MenuDefinition as GalleryMenu, ModeDefinition as Gallery} from "../gallery"; 3 | import {Definition as Ordering} from "../ordering/menu"; 4 | import {MenuDefinition as StageMenu, ModeDefinition as Stage} from "../stage"; 5 | 6 | export const Namespace = "..builtin"; 7 | 8 | export const builtinMenus = [ 9 | Tagging, 10 | Ordering, 11 | GalleryMenu, 12 | StageMenu, 13 | ]; 14 | 15 | export const builtinModes = [ 16 | Gallery, 17 | Stage, 18 | ]; -------------------------------------------------------------------------------- /src/application/esimport.d.ts: -------------------------------------------------------------------------------- 1 | export function importModule(path: string): Promise; -------------------------------------------------------------------------------- /src/application/esimport.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | exports.importModule = function importModule(path) { 3 | return import(`file://${path}`); 4 | } -------------------------------------------------------------------------------- /src/application/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Viewer 7 | 8 | 9 |
10 | 11 | -------------------------------------------------------------------------------- /src/application/index.sass: -------------------------------------------------------------------------------- 1 | @import '../global.sass' 2 | 3 | * 4 | box-sizing: border-box 5 | 6 | html, body 7 | width: 100% 8 | height: 100% 9 | padding: 0 10 | margin: 0 11 | font-size: 14px 12 | font-family: sans-serif 13 | 14 | body 15 | background-color: $color-background 16 | user-select: none -------------------------------------------------------------------------------- /src/application/index.ts: -------------------------------------------------------------------------------- 1 | import "./index.sass" 2 | 3 | declare global { 4 | const BUILD_TYPE: "dev" | "pub"; 5 | const PLATFORM: NodeJS.Platform; 6 | } 7 | 8 | // Take exclusive ownership of the API 9 | const api = window.api.acquire(); 10 | 11 | // Expose libraries that can't be compartmentalized 12 | import * as React from "react"; 13 | window.React = React; 14 | 15 | import {createBrowserHistory} from "history"; 16 | import {Path as GalleryPath} from "../gallery"; 17 | const history = createBrowserHistory({ basename: window.location.pathname }); 18 | history.replace(GalleryPath); 19 | 20 | import {Application} from "./component"; 21 | const application = React.createElement(Application, { 22 | history, 23 | api, 24 | document, 25 | window: { 26 | on: window.addEventListener.bind(window), 27 | off: window.removeEventListener.bind(window), 28 | 29 | close: () => api.window.close(), 30 | maximize: () => api.window.maximize(), 31 | minimize: () => api.window.minimize(), 32 | unmaximize: () => api.window.unmaximize(), 33 | getStatus: () => api.window.getStatus(), 34 | }, 35 | }); 36 | 37 | import {render} from "react-dom"; 38 | import {Router} from "react-router"; 39 | const dom = React.createElement(Router, {history}, application); 40 | 41 | render(dom, document.getElementById("shell"), () => api.window.show()); -------------------------------------------------------------------------------- /src/application/preference-service.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from "events"; 2 | 3 | import type {preference} from ".."; 4 | 5 | export function create(): [preference.Service, preference.UpdateFn] { 6 | const service = new EventEmitter() as preference.Service; 7 | 8 | function onUpdate(delta: Partial, previous: preference.Set) { 9 | service.emit("change", delta, previous); 10 | } 11 | 12 | return [service, onUpdate]; 13 | } -------------------------------------------------------------------------------- /src/application/react-loader.ts: -------------------------------------------------------------------------------- 1 | import {Loader} from "inconel"; 2 | 3 | import {Extension} from "../extension"; 4 | import {importModule} from "./esimport"; 5 | 6 | // Setup a receptacle for UMD extensions 7 | let loadedExtension: Extension | null = null; 8 | 9 | export function isValidExtension(obj: Extension | null): obj is Extension { 10 | return !!(obj && obj.namespace && ( 11 | obj.services || obj.menus || obj.extras || obj.modes 12 | )); 13 | } 14 | 15 | class ResolveUrlError extends Error { 16 | constructor(public readonly url: URL, message: string) { 17 | super(message); 18 | } 19 | } 20 | 21 | export class WebExtensionLoader implements Loader { 22 | constructor(private readonly basePath: string) {} 23 | 24 | private nsToPathMap = new Map(); 25 | 26 | public async load(reference: string): Promise { 27 | const modulePath = this.basePath.concat(reference, "/index.js"); 28 | const loaded = await importModule(modulePath); 29 | let module: Extension | undefined; 30 | if (isValidExtension(loaded)) 31 | module = loaded; 32 | else if (isValidExtension(loadedExtension)) 33 | module = loadedExtension; 34 | 35 | loadedExtension = null; 36 | 37 | if (!module) 38 | throw new Error("Extension does not export any expected properties"); 39 | 40 | this.nsToPathMap.set(module.namespace, reference); 41 | return module; 42 | } 43 | 44 | public resolve(url: URL): string { 45 | switch (url.protocol) { 46 | case "http:": 47 | case "https:": 48 | return url.toString(); 49 | 50 | case "extension:": { 51 | const ns = url.searchParams.get("namespace"); 52 | if (!ns) 53 | throw new ResolveUrlError( 54 | url, "Namespace not specified for custom protocol"); 55 | 56 | const path = this.nsToPathMap.get(ns); 57 | if (!path) 58 | throw new ResolveUrlError( 59 | url, "Unrecognized extension namespace"); 60 | 61 | return this.basePath.concat(path, url.pathname); 62 | } 63 | 64 | default: 65 | throw new Error("Unsupported protocol"); 66 | } 67 | } 68 | 69 | public static init(): void { 70 | Object.defineProperty(window, "extension", { 71 | configurable: false, 72 | enumerable: false, 73 | get: () => loadedExtension, 74 | set: v => { loadedExtension = v; }, 75 | }); 76 | } 77 | } -------------------------------------------------------------------------------- /src/contextual-menu.sass: -------------------------------------------------------------------------------- 1 | @use 'sass:math' 2 | @import './global.sass' 3 | @import './menu.sass' 4 | 5 | .menu.contextual 6 | display: inline-block 7 | position: fixed 8 | min-width: 15em 9 | max-width: 30em 10 | border-radius: 0 0 math.div($space-narrow, 2) math.div($space-narrow, 2) 11 | transition: box-shadow $time-resize-animation * 2, background-color $time-resize-animation 12 | box-shadow: 0 0 $space-default $color-panel-shadow 13 | background-color: $color-panel-opaque 14 | backdrop-filter: blur(0.2em) 15 | 16 | > li 17 | padding-top: $space-narrow 18 | 19 | > :first-child 20 | flex-grow: 0 21 | 22 | > :last-child 23 | flex-grow: 1 24 | 25 | .divider 26 | height: 1px 27 | padding: 0 28 | margin: $space-narrow 0 29 | border: 0px none 30 | background-color: $color-text-disabled -------------------------------------------------------------------------------- /src/contextual-menu.tsx: -------------------------------------------------------------------------------- 1 | import "./contextual-menu.sass"; 2 | import {Icon} from "@mdi/react"; 3 | import * as React from "react"; 4 | 5 | import {sinkEvent} from "./event"; 6 | 7 | function renderItem({ 8 | icon, 9 | children, 10 | onClick, 11 | }: { 12 | icon?: string, 13 | children: React.ReactNode, 14 | onClick: (ev: React.MouseEvent) => void, 15 | }) { 16 | return
  • 17 | 18 | {children} 19 |
  • 20 | } 21 | 22 | export const Item = React.memo(renderItem); 23 | 24 | export function Divider(): JSX.Element { 25 | return
  • ; 26 | } 27 | 28 | export interface Position { 29 | x: number; 30 | y: number; 31 | } 32 | 33 | function renderMenu({ 34 | position: {x, y}, 35 | children, 36 | }: { 37 | position: Position, 38 | children: React.ReactNode, 39 | }) { 40 | const styles = {top: `${y + 1}px`, left: `${x + 1}px`}; 41 | return
      45 | {children} 46 |
    ; 47 | } 48 | 49 | export const Menu = React.memo(renderMenu); -------------------------------------------------------------------------------- /src/debounce.ts: -------------------------------------------------------------------------------- 1 | type Operation = () => T | PromiseLike; 2 | 3 | export class Debounce { 4 | private pending: Promise | null; 5 | private readonly duration: number; 6 | 7 | constructor(private readonly op: Operation, duration?: number) { 8 | this.pending = null; 9 | this.createTask = this.createTask.bind(this); 10 | this.duration = duration || 0; 11 | } 12 | 13 | public schedule(): Promise { 14 | if (!this.pending) 15 | this.pending = new Promise(this.createTask); 16 | 17 | return this.pending; 18 | } 19 | 20 | private createTask( 21 | resolve: (v: T | PromiseLike) => void, 22 | reject: (e: Error) => void, 23 | ): void { 24 | setTimeout(() => { 25 | try { 26 | resolve(this.op()); 27 | } catch (e) { 28 | console.error(e); 29 | reject(e); 30 | } finally { 31 | this.pending = null; 32 | } 33 | }, this.duration); 34 | } 35 | } -------------------------------------------------------------------------------- /src/dialog.ts: -------------------------------------------------------------------------------- 1 | import type {FileFilter} from "electron"; 2 | 3 | export interface Service { 4 | openDirectoryPrompt(): Promise; 5 | openFilePrompt(filters: FileFilter[], multi?: boolean): Promise; 6 | } -------------------------------------------------------------------------------- /src/elements.sass: -------------------------------------------------------------------------------- 1 | @import './global.sass' 2 | 3 | body 4 | color: $color-text 5 | 6 | button, input, label, select 7 | font-size: 1em 8 | min-width: 1em 9 | 10 | input, select 11 | display: inline-block 12 | vertical-align: bottom 13 | width: 100% 14 | height: $space-text-box-height 15 | margin: 0 16 | border: 1px solid transparent 17 | outline: none 18 | color: $color-text 19 | background-color: $color-input 20 | transition: border-color $time-color-change 21 | 22 | &:focus 23 | outline: none 24 | border-color: $color-input-outline-focus 25 | 26 | input 27 | // Select adds its own padding 28 | // TODO: find out how to reliably sync it with inputs 29 | padding: 0 $space-narrow 30 | 31 | input[type=range] 32 | -webkit-appearance: none 33 | appearance: none 34 | padding: 0 35 | 36 | &::-webkit-slider-thumb 37 | -webkit-appearance: none 38 | appearance: none 39 | width: 1em 40 | height: 2em 41 | background-color: $color-clickable-hover 42 | 43 | select > option 44 | color: $color-text 45 | background-color: rgba-to-rgb($color-panel-focus, $color-background) 46 | 47 | label > div:first-child 48 | @include form-label-text() 49 | 50 | button 51 | border: 0 52 | outline: 0 53 | background-color: $color-clickable 54 | color: $color-text 55 | transition: background-color $time-button-background 56 | vertical-align: middle 57 | 58 | // A choice is a series of icon + text large radio buttons 59 | &.choice 60 | display: block 61 | height: $space-text-box-height 62 | width: 100% 63 | text-align: left 64 | 65 | svg 66 | margin-right: $space-default 67 | 68 | // A toggle is a square button containing solely an icon 69 | &.toggle 70 | padding: 0 71 | text-align: center 72 | border: 1px solid transparent 73 | 74 | &.active 75 | border-color: $color-input-outline-focus 76 | background-color: transparentize($color-input-outline-focus, 0.8) 77 | 78 | &:hover 79 | background-color: $color-clickable-hover 80 | 81 | pre 82 | margin: 0 83 | font-family: inherit -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any 2 | export function isCancellation(fault: any): boolean { 3 | return fault.cancelled; 4 | } 5 | 6 | const excludedErrorProperties: {[k in string]: 1} = { 7 | message: 1, 8 | stack: 1, 9 | name: 1, 10 | }; 11 | 12 | export function stringifyError(err: Error): string { 13 | let result = err.message; 14 | 15 | for (const n of Object.getOwnPropertyNames(err)) 16 | if (!excludedErrorProperties[n]) 17 | result += `\n\t${n}: ${JSON.stringify(err[n as keyof Error])}`; 18 | 19 | return result; 20 | } -------------------------------------------------------------------------------- /src/event.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export function sinkEvent(event: E): void { 4 | event.stopPropagation(); 5 | } 6 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Extension as BaseExtension, 3 | ComponentDefinition as BaseComponentDefinition, 4 | } from "inconel"; 5 | import type {match} from "react-router"; 6 | 7 | import type {Service as DialogService} from "./dialog"; 8 | import type {TransitionService} from "./stage/transition-service"; 9 | import type {ProgressService} from "./progress"; 10 | 11 | import type {browsing, ipc, io, preference, tag} from ".."; 12 | 13 | /** 14 | * All services offered by the application to extensions 15 | */ 16 | export interface BuiltinServices { 17 | // Creates modal dialogs to prompt user for information 18 | readonly dialog: DialogService; 19 | 20 | // Manages IPC tunnels to other programs 21 | readonly ipc: ipc.Service; 22 | 23 | // Readonly file system operations 24 | readonly reader: io.Reader; 25 | readonly writer: io.Writer; 26 | 27 | readonly browsing: browsing.Service; 28 | readonly tagging: tag.Service; 29 | readonly transition: TransitionService; 30 | readonly preference: preference.Service; 31 | readonly progress: ProgressService; 32 | } 33 | 34 | /** 35 | * Valid names of services to request 36 | */ 37 | export type BuiltinServiceNames = keyof BuiltinServices; 38 | 39 | /** 40 | * Props common to all UI components 41 | */ 42 | export interface CommonComponentProps{ 43 | onNavigate: (path: string, state?: unknown) => void; 44 | }; 45 | 46 | /** 47 | * Props common to all exported UI components 48 | */ 49 | export interface ComponentDefinition 50 | extends BaseComponentDefinition { 51 | 52 | /** 53 | * The function that maps preferences to the component's props 54 | */ 55 | readonly selectPreferences?: (prefs: preference.Set) => PreferenceMappedProps; 56 | } 57 | 58 | export interface MenuSpecificProps { 59 | readonly path: string; 60 | 61 | onSetPreferences(values: Partial): void; 62 | 63 | readonly localPreferences: preference.NameSet; 64 | onTogglePreferenceScope(name: preference.Name): void; 65 | } 66 | 67 | export interface MenuSpecificDefs { 68 | readonly icon: string; 69 | readonly label: string; 70 | 71 | readonly path?: ReadonlyArray; 72 | readonly requireDirectory?: boolean; 73 | } 74 | 75 | export type MenuDefinition = MenuSpecificDefs 76 | & ComponentDefinition 77 | ; 78 | 79 | export interface ModeSpecificDefs { 80 | readonly path: string; 81 | 82 | readonly selectRouteParams?: (location: Location, match: match) => Props; 83 | } 84 | 85 | export type ModeDefinition = ModeSpecificDefs 86 | & ComponentDefinition 87 | ; 88 | 89 | export interface ExtraSpecificDefs { 90 | readonly path: string; 91 | } 92 | 93 | export type ExtraDefinition = ExtraSpecificDefs 94 | & ComponentDefinition 95 | ; 96 | 97 | /** 98 | * Expected shape of an extension module (i.e. its entry point file) 99 | */ 100 | export interface Extension extends BaseExtension { 101 | /** 102 | * Menus to add to the shell 103 | */ 104 | readonly menus?: ReadonlyArray>; 105 | 106 | /** 107 | * Modes to be added 108 | */ 109 | readonly modes?: ReadonlyArray>; 110 | 111 | /** 112 | * Extra components to add to particular modes 113 | */ 114 | readonly extras?: ReadonlyArray>; 115 | } -------------------------------------------------------------------------------- /src/gallery/constants.ts: -------------------------------------------------------------------------------- 1 | export const Path = "/gallery"; 2 | -------------------------------------------------------------------------------- /src/gallery/gallery.sass: -------------------------------------------------------------------------------- 1 | @import "../global.sass" 2 | 3 | .gallery 4 | ul 5 | display: block 6 | list-style: none 7 | padding: 0 8 | margin: 0 9 | 10 | .animate .thumbnail 11 | transition: width $time-resize-animation, height $time-resize-animation 12 | 13 | .scroll-pane > .track 14 | top: $space-application-header 15 | 16 | .unrendered 17 | position: relative 18 | width: 100% 19 | height: 0 20 | margin: 0 21 | overflow: visible 22 | pointer-events: none 23 | 24 | > div 25 | position: absolute 26 | width: 100% 27 | 28 | &.top > div 29 | bottom: 0 30 | 31 | &.bot > div 32 | top: 0 33 | 34 | &.disable-thumbnail-label .thumbnail > div 35 | display: none 36 | 37 | &.one-line-thumbnail-label .thumbnail > div 38 | white-space: nowrap 39 | text-overflow: ellipsis -------------------------------------------------------------------------------- /src/gallery/index.ts: -------------------------------------------------------------------------------- 1 | export {Path} from "./constants"; 2 | export {Menu, Definition as MenuDefinition} from "./menu"; 3 | export {Definition as ModeDefinition} from "./mode"; 4 | -------------------------------------------------------------------------------- /src/gallery/menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {mdiClose, mdiViewGrid, mdiText, mdiTextShort} from "@mdi/js"; 3 | 4 | import {RadioButtons} from "../radio-buttons"; 5 | import {Help} from "../help"; 6 | import {NumericInput} from "../number-input"; 7 | import {ScopeToggle} from "../scope-toggle"; 8 | 9 | import {Path} from "./constants"; 10 | 11 | import type {preference} from ".."; 12 | 13 | interface PreferenceMappedProps { 14 | columns: number; 15 | thumbnail: preference.Thumbnailer; 16 | thumbnailLabel: preference.ThumbnailLabel, 17 | thumbnailSizing: preference.ThumbnailSizing; 18 | thumbnailPath?: string; 19 | thumbnailResolution?: preference.ThumbnailResolution; 20 | } 21 | 22 | interface Props extends PreferenceMappedProps { 23 | 24 | onSetPreferences(values: Partial): void; 25 | 26 | localPreferences: preference.NameSet; 27 | onTogglePreferenceScope(name: preference.Name): void; 28 | } 29 | 30 | const thumbnailPathHelp = "Supported variables: \ 31 | \n {directory} - The opened directory\ 32 | \n {file-stem} - File name, without extension\ 33 | \n {file-name} - File name, with extension"; 34 | 35 | const lineupDockingModes: { 36 | id: preference.ThumbnailLabel, title: string, icon: string 37 | }[] = [ 38 | { 39 | id: "disable", 40 | title: "Disable", 41 | icon: mdiClose, 42 | }, 43 | { 44 | id: "full", 45 | title: "Full", 46 | icon: mdiText, 47 | }, 48 | { 49 | id: "one-line", 50 | title: "Limited", 51 | icon: mdiTextShort, 52 | }, 53 | ]; 54 | 55 | export class Menu extends React.PureComponent { 56 | constructor(props: Props) { 57 | super(props); 58 | 59 | this.handleColumnsChange = this.handleColumnsChange.bind(this); 60 | this.handleThumbnailerChange = this.handleThumbnailerChange.bind(this); 61 | this.handleThumbnailLabelChanged = this.handleThumbnailLabelChanged.bind(this); 62 | this.handleThumbnailPathFormatChanged = this.handleThumbnailPathFormatChanged.bind(this); 63 | this.handleThumbnailResolutionChanged = this.handleThumbnailResolutionChanged.bind(this); 64 | this.handleThumbnailSizingChanged = this.handleThumbnailSizingChanged.bind(this); 65 | } 66 | 67 | toggleColumnsScope = () => this.props.onTogglePreferenceScope("columns"); 68 | toggleThumbnailerScope = () => this.props.onTogglePreferenceScope("thumbnail"); 69 | toggleThumbnailLabelScope = () => this.props.onTogglePreferenceScope("thumbnailLabel"); 70 | toggleThumbnailSizingScope = () => this.props.onTogglePreferenceScope("thumbnailSizing"); 71 | 72 | render(): React.ReactNode { 73 | const { 74 | localPreferences, 75 | columns, 76 | thumbnail, 77 | thumbnailSizing, 78 | thumbnailPath, 79 | thumbnailResolution, 80 | } = this.props; 81 | 82 | return
      83 |
    • 84 | 88 | 91 |
    • 92 |
    • 93 | 103 | 106 |
    • 107 | {thumbnail === "mapped" &&
    • 108 | 118 |
    • } 119 | {thumbnail === "system" &&
    • 120 | 130 |
    • } 131 |
    • 132 | 142 | 145 |
    • 146 |
    • 147 | 154 | 157 |
    • 158 |
    ; 159 | } 160 | 161 | handleColumnsChange(columns: number): void { 162 | this.props.onSetPreferences({columns}); 163 | } 164 | 165 | handleThumbnailLabelChanged( 166 | thumbnailLabel: preference.ThumbnailLabel 167 | ): void { 168 | this.props.onSetPreferences({thumbnailLabel}); 169 | } 170 | 171 | handleThumbnailPathFormatChanged( 172 | ev: React.ChangeEvent 173 | ): void { 174 | const thumbnailPath = ev.target.value; 175 | this.props.onSetPreferences({thumbnailPath}); 176 | } 177 | 178 | handleThumbnailResolutionChanged( 179 | ev: React.ChangeEvent 180 | ): void { 181 | const thumbnailResolution = ev.target.value as preference.ThumbnailResolution; 182 | this.props.onSetPreferences({thumbnailResolution}); 183 | } 184 | 185 | handleThumbnailerChange( 186 | ev: React.ChangeEvent 187 | ): void { 188 | const thumbnail = ev.target.value as preference.Thumbnailer; 189 | this.props.onSetPreferences({thumbnail}); 190 | } 191 | 192 | handleThumbnailSizingChanged( 193 | ev: React.ChangeEvent 194 | ): void { 195 | const thumbnailSizing = ev.target.value as preference.ThumbnailSizing; 196 | this.props.onSetPreferences({thumbnailSizing}); 197 | } 198 | } 199 | 200 | export const Definition = { 201 | id: "thumbnails", 202 | icon: mdiViewGrid, 203 | label: "Thumbnails", 204 | path: [Path], 205 | requireDirectory: true, 206 | component: Menu, 207 | selectPreferences: ({ 208 | columns, 209 | thumbnail, 210 | thumbnailLabel, 211 | thumbnailSizing, 212 | thumbnailPath, 213 | thumbnailResolution, 214 | }: preference.Set): PreferenceMappedProps => ({ 215 | columns, 216 | thumbnail, 217 | thumbnailLabel, 218 | thumbnailSizing, 219 | thumbnailPath, 220 | thumbnailResolution, 221 | }), 222 | }; -------------------------------------------------------------------------------- /src/global.sass: -------------------------------------------------------------------------------- 1 | $color-background: #000 2 | $color-text: #ABABAB 3 | $color-text-disabled: darken($color-text, 33%) 4 | $color-positive: #22c986 5 | $color-warning: #d8d83c 6 | $color-error: #e8635c 7 | 8 | $color-clickable: rgba(#FFF, 0.05) 9 | $color-clickable-hover: rgba(#FFF, 0.15) 10 | $color-clickable-outline-emphasis: rgba(28, 98, 185) 11 | $color-input: $color-clickable 12 | $color-input-outline-focus: rgba(28, 98, 185) 13 | $color-list-hover: rgba(#FFF, 0.05) 14 | 15 | $color-panel: rgba(#000, 0.33) 16 | $color-panel-opaque: #1E1E1E 17 | $color-panel-focus: rgba($color-panel-opaque, 0.9) 18 | $color-panel-shadow: #000 19 | 20 | $space-default: 0.6em 21 | $space-narrow: 0.3em 22 | $space-wide: 1em 23 | 24 | $space-line-height: 1.5em 25 | $space-line-height-minimal: 1em 26 | $space-line-touchable: 2em 27 | 28 | $space-text-box-height: 2em 29 | 30 | $space-contextual-item-vertical: $space-narrow 31 | $space-contextual-item-sides: $space-default 32 | $space-contextual-input-sides: $space-narrow 33 | 34 | $space-application-header: 2.2em 35 | 36 | $size-font-small: 0.8em 37 | $size-font-form-label: $size-font-small 38 | 39 | $time-color-change: 0.1s 40 | $time-button-background: $time-color-change 41 | $time-resize-animation: 0.2s 42 | 43 | @mixin form-label-text 44 | font-size: $size-font-form-label 45 | margin-bottom: $space-narrow 46 | text-transform: uppercase 47 | 48 | @mixin list-no-defaults 49 | display: block 50 | list-style: none 51 | padding: 0 52 | margin: 0 53 | 54 | li 55 | display: block 56 | padding: 0 57 | margin: 0 58 | 59 | @mixin svg-icon($size) 60 | width: $size 61 | height: $size 62 | vertical-align: middle 63 | 64 | path 65 | fill: $color-text 66 | 67 | @function rgba-to-rgb($rgba, $background: #fff) 68 | @return mix(rgb(red($rgba), green($rgba), blue($rgba)), $background, alpha($rgba) * 100%) 69 | 70 | $nav-icon-size: 1.2em 71 | $nav-button-size: $space-application-header 72 | 73 | $color-thumbnail-background: mix(#FFF, #000, 10%) -------------------------------------------------------------------------------- /src/help.sass: -------------------------------------------------------------------------------- 1 | @import './global.sass' 2 | 3 | .tooltip 4 | margin-left: $space-default 5 | 6 | > pre 7 | display: none 8 | font-size: 1.2em 9 | 10 | &.active > pre 11 | text-transform: none 12 | display: block -------------------------------------------------------------------------------- /src/help.tsx: -------------------------------------------------------------------------------- 1 | import "./help.sass"; 2 | import * as React from "react"; 3 | import {mdiInformation} from "@mdi/js"; 4 | import Icon from "@mdi/react"; 5 | 6 | interface Props { 7 | children: string; 8 | } 9 | 10 | export function Help({children}: Props) { 11 | const [open, setOpen] = React.useState(false); 12 | 13 | const className = open ? "tooltip active" : "tooltip"; 14 | return setOpen(!open)} className={className}> 15 | 16 |
    {children}
    17 |
    ; 18 | } -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import type {EventEmitter} from "events"; 2 | import type {Dirent, Stats} from "fs"; 3 | 4 | import type { 5 | Config as PipelineConfig, 6 | Provider as PipelineStageProvider, 7 | Stage as PipelineStage, 8 | } from "./pipeline"; 9 | 10 | export namespace browsing { 11 | interface FilesView { 12 | /** 13 | * The path of the directory 14 | */ 15 | path: string; 16 | 17 | /** 18 | * Names of files relative to `path` 19 | */ 20 | names: string[]; 21 | } 22 | 23 | /** 24 | * A stateful visitor is one that keeps state between invocations of its visit 25 | * function. These type allows one to keep data within or between invocations. 26 | */ 27 | interface StatefulVisitor extends PipelineStage { 28 | begin?(): void; 29 | end?(): void; 30 | } 31 | 32 | export interface Filter extends StatefulVisitor { 33 | filter(files: FilesView): FilesView; 34 | } 35 | 36 | export interface Comparer extends StatefulVisitor { 37 | compare(workingDirectory: string, first: string, second: string): number; 38 | } 39 | 40 | export type FilterConfig = PipelineConfig; 41 | 42 | export type ComparerConfig = PipelineConfig; 43 | 44 | export type FilterProvider = PipelineStageProvider; 45 | 46 | export type ComparerProvider = PipelineStageProvider; 47 | 48 | export interface ServiceProperties { 49 | readonly files: FilesView; 50 | readonly selected: Set; 51 | readonly filters: ReadonlyArray; 52 | readonly comparers: ReadonlyArray; 53 | readonly focusedFile: number | null; 54 | } 55 | 56 | /** 57 | * Serving as the source of truth of what content is shown in what order, the 58 | * browsing service allows other components to tell it what are the filtering 59 | * and sorting parameters 60 | */ 61 | export interface Service extends EventEmitter, ServiceProperties { 62 | 63 | addFilter(filter: FilterConfig): Promise; 64 | removeFilter(id: number): void; 65 | registerFilterProvider(type: string, provider: FilterProvider): void; 66 | 67 | addComparer(comparer: ComparerConfig): Promise; 68 | removeComparer(id: number): void; 69 | registerComparerProvider(type: string, provider: ComparerProvider): void; 70 | 71 | addSelection(start: number, end: number): void; 72 | clearSelection(): void; 73 | removeSelection(start: number, end: number): void; 74 | 75 | setFocus(index: number | null): void; 76 | 77 | getSelectedNames(): string[] | null; 78 | 79 | on(event: "fileschange", cb: () => void): this; 80 | on(event: "selectchange", cb: () => void): this; 81 | on(event: "filefocus", cb: (index: number | null) => void): this; 82 | } 83 | } 84 | 85 | export namespace io { 86 | export interface Writer { 87 | /** 88 | * Sets an attribute on a file 89 | * @param path The full path to the file 90 | * @param name The name of the attribute 91 | * @param value The value to set on the file 92 | */ 93 | setAttr(path: string, name: string, value: ArrayBuffer): Promise; 94 | 95 | /** 96 | * Removes an attribute from a file 97 | * @param path The full path to the file 98 | * @param name The name of the attribute 99 | */ 100 | removeAttr(path: string, name: string): Promise; 101 | 102 | /** 103 | * Equivalent to performing `Object.assign(FILE_ON_DISK, patch)` 104 | * @param path The full path to the file 105 | * @param patch The changes to apply keyed by property name 106 | */ 107 | patchObject(path: string, patch: Record): Promise; 108 | 109 | /** 110 | * Given that the specified file is split on new lines, assign new 111 | * values to the specified lines 112 | * @param path The full path to the file 113 | * @param patch The changes to apply keyed by line number 114 | */ 115 | patchTextFile(path: string, patch: Record): Promise; 116 | 117 | /** 118 | * Request that all data be evicted out of the cache, useful for when a 119 | * directory is no longer opened 120 | * 121 | * @param directory The path of the directory to flush 122 | */ 123 | flush(directory: string): Promise; 124 | } 125 | 126 | export interface Reader { 127 | joinPath(...parts: string[]): string; 128 | 129 | getStat(path: string): Promise; 130 | readDirectory(path: string): Promise>; 131 | 132 | getAttr(path: string, name: string): Promise; 133 | loadObject(path: string): Promise>; 134 | loadTextFile(path: string): Promise; 135 | 136 | /** 137 | * Visits each line of a (possibly large) text file, updating a value 138 | * @param path The full path to the file 139 | * @param visitor The function to call per line of the file. 140 | * Stops if returns false 141 | * @param initial The initial value passed into he reduction algorithm 142 | */ 143 | reduceTextFile( 144 | path: string, 145 | visitor: (value: T, line: string) => boolean, 146 | initial: T, 147 | ): Promise; 148 | } 149 | } 150 | 151 | export namespace ipc { 152 | export interface RPCProxy { 153 | call(payload: Uint8Array): Promise; 154 | close(): void; 155 | } 156 | 157 | export interface ProcessResult { 158 | status: number; 159 | out: string; 160 | err: string; 161 | } 162 | 163 | // Called when the other side of the IPC socket initiates a message 164 | export type Listener = (data: Uint8Array) => Promise; 165 | 166 | export interface Service { 167 | connect( 168 | socketPath: string, 169 | disconnect?: () => void, 170 | listener?: Listener, 171 | ): Promise; 172 | 173 | spawn( 174 | executablePath: string, 175 | listener?: Listener, 176 | ...argv: string[] 177 | ): Promise; 178 | 179 | execute(executablePath: string, ...argv: string[]): Promise; 180 | } 181 | } 182 | 183 | export namespace preference { 184 | type Thumbnailer = "none" | "system" | "mapped"; 185 | 186 | type ThumbnailSizing = "cover" | "full"; 187 | 188 | type ThumbnailResolution = "default" | "high"; 189 | 190 | type ThumbnailLabel = "full" | "one-line" | "disable"; 191 | 192 | type PanelPosition = "left" | "right" | "bottom" | "disable"; 193 | 194 | interface Set { 195 | /** 196 | * Number of columns in grid view 197 | */ 198 | columns: number; 199 | 200 | /** 201 | * The ID of the ordering strategy to apply 202 | */ 203 | order: number; 204 | 205 | /** 206 | * A choice specific parameter to apply 207 | */ 208 | orderParam?: string; 209 | 210 | /** 211 | * The source of thumbnail files 212 | */ 213 | thumbnail: Thumbnailer; 214 | 215 | /** 216 | * How thumbnail labels are presented 217 | */ 218 | thumbnailLabel: ThumbnailLabel; 219 | 220 | /** 221 | * (if thumbnail is set) the format of the thumbnail path 222 | */ 223 | thumbnailPath?: string; 224 | 225 | /** 226 | * Resolution of thumbnails generated from the system thumbnailer 227 | */ 228 | thumbnailResolution?: ThumbnailResolution; 229 | 230 | /** 231 | * How thumbnail images are sized 232 | */ 233 | thumbnailSizing: ThumbnailSizing; 234 | 235 | /** 236 | * How many files to preload in both directions in stage mode 237 | */ 238 | preload: number; 239 | 240 | /** 241 | * The names of extensions to use 242 | */ 243 | extensions: string[]; 244 | 245 | /** 246 | * The position where the image lineup is docked 247 | */ 248 | lineupPosition: PanelPosition; 249 | 250 | /** 251 | * The number of files to include in either directions 252 | */ 253 | lineupEntries: number; 254 | } 255 | 256 | type Name = keyof Set; 257 | 258 | type NameSet = {[name in Name]?: 1}; 259 | 260 | export type UpdateFn = (delta: Partial, previous: preference.Set) => void; 261 | 262 | // Meant for persisted preferences that has a mutative effect on service state 263 | // this serves as a notifier that fires whenever a preference changes 264 | export interface Service extends EventEmitter { 265 | on(event: "change", cb: UpdateFn): this; 266 | } 267 | } 268 | 269 | export namespace tag { 270 | export type NamespaceID = number; 271 | 272 | export interface Namespace { 273 | /** 274 | * A magic number that identifies tags translatable by this namespace 275 | */ 276 | identifier: NamespaceID; 277 | 278 | /** 279 | * The tags that are available in the directory 280 | */ 281 | names: Map; 282 | 283 | /** 284 | * The next free to assign to a tag 285 | */ 286 | nextId: number; 287 | } 288 | 289 | export type ID = number; 290 | 291 | export interface Tags { 292 | namespace: NamespaceID; 293 | ids: Set; 294 | } 295 | 296 | export interface ServiceProperties { 297 | names: ReadonlyMap; 298 | namespace: number; 299 | } 300 | 301 | // Think of the tagging service as a caching layer for file tag data 302 | export interface Service extends EventEmitter, ServiceProperties { 303 | createTag(name: string): Promise; 304 | deleteTag(id: ID): Promise; 305 | renameTag(id: ID, newName: string): Promise; 306 | 307 | toggleFileTag(tag: ID, fileName: string): Promise; 308 | 309 | assignTag(tag: ID, fileNames: ReadonlyArray): Promise; 310 | clearTag(tag: ID, fileNames: ReadonlyArray): Promise; 311 | 312 | getFiles(id: ID): Promise>; 313 | getUntaggedFiles(): Promise>; 314 | getTags(file: string): Promise; 315 | 316 | on(event: "change", handler: () => void): this; 317 | on(event: "filechange", handler: (fileName: string, tags: Tags) => void): this; 318 | } 319 | } -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | export const method = { 2 | configurable: false, 3 | writable: false, 4 | }; -------------------------------------------------------------------------------- /src/ipc.contract.ts: -------------------------------------------------------------------------------- 1 | export const ChannelName = "ipc"; 2 | 3 | /** 4 | * Opcodes of all supported RPC requests 5 | */ 6 | export enum RequestType { 7 | // Not used, reserving 0 to detect serialization error 8 | Unsupported = 0, 9 | 10 | WindowShow, 11 | WindowClose, 12 | WindowMaximize, 13 | WindowUnmaximize, 14 | WindowMinimize, 15 | WindowGetStatus, 16 | WindowPromptDirectory, 17 | WindowPromptFile, 18 | 19 | FileRemoveAttr, 20 | FileSetAttr, 21 | FileGetAttr, 22 | FileLoadObject, 23 | FilePatchObject, 24 | FileLoadText, 25 | FilePatchText, 26 | FileFlush, 27 | } 28 | 29 | export type RendererArguments = [ 30 | homePath: string, 31 | statePath: string, 32 | ]; 33 | 34 | export enum ErrorCode { 35 | Unexpected = 1, 36 | 37 | NotFound, 38 | IOError, 39 | DataFormat, 40 | } 41 | 42 | export interface Fault { 43 | code: ErrorCode; 44 | } 45 | 46 | export function isFault(v: unknown): v is Fault { 47 | return !!(v as Partial)?.code; 48 | } 49 | 50 | export function createResponseHandler( 51 | operation: string, 52 | resource: string, 53 | ): (v: T | Fault) => T { 54 | return function (v: T | Fault) { 55 | if (isFault(v)) 56 | throw new Error(translateFault(operation, resource, v.code)); 57 | else 58 | return v; 59 | } 60 | } 61 | 62 | export function translateFault( 63 | operation: string, 64 | resource: string, 65 | code: ErrorCode, 66 | ): string { 67 | switch (code) { 68 | case ErrorCode.NotFound: 69 | return `Unable to find ${resource}`; 70 | 71 | case ErrorCode.IOError: 72 | return `Unexpected IO error while ${operation} ${resource}`; 73 | 74 | default: 75 | return `Unexpected error while ${operation} ${resource}`; 76 | } 77 | } 78 | 79 | import {browsing, preference} from ".."; 80 | 81 | export interface OpenDirectoryResult { 82 | /** 83 | * The default view 84 | */ 85 | files: browsing.FilesView; 86 | 87 | /** 88 | * Location specific preference overrides (if any) 89 | */ 90 | preferences: Partial; 91 | } 92 | 93 | import type {WindowStatus} from "./main"; 94 | 95 | export interface WindowService { 96 | on: typeof window.addEventListener; 97 | off: typeof window.removeEventListener; 98 | 99 | close(): void; 100 | maximize(): void; 101 | minimize(): void; 102 | unmaximize(): void; 103 | 104 | getStatus(): Promise; 105 | } -------------------------------------------------------------------------------- /src/main/attrs.d.ts: -------------------------------------------------------------------------------- 1 | export function getAttr(path: string, name: string): Promise; 2 | export function setAttr(path: string, name: string, value: Buffer): Promise; 3 | export function removeAttr(path: string, name: string): Promise; 4 | -------------------------------------------------------------------------------- /src/main/attrs.linux.ts: -------------------------------------------------------------------------------- 1 | export {get as getAttr, set as setAttr, remove as removeAttr} from "fs-xattr"; 2 | -------------------------------------------------------------------------------- /src/main/attrs.win32.ts: -------------------------------------------------------------------------------- 1 | import {promises as afs} from "fs"; 2 | 3 | export function getAttr(path: string, name: string): Promise { 4 | return afs.readFile(`${path}:${name}`); 5 | } 6 | 7 | export function setAttr(path: string, name: string, value: Buffer): Promise { 8 | return afs.writeFile(`${path}:${name}`, value); 9 | } 10 | 11 | export function removeAttr(path: string, name: string): Promise { 12 | return afs.unlink(`${path}:${name}`); 13 | } 14 | -------------------------------------------------------------------------------- /src/main/dbus-native.d.ts: -------------------------------------------------------------------------------- 1 | declare module "dbus-native" { 2 | type EventHandler = () => void; 3 | 4 | interface Interface { 5 | on(eventName: string, handler: EventHandler): void; 6 | } 7 | 8 | type GetInterfaceCallback = (error: Error, interface: T) => void; 9 | 10 | interface Service { 11 | getInterface( 12 | path: string, 13 | id: string, 14 | callback: GetInterfaceCallback): void; 15 | } 16 | 17 | interface SessionBus { 18 | getService(id: string): Service; 19 | } 20 | 21 | export function sessionBus(): SessionBus; 22 | } -------------------------------------------------------------------------------- /src/main/file.ts: -------------------------------------------------------------------------------- 1 | import {promises as afs} from "fs"; 2 | 3 | import {Debounce} from "../debounce"; 4 | import {ErrorCode, Fault} from "../ipc.contract"; 5 | import {getAttr, removeAttr, setAttr} from "./attrs"; 6 | 7 | const textEncoding = "utf-8"; 8 | 9 | interface FileSystem { 10 | flush(directory?: string): Promise; 11 | 12 | setAttribute(path: string, name: string, value: ArrayBuffer): void; 13 | getAttribute(path: string, name: string): Promise; 14 | removeAttribute(path: string, name: string): void; 15 | 16 | loadObject(path: string): Promise | Fault>; 17 | patchObject(path: string, patch: Record): Promise; 18 | 19 | loadTextFile(path: string): Promise; 20 | patchTextFile(path: string, patch: Record): Promise; 21 | } 22 | 23 | function attrKey(path: string, attr: string): string { 24 | return `${path}\n${attr}`; 25 | } 26 | 27 | interface CachedValue { 28 | changed: boolean; 29 | data: D; 30 | } 31 | 32 | export class CachedFileSystem implements FileSystem { 33 | readonly attrs = new Map>(); 34 | readonly objects = new Map>>(); 35 | readonly texts = new Map>>(); 36 | private readonly save = new Debounce(() => this.flush(), 5000); 37 | 38 | removeAttribute(path: string, name: string): void { 39 | this.attrs.set(attrKey(path, name), {data: new ArrayBuffer(0), changed: true}); 40 | this.save.schedule(); 41 | } 42 | 43 | setAttribute(path: string, name: string, value: ArrayBuffer): void { 44 | this.attrs.set(attrKey(path, name), {data: value, changed: true}); 45 | this.save.schedule(); 46 | } 47 | 48 | async getAttribute(path: string, name: string): Promise { 49 | const k = attrKey(path, name); 50 | let value = this.attrs.get(k); 51 | if (!value) { 52 | let contents: Buffer; 53 | try { 54 | contents = await getAttr(path, name); 55 | } catch (e) { 56 | if (e.code === "ENODATA" || e.code === "ENOENT") 57 | return {code: ErrorCode.NotFound}; 58 | 59 | return {code: ErrorCode.IOError}; 60 | } 61 | 62 | let data = contents.buffer; 63 | if (contents.byteLength !== data.byteLength) 64 | data = data.slice( 65 | contents.byteOffset, 66 | contents.byteOffset + contents.byteLength, 67 | ); 68 | 69 | value = {changed: false, data}; 70 | 71 | this.attrs.set(k, value); 72 | } 73 | 74 | return value.data; 75 | } 76 | 77 | private async ensureObjectLoaded(path: string): Promise> | ErrorCode> { 78 | let value = this.objects.get(path); 79 | if (!value) { 80 | let content: string; 81 | try { 82 | content = await afs.readFile(path, textEncoding); 83 | } catch (e) { 84 | if (e.code === "ENODATA" || e.code === "ENOENT") 85 | return ErrorCode.NotFound; 86 | 87 | return ErrorCode.IOError; 88 | } 89 | 90 | try { 91 | value = { 92 | changed: false, 93 | data: (JSON.parse(content) || {}) as Record, 94 | }; 95 | } catch (e) { 96 | return ErrorCode.DataFormat; 97 | } 98 | 99 | this.objects.set(path, value); 100 | } 101 | 102 | return value; 103 | } 104 | 105 | async loadObject(path: string): Promise | Fault> { 106 | const value = await this.ensureObjectLoaded(path); 107 | return typeof value === "number" 108 | ? {code: value as ErrorCode} 109 | : value.data; 110 | } 111 | 112 | async patchObject(path: string, patch: Record): Promise { 113 | const value = await this.ensureObjectLoaded(path); 114 | if (typeof value === "number") { 115 | this.objects.set(path, { 116 | changed: true, 117 | data: patch, 118 | }); 119 | } else { 120 | Object.assign(value.data, patch); 121 | value.changed = true; 122 | } 123 | 124 | this.save.schedule(); 125 | } 126 | 127 | private async ensureTextLoaded(path: string): Promise> | ErrorCode> { 128 | let file = this.texts.get(path); 129 | if (!file) { 130 | let content: string; 131 | try { 132 | content = await afs.readFile(path, textEncoding); 133 | } catch (e) { 134 | if (e.code === "ENODATA" || e.code === "ENOENT") 135 | return ErrorCode.NotFound; 136 | 137 | return ErrorCode.IOError; 138 | } 139 | 140 | file = { 141 | changed: false, 142 | data: content.split("\n"), 143 | }; 144 | 145 | this.texts.set(path, file); 146 | } 147 | 148 | return file; 149 | } 150 | 151 | async loadTextFile(path: string): Promise { 152 | const file = await this.ensureTextLoaded(path); 153 | return typeof file === "number" 154 | ? {code: file as ErrorCode} 155 | : file.data; 156 | } 157 | 158 | async patchTextFile(path: string, patch: Record): Promise { 159 | let file = await this.ensureTextLoaded(path); 160 | if (typeof file === "number") { 161 | file = { 162 | changed: true, 163 | data: [], 164 | }; 165 | 166 | this.texts.set(path, file); 167 | } 168 | 169 | for (const key in patch) 170 | file.data[key] = patch[key]; 171 | 172 | file.changed = true; 173 | this.save.schedule(); 174 | } 175 | 176 | private flushAttr(path: string, name: string, entry: CachedValue): Promise { 177 | if (entry.data.byteLength) 178 | return setAttr(path, name, Buffer.from(entry.data)). 179 | finally(() => { entry.changed = false; }); 180 | 181 | return removeAttr(path, name).then( 182 | () => { this.attrs.delete(attrKey(path, name)); }, 183 | e => (e.code === "ENODATA" || e.code === "ENOENT") ? undefined : Promise.reject(e) 184 | ); 185 | } 186 | 187 | private flushObject(path: string, entry: CachedValue>): Promise { 188 | return afs.writeFile(path, JSON.stringify(entry.data)). 189 | finally(() => entry.changed = false); 190 | } 191 | 192 | private flushTextFile(path: string, entry: CachedValue>): Promise { 193 | return afs.writeFile(path, entry.data.join("\n")). 194 | finally(() => entry.changed = false); 195 | } 196 | 197 | flush(directory?: string): Promise { 198 | const tasks = new Array>(); 199 | for (const [key, entry] of this.attrs.entries()) 200 | if (entry.changed) { 201 | const separator = key.lastIndexOf("\n"); 202 | separator > 0 && tasks.push(this.flushAttr( 203 | key.slice(0, separator), 204 | key.slice(separator + 1), 205 | entry)); 206 | } 207 | 208 | for (const [path, entry] of this.texts.entries()) 209 | if (entry.changed) 210 | tasks.push(this.flushTextFile(path, entry)); 211 | 212 | for (const [path, entry] of this.objects.entries()) 213 | if (entry.changed) 214 | tasks.push(this.flushObject(path, entry)); 215 | 216 | return Promise.all(tasks) as unknown as Promise; 217 | } 218 | } -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import {app as runtime, ipcMain} from "electron"; 2 | import {join as joinPath} from "path"; 3 | 4 | import {ChannelName, RequestType} from "../ipc.contract"; 5 | 6 | import {CachedFileSystem} from "./file"; 7 | import {registerThumbnailProtocol} from "./thumbnail"; 8 | import {ClientWindow} from "./window"; 9 | 10 | export type {Status as WindowStatus} from "./window"; 11 | 12 | const fs = new CachedFileSystem(); 13 | 14 | const configRoot = joinPath(runtime.getPath("appData"), "fs-viewer"); 15 | const windowStatePath = joinPath(configRoot, "window-state.json"); 16 | const loadWinStateTask = fs.loadObject(windowStatePath); 17 | let mainWindow: ClientWindow; 18 | 19 | // Handlers have divergent arguments so any other type will be just as useless 20 | type IPCHandler = (...args: any[]) => R; // eslint-disable-line @typescript-eslint/no-explicit-any 21 | 22 | const sharedHandlers: Partial>> = { 23 | [RequestType.WindowShow]: () => loadWinStateTask.then(s => mainWindow.show(s)), 24 | 25 | [RequestType.FileSetAttr]: fs.setAttribute.bind(fs), 26 | [RequestType.FileRemoveAttr]: fs.removeAttribute.bind(fs), 27 | [RequestType.FileGetAttr]: fs.getAttribute.bind(fs), 28 | 29 | [RequestType.FileLoadObject]: fs.loadObject.bind(fs), 30 | [RequestType.FilePatchObject]: fs.patchObject.bind(fs), 31 | 32 | [RequestType.FileLoadText]: fs.loadTextFile.bind(fs), 33 | [RequestType.FilePatchText]: fs.patchTextFile.bind(fs), 34 | 35 | [RequestType.FileFlush]: fs.flush.bind(fs), 36 | }; 37 | 38 | const dispatchedHandlers: Partial>> = { 39 | [RequestType.WindowClose]: ClientWindow.prototype.close, 40 | [RequestType.WindowMaximize]: ClientWindow.prototype.maximize, 41 | [RequestType.WindowUnmaximize]: ClientWindow.prototype.unmaximize, 42 | [RequestType.WindowMinimize]: ClientWindow.prototype.minimize, 43 | [RequestType.WindowGetStatus]: ClientWindow.prototype.getStatus, 44 | [RequestType.WindowPromptDirectory]: ClientWindow.prototype.openDirectoryPrompt, 45 | [RequestType.WindowPromptFile]: ClientWindow.prototype.openFilePrompt, 46 | }; 47 | 48 | ipcMain.handle(ChannelName, (ev, type, ...params) => { 49 | let handler = sharedHandlers[type as RequestType]; 50 | if (handler) 51 | return handler(...params); 52 | 53 | handler = dispatchedHandlers[type as RequestType]; 54 | if (handler) 55 | return handler.call(mainWindow, params); 56 | else 57 | console.error(`Unsupported RPC request id: ${type}`); 58 | }); 59 | 60 | // Quit when all windows are closed. 61 | runtime.on("window-all-closed", async function onAllWindowsClosed(): Promise { 62 | await fs.flush(); 63 | runtime.quit(); 64 | }); 65 | 66 | // This method will be called when Electron has finished 67 | // initialization and is ready to create browser windows. 68 | // Some APIs can only be used after this event occurs. 69 | runtime.on("ready", async function onReady(): Promise { 70 | await registerThumbnailProtocol(); 71 | mainWindow = new ClientWindow(s => fs.patchObject(windowStatePath, s)); 72 | }); -------------------------------------------------------------------------------- /src/main/thumbnail.d.ts: -------------------------------------------------------------------------------- 1 | export function registerThumbnailProtocol(): Promise; 2 | -------------------------------------------------------------------------------- /src/main/thumbnail.linux.ts: -------------------------------------------------------------------------------- 1 | import {app, protocol, FilePathWithHeaders, ProtocolRequest} from "electron"; 2 | import {createHash} from "crypto"; 3 | import {sessionBus, Interface} from "dbus-native"; 4 | import {getType as getMimeType} from "mime"; 5 | import {join as joinPath} from "path"; 6 | import {setTimeout} from "timers"; 7 | 8 | import type {preference} from ".."; 9 | 10 | interface DequeueRequest { 11 | handle: number; 12 | } 13 | 14 | type QueueCallback = (err: Error, handle: number) => void; 15 | 16 | type ReadySignalHandler = (handle: number, uris: string[]) => void; 17 | 18 | type ErrorSignalHandler = (handle: number, uris: string[], errorCode: number, message: string) => void; 19 | 20 | interface Thumbnailer extends Interface { 21 | GetSupported(): void; 22 | GetSchedulers(): void; 23 | GetFlavors(): void; 24 | Dequeue(request: DequeueRequest): void; 25 | Queue( 26 | uris: string[], 27 | mimeTypes: string[], 28 | flavor: "normal" | string, 29 | scheduler: "default" | string, 30 | handleToDequeue: 0 | number, 31 | cb: QueueCallback): void; 32 | 33 | on(event: "Ready", handler: ReadySignalHandler): void; 34 | on(event: "Error", handler: ErrorSignalHandler): void; 35 | } 36 | 37 | const resolutionMapping: {[k in preference.ThumbnailResolution]: string} = { 38 | "default": "normal", 39 | "high": "large", 40 | }; 41 | 42 | type RequestCallback = (path: string | FilePathWithHeaders) => void; 43 | 44 | interface Batch { 45 | paths: string[]; 46 | mimeTypes: string[]; 47 | callbacks: RequestCallback[]; 48 | } 49 | 50 | function createBatch(): Batch { 51 | return { 52 | paths: [], 53 | mimeTypes: [], 54 | callbacks: [], 55 | }; 56 | } 57 | 58 | interface PendingRequest { 59 | resolution: string; 60 | callback: RequestCallback; 61 | } 62 | 63 | const pendingRequests: {[uri: string]: PendingRequest} = {}; 64 | let unsubmitted: Batch = createBatch(); 65 | let batchTimer: NodeJS.Timeout | null = null; 66 | 67 | function submitBatch(thumbnailer: Thumbnailer, size: preference.ThumbnailResolution) { 68 | batchTimer = null; 69 | const batch = unsubmitted; 70 | unsubmitted = createBatch(); 71 | const resolution = resolutionMapping[size] || resolutionMapping.default; 72 | 73 | function callback(err: Error | null) { 74 | if (err) 75 | console.error("Unable to submit thumbnailing request", err); 76 | else for (let n = batch.callbacks.length; n --> 0;) 77 | pendingRequests[batch.paths[n]] = { 78 | resolution, 79 | callback: batch.callbacks[n], 80 | }; 81 | } 82 | 83 | thumbnailer.Queue( 84 | batch.paths, 85 | batch.mimeTypes, 86 | resolution, 87 | "foreground", 88 | 0, 89 | callback); 90 | } 91 | 92 | function handleThumbnailRequest( 93 | thumbnailer: Thumbnailer, 94 | request: ProtocolRequest, 95 | callback: RequestCallback, 96 | ): void { 97 | const prefixLength = 8; // len("thumb://") 98 | let suffixOffset = request.url.indexOf("?", prefixLength); 99 | let size: string | undefined; 100 | if (suffixOffset > 0) 101 | size = request.url.slice(suffixOffset + 3) 102 | else 103 | suffixOffset = request.url.length; 104 | 105 | const path = request.url.slice(prefixLength, suffixOffset); 106 | const mimeType = getMimeType(path); 107 | if (!mimeType) return callback(""); 108 | 109 | unsubmitted.paths.push(`file://${path}`); 110 | unsubmitted.mimeTypes.push(mimeType); 111 | unsubmitted.callbacks.push(callback); 112 | 113 | if (batchTimer == null) 114 | batchTimer = setTimeout(submitBatch, 500, thumbnailer, size); 115 | } 116 | 117 | function handleResponse( 118 | handle: number, 119 | uris: string[], 120 | process: (uri: string, pr: PendingRequest) => void, 121 | ) { 122 | for (let n = uris.length; n --> 0;) { 123 | const path = uris[n]; 124 | 125 | const pr = pendingRequests[path]; 126 | if (!pr) continue 127 | 128 | process(path, pr); 129 | } 130 | } 131 | 132 | const homePath = app.getPath("home"); 133 | 134 | function handleReady(handle: number, uris: string[]): void { 135 | handleResponse(handle, uris, (uri, pr) => { 136 | const hash = createHash("md5").update(uri).digest("hex"); 137 | const path = `.cache/thumbnails/${pr.resolution}/${hash}.png`; 138 | pr.callback(joinPath(homePath, path)); 139 | }); 140 | } 141 | 142 | function handleError(handle: number, uris: string[]): void { 143 | handleResponse(handle, uris, (uri, pr) => pr.callback("")); 144 | } 145 | 146 | const interfaceID = "org.freedesktop.thumbnails.Thumbnailer1"; 147 | const interfacePath = "/org/freedesktop/thumbnails/Thumbnailer1"; 148 | 149 | export function registerThumbnailProtocol(): Promise { 150 | return new Promise((resolve, reject) => { 151 | sessionBus() 152 | .getService(interfaceID) 153 | .getInterface(interfacePath, interfaceID, (err, thumbnailer) => { 154 | if (err) { 155 | reject(new Error(`Thumbnailer startup failed: ${err}`)); 156 | } else { 157 | thumbnailer.on("Error", handleError); 158 | thumbnailer.on("Ready", handleReady); 159 | 160 | const handler = handleThumbnailRequest.bind(null, thumbnailer); 161 | protocol.registerFileProtocol("thumb", handler); 162 | resolve(); 163 | } 164 | }); 165 | }); 166 | } -------------------------------------------------------------------------------- /src/main/thumbnail.win32.ts: -------------------------------------------------------------------------------- 1 | import {protocol, ProtocolRequest, ProtocolResponse} from "electron"; 2 | import {getImageForPath, flags} from "shell-image-win"; 3 | 4 | import type {preference} from ".."; 5 | 6 | type ResponseCallback = (response: (Buffer) | (ProtocolResponse)) => void; 7 | 8 | const resolutionMapping: {[k in preference.ThumbnailResolution]: number} = { 9 | "default": 256, 10 | "high": 400, 11 | }; 12 | 13 | function handleThumbnailRequest(request: ProtocolRequest, complete: ResponseCallback): void { 14 | if (!request.url || request.url.length < 10) { 15 | complete({ statusCode: 400 }); 16 | return; 17 | } 18 | 19 | const prefixLength = 8; // len("thumb://") 20 | let suffixOffset = request.url.indexOf("?", prefixLength); 21 | let size: string | undefined; 22 | if (suffixOffset > 0) 23 | size = request.url.slice(suffixOffset + 3) 24 | else 25 | suffixOffset = request.url.length; 26 | 27 | const resolution = resolutionMapping[size as preference.ThumbnailResolution] || resolutionMapping.default; 28 | const requestPath = request.url.slice(prefixLength, suffixOffset) 29 | .replace("/", "\\"); 30 | 31 | getImageForPath(requestPath, { 32 | width: resolution, 33 | height: resolution, 34 | flags: flags.BiggerSizeOk, 35 | }).then(complete, () => complete({ statusCode: 500 })); 36 | } 37 | 38 | export function registerThumbnailProtocol(): Promise { 39 | protocol.registerBufferProtocol("thumb", handleThumbnailRequest); 40 | return Promise.resolve(); 41 | } 42 | -------------------------------------------------------------------------------- /src/main/window.ts: -------------------------------------------------------------------------------- 1 | import {app, dialog, BrowserWindow, BrowserWindowConstructorOptions} from "electron"; 2 | import {join as joinPath} from "path"; 3 | 4 | import type {FileFilter, OpenDialogOptions} from "electron/main"; 5 | import {Fault, isFault} from "../ipc.contract"; 6 | 7 | interface WindowEvent { 8 | sender: BrowserWindow 9 | } 10 | 11 | export interface Status { 12 | maximized: boolean; 13 | tabletMode: boolean; 14 | } 15 | 16 | export interface WindowState extends Pick { 17 | maximized?: boolean; 18 | } 19 | 20 | const defaultState = Object.freeze({ 21 | width: 800, 22 | height: 600, 23 | }); 24 | 25 | const openDirOptions: OpenDialogOptions = { 26 | properties: ["openDirectory"], 27 | }; 28 | 29 | export class ClientWindow { 30 | private window: BrowserWindow; 31 | 32 | constructor(private readonly onStateChanged: (diff: Partial) => void) { 33 | this.window = new BrowserWindow({ 34 | frame: false, 35 | minWidth: 600, 36 | minHeight: 400, 37 | backgroundColor: "#000", 38 | show: false, 39 | paintWhenInitiallyHidden: true, 40 | webPreferences: { 41 | nodeIntegration: false, 42 | nodeIntegrationInWorker: false, 43 | nodeIntegrationInSubFrames: false, 44 | contextIsolation: true, 45 | enableRemoteModule: false, 46 | preload: joinPath(app.getAppPath(), "build/api.js"), 47 | additionalArguments: [ 48 | app.getPath("home"), 49 | app.getPath("appData"), 50 | ], 51 | }, 52 | }); 53 | 54 | this.window.setMenu(null); 55 | this.window.loadFile("build/index.html"); 56 | 57 | if (BUILD_TYPE === "dev") 58 | this.window.webContents.openDevTools(); 59 | 60 | this.window.once("closed", () => { 61 | // Dereference the window so it can be GC'd 62 | (this.window as unknown) = null; 63 | }); 64 | } 65 | 66 | private handleWindowMove({sender}: WindowEvent): void { 67 | this.onStateChanged({ 68 | ...sender.getBounds(), 69 | maximized: sender.isMaximized(), 70 | }); 71 | } 72 | 73 | show(initialState: WindowState | Fault): void { 74 | const moveHandler = this.handleWindowMove.bind(this); 75 | this.window.on("move", moveHandler); 76 | this.window.on("resize", moveHandler); 77 | 78 | if (isFault(initialState)) 79 | initialState = {}; // Window state failed to load, who cares 80 | 81 | const {maximized, ...bounds} = Object.assign( 82 | {}, defaultState, initialState); 83 | 84 | this.window.setBounds(bounds); 85 | 86 | if (maximized) 87 | this.window.maximize(); 88 | else 89 | this.window.show(); 90 | } 91 | 92 | close(): void { 93 | this.window.close(); 94 | } 95 | 96 | maximize(): void { 97 | this.window.maximize(); 98 | } 99 | 100 | minimize(): void { 101 | this.window.minimize(); 102 | } 103 | 104 | unmaximize(): void { 105 | this.window.unmaximize(); 106 | } 107 | 108 | getStatus(): Status { 109 | return { 110 | maximized: this.window?.isMaximized() as boolean, 111 | tabletMode: this.window?.isTabletMode() as boolean, 112 | }; 113 | } 114 | 115 | private async openDialog(options: OpenDialogOptions): Promise { 116 | const {canceled, filePaths} = await dialog.showOpenDialog(this.window as BrowserWindow, options); 117 | return !canceled && filePaths; 118 | } 119 | 120 | async openDirectoryPrompt(): Promise { 121 | const filePaths = await this.openDialog(openDirOptions); 122 | return filePaths && filePaths[0]; 123 | } 124 | 125 | async openFilePrompt(filters: FileFilter[], multi?: boolean): Promise { 126 | const properties: typeof options.properties = []; 127 | const options: OpenDialogOptions = { properties, filters }; 128 | 129 | if (multi) 130 | properties.push("multiSelections"); 131 | 132 | return await this.openDialog(options); 133 | } 134 | } -------------------------------------------------------------------------------- /src/menu.sass: -------------------------------------------------------------------------------- 1 | @import './global.sass' 2 | 3 | .menu 4 | > li 5 | display: flex 6 | flex-direction: row 7 | padding: $space-narrow $space-contextual-input-sides $space-narrow $space-contextual-input-sides 8 | -------------------------------------------------------------------------------- /src/notice.sass: -------------------------------------------------------------------------------- 1 | @import './global.sass' 2 | 3 | .notice 4 | position: relative 5 | border: 1px solid $color-text 6 | 7 | &.warning 8 | background-color: transparentize($color-warning, 0.8) 9 | border-color: $color-warning 10 | 11 | &.error 12 | background-color: transparentize($color-error, 0.8) 13 | border-color: $color-error 14 | 15 | h1, div 16 | margin: $space-narrow $space-default 17 | 18 | h1 19 | display: block 20 | font-size: 1em 21 | padding: 0 22 | 23 | svg 24 | display: inline-block 25 | width: 1em 26 | height: 1em 27 | margin-right: $space-narrow 28 | 29 | span 30 | overflow: hidden 31 | text-overflow: ellipsis 32 | white-space: nowrap -------------------------------------------------------------------------------- /src/notice.tsx: -------------------------------------------------------------------------------- 1 | import "./notice.sass"; 2 | import * as React from "react"; 3 | import {Icon} from "@mdi/react"; 4 | 5 | export enum Level { 6 | Info = 1, 7 | Warning, 8 | Error, 9 | } 10 | 11 | interface Props { 12 | children: React.ReactNode; 13 | title: string; 14 | level?: Level; 15 | icon?: string; 16 | } 17 | 18 | function renderNotice({children, title, icon, level}: Props) { 19 | const subClassName = (level && Level[level] || "").toLowerCase(); 20 | return
    21 |

    22 | {icon && } 23 | {title} 24 |

    25 |
    {children}
    26 |
    ; 27 | } 28 | 29 | export const Notice = React.memo(renderNotice); -------------------------------------------------------------------------------- /src/number-input.sass: -------------------------------------------------------------------------------- 1 | .numeric-input 2 | input[type="number"]::-webkit-outer-spin-button, 3 | input[type="number"]::-webkit-inner-spin-button 4 | -webkit-appearance: none 5 | margin: 0 6 | -------------------------------------------------------------------------------- /src/number-input.tsx: -------------------------------------------------------------------------------- 1 | import "./number-input.sass"; 2 | import * as React from "react"; 3 | import {Icon} from "@mdi/react"; 4 | import {mdiMinus, mdiPlus} from "@mdi/js"; 5 | 6 | interface Props { 7 | value: number; 8 | min?: number; 9 | max?: number; 10 | onChange(value: number): void; 11 | } 12 | 13 | function isNumber(x: number | undefined): x is number { 14 | return !!x || x === 0; 15 | } 16 | 17 | export class NumericInput extends React.PureComponent { 18 | increment = () => { 19 | let {value, max} = this.props; 20 | if (++value, !isNumber(max) || value <= max) 21 | this.props.onChange(value); 22 | }; 23 | 24 | decrement = () => { 25 | let {value, min} = this.props; 26 | if (--value, !isNumber(min) || value >= min) 27 | this.props.onChange(value); 28 | }; 29 | 30 | handlePreloadChanged = (ev: React.ChangeEvent) => { 31 | let value = parseInt(ev.target.value) || 0; 32 | const {min, max} = this.props; 33 | if (isNumber(min) && value < min) 34 | value = min; 35 | 36 | if (isNumber(max) && value > max) 37 | value = max; 38 | 39 | if (value !== this.props.value) 40 | this.props.onChange(value); 41 | }; 42 | 43 | render() { 44 | return
    45 | 51 | 54 | 57 |
    ; 58 | } 59 | } -------------------------------------------------------------------------------- /src/ordering/comparer.ts: -------------------------------------------------------------------------------- 1 | import {browsing} from ".."; 2 | 3 | export enum FilesOrder { 4 | System = 0, 5 | Lexical, 6 | Numeric, 7 | Tokenize, 8 | Dimensional, 9 | LengthLexical, 10 | } 11 | 12 | export interface BuiltinComparerConfig extends browsing.ComparerConfig { 13 | type: "builtin.comparer"; 14 | mode: FilesOrder; 15 | token?: string; 16 | } 17 | 18 | interface CompareCache { 19 | [k: string]: V; 20 | } 21 | 22 | abstract class BuiltinComparer implements browsing.Comparer { 23 | multiplier: number; 24 | 25 | constructor(reverse: boolean) { 26 | this.multiplier = reverse ? -1 : 1; 27 | } 28 | 29 | static TypeID = "builtin.comparer"; 30 | 31 | public readonly id: number | undefined; 32 | 33 | protected cache: CompareCache = {}; 34 | 35 | public end(): void { 36 | this.cache = {}; 37 | } 38 | 39 | public abstract compare(workingDirectory: string, first: string, second: string): number; 40 | } 41 | 42 | function parseNumericName(fileName: string): number { 43 | const delimiterIndex = fileName.lastIndexOf("."); 44 | if (delimiterIndex > 0) 45 | fileName = fileName.slice(0, delimiterIndex); 46 | 47 | return parseInt(fileName, 10); 48 | } 49 | 50 | class LexicalComparer extends BuiltinComparer { 51 | public compare(workingDirectory: string, first: string, second: string): number { 52 | return first.localeCompare(second) * this.multiplier; 53 | } 54 | } 55 | 56 | class LengthLexicalComparer extends BuiltinComparer { 57 | public compare(workingDirectory: string, first: string, second: string): number { 58 | return first.length - second.length || 59 | first.localeCompare(second) * this.multiplier; 60 | } 61 | } 62 | 63 | class NumericComparer extends BuiltinComparer { 64 | public compare(workingDirectory: string, a: string, b: string): number { 65 | const cache = this.cache as CompareCache; 66 | return ( 67 | (cache[a] || (cache[a] = parseNumericName(a))) - 68 | (cache[b] || (cache[b] = parseNumericName(b))) 69 | ) * this.multiplier; 70 | } 71 | } 72 | 73 | abstract class TokenizingComparer extends BuiltinComparer { 74 | protected readonly token: string; 75 | constructor({token}: BuiltinComparerConfig, reverse: boolean) { 76 | super(reverse); 77 | this.token = token || ""; 78 | } 79 | } 80 | 81 | class SplittingLexicalComparer extends TokenizingComparer { 82 | public compare(workingDirectory: string, a: string, b: string): number { 83 | const cache = this.cache as CompareCache; 84 | const {token} = this; 85 | const av: Array = cache[a] || (cache[a] = a.split(token)); 86 | const bv: Array = cache[b] || (cache[b] = b.split(token)); 87 | 88 | for (let n = 0; n < av.length; ++n) { 89 | if (n >= bv.length) 90 | return this.multiplier; 91 | 92 | const diff = av[n].localeCompare(bv[n]); 93 | if (diff !== 0) 94 | return diff * this.multiplier; 95 | } 96 | 97 | return 0; 98 | } 99 | } 100 | 101 | class SplittingNumericComparer extends TokenizingComparer { 102 | public compare(workingDirectory: string, a: string, b: string): number { 103 | const cache = this.cache as CompareCache; 104 | const {token} = this; 105 | const av: Array = cache[a] || (cache[a] = a.split(token).map(parseNumericName)); 106 | const bv: Array = cache[b] || (cache[b] = b.split(token).map(parseNumericName)); 107 | 108 | for (let n = 0; n < av.length; ++n) { 109 | if (n >= bv.length) 110 | return this.multiplier; 111 | 112 | const diff = av[n] - bv[n]; 113 | if (diff !== 0) 114 | return diff * this.multiplier; 115 | } 116 | 117 | return 0; 118 | } 119 | } 120 | 121 | export class BuiltinComparerProvider implements browsing.ComparerProvider { 122 | public async create(config: BuiltinComparerConfig): Promise { 123 | const reverse = config.mode < 0; 124 | switch (Math.abs(config.mode)) { 125 | case FilesOrder.Lexical: return new LexicalComparer(reverse); 126 | case FilesOrder.LengthLexical: return new LengthLexicalComparer(reverse); 127 | case FilesOrder.Numeric: return new NumericComparer(reverse); 128 | case FilesOrder.Tokenize: return new SplittingLexicalComparer(config, reverse); 129 | case FilesOrder.Dimensional: return new SplittingNumericComparer(config, reverse); 130 | } 131 | 132 | throw new Error("Unsupported comparer request"); 133 | } 134 | } 135 | 136 | export function isBuiltinComparer(filter: browsing.ComparerConfig): filter is BuiltinComparerConfig { 137 | return filter.type === BuiltinComparer.TypeID; 138 | } -------------------------------------------------------------------------------- /src/ordering/index.ts: -------------------------------------------------------------------------------- 1 | export {Definition as MenuDefinition, Ordering as Menu} from "./menu"; 2 | export {initialize as initialize} from "./service"; -------------------------------------------------------------------------------- /src/ordering/menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {mdiSort} from "@mdi/js"; 3 | 4 | import {Path as GalleryPath} from "../gallery"; 5 | import {Path as StagePath} from "../stage"; 6 | import {ScopeToggle} from "../scope-toggle"; 7 | 8 | import {FilesOrder} from "./comparer"; 9 | 10 | import type {browsing, preference} from ".."; 11 | 12 | interface PreferenceMappedProps { 13 | order: number; 14 | orderParam?: string; 15 | } 16 | 17 | interface Props extends PreferenceMappedProps { 18 | browsing: browsing.Service; 19 | 20 | onSetPreferences(values: Partial): void; 21 | 22 | localPreferences: preference.NameSet; 23 | onTogglePreferenceScope(name: preference.Name): void; 24 | } 25 | 26 | export class Ordering extends React.PureComponent { 27 | private readonly toggleApproachScope: () => void; 28 | 29 | constructor(props: Props) { 30 | super(props); 31 | 32 | this.toggleApproachScope = () => this.props.onTogglePreferenceScope("order"); 33 | 34 | this.handleSetOrder = this.handleSetOrder.bind(this); 35 | this.handleSetOrderParam = this.handleSetOrderParam.bind(this); 36 | } 37 | 38 | render(): React.ReactNode { 39 | const {localPreferences, order, orderParam} = this.props; 40 | const unsignedOrder = Math.abs(order); 41 | const requireSplitting = unsignedOrder === FilesOrder.Tokenize 42 | || unsignedOrder === FilesOrder.Dimensional; 43 | 44 | return
      45 |
    • 46 | 57 | 60 |
    • 61 | {unsignedOrder !== FilesOrder.System &&
    • 62 | 69 |
    • } 70 | {requireSplitting &&
    • 71 | 79 |
    • } 80 |
    ; 81 | } 82 | 83 | handleSetOrder(ev: React.ChangeEvent): void { 84 | const order = parseInt(ev.target.value); 85 | this.props.onSetPreferences({order}); 86 | } 87 | 88 | handleSetOrderParam(ev: React.ChangeEvent): void { 89 | const orderParam = ev.target.value; 90 | this.props.onSetPreferences({orderParam}); 91 | } 92 | } 93 | 94 | export const Definition = { 95 | id: "ordering", 96 | icon: mdiSort, 97 | label: "Ordering", 98 | path: [GalleryPath, StagePath], 99 | requireDirectory: true, 100 | services: ["browsing"], 101 | component: Ordering, 102 | selectPreferences: ({ 103 | order, 104 | orderParam, 105 | }: preference.Set): PreferenceMappedProps => ({ 106 | order, 107 | orderParam, 108 | }), 109 | } -------------------------------------------------------------------------------- /src/ordering/service.ts: -------------------------------------------------------------------------------- 1 | import {FilesOrder, BuiltinComparerConfig, BuiltinComparerProvider, isBuiltinComparer} from "./comparer" 2 | 3 | import type {preference, browsing} from ".."; 4 | 5 | export function initialize( 6 | browsing: browsing.Service, 7 | preferences: preference.Service, 8 | ): void { 9 | browsing.registerComparerProvider("builtin.comparer", new BuiltinComparerProvider()); 10 | 11 | preferences.on("change", (delta, prefs) => { 12 | const paramChanged = "orderParam" in delta; 13 | const orderChanged = "order" in delta; 14 | if (paramChanged || orderChanged) { 15 | replaceComparer( 16 | browsing, 17 | orderChanged ? delta.order as FilesOrder : prefs.order, 18 | paramChanged ? delta.orderParam : prefs.orderParam); 19 | } 20 | }); 21 | } 22 | 23 | function replaceComparer(browsing: browsing.Service, mode: FilesOrder, param?: string): void { 24 | for (const comparer of browsing.comparers) { 25 | if (isBuiltinComparer(comparer)) { 26 | browsing.removeComparer((comparer as unknown as browsing.Comparer).id as number); 27 | break; 28 | } 29 | } 30 | 31 | if (mode !== FilesOrder.System) { 32 | const config: BuiltinComparerConfig = { 33 | type: "builtin.comparer", 34 | mode, 35 | token: param, 36 | }; 37 | 38 | browsing.addComparer(config); 39 | } 40 | } -------------------------------------------------------------------------------- /src/pipeline.ts: -------------------------------------------------------------------------------- 1 | export interface Stage { 2 | readonly id: number | undefined; 3 | } 4 | 5 | export interface Config { 6 | readonly type: string; 7 | } 8 | 9 | export interface Provider { 10 | create(config: C): Promise; 11 | } 12 | 13 | type StageListElement = S & C & {id: number}; 14 | 15 | export class Pipeline { 16 | public stages: ReadonlyArray> = []; 17 | private readonly providers: { [id: string]: Provider } = {}; 18 | private nextID: number = 1; 19 | 20 | public async add(config: C): Promise { 21 | const provider = this.providers[config.type]; 22 | if (!provider) 23 | throw new Error(`Unkonwn component type id ${config.type}`); 24 | 25 | while (this.stages.find(s => s.id === this.nextID)) 26 | this.nextID = (this.nextID + 1) % 10240; 27 | 28 | const instance = Object.assign( 29 | Object.create(await provider.create(config)), 30 | config) as StageListElement; 31 | 32 | (instance as {id: number}).id = this.nextID++; 33 | this.stages = this.stages.concat(instance); 34 | return instance.id; 35 | } 36 | 37 | public remove(id: number): void { 38 | const index = this.stages.findIndex(s => s.id === id); 39 | if (index > -1) 40 | this.stages = this.stages.slice(0, index).concat(this.stages.slice(index + 1)); 41 | } 42 | 43 | public clear(): void { 44 | this.stages = []; 45 | this.nextID = 1; 46 | } 47 | 48 | public register(type: string, provider: Provider): void { 49 | this.providers[type] = provider; 50 | } 51 | } -------------------------------------------------------------------------------- /src/progress.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from "events"; 2 | 3 | export interface Task { 4 | promise: Promise; 5 | menu?: string; 6 | } 7 | 8 | interface Events { 9 | on(event: "change", cb: (id: string) => void): this; 10 | } 11 | 12 | /** 13 | * This service tracks incomplete tasks, allowing components to recover some 14 | * ephemeral state between instantiations 15 | */ 16 | export class ProgressService extends EventEmitter implements Events { 17 | #store = new Map(); 18 | 19 | get entries(): Iterable<[string, Task]> { 20 | return this.#store.entries(); 21 | } 22 | 23 | get(id: string): Task | undefined { 24 | return this.#store.get(id); 25 | } 26 | 27 | set(id: string, promise: Promise, menu?: string): void { 28 | this.#store.set(id, { 29 | promise, 30 | menu, 31 | }); 32 | 33 | this.emit("change", id); 34 | 35 | promise.finally(() => { 36 | this.#store.delete(id); 37 | this.emit("change", id); 38 | }); 39 | } 40 | } -------------------------------------------------------------------------------- /src/radio-buttons.sass: -------------------------------------------------------------------------------- 1 | @import "./global.sass" 2 | 3 | .radio-buttons button 4 | margin-right: 1px 5 | 6 | &.active 7 | border-bottom: $space-narrow solid $color-clickable-outline-emphasis -------------------------------------------------------------------------------- /src/radio-buttons.tsx: -------------------------------------------------------------------------------- 1 | import "./radio-buttons.sass"; 2 | import * as React from "react"; 3 | import {Icon} from "@mdi/react"; 4 | 5 | export interface Option { 6 | id: T; 7 | title: string; 8 | icon: string; 9 | } 10 | 11 | export function Component({ 12 | options, 13 | value, 14 | onChange, 15 | }: { 16 | options: Option[], 17 | value: T, 18 | onChange(value: T): void, 19 | }) { 20 | return
    21 | {options.map(v => )} 27 |
    ; 28 | } 29 | 30 | export const RadioButtons = React.memo(Component); -------------------------------------------------------------------------------- /src/scope-toggle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {Icon} from "@mdi/react"; 3 | import { 4 | mdiAccount, 5 | mdiFolder, 6 | } from "@mdi/js"; 7 | 8 | interface Props { 9 | active: boolean; 10 | onClick: React.MouseEventHandler; 11 | } 12 | 13 | export function ScopeToggle({active, onClick}: Props): JSX.Element { 14 | let tooltip: string; 15 | let icon: string; 16 | 17 | if (active) { 18 | tooltip = "Saved to directory"; 19 | icon = mdiFolder; 20 | } else { 21 | tooltip = "Saved to account"; 22 | icon = mdiAccount; 23 | } 24 | 25 | return
    26 | 29 |
    30 | } -------------------------------------------------------------------------------- /src/scroll-pane.sass: -------------------------------------------------------------------------------- 1 | @import './global.sass' 2 | 3 | .scroll-pane 4 | position: relative 5 | height: 100% 6 | 7 | .content 8 | overflow: auto 9 | max-height: 100% 10 | 11 | &::-webkit-scrollbar 12 | display: none 13 | 14 | .track, .track > div 15 | position: absolute 16 | 17 | .track 18 | right: 0 19 | top: 0 20 | bottom: 0 21 | width: 1em 22 | background-color: transparent 23 | transition: box-shadow $time-resize-animation * 2, background-color $time-resize-animation 24 | 25 | &:hover 26 | background-color: $color-panel-focus 27 | box-shadow: 0 0 $space-default $color-panel-shadow 28 | 29 | .menu & 30 | background-color: $color-clickable 31 | box-shadow: none 32 | 33 | > div 34 | background-color: opacify($color-clickable-hover, 0.2) 35 | 36 | > div 37 | width: 100% 38 | height: 100% 39 | background-color: opacify($color-clickable-hover, 0.1) 40 | 41 | // This might actually be enough going forwards, but who knows what happens on mobile? 42 | ::-webkit-scrollbar 43 | width: 1em 44 | 45 | ::-webkit-scrollbar-track 46 | background-color: transparent 47 | transition: box-shadow $time-resize-animation * 2, background-color $time-resize-animation 48 | 49 | &:hover 50 | background-color: $color-panel-focus 51 | box-shadow: 0 0 $space-default $color-panel-shadow 52 | 53 | ::-webkit-scrollbar-thumb 54 | background-color: opacify($color-clickable-hover, 0.1) 55 | 56 | ::-webkit-scrollbar-thumb:hover 57 | background-color: opacify($color-clickable-hover, 0.2) 58 | -------------------------------------------------------------------------------- /src/scroll-pane.tsx: -------------------------------------------------------------------------------- 1 | import "./scroll-pane.sass" 2 | import * as React from "react"; 3 | import {DraggableCore, DraggableData, DraggableEvent} from 'react-draggable'; 4 | 5 | interface Props { 6 | children: React.ReactNode; 7 | contentRef?: React.RefObject; 8 | } 9 | 10 | interface State { 11 | anchor: number | null; 12 | scrollStart: number | null; 13 | } 14 | 15 | // Compute the dimensions of a scroll handle and the content it controls 16 | function getScroll(contentElement: HTMLElement, handleElement: HTMLElement) { 17 | const contentHeight = contentElement.scrollHeight; 18 | 19 | const viewportElement = contentElement.parentElement as HTMLElement; 20 | const viewportHeight = viewportElement.clientHeight; 21 | 22 | const trackElement = handleElement.parentElement as HTMLElement; 23 | const trackHeight = trackElement.clientHeight; 24 | 25 | let handleHeight: number; 26 | if (contentHeight <= viewportHeight) { 27 | handleHeight = 0; 28 | } else { 29 | handleHeight = Math.max(32, trackHeight * viewportHeight / contentHeight); 30 | } 31 | 32 | return { 33 | position: contentElement.scrollTop, 34 | range: contentHeight - viewportHeight, 35 | 36 | handle: { 37 | height: handleHeight, 38 | range: trackHeight - handleHeight, 39 | }, 40 | }; 41 | } 42 | 43 | // We don't emulate scrolling, instead, we report what it is saying 44 | export class ScrollPane extends React.PureComponent { 45 | private handleUpdateTimer?: number; 46 | private readonly content: React.RefObject; 47 | private readonly handle: React.RefObject; 48 | 49 | constructor(props: Props) { 50 | super(props); 51 | 52 | this.state = { 53 | anchor: null, 54 | scrollStart: null, 55 | }; 56 | 57 | this.content = React.createRef(); 58 | this.handle = React.createRef(); 59 | 60 | this.handleContentScroll = this.handleContentScroll.bind(this); 61 | this.handleDragEnd = this.handleDragEnd.bind(this); 62 | this.handleDragStart = this.handleDragStart.bind(this); 63 | this.handleTrackWheel = this.handleTrackWheel.bind(this); 64 | this.handleDragMove = this.handleDragMove.bind(this); 65 | this.updateHandle = this.updateHandle.bind(this); 66 | } 67 | 68 | componentDidMount(): void { 69 | // intentional hack 70 | // eslint-disable @typescript-eslint/no-explicit-any 71 | if (this.props.contentRef) 72 | (this.props.contentRef as any).current = this.content.current; 73 | 74 | if (this.content.current) 75 | this.content.current.addEventListener( 76 | "scroll", this.handleContentScroll, {passive: true}); 77 | 78 | this.updateHandle(); 79 | } 80 | 81 | componentDidUpdate(prevProps: Props): void { 82 | // intentional hack 83 | // eslint-disable @typescript-eslint/no-explicit-any 84 | if (this.props.contentRef !== prevProps.contentRef) 85 | (this.props.contentRef as any).current = this.content.current; 86 | 87 | this.handleContentScroll(); 88 | } 89 | 90 | componentWillUnmount(): void { 91 | if (this.handleUpdateTimer) 92 | clearTimeout(this.handleUpdateTimer); 93 | 94 | if (this.content.current) 95 | this.content.current.removeEventListener("scroll", this.handleContentScroll); 96 | } 97 | 98 | render(): React.ReactNode { 99 | const {children} = this.props; 100 | 101 | return
    102 |
    103 | {children} 104 |
    105 |
    106 | 111 |
    112 |
    113 |
    114 |
    ; 115 | } 116 | 117 | handleContentScroll(): void { 118 | if (!this.handleUpdateTimer) 119 | this.handleUpdateTimer = window.setTimeout(this.updateHandle, 20); 120 | } 121 | 122 | handleTrackWheel(ev: React.WheelEvent): void { 123 | const contentElement = this.content.current; 124 | if (contentElement) { 125 | contentElement.scrollTop += ev.deltaY; 126 | } 127 | } 128 | 129 | handleDragEnd(event: DraggableEvent, data: DraggableData): void { 130 | this.setState({ 131 | anchor: null, 132 | scrollStart: null, 133 | }); 134 | } 135 | 136 | handleDragStart(event: DraggableEvent, {y}: DraggableData): void { 137 | this.setState({ 138 | anchor: y, 139 | scrollStart: this.content.current?.scrollTop || 0, 140 | }); 141 | } 142 | 143 | handleDragMove(event: DraggableEvent, {y}: DraggableData): void { 144 | const contentElement = this.content.current; 145 | if (contentElement) { 146 | const scroll = getScroll(contentElement, this.handle.current as HTMLElement); 147 | const mouseOffset = y - (this.state.anchor as number); 148 | 149 | let handleOffset = (this.state.scrollStart as number) + scroll.range * mouseOffset / scroll.handle.range; 150 | if (handleOffset < 0) { 151 | handleOffset = 0; 152 | } else if (handleOffset > scroll.range) { 153 | handleOffset = scroll.range; 154 | } 155 | 156 | contentElement.scrollTop = handleOffset; 157 | } 158 | } 159 | 160 | private updateHandle(): void { 161 | const handleElement = this.handle.current; 162 | if (handleElement) { 163 | const scroll = getScroll(this.content.current as HTMLElement, handleElement); 164 | const handleOffset = Math.round(scroll.handle.range * scroll.position / scroll.range); 165 | handleElement.style.top = `${handleOffset}px`; 166 | handleElement.style.height = `${scroll.handle.height}px`; 167 | } 168 | 169 | delete this.handleUpdateTimer; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/shell/extras.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {ServiceLookup} from "inconel"; 3 | 4 | import {GenericExtraDef} from "../application/component"; 5 | import {BuiltinServices} from "../extension"; 6 | 7 | import type {preference} from ".."; 8 | 9 | interface Props { 10 | services: ServiceLookup & BuiltinServices; 11 | preferences: preference.Set; 12 | 13 | onNavigate(path: string, state?: unknown): void; 14 | 15 | extras: ReadonlyArray; 16 | 17 | locationPath: string; 18 | } 19 | 20 | interface State { 21 | menu: string | null; 22 | openTime: number; 23 | } 24 | 25 | export class Extras extends React.PureComponent { 26 | render(): React.ReactNode { 27 | const elements = new Array(); 28 | 29 | for (const {id, path, component: Component} of this.props.extras) { 30 | if (!path || path === this.props.locationPath) { 31 | elements.push(); 37 | } 38 | } 39 | 40 | return
    {elements}
    ; 41 | } 42 | } -------------------------------------------------------------------------------- /src/shell/index.tsx: -------------------------------------------------------------------------------- 1 | import "./shell.sass"; 2 | import * as React from "react"; 3 | import {withRouter, RouteComponentProps} from "react-router"; 4 | import {History} from "history"; 5 | import {ServiceLookup} from "inconel"; 6 | 7 | import {GenericExtraDef, GenericMenuDef, GenericModeDef} from "../application/component"; 8 | import {sinkEvent} from "../event"; 9 | import {BuiltinServices} from "../extension"; 10 | 11 | import {Extras} from "./extras"; 12 | import {Menu} from "./menu"; 13 | import {Modes} from "./modes"; 14 | import {SystemButtons} from "./system-buttons"; 15 | 16 | import type {preference} from ".."; 17 | import type {WindowService} from "../ipc.contract"; 18 | 19 | // Props mapped from router provided props 20 | interface RouterProps { 21 | locationPath: string; 22 | history: History; 23 | } 24 | 25 | // Props accepted from the parent component 26 | interface ExternalProps { 27 | window: WindowService; 28 | 29 | workingPath: string | null; 30 | 31 | preferences: preference.Set; 32 | onSetPreferences(values: Partial): void; 33 | 34 | localPreferences: preference.NameSet; 35 | onTogglePreferenceScope(name: preference.Name): void; 36 | 37 | services: ServiceLookup & BuiltinServices; 38 | extras: ReadonlyArray; 39 | menus: ReadonlyArray; 40 | modes: ReadonlyArray; 41 | 42 | onOpenDirectory(): void; 43 | onNavigate(): void; 44 | } 45 | 46 | // Effective props of the component 47 | interface Props extends ExternalProps, RouterProps {} 48 | 49 | interface State { 50 | // Time when focus was last lost 51 | focusLossTime: number; 52 | 53 | // Whether residual UI focus should be applied 54 | focusAcquired: boolean; 55 | } 56 | 57 | export class ShellComponent extends React.PureComponent { 58 | constructor(props: Props) { 59 | super(props); 60 | 61 | this.state = { 62 | focusLossTime: 1, // Resting state should be unfocused, so timestamp it 1 ms after epoch (heh) 63 | focusAcquired: false, 64 | }; 65 | } 66 | 67 | componentDidUpdate(nextProps: Props): void { 68 | if (nextProps.locationPath !== this.props.locationPath) 69 | this.handleMaybeLostFocus(); 70 | } 71 | 72 | render(): React.ReactNode { 73 | const {preferences, services} = this.props; 74 | 75 | return
    79 | 86 | 93 | 113 |
    ; 114 | } 115 | 116 | handleBacktrack = () => { 117 | this.props.history.goBack(); 118 | }; 119 | 120 | handleKeyDown = (ev: React.KeyboardEvent) => { 121 | if (ev.key === "Escape") 122 | this.handleMaybeLostFocus(); 123 | }; 124 | 125 | handleMaybeLostFocus = () => { 126 | this.setState(({focusAcquired}) => focusAcquired 127 | ? { 128 | focusAcquired: false, 129 | focusLossTime: new Date().getTime(), 130 | } 131 | : null); 132 | }; 133 | 134 | handleMenuFocus = () => { 135 | this.setState({ 136 | focusAcquired: true, 137 | }); 138 | }; 139 | 140 | handleNavigate = (path: string, state?: unknown) => { 141 | const {history} = this.props; 142 | history.push(path, state); 143 | this.props.onNavigate(); 144 | }; 145 | } 146 | 147 | export const Shell = withRouter(({ 148 | location, 149 | history, 150 | workingPath, 151 | preferences, 152 | onSetPreferences, 153 | localPreferences, 154 | onTogglePreferenceScope, 155 | services, 156 | extras, 157 | menus, 158 | modes, 159 | onOpenDirectory, 160 | onNavigate, 161 | window, 162 | }: ExternalProps & RouteComponentProps) => React.createElement(ShellComponent, { 163 | // This may look redundant and one would be tempted to use varidic syntax 164 | // but please do not, as future versions of router could start injecting 165 | // variables that will cause superfluous redraws 166 | 167 | // External props 168 | workingPath, 169 | preferences, 170 | onSetPreferences, 171 | localPreferences, 172 | onTogglePreferenceScope, 173 | services, 174 | extras, 175 | menus, 176 | modes, 177 | onOpenDirectory, 178 | onNavigate, 179 | window, 180 | 181 | // Mapped props 182 | history, 183 | locationPath: location.pathname, 184 | })); -------------------------------------------------------------------------------- /src/shell/menu-button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {mdiLoading} from "@mdi/js"; 3 | import {Icon} from "@mdi/react"; 4 | 5 | interface Props { 6 | id: string; 7 | label: string; 8 | icon: string; 9 | visible?: boolean; 10 | busy?: boolean; 11 | onClick: (id: string) => void; 12 | } 13 | 14 | function renderButton({id, label, icon, visible, busy, onClick}: Props) { 15 | return
  • onClick(id)} 19 | > 20 | {busy 21 | ? 22 | : } 23 |
  • ; 24 | } 25 | 26 | export const MenuButton = React.memo(renderButton); -------------------------------------------------------------------------------- /src/shell/menu.sass: -------------------------------------------------------------------------------- 1 | @import '../global.sass' 2 | 3 | nav .menu 4 | display: flex 5 | flex-direction: column 6 | max-height: 90vh 7 | 8 | > li 9 | padding-top: $space-default 10 | 11 | > :first-child 12 | flex-grow: 1 13 | 14 | button 15 | height: $space-text-box-height 16 | 17 | .group 18 | display: flex 19 | flex-direction: row-reverse 20 | 21 | button 22 | width: $nav-button-size 23 | 24 | > input:first-of-type 25 | flex-grow: 1 26 | flex-shrink: 1 27 | width: 1em 28 | 29 | > *:not(:first-child) 30 | display: inline-block 31 | border-right: 1px solid $color-panel-opaque 32 | 33 | svg 34 | @include svg-icon($nav-icon-size) 35 | 36 | .notice 37 | margin: $space-default -------------------------------------------------------------------------------- /src/shell/menu.tsx: -------------------------------------------------------------------------------- 1 | import "./menu.sass"; 2 | import * as React from "react"; 3 | import {mdiImageBroken, mdiFolderOpen, mdiArrowLeft} from "@mdi/js"; 4 | import {Icon} from "@mdi/react"; 5 | import {ServiceLookup} from "inconel"; 6 | 7 | import {Path as GalleryPath} from "../gallery"; 8 | import {GenericMenuDef} from "../application/component"; 9 | import {BuiltinServices} from "../extension"; 10 | 11 | import {MenuButton} from "./menu-button"; 12 | import {Selection} from "./selection"; 13 | 14 | import type {preference} from ".."; 15 | 16 | interface Props { 17 | services: ServiceLookup & BuiltinServices; 18 | preferences: preference.Set; 19 | onSetPreferences(values: Partial): void; 20 | 21 | localPreferences: preference.NameSet; 22 | onTogglePreferenceScope(name: preference.Name): void; 23 | 24 | workingPath: string | null; 25 | onOpenDirectory(): void; 26 | onBacktrack(): void; 27 | 28 | locationPath: string; 29 | onNavigate(path: string, state?: unknown): void; 30 | 31 | menus: ReadonlyArray; 32 | focusLossTime: number; 33 | onFocusGained(): void; 34 | } 35 | 36 | interface State { 37 | menu: string | null; 38 | openTime: number; 39 | } 40 | 41 | export class Menu extends React.PureComponent { 42 | readonly #forceUpdate = (): void => { this.forceUpdate(); }; 43 | 44 | constructor(props: Props) { 45 | super(props); 46 | 47 | this.state = { 48 | menu: null, 49 | openTime: 0, 50 | }; 51 | 52 | this.handleToggleMenu = this.handleToggleMenu.bind(this); 53 | } 54 | 55 | componentDidMount(): void { 56 | this.props.services.progress.on("change", this.#forceUpdate); 57 | } 58 | 59 | componentWillUnmount(): void { 60 | this.props.services.progress.off("change", this.#forceUpdate); 61 | } 62 | 63 | render(): React.ReactNode { 64 | const {locationPath, services, workingPath} = this.props; 65 | const {browsing, progress} = services; 66 | 67 | const busyMenus: {[id: string]: true} = {}; 68 | for (const [, t] of progress.entries) 69 | if (t.menu) 70 | busyMenus[t.menu] = true; 71 | 72 | let MenuComponent: GenericMenuDef["component"] | undefined; 73 | const menus = new Array(this.props.menus.length); 74 | for (let m = menus.length; m --> 0;) { 75 | const {id, icon, label, path, requireDirectory, component} = this.props.menus[m]; 76 | menus[m] = 85 | 86 | if (id === this.state.menu && this.state.openTime > this.props.focusLossTime) 87 | MenuComponent = component; 88 | } 89 | 90 | return
    91 |
      92 |
    • 93 | 94 |
    • 95 |
    • 96 | 97 |
    • 98 | {menus} 99 | 105 | 106 |
    107 | 108 | {MenuComponent && } 117 |
    ; 118 | } 119 | 120 | handleToggleMenu(newMenu: string): void { 121 | this.setState(({menu, openTime}, {focusLossTime}) => { 122 | if (openTime > focusLossTime && newMenu === menu) 123 | return {menu: null, openTime: 0}; 124 | 125 | this.props.onFocusGained(); 126 | return { 127 | menu: newMenu, 128 | openTime: new Date().getTime(), 129 | }; 130 | }); 131 | } 132 | } -------------------------------------------------------------------------------- /src/shell/modes.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {findDOMNode} from "react-dom"; 3 | import {Switch, Route} from "react-router"; 4 | import {ServiceLookup} from "inconel"; 5 | 6 | import {GenericModeDef} from "../application/component"; 7 | import {BuiltinServices} from "../extension"; 8 | 9 | import type {preference} from ".."; 10 | 11 | interface Props { 12 | modes: ReadonlyArray; 13 | preferences: preference.Set; 14 | services: ServiceLookup & BuiltinServices; 15 | 16 | focusAcquired: boolean; 17 | 18 | onNavigate(path: string, state?: unknown): void; 19 | } 20 | 21 | export class Modes extends React.PureComponent { 22 | componentDidUpdate(oldProps: Props) { 23 | if (!oldProps.focusAcquired && this.props.focusAcquired) { 24 | const node = findDOMNode(this) as HTMLElement; 25 | if (node && node.focus) 26 | node.focus(); 27 | } 28 | } 29 | 30 | render(): React.ReactNode { 31 | const routes = this.props.modes.map(({id, path, component: Component}) => ( 32 | 33 | {({match, location}) => } 40 | 41 | )); 42 | 43 | return {routes}; 44 | } 45 | } -------------------------------------------------------------------------------- /src/shell/selection.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {mdiSelection} from "@mdi/js"; 3 | import {Icon} from "@mdi/react"; 4 | 5 | import type {browsing} from ".."; 6 | 7 | interface Props { 8 | browsing: browsing.Service; 9 | } 10 | 11 | export class Selection extends React.PureComponent { 12 | readonly #forceUpdate = (): void => { this.forceUpdate(); }; 13 | 14 | componentDidMount(): void { 15 | this.props.browsing.on("selectchange", this.#forceUpdate); 16 | } 17 | 18 | componentWillUnmount(): void { 19 | this.props.browsing.off("selectchange", this.#forceUpdate); 20 | } 21 | 22 | render(): React.ReactNode { 23 | const selectionSize = this.props.browsing.selected.size; 24 | 25 | return
  • 27 | 28 | 29 | {selectionSize > 0 && {selectionSize}} 30 | 31 |
  • ; 32 | } 33 | } -------------------------------------------------------------------------------- /src/shell/shell.sass: -------------------------------------------------------------------------------- 1 | @use 'sass:math' 2 | @import '../global.sass' 3 | 4 | #shell 5 | display: block 6 | height: 100% 7 | 8 | > div 9 | height: 100% 10 | outline: none 11 | 12 | section 13 | height: 100% 14 | outline: 0px none 15 | 16 | nav 17 | position: absolute 18 | top: 0 19 | left: 0 20 | right: 0 21 | pointer-events: none 22 | 23 | ul 24 | @include list-no-defaults 25 | 26 | .panel.system 27 | float: right 28 | 29 | .scope-toggle 30 | width: $nav-button-size 31 | align-self: flex-end 32 | 33 | button 34 | width: 100% 35 | height: $space-text-box-height 36 | border-left: 1px solid $color-panel-opaque 37 | text-align: center 38 | 39 | .actions 40 | display: inline-block 41 | height: $nav-button-size 42 | padding: 0 $space-narrow 43 | vertical-align: bottom 44 | 45 | > li 46 | display: inline-block 47 | width: $nav-button-size 48 | max-width: $nav-button-size 49 | height: $nav-button-size 50 | text-align: center 51 | overflow: hidden 52 | transition: background $time-resize-animation, max-width $time-resize-animation 53 | 54 | &:hover 55 | background-color: $color-clickable-hover 56 | 57 | &.application 58 | -webkit-app-region: drag 59 | cursor: move 60 | 61 | &.variadic 62 | width: auto 63 | max-width: 50vw 64 | 65 | &.hidden 66 | min-width: 0 67 | max-width: 0 68 | 69 | > span 70 | padding: 0 $space-narrow 71 | 72 | svg 73 | @include svg-icon($nav-icon-size) 74 | margin-top: math.div($nav-button-size - $nav-icon-size, 2) 75 | 76 | .pill 77 | display: inline-block 78 | background: rgba(28, 98, 185) 79 | min-width: 1.2em 80 | padding: 0 $space-narrow 81 | font-size: 80% 82 | vertical-align: bottom 83 | text-align: center 84 | border-radius: $space-narrow 85 | margin: 0 0 (-$space-narrow) (-$space-default) 86 | 87 | // Shared components, These are considered a part of the interface 88 | .layer 89 | .panel 90 | display: inline-block 91 | border-radius: 0 0 math.div($space-narrow, 2) math.div($space-narrow, 2) 92 | background-color: $color-panel 93 | pointer-events: auto 94 | transition: box-shadow $time-resize-animation * 2, background-color $time-resize-animation 95 | 96 | &.focus .panel, .panel:hover 97 | box-shadow: 0 0 $space-default $color-panel-shadow 98 | background-color: $color-panel-focus 99 | backdrop-filter: blur(0.2em) 100 | 101 | .icon-spin 102 | animation: ia-spin 1s infinite linear 103 | 104 | @keyframes ia-spin 105 | 0% 106 | transform: rotate(0deg) 107 | 108 | 100% 109 | transform: rotate(359deg) 110 | -------------------------------------------------------------------------------- /src/shell/system-buttons.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {Icon} from "@mdi/react"; 3 | import { 4 | mdiWindowClose, 5 | mdiWindowMaximize, 6 | mdiWindowMinimize, 7 | mdiWindowRestore, 8 | } from "@mdi/js"; 9 | 10 | import {Debounce} from "../debounce"; 11 | import type {WindowService} from "../window"; 12 | 13 | interface Props { 14 | window: WindowService; 15 | } 16 | 17 | interface State { 18 | maximized: boolean; 19 | hidden: boolean; 20 | } 21 | 22 | export class SystemButtons extends React.PureComponent { 23 | private readonly update = new Debounce(async () => { 24 | const status = await this.props.window.getStatus(); 25 | this.setState({ 26 | hidden: status.tabletMode, 27 | maximized: status.maximized, 28 | }); 29 | }, 100); 30 | 31 | constructor(props: Props) { 32 | super(props); 33 | 34 | this.state = { 35 | maximized: false, 36 | hidden: false, 37 | }; 38 | } 39 | 40 | handleToggleMaximized = () => { 41 | if (this.state.maximized) 42 | this.props.window.unmaximize(); 43 | else 44 | this.props.window.maximize(); 45 | } 46 | 47 | componentDidMount(): void { 48 | const {window} = this.props; 49 | 50 | window.on("maximize", () => { 51 | this.setState({maximized: true}); 52 | }); 53 | 54 | window.on("unmaximize", () => { 55 | this.setState({maximized: false}); 56 | }); 57 | 58 | window.on("resize", () => { 59 | this.update.schedule(); 60 | }); 61 | 62 | this.update.schedule(); 63 | } 64 | 65 | render(): React.ReactNode { 66 | const {window} = this.props; 67 | return !this.state.hidden &&
    68 |
      69 |
    • 70 | 71 |
    • 72 |
    • 73 | 74 |
    • 75 |
    • 76 | 77 |
    • 78 |
    79 |
    ; 80 | } 81 | } -------------------------------------------------------------------------------- /src/stage/center.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {DraggableCore, DraggableData, DraggableEvent} from "react-draggable"; 3 | 4 | interface Props { 5 | children: React.ReactNode; 6 | atStart: boolean; 7 | atEnd: boolean; 8 | onNavigatePrev(): void; 9 | onNavigateNext(): void; 10 | } 11 | 12 | interface Coordinate { 13 | x: number; 14 | y: number; 15 | } 16 | 17 | interface State { 18 | scale: number; 19 | anchor: Coordinate | null; 20 | offset: Coordinate; 21 | } 22 | 23 | export class Center extends React.PureComponent { 24 | constructor(props: Props) { 25 | super(props); 26 | 27 | this.state = { 28 | scale: 1.0, 29 | anchor: null, 30 | offset: { 31 | x: 0, 32 | y: 0, 33 | }, 34 | }; 35 | 36 | this.handleDragEnd = this.handleDragEnd.bind(this); 37 | this.handleDragMove = this.handleDragMove.bind(this); 38 | this.handleDragStart = this.handleDragStart.bind(this); 39 | this.handleMouseWheel = this.handleMouseWheel.bind(this); 40 | } 41 | 42 | public render(): React.ReactNode { 43 | const {anchor, offset, scale} = this.state; 44 | 45 | const style = { 46 | transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`, 47 | }; 48 | 49 | const dragging = !!anchor; 50 | 51 | return
    52 | 57 |
    58 |
    {this.props.children}
    59 |
    60 |
    61 | 62 |
    ; 69 | } 70 | 71 | private handleDragEnd(): void { 72 | this.setState({anchor: null}); 73 | } 74 | 75 | private handleDragMove(ev: DraggableEvent, {x, y}: DraggableData): void { 76 | this.setState(p => p.anchor && { 77 | offset: { 78 | x: x - p.anchor.x, 79 | y: y - p.anchor.y, 80 | }, 81 | }); 82 | } 83 | 84 | private handleDragStart(ev: DraggableEvent, {x, y}: DraggableData): void { 85 | this.setState(p => ({ 86 | anchor: { 87 | // We subtract because ultimately we want this in the move handler: 88 | // offset = mouse - mouse_original + offset_original 89 | // Since on the other side is doing: 90 | // offset = mouse - original 91 | // We need to negate the offset part so it is double negated 92 | x: x - p.offset.x, 93 | y: y - p.offset.y, 94 | }, 95 | })); 96 | } 97 | 98 | private handleMouseWheel({ 99 | currentTarget, 100 | deltaY, 101 | clientX, 102 | clientY, 103 | }: React.WheelEvent): void { 104 | let change = 1 + deltaY / -600; 105 | if (change > 1) { 106 | if (change * this.state.scale > 8) { 107 | return; 108 | } 109 | } else { 110 | if (change * this.state.scale < 0.3) { 111 | return; 112 | } 113 | } 114 | 115 | const rect = (currentTarget as HTMLElement).getBoundingClientRect(); 116 | this.setState(({offset, scale}) => { 117 | // Transform to image centric coordniates 118 | const relativeX = (clientX - rect.x) - (rect.width / 2) - offset.x; 119 | const relativeY = (clientY - rect.y) - (rect.height / 2) - offset.y; 120 | 121 | return { 122 | offset: { 123 | x: offset.x + (relativeX - (relativeX * (scale * change) / scale)), 124 | y: offset.y + (relativeY - (relativeY * (scale * change) / scale)), 125 | }, 126 | scale: scale * change, 127 | }; 128 | }); 129 | } 130 | } -------------------------------------------------------------------------------- /src/stage/constants.ts: -------------------------------------------------------------------------------- 1 | export const Path = "/stage"; 2 | -------------------------------------------------------------------------------- /src/stage/index.ts: -------------------------------------------------------------------------------- 1 | export {Path} from "./constants"; 2 | export {Menu, Definition as MenuDefinition} from "./menu"; 3 | export {Definition as ModeDefinition} from "./mode"; 4 | export {create as createTransitionService} from "./transition-service"; 5 | -------------------------------------------------------------------------------- /src/stage/lineup.sass: -------------------------------------------------------------------------------- 1 | @import "../global.sass" 2 | 3 | .stage .lineup 4 | position: relative 5 | overflow: hidden 6 | 7 | &.dock-left 8 | padding-right: $space-narrow 9 | float: left 10 | 11 | &.dock-right 12 | padding-left: $space-narrow 13 | float: right 14 | 15 | &.dock-bottom 16 | padding-top: $space-narrow 17 | height: 10vh 18 | width: 100% 19 | 20 | .thumbnail 21 | width: 10vh 22 | height: 100% 23 | 24 | ul 25 | height: 100% 26 | left: 50% 27 | transform: translate(-50%, 0) 28 | white-space: nowrap 29 | 30 | > li 31 | width: 9vw 32 | 33 | > li:not(:last-child) 34 | margin-right: $space-narrow 35 | 36 | &.dock-left, &.dock-right 37 | height: 100% 38 | width: 10vw 39 | 40 | .thumbnail 41 | height: 10vh 42 | width: 100% 43 | 44 | ul 45 | width: 100% 46 | top: 50% 47 | transform: translate(0, -50%) 48 | 49 | > li 50 | height: 9vh 51 | 52 | > li:not(:last-child) 53 | margin-bottom: $space-narrow 54 | 55 | ul 56 | display: block 57 | position: absolute 58 | 59 | list-style: none 60 | padding: 0 61 | margin: 0 62 | 63 | text-align: center 64 | 65 | .thumbnail 66 | &.anchor 67 | outline: 4px solid #FFF 68 | 69 | &::after 70 | display: none 71 | 72 | > div 73 | display: none 74 | -------------------------------------------------------------------------------- /src/stage/lineup.tsx: -------------------------------------------------------------------------------- 1 | import "./lineup.sass"; 2 | import * as React from "react"; 3 | 4 | import {Thumbnail} from "../thumbnail"; 5 | import type {browsing, preference} from ".."; 6 | 7 | interface PreferenceMappedProps { 8 | lineupEntries: number; 9 | lineupPosition: preference.PanelPosition; 10 | thumbnailPath?: string; 11 | thumbnailSizing: preference.ThumbnailSizing; 12 | thumbnailResolution?: preference.ThumbnailResolution; 13 | } 14 | 15 | interface Props extends PreferenceMappedProps { 16 | browsing: browsing.Service; 17 | } 18 | 19 | export class Lineup extends React.PureComponent { 20 | constructor(props: Props) { 21 | super(props); 22 | } 23 | 24 | componentDidMount(): void { 25 | this.props.browsing.on("filefocus", this.handleFocusedFileChanged); 26 | this.props.browsing.on("fileschange", this.handleFilesChanged); 27 | } 28 | 29 | componentWillUnmount(): void { 30 | this.props.browsing.off("filefocus", this.handleFocusedFileChanged); 31 | this.props.browsing.off("fileschange", this.handleFilesChanged); 32 | } 33 | 34 | render() { 35 | const {files, selected} = this.props.browsing; 36 | const adjacents = this.props.lineupEntries; 37 | 38 | const focusedFile = this.props.browsing.focusedFile || 0; 39 | let firstDrawn: number; 40 | let lastDrawn: number; 41 | if (focusedFile <= adjacents) { 42 | firstDrawn = 0; 43 | lastDrawn = Math.min(files.names.length - 1, 2 * adjacents); 44 | } else if (files.names.length - 1 - focusedFile < adjacents) { 45 | lastDrawn = files.names.length - 1; 46 | firstDrawn = Math.max(0, files.names.length - 2 * adjacents - 1); 47 | } else { 48 | firstDrawn = focusedFile - adjacents; 49 | lastDrawn = focusedFile + adjacents; 50 | } 51 | 52 | const names = files.names.slice(firstDrawn, lastDrawn + 1); 53 | 54 | return
    55 |
      56 | {names.map((_, i) => { 57 | const index = i + firstDrawn; 58 | 59 | return 67 | })} 68 |
    69 |
    ; 70 | } 71 | 72 | handleFocusedFileChanged = (fileIndex: number | null): void => { 73 | this.forceUpdate(); 74 | }; 75 | 76 | handleFilesChanged = () => { 77 | this.forceUpdate(); 78 | }; 79 | } -------------------------------------------------------------------------------- /src/stage/menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {Icon} from "@mdi/react"; 3 | import { 4 | mdiClose, 5 | mdiDockBottom, 6 | mdiDockLeft, 7 | mdiDockRight, 8 | mdiPlay, 9 | mdiStop, 10 | mdiImageSizeSelectActual, 11 | mdiFitToPage, 12 | mdiViewCarousel 13 | } from "@mdi/js"; 14 | 15 | import {NumericInput} from "../number-input"; 16 | import {RadioButtons} from "../radio-buttons"; 17 | import {ScopeToggle} from "../scope-toggle"; 18 | 19 | import {Path} from "./constants"; 20 | import {TransitionService} from "./transition-service"; 21 | 22 | import type {preference} from ".."; 23 | 24 | interface PreferenceMappedProps { 25 | lineupEntries: number; 26 | lineupPosition: preference.PanelPosition; 27 | preload: number; 28 | } 29 | 30 | interface Props extends PreferenceMappedProps { 31 | transition: TransitionService; 32 | 33 | onSetPreferences(values: Partial): void; 34 | 35 | localPreferences: preference.NameSet; 36 | onTogglePreferenceScope(name: preference.Name): void; 37 | } 38 | 39 | const lineupDockingModes: { 40 | id: preference.PanelPosition, title: string, icon: string 41 | }[] = [ 42 | { 43 | id: "disable", 44 | title: "Disable", 45 | icon: mdiClose, 46 | }, 47 | { 48 | id: "bottom", 49 | title: "Dock bottom", 50 | icon: mdiDockBottom, 51 | }, 52 | { 53 | id: "left", 54 | title: "Dock left", 55 | icon: mdiDockLeft, 56 | }, 57 | { 58 | id: "right", 59 | title: "Dock right", 60 | icon: mdiDockRight, 61 | }, 62 | ]; 63 | 64 | export class Menu extends React.PureComponent { 65 | private readonly redraw: () => void; 66 | private readonly togglePreloadScope: () => void; 67 | private readonly toggleLineupPositionScope: () => void; 68 | private readonly toggleLineupEntriesScope: () => void; 69 | 70 | constructor(props: Props) { 71 | super(props); 72 | this.redraw = this.forceUpdate.bind(this); 73 | 74 | this.togglePreloadScope = () => this.props.onTogglePreferenceScope("preload"); 75 | this.toggleLineupPositionScope = () => this.props.onTogglePreferenceScope("lineupPosition"); 76 | this.toggleLineupEntriesScope = () => this.props.onTogglePreferenceScope("lineupEntries"); 77 | 78 | this.handlePreloadChanged = this.handlePreloadChanged.bind(this); 79 | this.handleSetLineupEntries = this.handleSetLineupEntries.bind(this); 80 | this.handleSetLineupPosition = this.handleSetLineupPosition.bind(this); 81 | this.handleToggleTransition = this.handleToggleTransition.bind(this); 82 | this.handleTransitionIntervalChanged = this.handleTransitionIntervalChanged.bind(this); 83 | this.toggleScaleToFit = this.toggleScaleToFit.bind(this); 84 | 85 | this.props.transition.on("intervalchange", this.redraw); 86 | } 87 | 88 | componentWillUnmount(): void { 89 | this.props.transition.off("intervalchange", this.redraw); 90 | } 91 | 92 | render(): React.ReactNode { 93 | const { 94 | localPreferences, 95 | preload, 96 | transition, 97 | } = this.props; 98 | 99 | const transitionInterval = this.props.transition.interval; 100 | 101 | return
      102 |
    • 103 | 110 |
    • 111 |
    • 112 | 124 |
    • 125 |
    • 126 | 130 | 133 |
    • 134 |
    • 135 | 142 | 145 |
    • 146 | {this.props.lineupPosition !== "disable" &&
    • 147 | 155 | 158 |
    • } 159 |
    ; 160 | } 161 | 162 | handlePreloadChanged(preload: number): void { 163 | this.props.onSetPreferences({preload}); 164 | } 165 | 166 | handleSetLineupEntries(lineupEntries: number): void { 167 | this.props.onSetPreferences({lineupEntries}); 168 | } 169 | 170 | handleSetLineupPosition(lineupPosition: preference.PanelPosition): void { 171 | this.props.onSetPreferences({lineupPosition}); 172 | } 173 | 174 | handleToggleTransition(): void { 175 | const {transition} = this.props; 176 | transition.setInterval(transition.interval === 0 ? 3000 : 0); 177 | } 178 | 179 | handleTransitionIntervalChanged(ev: React.ChangeEvent): void { 180 | const value = parseInt(ev.target.value) || 0; 181 | const {transition} = this.props; 182 | if (value !== transition.interval) 183 | transition.setInterval(value); 184 | } 185 | 186 | toggleScaleToFit(): void { 187 | const {transition} = this.props; 188 | transition.setScaleToFit(!transition.scaleToFit); 189 | this.forceUpdate(); 190 | } 191 | } 192 | 193 | export const Definition = { 194 | id: "stage", 195 | icon: mdiViewCarousel, 196 | label: "Stage", 197 | path: [Path], 198 | requireDirectory: true, 199 | services: ["transition"], 200 | component: Menu, 201 | selectPreferences: ({ 202 | lineupEntries, 203 | lineupPosition, 204 | preload, 205 | }: preference.Set): PreferenceMappedProps => ({ 206 | lineupEntries, 207 | lineupPosition, 208 | preload, 209 | }), 210 | }; -------------------------------------------------------------------------------- /src/stage/mode.tsx: -------------------------------------------------------------------------------- 1 | import "./stage.sass"; 2 | import * as React from "react"; 3 | 4 | import {Center} from "./center"; 5 | import {Path} from "./constants"; 6 | import {Lineup} from "./lineup"; 7 | import {TransitionService} from "./transition-service"; 8 | 9 | import type {browsing, preference} from ".."; 10 | 11 | interface PreferenceMappedProps { 12 | lineupEntries: number; 13 | lineupPosition: preference.PanelPosition; 14 | preload: number; 15 | thumbnailPath?: string; 16 | thumbnailSizing: preference.ThumbnailSizing; 17 | thumbnailResolution?: preference.ThumbnailResolution; 18 | } 19 | 20 | interface Props extends PreferenceMappedProps { 21 | browsing: browsing.Service; 22 | transition: TransitionService; 23 | 24 | showActualSize: boolean; 25 | } 26 | 27 | interface State { 28 | fileType?: string; 29 | } 30 | 31 | const videoFileSuffices = new Set(["avi", "mkv", "mp4", "mov", "webm"]); 32 | 33 | function loadImage(files: browsing.FilesView, index: number): HTMLImageElement { 34 | const image = document.createElement("img"); 35 | image.src = `file://${files.path}/${files.names[index]}`; 36 | return image; 37 | } 38 | 39 | export class Stage extends React.PureComponent { 40 | preloadedImages: Array = []; 41 | 42 | private container: React.RefObject; 43 | 44 | private probeRequestController?: AbortController; 45 | 46 | constructor(props: Props) { 47 | super(props); 48 | 49 | this.state = {}; 50 | 51 | this.container = React.createRef(); 52 | 53 | this.handleFocusedFileChanged = this.handleFocusedFileChanged.bind(this); 54 | this.handleImageError = this.handleImageError.bind(this); 55 | this.handleKeyDownShell = this.handleKeyDownShell.bind(this); 56 | this.handleScalingChange = this.handleScalingChange.bind(this); 57 | this.handleTransition = this.handleTransition.bind(this); 58 | this.navigateNext = this.navigateNext.bind(this); 59 | this.navigatePrev = this.navigatePrev.bind(this); 60 | } 61 | 62 | componentDidMount(): void { 63 | this.props.browsing.on("filefocus", this.handleFocusedFileChanged); 64 | this.props.transition.on("transition", this.handleTransition); 65 | this.props.transition.on("scalingchange", this.handleScalingChange); 66 | 67 | (this.container.current as HTMLElement).focus(); 68 | } 69 | 70 | componentWillUnmount(): void { 71 | this.props.browsing.off("filefocus", this.handleFocusedFileChanged); 72 | this.props.transition.off("transition", this.handleTransition); 73 | this.props.transition.off("scalingchange", this.handleScalingChange); 74 | } 75 | 76 | render(): React.ReactNode { 77 | const {files, focusedFile} = this.props.browsing; 78 | const fileIndex = focusedFile || 0; 79 | 80 | const {fileType} = this.state; 81 | const fileName = files.names[fileIndex]; 82 | 83 | const suffixIndex = fileName.lastIndexOf('.'); 84 | let isVideo = false; 85 | if (suffixIndex > 0) { 86 | isVideo = videoFileSuffices.has(fileName.slice(suffixIndex + 1)); 87 | } else if (fileType) { 88 | isVideo = fileType.startsWith("video/"); 89 | } 90 | 91 | const fileUrl = `file://${files.path}/${fileName}`; 92 | let cssClass = this.props.transition.scaleToFit ? "stage fit" : "stage"; 93 | 94 | const {lineupPosition} = this.props; 95 | if (lineupPosition === "bottom") 96 | cssClass += " lineup-docked"; 97 | 98 | const lineup = ; 107 | 108 | return
    113 | {(lineupPosition === "left" || lineupPosition === "right") && lineup} 114 |
    = files.names.length - 1} 117 | onNavigateNext={this.navigateNext} 118 | onNavigatePrev={this.navigatePrev} 119 | > 120 | {isVideo 121 | ?
    126 | {lineupPosition === "bottom" && lineup} 127 |
    ; 128 | } 129 | 130 | navigatePrev(): void { 131 | const {focusedFile, setFocus} = this.props.browsing; 132 | if (focusedFile !== null && focusedFile > 0) 133 | setFocus(focusedFile - 1) 134 | } 135 | 136 | navigateNext(): boolean { 137 | const {focusedFile, setFocus, files} = this.props.browsing; 138 | if (focusedFile !== null) { 139 | const canNavigate = focusedFile < files.names.length - 1; 140 | canNavigate && setFocus(focusedFile + 1); 141 | return canNavigate; 142 | } 143 | 144 | return false; 145 | } 146 | 147 | handleImageError(): void { 148 | const {files, focusedFile} = this.props.browsing; 149 | if (focusedFile === null) 150 | return; 151 | 152 | this.probeRequestController = new AbortController(); 153 | const url = `file://${files.path}/${files.names[focusedFile]}`; 154 | fetch(url, { 155 | method: "HEAD", 156 | cache: "default", 157 | redirect: "follow", 158 | signal: this.probeRequestController.signal, 159 | }) 160 | .then(r => { 161 | if (url === r.url) { 162 | const fileType = r.headers.get("Content-Type"); 163 | fileType && this.setState({fileType}); 164 | } 165 | }) 166 | .finally(() => delete this.probeRequestController); 167 | } 168 | 169 | handleKeyDownShell(ev: React.KeyboardEvent): void { 170 | switch (ev.key) { 171 | case "ArrowRight": 172 | this.navigateNext(); 173 | break; 174 | 175 | case "ArrowLeft": 176 | this.navigatePrev(); 177 | break; 178 | } 179 | } 180 | 181 | handleTransition(): void { 182 | if (!this.navigateNext()) { 183 | this.props.transition.setInterval(-1); 184 | } 185 | } 186 | 187 | handleScalingChange(): void { 188 | this.forceUpdate(); 189 | } 190 | 191 | private handleFocusedFileChanged(fileIndex: number | null): void { 192 | if (this.probeRequestController) { 193 | this.probeRequestController.abort(); 194 | delete this.probeRequestController; 195 | } 196 | 197 | if (fileIndex === null) 198 | return; 199 | 200 | const {files} = this.props.browsing; 201 | let {preload} = this.props; 202 | if (preload > 0) { 203 | ++preload; 204 | 205 | const preloaded: Array = []; 206 | 207 | let n = Math.min(files.names.length, fileIndex + preload); 208 | while (--n > fileIndex) 209 | preloaded.push(loadImage(files, n)); 210 | 211 | n = Math.max(-1, fileIndex - preload); 212 | while (++n < fileIndex) 213 | preloaded.push(loadImage(files, n)); 214 | 215 | this.preloadedImages = preloaded; 216 | } 217 | 218 | this.forceUpdate(); 219 | } 220 | } 221 | 222 | export const Definition = { 223 | id: "stage", 224 | path: Path, 225 | services: ["browsing", "transition"], 226 | component: Stage, 227 | selectPreferences: ({ 228 | lineupEntries, 229 | lineupPosition, 230 | preload, 231 | thumbnailPath, 232 | thumbnailSizing, 233 | thumbnailResolution, 234 | }: preference.Set): PreferenceMappedProps => ({ 235 | lineupEntries, 236 | lineupPosition, 237 | preload, 238 | thumbnailPath, 239 | thumbnailSizing, 240 | thumbnailResolution, 241 | }), 242 | }; -------------------------------------------------------------------------------- /src/stage/stage.sass: -------------------------------------------------------------------------------- 1 | @import "../global.sass" 2 | 3 | .stage 4 | position: relative 5 | 6 | &.fit .center 7 | img, video 8 | max-width: 100% 9 | max-height: 100% 10 | 11 | &.lineup-docked .center 12 | height: 90vh 13 | 14 | .center 15 | height: 100% 16 | overflow: hidden 17 | position: relative 18 | 19 | > button 20 | position: absolute 21 | top: 0 22 | height: 100% 23 | width: 10vw 24 | min-width: 3em 25 | background-color: transparent 26 | 27 | &:hover 28 | background-color: $color-clickable-hover 29 | 30 | &:disabled 31 | background-color: transparent 32 | pointer-events: none 33 | 34 | &:first-of-type 35 | left: 0 36 | 37 | &:last-of-type 38 | right: 0 39 | 40 | .background 41 | height: 100% 42 | text-align: center 43 | 44 | .content 45 | display: inline-block 46 | height: 100% 47 | white-space: nowrap 48 | // This enables more aggressive GPU acceleration 49 | will-change: transform 50 | transform-origin: center 51 | 52 | &::after 53 | content: '' 54 | display: inline-block 55 | visibility: hidden 56 | width: 0 57 | height: 100% 58 | vertical-align: middle 59 | 60 | img 61 | pointer-events: none 62 | 63 | img, video 64 | display: inline-block 65 | vertical-align: middle -------------------------------------------------------------------------------- /src/stage/transition-service.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from "events"; 2 | 3 | import {method} from "../interface"; 4 | 5 | export interface TransitionService extends EventEmitter { 6 | interval: number; 7 | scaleToFit: boolean; // This really doesn't belong here, but functionality > purity 8 | 9 | setInterval(duration: number): void; 10 | setScaleToFit(enabled: boolean): void; 11 | 12 | on(event: "transition", cb: () => void): this; 13 | on(event: "intervalchange", cb: () => void): this; 14 | on(event: "scalingchange", cb: () => void): this; 15 | } 16 | 17 | type ModeChangeCallback = () => void; 18 | 19 | type Timer = number | NodeJS.Timeout; 20 | 21 | type TimerProvider = { 22 | setInterval: (handler: TimerHandler, timeout: number) => number; 23 | clearInterval: (t: number) => void; 24 | } | { 25 | setInterval: (handler: TimerHandler, timeout: number) => NodeJS.Timeout; 26 | clearInterval: (t: NodeJS.Timeout) => void; 27 | } 28 | 29 | export function create(provider: TimerProvider): [TransitionService, ModeChangeCallback] { 30 | let timer: Timer | null = null; 31 | let interval: number = 0; 32 | let scaleToFit: boolean = true; 33 | 34 | function stop() { 35 | if (timer !== null) { 36 | provider.clearInterval(timer as (number & NodeJS.Timeout)); 37 | timer = null; 38 | } 39 | } 40 | 41 | function onTick() { 42 | service.emit("transition"); 43 | } 44 | 45 | function setInterval(duration: number): void { 46 | if (duration === interval) 47 | return; 48 | 49 | stop(); 50 | if (duration > 0) 51 | timer = provider.setInterval(onTick, duration); 52 | 53 | interval = duration; 54 | service.emit("intervalchange"); 55 | } 56 | 57 | function setScaleToFit(enabled: boolean): void { 58 | scaleToFit = enabled; 59 | service.emit("scalingchange"); 60 | } 61 | 62 | const service = Object.defineProperties(new EventEmitter(), { 63 | interval: { 64 | configurable: false, 65 | get: () => interval, 66 | }, 67 | 68 | scaleToFit: { 69 | configurable: false, 70 | get: () => scaleToFit, 71 | }, 72 | 73 | setInterval: { ...method, value: setInterval }, 74 | setScaleToFit: { ...method, value: setScaleToFit }, 75 | }) as TransitionService; 76 | 77 | return [service, stop]; 78 | } -------------------------------------------------------------------------------- /src/tag/filter.ts: -------------------------------------------------------------------------------- 1 | import type {browsing, tag} from ".."; 2 | 3 | export const UntaggedID = -1; 4 | 5 | export interface TagFilterConfig extends browsing.FilterConfig { 6 | type: "builtin.filter.tag"; 7 | tag: tag.ID; 8 | namespace: tag.NamespaceID; 9 | } 10 | 11 | export class TagFilter implements browsing.Filter { 12 | static TypeID = "builtin.filter.tag"; 13 | 14 | public readonly id: number | undefined; 15 | 16 | constructor(private readonly files: Set) { 17 | // Do nothing 18 | } 19 | 20 | public filter({path, names}: browsing.FilesView): browsing.FilesView { 21 | const result: browsing.FilesView = { 22 | path, 23 | names: [], 24 | }; 25 | 26 | for (let i = 0; i < names.length; ++i) { 27 | const name = names[i]; 28 | 29 | if (this.files.has(name)) 30 | result.names.push(name); 31 | } 32 | 33 | return result; 34 | } 35 | } 36 | 37 | export class TagFilterProvider implements browsing.FilterProvider { 38 | constructor(private readonly tags: tag.Service) { 39 | // Do nothing 40 | } 41 | 42 | public async create(config: TagFilterConfig): Promise { 43 | const files = config.tag === UntaggedID 44 | ? await this.tags.getUntaggedFiles() 45 | : await this.tags.getFiles(config.tag) 46 | 47 | return new TagFilter(files); 48 | } 49 | } 50 | 51 | export function isTagFilter(filter: browsing.FilterConfig): filter is TagFilterConfig { 52 | return filter.type === TagFilter.TypeID; 53 | } -------------------------------------------------------------------------------- /src/tag/index.ts: -------------------------------------------------------------------------------- 1 | export {Filter as Menu, Definition as MenuDefinition} from "./menu"; 2 | export {create as createTaggingService} from "./service"; 3 | -------------------------------------------------------------------------------- /src/tag/list/index.sass: -------------------------------------------------------------------------------- 1 | @import '../../global' 2 | 3 | $size-icon: 1em 4 | $size-icon-spacing: $space-narrow 5 | $size-icon-offset: 1em + $size-icon-spacing 6 | 7 | nav .menu > li.tag-picker 8 | padding-top: 0 9 | 10 | .tag-picker 11 | flex: 1 1 auto 12 | min-height: 0 13 | 14 | &.disabled li 15 | pointer-events: none 16 | background-color: none 17 | color: $color-text-disabled 18 | 19 | > .scroll-pane 20 | height: auto 21 | 22 | li 23 | display: flex 24 | flex-direction: row 25 | align-items: center 26 | border: 1px solid transparent 27 | padding: $space-contextual-item-vertical $space-contextual-item-sides 28 | 29 | &:hover, &.focus 30 | background-color: $color-list-hover 31 | 32 | &.editing 33 | border: 1px solid $color-input-outline-focus 34 | 35 | svg 36 | @include svg-icon($size-icon) 37 | margin-right: $size-icon-spacing 38 | 39 | input 40 | flex: 1 41 | height: auto 42 | width: auto 43 | padding: 0 44 | border: 0 45 | background-color: transparent 46 | -------------------------------------------------------------------------------- /src/tag/list/index.tsx: -------------------------------------------------------------------------------- 1 | import "./index.sass"; 2 | import * as React from "react"; 3 | import {createSelector} from "reselect"; 4 | import {Icon} from "@mdi/react"; 5 | import {mdiTagPlusOutline} from "@mdi/js"; 6 | import Fuse from "fuse.js"; 7 | 8 | import {ScrollPane} from "../../scroll-pane"; 9 | import {Menu, Item as MenuItem} from "../../contextual-menu"; 10 | 11 | import {Item} from "./item"; 12 | import {Input} from "./input"; 13 | 14 | import type {tag} from "../.."; 15 | 16 | export interface Tag { 17 | id: number; 18 | label: string; 19 | } 20 | 21 | const searchOptions: Fuse.IFuseOptions = { 22 | keys: ["label"], 23 | shouldSort: true, 24 | threshold: 0.5, 25 | distance: 20, 26 | }; 27 | 28 | const getFilter = createSelector( 29 | (tags: ReadonlyArray) => tags, 30 | (tags: ReadonlyArray) => new Fuse(tags, searchOptions) 31 | ); 32 | 33 | const filterTags = createSelector( 34 | (tags: ReadonlyArray, searchTerm: string) => tags, 35 | (tags: ReadonlyArray, searchTerm: string) => searchTerm.trim(), 36 | (tags: ReadonlyArray, searchTerm: string): ReadonlyArray => searchTerm 37 | ? getFilter(tags).search(searchTerm).map(r => r.item) 38 | : tags 39 | ); 40 | 41 | interface Props { 42 | tags: ReadonlyArray; 43 | disabled?: boolean; 44 | selected: Set; 45 | onFilterCleared: () => void; 46 | onToggleTag: (tag: tag.ID) => void; 47 | onCreateTag: (tag: string) => void; 48 | onRenameTag: (tag: tag.ID, newName: string) => void; 49 | onDeleteTag: (tag: tag.ID) => void; 50 | } 51 | 52 | interface State { 53 | inputText: string; 54 | selectedIndex: number; 55 | forceCreate?: boolean; 56 | renaming: string | null; 57 | menu: {x: number, y: number} | null; 58 | } 59 | 60 | function offsetToIndex(offset: number, limit: number): number { 61 | const result = offset % limit; 62 | return result < 0 ? result + limit : result; 63 | } 64 | 65 | export class TagList extends React.PureComponent { 66 | private readonly focusTagRef: React.RefObject; 67 | private readonly inputRef: React.RefObject; 68 | 69 | constructor(props: Props) { 70 | super(props); 71 | 72 | this.focusTagRef = React.createRef(); 73 | this.inputRef = React.createRef(); 74 | 75 | this.state = { 76 | inputText: "", 77 | selectedIndex: 0, 78 | renaming: null, 79 | menu: null, 80 | }; 81 | 82 | this.handleClickTag = this.handleClickTag.bind(this); 83 | this.handleContextMenu = this.handleContextMenu.bind(this); 84 | this.handleDelete = this.handleDelete.bind(this); 85 | this.handleInputChange = this.handleInputChange.bind(this); 86 | this.handleInputSubmit = this.handleInputSubmit.bind(this); 87 | this.handleMouseDown = this.handleMouseDown.bind(this); 88 | this.handleSelectModeChange = this.handleSelectModeChange.bind(this); 89 | this.handleTagCursorChange = this.handleTagCursorChange.bind(this); 90 | this.handleRename = this.handleRename.bind(this); 91 | this.handleRenameBegin = this.handleRenameBegin.bind(this); 92 | this.handleRenameEnd = this.handleRenameEnd.bind(this); 93 | } 94 | 95 | componentDidUpdate(p: Props, s: State): void { 96 | if (s.selectedIndex !== this.state.selectedIndex) { 97 | const element = this.focusTagRef.current; 98 | if (element) { 99 | const parent = element.offsetParent; 100 | if (parent) { 101 | const box = element.getBoundingClientRect(); 102 | const parentBox = parent.getBoundingClientRect(); 103 | if (box.bottom < parentBox.top || box.top > parentBox.bottom) 104 | element.scrollIntoView({behavior: "auto"}); 105 | } 106 | } 107 | } 108 | } 109 | 110 | render(): React.ReactNode { 111 | const {forceCreate, selectedIndex, inputText, menu, renaming} = this.state; 112 | const searchTerm = inputText.trim(); 113 | const tags = filterTags(this.props.tags, searchTerm); 114 | const cursorIndex = searchTerm && forceCreate 115 | ? -1 116 | : offsetToIndex(selectedIndex, tags.length); 117 | 118 | const {disabled} = this.props; 119 | const listClassName = disabled ? "tag-picker disabled" : "tag-picker"; 120 | 121 | return <> 122 |
  • 123 | 131 |
  • 132 |
  • 133 | 134 |
      135 | {searchTerm && (tags.length < 1 || forceCreate) && ( 136 |
    • this.createTag(searchTerm)} 138 | > 139 | 140 | Create '{searchTerm}' 141 |
    • 142 | )} 143 | {tags.length < 1 144 | ? !inputText &&
    • No tags found
    • 145 | : tags.map(({id, label}, i) => ( 146 | 158 | {label} 159 | 160 | ))} 161 |
    162 |
    163 | {menu && 164 | Rename 165 | Delete 166 | } 167 |
  • 168 | ; 169 | } 170 | 171 | private createTag(name: string): void { 172 | this.props.onCreateTag(name); 173 | this.setState({inputText: ""}); 174 | } 175 | 176 | private getSelectedTag(): Tag | null { 177 | const searchTerm = this.state.inputText.trim(); 178 | const tags = filterTags(this.props.tags, searchTerm); 179 | if (tags.length < 1) return null; 180 | 181 | return tags[offsetToIndex(this.state.selectedIndex, tags.length)]; 182 | } 183 | 184 | handleClickTag(id: number): void { 185 | this.props.onToggleTag(id); 186 | } 187 | 188 | handleContextMenu(selectedIndex: number, {clientX: x, clientY: y}: React.MouseEvent): void { 189 | if (selectedIndex > 0) { 190 | const s = this.state; 191 | const menu = s.menu && s.selectedIndex === selectedIndex ? null : {x, y}; 192 | this.setState({menu, selectedIndex}); 193 | } 194 | } 195 | 196 | handleInputChange(inputText: string): void { 197 | if (!inputText) 198 | this.props.onFilterCleared(); 199 | 200 | this.setState({inputText, selectedIndex: 0}); 201 | } 202 | 203 | handleDelete(): void { 204 | const tag = this.getSelectedTag(); 205 | if (tag) this.props.onDeleteTag(tag.id); 206 | 207 | this.setState({menu: null}); 208 | } 209 | 210 | handleInputSubmit(): void { 211 | const searchTerm = this.state.inputText.trim(); 212 | const {onToggleTag, tags: unfiltered} = this.props; 213 | const tags = filterTags(unfiltered, searchTerm); 214 | if (searchTerm && (tags.length < 1 || this.state.forceCreate)) { 215 | this.createTag(searchTerm); 216 | } else if (tags.length > 0) { 217 | const t = offsetToIndex(this.state.selectedIndex, tags.length); 218 | onToggleTag(tags[t].id); 219 | } 220 | } 221 | 222 | handleMouseDown({button}: React.MouseEvent): void { 223 | if (this.state.menu && button !== 2) 224 | this.setState({menu: null}); 225 | } 226 | 227 | handleSelectModeChange(forceCreate: boolean): void { 228 | this.setState({forceCreate}); 229 | } 230 | 231 | handleTagCursorChange(offset: number): void { 232 | this.setState(p => ({ 233 | selectedIndex: (p.selectedIndex || 0) + offset, 234 | renaming: null, 235 | })); 236 | } 237 | 238 | handleRenameBegin(): void { 239 | const tag = this.getSelectedTag(); 240 | if (tag && tag.id > 0) { 241 | this.setState({ 242 | renaming: tag.label, 243 | menu: null, 244 | }); 245 | } 246 | } 247 | 248 | handleRename(ev?: React.ChangeEvent): void { 249 | this.setState({renaming: ev && ev.target && ev.target.value || ""}); 250 | } 251 | 252 | handleRenameEnd(submit: boolean): void { 253 | if (submit) { 254 | const tag = this.getSelectedTag(); 255 | if (tag) 256 | this.props.onRenameTag(tag.id, this.state.renaming as string); 257 | } 258 | 259 | this.setState({renaming: null}); 260 | 261 | if (this.inputRef.current) 262 | this.inputRef.current.focus(); 263 | } 264 | } -------------------------------------------------------------------------------- /src/tag/list/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | interface Props { 4 | value: string; 5 | disabled?: boolean; 6 | 7 | onChange: (term: string) => void; 8 | onSubmit: (toggle: boolean) => void; 9 | onSelectChange: (offset: number) => void; 10 | onModeChange: (forceCreate: boolean) => void; 11 | onRename: () => void; 12 | } 13 | 14 | interface InternalProps extends Props { 15 | forwardedRef?: React.Ref; 16 | } 17 | 18 | interface State { 19 | shiftDown?: boolean; 20 | controlDown?: boolean; 21 | altDown?: boolean; 22 | } 23 | 24 | const noKeyDown = { 25 | shiftDown: false, 26 | altDown: false, 27 | controlDown: false, 28 | }; 29 | 30 | type KeyStateNames = "shiftDown" | "controlDown" | "altDown"; 31 | 32 | export class InputComponent extends React.PureComponent { 33 | constructor(props: InternalProps) { 34 | super(props); 35 | 36 | this.state = {}; 37 | 38 | this.handleBlur = this.handleBlur.bind(this); 39 | this.handleChange = this.handleChange.bind(this); 40 | this.handleKeyDown = this.handleKeyDown.bind(this); 41 | this.handleKeyUp = this.handleKeyUp.bind(this); 42 | } 43 | 44 | render(): React.ReactNode { 45 | const {disabled, forwardedRef, value} = this.props; 46 | 47 | return ; 61 | } 62 | 63 | private dispatchModeChange(): void { 64 | this.props.onModeChange(!!this.state.controlDown); 65 | } 66 | 67 | handleBlur(): void { 68 | const {altDown, controlDown, shiftDown} = this.state; 69 | if (altDown || controlDown || shiftDown) 70 | this.setState(noKeyDown, () => this.dispatchModeChange()); 71 | } 72 | 73 | handleChange(ev: React.ChangeEvent): void { 74 | this.props.onChange(ev.target.value); 75 | } 76 | 77 | handleKeyDown(ev: React.KeyboardEvent): void { 78 | switch (ev.key) { 79 | case "Alt": 80 | case "Control": 81 | case "Shift": { 82 | const stateKey = `${ev.key.toLowerCase()}Down` as KeyStateNames; 83 | this.setState({[stateKey]: true}, () => this.dispatchModeChange()); 84 | break; 85 | } 86 | 87 | case "Enter": 88 | this.props.onSubmit(!!this.state.shiftDown); 89 | break; 90 | 91 | case "ArrowUp": 92 | this.props.onSelectChange(-1); 93 | break; 94 | 95 | case "ArrowDown": 96 | this.props.onSelectChange(1); 97 | break; 98 | 99 | case "F2": 100 | this.props.onRename(); 101 | break; 102 | } 103 | } 104 | 105 | handleKeyUp(ev: React.KeyboardEvent): void { 106 | switch (ev.key) { 107 | case "Alt": 108 | case "Control": 109 | case "Shift": { 110 | const stateKey = `${ev.key.toLowerCase()}Down` as KeyStateNames; 111 | this.setState({[stateKey]: false}, () => this.dispatchModeChange()); 112 | break; 113 | } 114 | } 115 | } 116 | } 117 | 118 | export const Input = React.forwardRef( 119 | (props, ref) => ); -------------------------------------------------------------------------------- /src/tag/list/item.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {Icon} from "@mdi/react"; 3 | import { 4 | mdiTag, 5 | mdiTagOutline, 6 | } from "@mdi/js"; 7 | 8 | interface Props { 9 | ref?: React.Ref, 10 | id: number, 11 | index: number, 12 | children: string, 13 | focused?: boolean, 14 | active?: boolean, 15 | renaming: string | null, 16 | onClick: (id: number) => void, 17 | onContextMenu: (index: number, ev: React.MouseEvent) => void, 18 | onRename: (value: React.ChangeEvent) => void, 19 | onRenameEnd: (submit: boolean) => void, 20 | } 21 | 22 | function renderInput({renaming, onRename, onRenameEnd}: Props) { 23 | return onRenameEnd(false)} 28 | onKeyDown={ev => { 29 | switch (ev.key) { 30 | case "Enter": onRenameEnd(true); break; 31 | case "Escape": onRenameEnd(false); break; 32 | } 33 | ev.stopPropagation(); 34 | }} 35 | autoFocus /> 36 | } 37 | 38 | function renderItem(props: Props, ref?: React.Ref): JSX.Element { 39 | const {id, index, children, focused, active, renaming, onClick, onContextMenu} = props; 40 | 41 | let className = focused ? "focus" : ""; 42 | let clickHandler = undefined; 43 | let menuHandler = undefined; 44 | let content: React.ReactNode; 45 | 46 | if (renaming === null) { 47 | clickHandler = onClick.bind(null, id); 48 | menuHandler = onContextMenu.bind(null, index); 49 | content = {children}; 50 | } else { 51 | className += " editing"; 52 | content = renderInput(props) 53 | } 54 | 55 | return
  • } 57 | className={className || undefined} 58 | onClick={clickHandler} 59 | onContextMenu={menuHandler} 60 | > 61 | 62 | {content} 63 |
  • ; 64 | } 65 | 66 | export const Item = React.memo(React.forwardRef(renderItem)); 67 | -------------------------------------------------------------------------------- /src/thumbnail/image.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {shallow, ShallowWrapper} from "enzyme"; 3 | 4 | import {Image} from './image'; 5 | 6 | describe("gallery/image", () => { 7 | let component: ShallowWrapper; 8 | 9 | function remount(names?: string[], format?: string) { 10 | const files = { 11 | path: "/tmp/nowhere", 12 | names: names || ["a.png"], 13 | }; 14 | 15 | component = shallow(); 16 | } 17 | 18 | test("renders", () => { 19 | remount(); 20 | }); 21 | 22 | [ 23 | { 24 | name: "replaces file stems", 25 | format: "/asdf/{fileStem}.img", 26 | expected: "/asdf/my-file.img", 27 | }, 28 | { 29 | name: "replaces full names", 30 | format: "/asdf-{fileName}", 31 | expected: "/asdf-my-file.png", 32 | }, 33 | { 34 | name: "passes through patternless input", 35 | format: "/not-available.jpg", 36 | expected: "/not-available.jpg", 37 | }, 38 | { 39 | name: "replaces multiple matches", 40 | format: "/thumbnails{directory}/{fileStem}.jpg", 41 | expected: "/thumbnails/tmp/nowhere/my-file.jpg", 42 | }, 43 | { 44 | name: "replaces empty brackets only as file name", 45 | format: "/thumbnails/{}", 46 | expected: "/thumbnails/my-file.png", 47 | }, 48 | { 49 | name: "replaces 0 as file name", 50 | format: "/thumbnails/{0}", 51 | expected: "/thumbnails/my-file.png", 52 | }, 53 | { 54 | name: "replaces 1 as file stem", 55 | format: "/thumbnails/{1}", 56 | expected: "/thumbnails/my-file", 57 | }, 58 | ].forEach(({name, format, expected}) => test(name, () => { 59 | remount(["my-file.png"], format); 60 | expect(component.find("img").props().src).toEqual(`file://${expected}`); 61 | })); 62 | }); -------------------------------------------------------------------------------- /src/thumbnail/image.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import type {browsing, preference} from ".."; 4 | 5 | const fieldPattern = /\{(\w*)\}/g; 6 | 7 | interface Props { 8 | files: browsing.FilesView, 9 | index: number, 10 | pathFormat?: string, 11 | thumbnailResolution?: preference.ThumbnailResolution; 12 | } 13 | 14 | // This component exists so that we don't need to evaluate regex every time 15 | // a thumbnail needs a repaint. 16 | function renderImage({files, index, pathFormat, thumbnailResolution}: Props) { 17 | const fileName = files.names[index]; 18 | 19 | let url: string; 20 | if (pathFormat) { 21 | url = "file://" + pathFormat.replace(fieldPattern, n => { 22 | switch (n.slice(1, n.length - 1)) { 23 | case "": 24 | case "0": 25 | case "fileName": 26 | case "file-name": 27 | return fileName; 28 | 29 | case "1": 30 | case "fileStem": 31 | case "file-stem": { 32 | const extensionStart = fileName.lastIndexOf('.'); 33 | return extensionStart === -1 ? fileName : fileName.slice(0, extensionStart); 34 | } 35 | 36 | case "directory": 37 | return files.path; 38 | 39 | default: 40 | return ""; 41 | } 42 | }); 43 | } else { 44 | const suffix = thumbnailResolution ? `?r=${thumbnailResolution}` : ""; 45 | url = `thumb://${files.path}/${fileName}${suffix}`; 46 | } 47 | 48 | return ; 49 | } 50 | 51 | export const Image = React.memo(renderImage); -------------------------------------------------------------------------------- /src/thumbnail/index.tsx: -------------------------------------------------------------------------------- 1 | import "./thumbnail.sass" 2 | import * as React from "react"; 3 | 4 | import {Image} from "./image"; 5 | import type {browsing, preference} from ".."; 6 | 7 | interface Props { 8 | files: browsing.FilesView; 9 | index: number; 10 | pathFormat?: string; 11 | thumbnailResolution?: preference.ThumbnailResolution; 12 | anchor?: boolean; 13 | selected?: boolean; 14 | onClick: (index: number, select: boolean, event: React.MouseEvent) => void; 15 | } 16 | 17 | function renderThumbnail({onClick, anchor, selected, ...props}: Props) { 18 | const fileName = props.files.names[props.index]; 19 | 20 | let className = "thumbnail"; 21 | if (anchor) className += " anchor"; 22 | if (selected) className += " selected"; 23 | 24 | function handleClick(ev: React.MouseEvent): void { 25 | if (ev.target === ev.currentTarget) 26 | onClick(props.index, ev.currentTarget.tagName === "DIV", ev); 27 | } 28 | 29 | return
  • 30 | 31 |
    {fileName}
    32 |
  • ; 33 | } 34 | 35 | export const Thumbnail = React.memo(renderThumbnail); -------------------------------------------------------------------------------- /src/thumbnail/thumbnail.sass: -------------------------------------------------------------------------------- 1 | @import "../global.sass" 2 | 3 | *.scale-cover .thumbnail > img 4 | object-fit: cover 5 | 6 | .thumbnail 7 | display: inline-block 8 | vertical-align: top 9 | position: relative 10 | margin: 0 4px 4px 0 11 | padding: 0 12 | background-color: $color-thumbnail-background 13 | 14 | &.selected 15 | outline: 4px solid $color-input-outline-focus 16 | 17 | > img 18 | filter: brightness(33%) 19 | 20 | > div 21 | background-color: $color-input-outline-focus 22 | 23 | &.anchor::after 24 | content: '' 25 | position: absolute 26 | bottom: 0 27 | right: 0 28 | border-bottom: 2em solid $color-input-outline-focus 29 | border-left: 2em solid transparent 30 | 31 | > img 32 | width: 100% 33 | height: 100% 34 | object-fit: scale-down 35 | pointer-events: none 36 | 37 | transition: filter $time-color-change 38 | 39 | > div 40 | position: absolute 41 | top: 0 42 | max-width: 100% 43 | max-height: 100% 44 | padding: $space-narrow $space-default 45 | overflow: hidden 46 | word-break: break-all 47 | background-color: transparentize($color-thumbnail-background, 0.2) -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 6 | "module": "esnext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 7 | // "lib": [], /* Specify library files to be included in the compilation. */ 8 | "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | // "outDir": "./", /* Redirect output structure to the directory. */ 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | "strictNullChecks": true, /* Enable strict null checks. */ 29 | "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 52 | 53 | /* Source Map Options */ 54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 58 | 59 | /* Experimental Options */ 60 | // "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */ 61 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 62 | }, 63 | "include": [ 64 | "src" 65 | ] 66 | } 67 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "only-arrow-functions": [ 9 | true, 10 | "allow-declarations", 11 | "allow-named-functions" 12 | ], 13 | "whitespace": [ 14 | true, 15 | "check-branch", 16 | "check-separator", 17 | "check-rest-spread", 18 | "check-type-operator", 19 | "check-preblock" 20 | ], 21 | "arrow-parens": false, 22 | "curly": false, 23 | "forin": false, 24 | "interface-name": false, 25 | "max-classes-per-file": false, 26 | "ordered-imports": false, 27 | "object-literal-sort-keys": false, 28 | "fileName": false, 29 | "prefer-const": false, 30 | "prefer-for-of": false 31 | }, 32 | "rulesDirectory": [] 33 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const {newConfigBuilder} = require("webpack-config-builder"); 4 | const os = require("os"); 5 | const path = require("path"); 6 | 7 | const platform = os.platform(); 8 | const pathBuild = path.resolve(__dirname, "build"); 9 | 10 | const builder = newConfigBuilder() 11 | .withoutLicense("GPL-3.0") 12 | .withDefine("BUILD_TYPE", "pub", "dev") 13 | .withDefine("PLATFORM", platform); 14 | 15 | var renderer = builder 16 | .withCss("index.css") 17 | .withReact() 18 | .withHtml("./src/application/index.html", "index.html") 19 | .withNoParse(/src(\\|\/)application(\\|\/)esimport.js$/); 20 | 21 | var main = builder 22 | .withNativeModules() 23 | .withExternals({ 24 | bindings: `require("bindings")`, 25 | }); 26 | 27 | if (platform === "linux") { 28 | main = main.withFiles([ 29 | { from: "node_modules/abstract-socket/build/Release/bindings.node" }, 30 | ]); 31 | } 32 | 33 | module.exports = [ 34 | renderer.compile("web", "/src/application/index.ts", pathBuild, "[chunkhash].js", "dev.js"), 35 | builder.compile("electron-preload", "/src/api/index.ts", pathBuild, "api.js"), 36 | main.compile("electron-main", "/src/main/index.ts", pathBuild, "index.js"), 37 | ]; --------------------------------------------------------------------------------