├── .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 | 
2 | 
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 ;
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 |
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 |
65 |
68 |
;
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 |