├── .dockerignore
├── .eslintrc.cjs
├── .gitignore
├── .prettierrc
├── .vscode
└── settings.json
├── Dockerfile
├── Dockerfile.slim
├── LICENSE
├── README.md
├── docs
├── demo1.png
├── demo2.png
└── demo3.png
├── index.html
├── nginx.conf
├── package-lock.json
├── package.json
├── public
└── logo.svg
├── src
├── App.tsx
├── ContextAction.ts
├── Initial.tsx
├── components
│ ├── Compare
│ │ ├── index.module.scss
│ │ └── index.tsx
│ ├── CompressOption
│ │ ├── index.module.scss
│ │ └── index.tsx
│ ├── ImageInput
│ │ ├── index.module.scss
│ │ └── index.tsx
│ ├── Indicator
│ │ ├── index.module.scss
│ │ └── index.tsx
│ ├── Loading
│ │ ├── index.module.scss
│ │ └── index.tsx
│ ├── Logo
│ │ ├── index.module.scss
│ │ └── index.tsx
│ ├── OptionItem
│ │ ├── index.module.scss
│ │ └── index.tsx
│ ├── ProgressHint
│ │ ├── index.module.scss
│ │ └── index.tsx
│ └── UploadCard
│ │ ├── index.module.scss
│ │ ├── index.tsx
│ │ └── state.ts
├── engines
│ ├── AvifImage.ts
│ ├── AvifWasmModule.js
│ ├── CanvasImage.ts
│ ├── GifImage.ts
│ ├── GifWasmModule.js
│ ├── ImageBase.ts
│ ├── PngImage.ts
│ ├── PngWasmModule.js
│ ├── Queue.ts
│ ├── SvgImage.ts
│ ├── WorkerCompress.ts
│ ├── WorkerPreview.ts
│ ├── avif.wasm
│ ├── gif.wasm
│ ├── handler.ts
│ ├── png.wasm
│ ├── support.ts
│ ├── svgConvert.ts
│ ├── svgParse.ts
│ └── transform.ts
├── functions.ts
├── global.tsx
├── locale.ts
├── locales
│ ├── en-US.ts
│ ├── es-ES.ts
│ ├── fa-IR.ts
│ ├── fr-FR.ts
│ ├── ja-JP.ts
│ ├── ko-KR.ts
│ ├── tr-TR.ts
│ ├── zh-CN.ts
│ └── zh-TW.ts
├── main.scss
├── main.tsx
├── media.ts
├── mimes.ts
├── modules.ts
├── pages
│ ├── error404
│ │ ├── index.module.scss
│ │ └── index.tsx
│ └── home
│ │ ├── LeftContent.module.scss
│ │ ├── LeftContent.tsx
│ │ ├── RightOption.module.scss
│ │ ├── RightOption.tsx
│ │ ├── index.module.scss
│ │ ├── index.tsx
│ │ └── useColumn.tsx
├── router.tsx
├── states
│ └── home.ts
├── type.ts
└── vite-env.d.ts
├── tests
└── utils.test.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.dockerignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | docs
3 | node_modules
4 |
5 | .DS_Store
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | "eslint:recommended",
6 | "plugin:@typescript-eslint/recommended",
7 | "plugin:react-hooks/recommended",
8 | ],
9 | ignorePatterns: ["dist", ".eslintrc.cjs"],
10 | parser: "@typescript-eslint/parser",
11 | plugins: ["react-refresh"],
12 | rules: {
13 | "react-refresh/only-export-components": [
14 | "warn",
15 | { allowConstantExport: true },
16 | ],
17 | "no-empty": "off",
18 | "@typescript-eslint/no-explicit-any": "off",
19 | },
20 | };
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 | /dist
19 |
20 | # misc
21 | .DS_Store
22 | *.pem
23 |
24 | # debug
25 | npm-debug.log*
26 | yarn-debug.log*
27 | yarn-error.log*
28 |
29 | # local env files
30 | .env*.local
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": false,
3 | "trailingComma": "all",
4 | "tabWidth": 2,
5 | "semi": true,
6 | "bracketSpacing": true,
7 | "bracketSameLine": false
8 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib"
3 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20-alpine
2 | WORKDIR /app
3 | COPY . /app
4 | RUN set -eux \
5 | && npm install --ignore-scripts \
6 | && npm run build:preview
7 | CMD [ "npm", "run", "preview" ]
8 | EXPOSE 3001
--------------------------------------------------------------------------------
/Dockerfile.slim:
--------------------------------------------------------------------------------
1 | # -------------------Desciption---------------------
2 |
3 | # FROM nginx:alpine-slim: Uses the alpine-slim nginx image as the base image.
4 | # COPY nginx.conf /etc/nginx/conf.d/default.conf: Copies the nginx.conf file to the /etc/nginx/conf.d directory in the container, serving as the nginx configuration file.
5 | # COPY dist /usr/share/nginx/html: Copies the contents of the dist directory to the /usr/share/nginx/html directory in the container, which serves as nginx's static file directory.
6 | # EXPOSE 3000: Declares that the container is listening on port 3000.
7 | # CMD ["nginx", "-g", "daemon off"]: Runs the nginx command with the parameters -g daemon off when the container starts, indicating nginx should start in the foreground.
8 |
9 | # -------------------Usage--------------------
10 |
11 | # $ docker build -f Dockerfile.slim -t picsmaller-slim .
12 | # $ docker run -d -p 9000:3000 picsmaller-slim
13 |
14 | FROM nginx:alpine-slim
15 |
16 | COPY nginx.conf /etc/nginx/conf.d/default.conf
17 |
18 | COPY dist /usr/share/nginx/html
19 |
20 | EXPOSE 3000
21 |
22 | CMD ["nginx", "-g", "daemon off;"]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Joye
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Pic Smaller (图小小)
2 |
3 | **Pic Smaller** is a super easy-to-use online image compression tool. Simply upload your desired image(s), and Pic Smaller will automatically perform its compress functionality and provide details on the results. Users can also customize features to suite their desired output, such as setting the output format or number of output colors. It's intuitive, website and mobile friendly, and supports compression configuration. At the same time, because of purely local compression without any server-side logic, it is completely safe.
4 |
5 |
6 |
7 |
8 | Figure 1: Pic Smaller's landing page, where users can upload their images for compression
9 |
10 |
11 |
12 | Figure 2: Example pictures uploaded for compression shown on the left, and Pic Smaller's customizable compression and editing features shown on the right
13 |
14 |
15 |
16 | Figure 3: Pic Smaller's comparison tool, that the user can drag to see the difference between the original and compressed image
17 |
18 |
19 |
20 | ## Usage
21 |
22 | Pic smaller has been deployed to [`vercel`](https://vercel.com/), you can use it by visiting the URL [pic-smaller.vercel.app](https://pic-smaller.vercel.app). Due to the GFW, Chinese users can use it by visiting the URL [picsmaller.com](https://picsmaller.com/)
23 |
24 | > [picsmaller.com](https://picsmaller.com/) is a new domain that has just been applied for. The old domain [txx.cssrefs.com](https://txx.cssrefs.com/) is still accessible, but will be expired on `2025-02-22` and payment will not continue. Please use the latest domain to access the service.
25 |
26 | ## Preqrequisites
27 |
28 | Node.js
29 | 1. Navigate to the Node.js website: https://nodejs.org/en/
30 | 2. Download the recommended version (which is currently v20.17.0).
31 | 3. Follow the steps on your computer to finish its installation.
32 | 4. To verify installation, open up the command prompt and run the following command. If the version is outputted, you have succesfully installed Node.js.
33 | ```
34 | node -v
35 | ```
36 |
37 | ## Develop
38 |
39 | Pic smaller is a [Vite](https://vitejs.dev/) + [React](https://react.dev/) project, you have to get familiar with them first. It uses modern browser technologies such as `OffscreenCanvas`, `WebAssembly`, and `Web Worker`. You should also be familiar with them before developing.
40 |
41 | ```bash
42 | # Clone the repo
43 | git clone https://github.com/joye61/pic-smaller.git
44 |
45 | # Change cwd
46 | cd ./pic-smaller
47 |
48 | # Install dependences
49 | npm install
50 |
51 | # Start to develop
52 | npm run dev
53 | ```
54 |
55 | Hold control and left click the URL next to "Local:" to open the website on your local machine.
56 |
57 | 
58 |
59 | Figure 4: Where to open the localhost website link
60 |
61 |
62 | ## Deploy
63 |
64 | If you want to independently deploy this project on your own server, the following document based on Docker, and [Dockerfile](./Dockerfile) script has been tested. Within the project root directory, follow the instructions to start docker application
65 |
66 | ```bash
67 | # Build docker image from Dockerfile
68 | docker build -t picsmaller .
69 |
70 | # Start a container
71 | docker run -p 3001:3001 -d picsmaller
72 | ```
73 |
74 | Now you can access the project via http://127.0.0.1:3001. If you want your project to be accessible to everyone, you need to prepare a domain name pointing to your local machine, and then proxy it to port 3001 of this machine, through a reverse proxy server like nginx.
75 |
76 | ## Contributing
77 |
78 | 1. Ensure all required dependency installations have been properly followed to accurately test your changes.
79 | 2. Update the README.md with information about changes to the interface, including new environment variables, important file locations, and container parameters.
80 | 4. Increase the version numbers in all example files and the README.md to reflect the new version represented by your changes.
81 | 5. Create a Pull Request with an appropriate and descriptive title and description.
82 | 6. You can reach out to other developers to review and merge the Pull Request if appropriate.
83 |
84 | Our standards for contributions: By using welcoming and inclusive language, respecting diverse viewpoints and experiences, embracing constructive criticism, and prioritizing what’s best for the community, we can create a positive and collaborative environment for everyone.
85 |
86 | ## Project Structure
87 |
88 | The src folder stores in all the files and components used in the react application like App.tsx.
89 |
90 | The tests folder includes code to test particular features during the development process.
91 |
92 | The docs folder includes the pictures used for this README documentation.
93 |
94 | ## License
95 |
96 | This project is under [MIT](LICENSE) license.
97 |
98 | ## Contact
99 |
100 | Please contact the repository owner joye61's email for any questions: 89065495@qq.com
101 |
102 | ## Thanks
103 |
104 | - [ant-design](https://github.com/ant-design/ant-design) Provides React-based UI solutions
105 | - [wasm-image-compressor](https://github.com/antelle/wasm-image-compressor) Provides PNG image compression implementation based on Webassembly
106 | - [gifsicle-wasm-browser](https://github.com/renzhezhilu/gifsicle-wasm-browser) Provides GIF image compression implementation based on Webassembly
107 | - [wasm_avif](https://github.com/packurl/wasm_avif) Provides AVIF image compression implementation based on Webassembly
108 | - [svgo](https://github.com/svg/svgo) Provides SVG vector compression
109 |
--------------------------------------------------------------------------------
/docs/demo1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joye61/pic-smaller/7ddf0508452372d25ff9a3c197e0debafef602fb/docs/demo1.png
--------------------------------------------------------------------------------
/docs/demo2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joye61/pic-smaller/7ddf0508452372d25ff9a3c197e0debafef602fb/docs/demo2.png
--------------------------------------------------------------------------------
/docs/demo3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joye61/pic-smaller/7ddf0508452372d25ff9a3c197e0debafef602fb/docs/demo3.png
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Pic Smaller – Compress JPEG, PNG, WEBP, AVIF, SVG and GIF images intelligently
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/nginx.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 3000;
3 | server_name localhost;
4 | location / {
5 | root /usr/share/nginx/html;
6 | index index.html;
7 | try_files $uri $uri/ /index.html;
8 | }
9 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "pic-smaller",
3 | "private": true,
4 | "version": "1.1.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "build:preview": "tsc && vite build --mode preview",
10 | "preview": "vite preview",
11 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
12 | "format": "prettier --write --no-error-on-unmatched-pattern --ignore-unknown src/**/*",
13 | "test": "vitest run"
14 | },
15 | "dependencies": {
16 | "@ant-design/icons": "^5.3.6",
17 | "@vercel/analytics": "^1.2.2",
18 | "antd": "^5.16.4",
19 | "classnames": "^2.5.1",
20 | "filesize": "^10.1.1",
21 | "get-user-locale": "^2.3.2",
22 | "history": "^5.3.0",
23 | "jszip": "^3.10.1",
24 | "mobx": "^6.12.3",
25 | "mobx-react-lite": "^4.0.7",
26 | "react": "^18.2.0",
27 | "react-dom": "^18.2.0",
28 | "react-responsive": "^10.0.0",
29 | "sprintf-js": "^1.1.3",
30 | "svgo": "^3.3.2"
31 | },
32 | "devDependencies": {
33 | "@types/lodash": "^4.17.0",
34 | "@types/node": "^20.12.7",
35 | "@types/react": "^18.2.66",
36 | "@types/react-dom": "^18.2.22",
37 | "@types/sprintf-js": "^1.1.4",
38 | "@typescript-eslint/eslint-plugin": "^7.2.0",
39 | "@typescript-eslint/parser": "^7.2.0",
40 | "@vitejs/plugin-react": "^4.2.1",
41 | "eslint": "^8.57.0",
42 | "eslint-plugin-react-hooks": "^4.6.0",
43 | "eslint-plugin-react-refresh": "^0.4.6",
44 | "prettier": "^3.2.5",
45 | "sass": "^1.75.0",
46 | "typescript": "^5.2.2",
47 | "vconsole": "^3.15.1",
48 | "vite": "^5.2.0",
49 | "vitest": "^1.6.0"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { ConfigProvider, App as AntApp } from "antd";
2 | import { observer } from "mobx-react-lite";
3 | import { gstate } from "./global";
4 | import { ContextAction } from "./ContextAction";
5 | import { Analytics } from "@vercel/analytics/react";
6 | import { Loading } from "./components/Loading";
7 | import { useResponse } from "./media";
8 | import { useEffect } from "react";
9 |
10 | function useMobileVConsole() {
11 | const { isMobile } = useResponse();
12 | useEffect(() => {
13 | if (!isMobile || !import.meta.env.DEV) return;
14 | let vConsole: any = null;
15 | import("vconsole").then((result) => {
16 | vConsole = new result.default({ theme: "dark" });
17 | });
18 | return () => vConsole?.destroy();
19 | }, [isMobile]);
20 | }
21 |
22 | export const App = observer(() => {
23 | useMobileVConsole();
24 |
25 | return (
26 |
36 |
37 |
38 |
39 | {import.meta.env.MODE === "production" && }
40 | {gstate.page}
41 | {gstate.loading && }
42 |
43 | );
44 | });
45 |
--------------------------------------------------------------------------------
/src/ContextAction.ts:
--------------------------------------------------------------------------------
1 | import { App } from "antd";
2 | import type { MessageInstance } from "antd/es/message/interface";
3 | import type { ModalStaticFunctions } from "antd/es/modal/confirm";
4 | import type { NotificationInstance } from "antd/es/notification/interface";
5 |
6 | let message: MessageInstance;
7 | let notification: NotificationInstance;
8 | let modal: Omit;
9 |
10 | export function ContextAction() {
11 | const staticFunction = App.useApp();
12 | message = staticFunction.message;
13 | modal = staticFunction.modal;
14 | notification = staticFunction.notification;
15 | return null;
16 | }
17 |
18 | export { message, notification, modal };
19 |
--------------------------------------------------------------------------------
/src/Initial.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from "mobx-react-lite";
2 | import { Flex, Typography } from "antd";
3 | import { useEffect } from "react";
4 | import { locales, modules } from "./modules";
5 | import { initRouter } from "./router";
6 | import { gstate } from "./global";
7 | import { Indicator } from "./components/Indicator";
8 | import { avifCheck } from "./engines/support";
9 |
10 | const loadResources = () => {
11 | const loadList: Array> = [
12 | import("jszip"),
13 | fetch(new URL("./engines/png.wasm", import.meta.url)),
14 | fetch(new URL("./engines/gif.wasm", import.meta.url)),
15 | fetch(new URL("./engines/avif.wasm", import.meta.url)),
16 | import("./engines/WorkerPreview?worker"),
17 | import("./engines/WorkerCompress?worker"),
18 | ];
19 | const langs = Object.values(locales);
20 | const pages = Object.values(modules);
21 | for (const load of [...langs, ...pages]) {
22 | loadList.push(load());
23 | }
24 | loadList.push(avifCheck());
25 | return Promise.all(loadList);
26 | };
27 |
28 | const useInit = () => {
29 | useEffect(() => {
30 | (async () => {
31 | await loadResources();
32 | initRouter();
33 | })();
34 | }, []);
35 | };
36 |
37 | export const Initial = observer(() => {
38 | useInit();
39 |
40 | return (
41 |
42 |
43 |
44 |
45 | {gstate.locale?.initial}
46 |
47 |
48 |
49 | );
50 | });
51 |
--------------------------------------------------------------------------------
/src/components/Compare/index.module.scss:
--------------------------------------------------------------------------------
1 | @keyframes BoxShow {
2 | from {
3 | opacity: 0;
4 | }
5 | to {
6 | opacity: 1;
7 | }
8 | }
9 |
10 | @keyframes BoxHide {
11 | from {
12 | opacity: 1;
13 | }
14 | to {
15 | opacity: 0;
16 | }
17 | }
18 |
19 | .container {
20 | width: 100vw;
21 | height: 100vh;
22 | position: absolute;
23 | left: 0;
24 | right: 0;
25 | top: 0;
26 | bottom: 0;
27 | z-index: 9;
28 | user-select: none;
29 | background-color: #fff;
30 | background-image: linear-gradient(
31 | 45deg,
32 | #e0e0e0 25%,
33 | transparent 25%,
34 | transparent 75%,
35 | #e0e0e0 75%
36 | ),
37 | linear-gradient(
38 | 45deg,
39 | #e0e0e0 25%,
40 | transparent 25%,
41 | transparent 75%,
42 | #e0e0e0 75%
43 | );
44 | background-size: 20px 20px;
45 | background-position:
46 | 0 0,
47 | 10px 10px;
48 |
49 | &.show {
50 | animation: BoxShow 0.3s ease-in forwards;
51 | }
52 | &.hide {
53 | animation: BoxHide 0.3s ease-out forwards;
54 | }
55 | &.moving {
56 | cursor: grab;
57 | }
58 |
59 | > div:nth-child(1),
60 | > div:nth-child(2) {
61 | position: absolute;
62 | top: 0;
63 | bottom: 0;
64 | overflow: hidden;
65 | box-sizing: border-box;
66 | img {
67 | position: absolute;
68 | top: 50%;
69 | transform: translateY(-50%);
70 | }
71 | }
72 | > div:nth-child(1) {
73 | left: 0;
74 | }
75 | > div:nth-child(2) {
76 | right: 0;
77 | }
78 |
79 | > div:nth-child(3) {
80 | position: absolute;
81 | top: 0;
82 | bottom: 0;
83 | z-index: 3;
84 | background-color: #000;
85 | > div {
86 | position: absolute;
87 | width: 30px;
88 | height: 30px;
89 | border-radius: 50%;
90 | cursor: grab;
91 | top: 50%;
92 | left: 50%;
93 | transform: translate(-50%, -50%);
94 | background-color: rgba(0, 0, 0, 0.8);
95 | svg {
96 | width: 20px;
97 | height: 20px;
98 | path {
99 | fill: #fff;
100 | }
101 | }
102 | }
103 | }
104 | }
105 |
106 | .action {
107 | position: absolute;
108 | top: 16px;
109 | right: 16px;
110 | }
111 |
112 | .before {
113 | position: absolute;
114 | left: 16px;
115 | bottom: 16px;
116 | }
117 | .after {
118 | position: absolute;
119 | right: 16px;
120 | bottom: 16px;
121 | }
122 |
123 | .help {
124 | width: 240px;
125 | }
126 |
--------------------------------------------------------------------------------
/src/components/Compare/index.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef, useState } from "react";
2 | import style from "./index.module.scss";
3 | import { Button, Flex, Popover, Space } from "antd";
4 | import { CloseOutlined, QuestionCircleOutlined } from "@ant-design/icons";
5 | import { createPortal } from "react-dom";
6 | import { ImageItem, homeState } from "@/states/home";
7 | import { observer } from "mobx-react-lite";
8 | import classNames from "classnames";
9 | import { gstate } from "@/global";
10 |
11 | export interface CompareState {
12 | x: number;
13 | xrate: number;
14 | scale: number;
15 | moving: boolean;
16 | status: "show" | "hide";
17 | dividerWidth: number;
18 | imageWidth: number;
19 | imageHeight: number;
20 | containerWidth: number;
21 | containerHeight: number;
22 | }
23 |
24 | export const Compare = observer(() => {
25 | const infoRef = useRef>(
26 | homeState.list.get(homeState.compareId!) as Required,
27 | );
28 | const containerRef = useRef(null);
29 | const barRef = useRef(null);
30 | const [state, setState] = useState({
31 | x: 0,
32 | xrate: 0.5,
33 | scale: 0.8,
34 | moving: false,
35 | status: "show",
36 | dividerWidth: 2,
37 | containerWidth: 0,
38 | containerHeight: 0,
39 | imageWidth: 0,
40 | imageHeight: 0,
41 | });
42 | const [oldLoaded, setOldLoaded] = useState(false);
43 | const [newLoaded, setNewLoaded] = useState(false);
44 |
45 | const update = useCallback(
46 | (newState: Partial) => {
47 | setState({
48 | ...state,
49 | ...newState,
50 | });
51 | },
52 | [state],
53 | );
54 |
55 | const getState = useCallback(() => {
56 | return state;
57 | }, [state]);
58 |
59 | const updateRef = useRef<(newState: Partial) => void>(update);
60 | const stateRef = useRef<() => CompareState>(getState);
61 | useEffect(() => {
62 | updateRef.current = update;
63 | stateRef.current = getState;
64 | }, [update, getState]);
65 |
66 | useEffect(() => {
67 | gstate.loading = true;
68 | }, []);
69 |
70 | useEffect(() => {
71 | if (oldLoaded && newLoaded) {
72 | gstate.loading = false;
73 | }
74 | }, [oldLoaded, newLoaded]);
75 |
76 | useEffect(() => {
77 | const doc = document.documentElement;
78 | const bar = barRef.current!;
79 |
80 | let isControl = false;
81 | let cursorX = 0;
82 |
83 | const resize = () => {
84 | const states = stateRef.current();
85 | const rect = containerRef.current!.getBoundingClientRect();
86 | let imageWidth: number;
87 | let imageHeight: number;
88 | if (
89 | infoRef.current.width / infoRef.current.height >
90 | rect.width / rect.height
91 | ) {
92 | imageWidth = rect.width * states.scale;
93 | imageHeight =
94 | (imageWidth * infoRef.current.height) / infoRef.current.width;
95 | } else {
96 | imageHeight = rect.height * states.scale;
97 | imageWidth =
98 | (imageHeight * infoRef.current.width) / infoRef.current.height;
99 | }
100 | updateRef.current({
101 | x: rect.width * states.xrate,
102 | imageWidth,
103 | imageHeight,
104 | containerWidth: rect.width,
105 | containerHeight: rect.height,
106 | });
107 | };
108 |
109 | const mousedown = (event: MouseEvent) => {
110 | isControl = true;
111 | cursorX = event.clientX;
112 | updateRef.current({ moving: true });
113 | };
114 |
115 | const mouseup = () => {
116 | isControl = false;
117 | cursorX = 0;
118 | updateRef.current({ moving: false });
119 | };
120 |
121 | const mousemove = (event: MouseEvent) => {
122 | if (isControl) {
123 | const states = stateRef.current();
124 | let x = states.x + event.clientX - cursorX;
125 | const min = (states.containerWidth - states.imageWidth) / 2;
126 | const max = (states.containerWidth + states.imageWidth) / 2;
127 | if (x < min) {
128 | x = min;
129 | }
130 | if (x > max) {
131 | x = max;
132 | }
133 | cursorX = event.clientX;
134 | updateRef.current({ x, xrate: x / states.containerWidth });
135 | }
136 | };
137 |
138 | const wheel = (event: WheelEvent) => {
139 | const states = stateRef.current();
140 | let scale = -0.001 * event.deltaY + states.scale;
141 | if (scale > 1) {
142 | scale = 1;
143 | }
144 | if (scale < 0.1) {
145 | scale = 0.1;
146 | }
147 |
148 | let imageWidth: number;
149 | let imageHeight: number;
150 | if (
151 | infoRef.current.width / infoRef.current.height >
152 | states.containerWidth / states.containerHeight
153 | ) {
154 | imageWidth = states.containerWidth * scale;
155 | imageHeight =
156 | (imageWidth * infoRef.current.height) / infoRef.current.width;
157 | } else {
158 | imageHeight = states.containerHeight * scale;
159 | imageWidth =
160 | (imageHeight * infoRef.current.width) / infoRef.current.height;
161 | }
162 |
163 | const innerRate =
164 | (states.x - (states.containerWidth - states.imageWidth) / 2) /
165 | states.imageWidth;
166 | const x =
167 | innerRate * imageWidth + (states.containerWidth - imageWidth) / 2;
168 |
169 | updateRef.current({ scale, imageWidth, imageHeight, x });
170 | };
171 |
172 | window.addEventListener("resize", resize);
173 | window.addEventListener("wheel", wheel);
174 | bar.addEventListener("mousedown", mousedown);
175 | doc.addEventListener("mousemove", mousemove);
176 | doc.addEventListener("mouseup", mouseup);
177 |
178 | resize();
179 |
180 | return () => {
181 | window.removeEventListener("resize", resize);
182 | window.removeEventListener("wheel", wheel);
183 | bar.removeEventListener("mousedown", mousedown);
184 | doc.removeEventListener("mousemove", mousemove);
185 | doc.removeEventListener("mouseup", mouseup);
186 | };
187 | }, []);
188 |
189 | const leftStyle: React.CSSProperties = {
190 | width: `${state.x}px`,
191 | };
192 | const rightStyle: React.CSSProperties = {
193 | width: `${state.containerWidth - state.x}px`,
194 | };
195 | const barStyle: React.CSSProperties = {
196 | width: `${state.dividerWidth}px`,
197 | left: `${state.x - state.dividerWidth / 2}px`,
198 | opacity: state.x === 0 ? 0 : 1,
199 | };
200 | const imageStyle: React.CSSProperties = {
201 | opacity: newLoaded && oldLoaded ? 1 : 0,
202 | };
203 | const leftImageStyle: React.CSSProperties = {
204 | width: state.imageWidth,
205 | height: state.imageHeight,
206 | left: (state.containerWidth - state.imageWidth) / 2 + "px",
207 | ...imageStyle,
208 | };
209 | const rightImageStyle: React.CSSProperties = {
210 | width: state.imageWidth,
211 | height: state.imageHeight,
212 | right: (state.containerWidth - state.imageWidth) / 2 + "px",
213 | ...imageStyle,
214 | };
215 |
216 | let statusClass: string | undefined = undefined;
217 | if (state.status === "show") {
218 | statusClass = style.show;
219 | }
220 | if (state.status === "hide") {
221 | statusClass = style.hide;
222 | }
223 |
224 | return createPortal(
225 | {
233 | if (event.animationName === style.BoxHide) {
234 | homeState.compareId = null;
235 | }
236 | }}
237 | >
238 |
239 |
{
243 | setOldLoaded(true);
244 | }}
245 | />
246 |
247 |
248 |
{
252 | setNewLoaded(true);
253 | }}
254 | />
255 |
256 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
264 | {gstate.locale?.previewHelp}
267 | }
268 | placement="bottomRight"
269 | >
270 | } />
271 |
272 | }
274 | onClick={() => {
275 | updateRef.current?.({ status: "hide" });
276 | }}
277 | />
278 |
279 | ,
280 | document.body,
281 | );
282 | });
283 |
--------------------------------------------------------------------------------
/src/components/CompressOption/index.module.scss:
--------------------------------------------------------------------------------
1 | .resizeInput {
2 | margin-top: 16px;
3 | > div {
4 | width: 100%;
5 | }
6 | }
7 |
8 | .cropInput {
9 | margin-top: 16px;
10 | > div:nth-child(1),
11 | > div:nth-child(3) {
12 | flex-grow: 1;
13 | }
14 | > div:nth-child(2) {
15 | flex-grow: 0;
16 | flex-shrink: 0;
17 | font-size: 16px;
18 | width: 28px;
19 | text-align: center;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/CompressOption/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Checkbox,
3 | ColorPicker,
4 | Divider,
5 | Flex,
6 | InputNumber,
7 | Select,
8 | Slider,
9 | } from "antd";
10 | import style from "./index.module.scss";
11 | import { observer } from "mobx-react-lite";
12 | import { DefaultCompressOption, homeState } from "@/states/home";
13 | import { gstate } from "@/global";
14 | import { OptionItem } from "../OptionItem";
15 | import { Mimes } from "@/mimes";
16 |
17 | export const CompressOption = observer(() => {
18 | const disabled = homeState.hasTaskRunning();
19 | const resizeMethod = homeState.tempOption.resize.method;
20 | const resizeOptions = [
21 | {
22 | value: "fitWidth",
23 | label: gstate.locale?.optionPannel?.fitWidth,
24 | },
25 | {
26 | value: "fitHeight",
27 | label: gstate.locale?.optionPannel?.fitHeight,
28 | },
29 | {
30 | value: "setShort",
31 | label: gstate.locale?.optionPannel?.setShort,
32 | },
33 | {
34 | value: "setLong",
35 | label: gstate.locale?.optionPannel?.setLong,
36 | },
37 | {
38 | value: "setCropRatio",
39 | label: gstate.locale?.optionPannel?.setCropRatio,
40 | },
41 | {
42 | value: "setCropSize",
43 | label: gstate.locale?.optionPannel?.setCropSize,
44 | },
45 | ];
46 |
47 | const getFormatOptions = () => {
48 | const options: { label: string; value: string }[] = [];
49 | Object.keys(Mimes).forEach((mime) => {
50 | if (!["svg", "gif"].includes(mime)) {
51 | options.push({
52 | value: mime,
53 | label: mime.toUpperCase(),
54 | });
55 | }
56 | });
57 | return options;
58 | };
59 |
60 | // You should only allow to resize a side
61 | let input: React.ReactNode = null;
62 | if (resizeMethod === "fitWidth") {
63 | input = (
64 |
65 | {
72 | homeState.tempOption.resize.width = value!;
73 | }}
74 | />
75 |
76 | );
77 | } else if (resizeMethod === "fitHeight") {
78 | input = (
79 |
80 | {
87 | homeState.tempOption.resize.height = value!;
88 | }}
89 | />
90 |
91 | );
92 | } else if (resizeMethod === "setShort") {
93 | input = (
94 |
95 | {
102 | homeState.tempOption.resize.short = value!;
103 | }}
104 | />
105 |
106 | );
107 | } else if (resizeMethod === "setLong") {
108 | input = (
109 |
110 | {
117 | homeState.tempOption.resize.long = value!;
118 | }}
119 | />
120 |
121 | );
122 | } else if (resizeMethod === "setCropRatio") {
123 | input = (
124 |
125 | {
132 | homeState.tempOption.resize.cropWidthRatio = value!;
133 | }}
134 | />
135 | :
136 | {
143 | homeState.tempOption.resize.cropHeightRatio = value!;
144 | }}
145 | />
146 |
147 | );
148 | } else if (resizeMethod === "setCropSize") {
149 | input = (
150 |
151 | {
158 | homeState.tempOption.resize.cropWidthSize = value!;
159 | }}
160 | />
161 | ×
162 | {
169 | homeState.tempOption.resize.cropHeightSize = value!;
170 | }}
171 | />
172 |
173 | );
174 | }
175 |
176 | // JPEG dont't support transparent, when convert to JPEG,
177 | // we should give an option to choose transparent fill color
178 | let colorPicker: React.ReactNode = null;
179 | const target = homeState.tempOption.format.target;
180 | if (target && ["jpg", "jpeg"].includes(target)) {
181 | colorPicker = (
182 |
183 | {
189 | homeState.tempOption.format.transparentFill =
190 | "#" + value.toHex().toUpperCase();
191 | }}
192 | />
193 |
194 | );
195 | }
196 |
197 | return (
198 | <>
199 |
200 | {gstate.locale?.optionPannel.resizeLable}
201 |
202 |
203 | {
211 | homeState.tempOption.resize = {
212 | method: value,
213 | width: undefined,
214 | height: undefined,
215 | short: undefined,
216 | long: undefined,
217 | cropWidthRatio: undefined,
218 | cropHeightRatio: undefined,
219 | cropWidthSize: undefined,
220 | cropHeightSize: undefined,
221 | };
222 | }}
223 | />
224 | {input}
225 |
226 |
227 |
228 | {gstate.locale?.optionPannel.outputFormat}
229 |
230 |
231 | {
239 | homeState.tempOption.format.target = value;
240 | }}
241 | />
242 |
243 | {/* Colorpicker option */}
244 | {colorPicker}
245 |
246 |
247 | {gstate.locale?.optionPannel.jpegLable}
248 |
249 |
250 |
251 | {
259 | homeState.tempOption.jpeg.quality = value;
260 | }}
261 | />
262 |
263 |
264 |
265 | {gstate.locale?.optionPannel.pngLable}
266 |
267 |
268 |
269 | {
277 | homeState.tempOption.png.colors = value;
278 | }}
279 | />
280 |
281 |
282 |
283 | {
291 | homeState.tempOption.png.dithering = value;
292 | }}
293 | />
294 |
295 |
296 |
297 | {gstate.locale?.optionPannel.gifLable}
298 |
299 |
300 |
301 | {
305 | homeState.tempOption.gif.dithering = event.target.checked;
306 | }}
307 | >
308 | {gstate.locale?.optionPannel.gifDithering}
309 |
310 |
311 |
312 |
313 | {
321 | homeState.tempOption.gif.colors = value;
322 | }}
323 | />
324 |
325 |
326 | {Mimes.avif && (
327 | <>
328 |
329 | {gstate.locale?.optionPannel.avifLable}
330 |
331 |
332 |
333 | {
341 | homeState.tempOption.avif.quality = value;
342 | }}
343 | />
344 |
345 |
346 |
347 | {
355 | homeState.tempOption.avif.speed = value;
356 | }}
357 | />
358 |
359 | >
360 | )}
361 | >
362 | );
363 | });
364 |
--------------------------------------------------------------------------------
/src/components/ImageInput/index.module.scss:
--------------------------------------------------------------------------------
1 | .file {
2 | display: none;
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/ImageInput/index.tsx:
--------------------------------------------------------------------------------
1 | import { ForwardedRef, forwardRef } from "react";
2 | import style from "./index.module.scss";
3 | import { observer } from "mobx-react-lite";
4 | import { createImageList } from "@/engines/transform";
5 | import { Mimes } from "@/mimes";
6 |
7 | export const ImageInput = observer(
8 | forwardRef((_, ref: ForwardedRef) => {
9 | return (
10 | "." + item)
17 | .join(",")}
18 | onChange={async (event) => {
19 | const files = event.target.files;
20 | if (!files?.length) {
21 | event.target.value = "";
22 | return;
23 | }
24 | const list = Array.from(files).filter((file) => !!file);
25 | await createImageList(list);
26 | event.target.value = "";
27 | }}
28 | />
29 | );
30 | }),
31 | );
32 |
--------------------------------------------------------------------------------
/src/components/Indicator/index.module.scss:
--------------------------------------------------------------------------------
1 | @use "sass:math";
2 |
3 | $size: 16px;
4 | $barNum: 10;
5 | $barWidth: 2px;
6 | $barHeight: 4px;
7 | $barColor: #444;
8 |
9 | @keyframes IndicatorRun {
10 | from {
11 | transform: rotate(0);
12 | }
13 | to {
14 | transform: rotate(1turn);
15 | }
16 | }
17 |
18 | .container {
19 | position: relative;
20 | width: $size;
21 | height: $size;
22 | animation: IndicatorRun 0.8s steps($barNum, jump-end) infinite;
23 | &.large {
24 | width: $size + 4px;
25 | height: $size + 4px;
26 | > div {
27 | &::after,
28 | &:after {
29 | height: $barHeight + 1px;
30 | }
31 | }
32 | }
33 | &.white {
34 | > div {
35 | &::after,
36 | &:after {
37 | background-color: #fff;
38 | }
39 | }
40 | }
41 | > div {
42 | position: absolute;
43 | top: 0;
44 | left: 0;
45 | width: 100%;
46 | height: 100%;
47 | &::after,
48 | &:after {
49 | content: "";
50 | display: block;
51 | position: absolute;
52 | top: 0;
53 | left: 50%;
54 | margin-left: math.div(-$barWidth, 2);
55 | width: $barWidth;
56 | height: $barHeight;
57 | background-color: $barColor;
58 | }
59 | @for $index from 1 through $barNum {
60 | $rotate: math.div(360deg, $barNum) * ($index - 1);
61 | $opacity: math.div($index, $barNum);
62 | &:nth-child(#{$index}) {
63 | transform: rotate($rotate);
64 | &::after,
65 | &:after {
66 | opacity: $opacity;
67 | }
68 | }
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/components/Indicator/index.tsx:
--------------------------------------------------------------------------------
1 | import style from "./index.module.scss";
2 | import clsx from "classnames";
3 |
4 | export interface IndicatorProps {
5 | size?: "large";
6 | white?: boolean;
7 | }
8 |
9 | export function Indicator({ size, white = false }: IndicatorProps) {
10 | const bars = [];
11 | for (let i = 0; i < 10; i++) {
12 | bars.push(
);
13 | }
14 | return (
15 |
22 | {bars}
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/Loading/index.module.scss:
--------------------------------------------------------------------------------
1 | .container {
2 | width: 100vw;
3 | height: 100vh;
4 | position: fixed;
5 | top: 0;
6 | left: 0;
7 | bottom: 0;
8 | right: 0;
9 | z-index: 9999;
10 | > div {
11 | width: 60px;
12 | height: 60px;
13 | background-color: rgba(0, 0, 0, 0.8);
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/Loading/index.tsx:
--------------------------------------------------------------------------------
1 | import style from "./index.module.scss";
2 | import { Flex, theme } from "antd";
3 | import { observer } from "mobx-react-lite";
4 | import { Indicator } from "../Indicator";
5 | import { createPortal } from "react-dom";
6 |
7 | export const Loading = observer(() => {
8 | const { token } = theme.useToken();
9 |
10 | return createPortal(
11 |
12 |
19 |
20 |
21 | ,
22 | document.body,
23 | );
24 | });
25 |
--------------------------------------------------------------------------------
/src/components/Logo/index.module.scss:
--------------------------------------------------------------------------------
1 | .container {
2 | line-height: 1;
3 | font-family: Arial, Helvetica, sans-serif;
4 | font-weight: 600;
5 | span {
6 | font-size: 22px;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/components/Logo/index.tsx:
--------------------------------------------------------------------------------
1 | import { Typography } from "antd";
2 | import style from "./index.module.scss";
3 | import { observer } from "mobx-react-lite";
4 |
5 | interface LogoProps {
6 | iconSize?: number;
7 | title?: string;
8 | }
9 |
10 | export const Logo = observer(({ title = "Pic Smaller" }: LogoProps) => {
11 | return (
12 |
13 | {title}
14 |
15 | );
16 | });
17 |
--------------------------------------------------------------------------------
/src/components/OptionItem/index.module.scss:
--------------------------------------------------------------------------------
1 | .container {
2 | margin-bottom: 24px;
3 | }
4 |
5 | .desc {
6 | margin-bottom: 8px;
7 | }
8 |
--------------------------------------------------------------------------------
/src/components/OptionItem/index.tsx:
--------------------------------------------------------------------------------
1 | import { HTMLProps } from "react";
2 | import style from "./index.module.scss";
3 | import { Typography } from "antd";
4 |
5 | export interface OptionItemProps extends HTMLProps {
6 | desc?: React.ReactNode;
7 | children?: React.ReactNode;
8 | }
9 |
10 | export function OptionItem(props: OptionItemProps) {
11 | /* eslint-disable prefer-const */
12 | let { desc, children, ...extra } = props;
13 | if (desc && typeof desc === "string") {
14 | desc = {desc} ;
15 | }
16 | return (
17 |
18 |
{desc}
19 |
{children}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/ProgressHint/index.module.scss:
--------------------------------------------------------------------------------
1 | .progress {
2 | margin-left: 8px;
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/ProgressHint/index.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from "mobx-react-lite";
2 | import style from "./index.module.scss";
3 | import { Flex, Progress, Typography } from "antd";
4 | import { gstate } from "@/global";
5 | import { ArrowDownOutlined, ArrowUpOutlined } from "@ant-design/icons";
6 | import { homeState } from "@/states/home";
7 | import { formatSize } from "@/functions";
8 | import { useResponse } from "@/media";
9 |
10 | export const ProgressHint = observer(() => {
11 | const info = homeState.getProgressHintInfo();
12 | const { isMobile } = useResponse();
13 |
14 | let rate: React.ReactNode = null;
15 | if (info.originSize > info.outputSize) {
16 | rate = (
17 |
18 | {info.rate}%
19 |
20 |
21 | );
22 | } else {
23 | rate = (
24 |
25 | {info.rate}%
26 |
27 |
28 | );
29 | }
30 |
31 | return (
32 |
33 |
40 |
41 |
42 | {info.loadedNum}
43 |
44 |
45 | / {info.totalNum}
46 |
47 | {!isMobile && (
48 | <>
49 |
50 | {gstate.locale?.progress.before}:
51 | {formatSize(info.originSize)}
52 |
53 |
54 |
55 | {gstate.locale?.progress.after}:
56 | {formatSize(info.outputSize)}
57 |
58 |
59 | >
60 | )}
61 |
62 | {gstate.locale?.progress.rate}:{rate}
63 |
64 |
65 |
66 |
67 | );
68 | });
69 |
--------------------------------------------------------------------------------
/src/components/UploadCard/index.module.scss:
--------------------------------------------------------------------------------
1 | .container {
2 | width: 100%;
3 | position: relative;
4 | cursor: pointer;
5 | user-select: none;
6 | background-color: #f5f5f5;
7 | overflow: hidden;
8 | box-sizing: border-box;
9 | &:active {
10 | background-color: darken($color: #f5f5f5, $amount: 5);
11 | }
12 | > input {
13 | display: none;
14 | }
15 |
16 | &.active {
17 | background-color: darken($color: #f5f5f5, $amount: 5);
18 | }
19 | }
20 |
21 | .inner {
22 | line-height: 1;
23 | svg {
24 | width: 120px;
25 | height: 120px;
26 | path {
27 | fill: #1da565;
28 | }
29 | }
30 | > span:nth-child(2) {
31 | margin-top: 16px;
32 | font-size: 22px;
33 | font-weight: bold;
34 | text-align: center;
35 | padding: 0 16px;
36 | }
37 | > div:nth-child(3) {
38 | margin-top: 8px;
39 | color: #aaa;
40 | text-align: center;
41 | padding: 0 16px;
42 | line-height: 1.6;
43 | }
44 | }
45 |
46 | .mask {
47 | width: 100%;
48 | height: 100%;
49 | position: absolute;
50 | top: 0;
51 | left: 0;
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/UploadCard/index.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, Typography, theme } from "antd";
2 | import style from "./index.module.scss";
3 | import { useEffect, useRef } from "react";
4 | import classNames from "classnames";
5 | import { observer } from "mobx-react-lite";
6 | import { gstate } from "@/global";
7 | import { ImageInput } from "../ImageInput";
8 | import { state } from "./state";
9 | import { createImageList } from "@/engines/transform";
10 | import { getFilesFromEntry, getFilesFromHandle } from "@/functions";
11 | import { sprintf } from "sprintf-js";
12 | import { Mimes } from "@/mimes";
13 |
14 | export const UploadCard = observer(() => {
15 | const { token } = theme.useToken();
16 | const fileRef = useRef(null);
17 | const dragRef = useRef(null);
18 |
19 | useEffect(() => {
20 | const dragLeave = () => {
21 | state.dragActive = false;
22 | };
23 | const dragOver = (event: DragEvent) => {
24 | event.preventDefault();
25 | state.dragActive = true;
26 | };
27 | const drop = async (event: DragEvent) => {
28 | event.preventDefault();
29 | state.dragActive = false;
30 | const files: Array = [];
31 | if (event.dataTransfer?.items) {
32 | // https://stackoverflow.com/questions/55658851/javascript-datatransfer-items-not-persisting-through-async-calls
33 | const list: Array> = [];
34 | for (const item of event.dataTransfer.items) {
35 | if (typeof item.getAsFileSystemHandle === "function") {
36 | list.push(
37 | (async () => {
38 | const handle = await item.getAsFileSystemHandle!();
39 | const result = await getFilesFromHandle(handle);
40 | files.push(...result);
41 | })(),
42 | );
43 | continue;
44 | }
45 | if (typeof item.webkitGetAsEntry === "function") {
46 | list.push(
47 | (async () => {
48 | const entry = await item.webkitGetAsEntry();
49 | if (entry) {
50 | const result = await getFilesFromEntry(entry);
51 | files.push(...result);
52 | }
53 | })(),
54 | );
55 | }
56 | }
57 | await Promise.all(list);
58 | } else if (event.dataTransfer?.files) {
59 | const list = event.dataTransfer?.files;
60 | for (let index = 0; index < list.length; index++) {
61 | const file = list.item(index);
62 | if (file) {
63 | files.push(file);
64 | }
65 | }
66 | }
67 |
68 | files.length > 0 && createImageList(files);
69 | };
70 |
71 | const target = dragRef.current!;
72 | target.addEventListener("dragover", dragOver);
73 | target.addEventListener("dragleave", dragLeave);
74 | target.addEventListener("drop", drop);
75 |
76 | return () => {
77 | target.removeEventListener("dragover", dragOver);
78 | target.removeEventListener("dragleave", dragLeave);
79 | target.removeEventListener("drop", drop);
80 | };
81 | }, []);
82 |
83 | return (
84 |
90 |
91 |
92 |
93 |
94 | {gstate.locale?.uploadCard.title}
95 |
96 | {sprintf(
97 | gstate.locale?.uploadCard.subTitle ?? "",
98 | Object.keys(Mimes)
99 | .map((item) => item.toUpperCase())
100 | .join("/"),
101 | )}
102 |
103 |
104 |
105 | {
109 | fileRef.current?.click();
110 | }}
111 | />
112 |
113 | );
114 | });
115 |
--------------------------------------------------------------------------------
/src/components/UploadCard/state.ts:
--------------------------------------------------------------------------------
1 | import { observable } from "mobx";
2 |
3 | export interface UploadCardState {
4 | dragActive: boolean;
5 | }
6 |
7 | export const state = observable.object
({
8 | dragActive: false,
9 | });
10 |
--------------------------------------------------------------------------------
/src/engines/AvifImage.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Reference:
3 | * https://github.com/packurl/wasm_avif
4 | */
5 |
6 | import { Mimes } from "@/mimes";
7 | import { avif } from "./AvifWasmModule";
8 | import { ImageBase, ProcessOutput } from "./ImageBase";
9 |
10 | export class AvifImage extends ImageBase {
11 | /**
12 | * Encode avif image with canvas context
13 | * @param context
14 | * @param width
15 | * @param height
16 | * @param quality
17 | * @param speed
18 | * @returns
19 | */
20 | static async encode(
21 | context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
22 | width: number,
23 | height: number,
24 | quality: number = 50,
25 | speed: number = 8,
26 | ): Promise {
27 | const imageData = context.getImageData(0, 0, width, height).data;
28 | const bytes = new Uint8Array(imageData);
29 | const result: Uint8Array = await avif(bytes, width, height, quality, speed);
30 | return new Blob([result], { type: Mimes.avif });
31 | }
32 |
33 | async compress(): Promise {
34 | const { width, height, x, y } = this.getOutputDimension();
35 | try {
36 | const { context } = await this.createCanvas(width, height, x, y);
37 | const blob = await AvifImage.encode(
38 | context,
39 | width,
40 | height,
41 | this.option.avif.quality,
42 | this.option.avif.speed,
43 | );
44 |
45 | return {
46 | width,
47 | height,
48 | blob,
49 | src: URL.createObjectURL(blob),
50 | };
51 | } catch (error) {
52 | return this.failResult();
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/engines/AvifWasmModule.js:
--------------------------------------------------------------------------------
1 | import avifWasmBinaryFile from "./avif.wasm?url";
2 |
3 | /**
4 | * Encodes the supplied ImageData rgba array.
5 | * @param {Uint8Array} bytes
6 | * @param {number} width
7 | * @param {number} height
8 | * @param {number} quality (1 to 100)
9 | * @param {number} speed (1 to 10)
10 | * @return {Uint8Array}
11 | */
12 | export const avif = async (bytes, width, height, quality = 50, speed = 6) => {
13 | const imports = {
14 | wbg: {
15 | __wbg_log_12edb8942696c207: (p, n) => {
16 | new TextDecoder().decode(
17 | new Uint8Array(wasm.memory.buffer).subarray(p, p + n),
18 | );
19 | },
20 | },
21 | };
22 | const {
23 | instance: { exports: wasm },
24 | } = await WebAssembly.instantiateStreaming(
25 | await fetch(avifWasmBinaryFile, { cache: "force-cache" }),
26 | imports,
27 | );
28 | const malloc = wasm.__wbindgen_malloc;
29 | const free = wasm.__wbindgen_free;
30 | const pointer = wasm.__wbindgen_add_to_stack_pointer;
31 |
32 | const n1 = bytes.length;
33 | const p1 = malloc(n1, 1);
34 | const r = pointer(-16);
35 | try {
36 | new Uint8Array(wasm.memory.buffer).set(bytes, p1);
37 | wasm.avif_from_imagedata(r, p1, n1, width, height, quality, speed);
38 | const arr = new Int32Array(wasm.memory.buffer);
39 | const p2 = arr[r / 4];
40 | const n2 = arr[r / 4 + 1];
41 | const res = new Uint8Array(wasm.memory.buffer)
42 | .subarray(p2, p2 + n2)
43 | .slice();
44 | free(p2, n2);
45 | return res;
46 | } finally {
47 | pointer(16);
48 | }
49 | };
50 |
--------------------------------------------------------------------------------
/src/engines/CanvasImage.ts:
--------------------------------------------------------------------------------
1 | import { ImageBase, ProcessOutput } from "./ImageBase";
2 |
3 | /**
4 | * JPEG/JPG/WEBP is compatible
5 | */
6 | export class CanvasImage extends ImageBase {
7 | async compress(): Promise {
8 | const dimension = this.getOutputDimension();
9 | const blob = await this.createBlob(
10 | dimension.width,
11 | dimension.height,
12 | this.option.jpeg.quality,
13 | dimension.x,
14 | dimension.y,
15 | );
16 | return {
17 | ...dimension,
18 | blob,
19 | src: URL.createObjectURL(blob),
20 | };
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/engines/GifImage.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Reference:
3 | * https://github.com/renzhezhilu/gifsicle-wasm-browser
4 | * https://www.lcdf.org/gifsicle/man.html
5 | */
6 |
7 | import { gifsicle } from "./GifWasmModule";
8 | import { ImageBase, ProcessOutput } from "./ImageBase";
9 |
10 | export class GifImage extends ImageBase {
11 | async compress(): Promise {
12 | try {
13 | const { width, height, x, y } = this.getOutputDimension();
14 |
15 | const commands: string[] = [
16 | `--optimize=3`,
17 | // `--resize=${width}x${height}`,
18 | `--colors=${this.option.gif.colors}`,
19 | ];
20 |
21 | // Crop mode
22 | if (width !== this.info.width || height !== this.info.height) {
23 | commands.push(`--crop=${x},${y}+${width}x${height}`);
24 | } else {
25 | commands.push(`--resize=${width}x${height}`);
26 | }
27 |
28 | if (this.option.gif.dithering) {
29 | commands.push(`--dither=floyd-steinberg`);
30 | }
31 | commands.push(`--output=/out/${this.info.name}`);
32 | commands.push(this.info.name);
33 | const buffer = await this.info.blob.arrayBuffer();
34 | const result = await gifsicle({
35 | data: [
36 | {
37 | file: buffer,
38 | name: this.info.name,
39 | },
40 | ],
41 | command: [commands.join(" ")],
42 | });
43 |
44 | if (!Array.isArray(result) || result.length !== 1) {
45 | return this.failResult();
46 | }
47 |
48 | const blob = new Blob([result[0].file], {
49 | type: this.info.blob.type,
50 | });
51 | return {
52 | width,
53 | height,
54 | blob,
55 | src: URL.createObjectURL(blob),
56 | };
57 | } catch (error) {
58 | return this.failResult();
59 | }
60 | }
61 |
62 | async preview(): Promise {
63 | const { width, height, x, y } = this.getPreviewDimension();
64 |
65 | const commands: string[] = [
66 | `--colors=${this.option.gif.colors}`,
67 | // `--resize=${width}x${height}`,
68 | `--output=/out/${this.info.name}`,
69 | ];
70 |
71 | // Crop mode
72 | if (width !== this.info.width || height !== this.info.height) {
73 | commands.push(`--crop=${x},${y}+${width}x${height}`);
74 | } else {
75 | commands.push(`--resize=${width}x${height}`);
76 | }
77 |
78 | commands.push(this.info.name);
79 |
80 | const buffer = await this.info.blob.arrayBuffer();
81 | const result = await gifsicle({
82 | data: [
83 | {
84 | file: buffer,
85 | name: this.info.name,
86 | },
87 | ],
88 | command: [commands.join(" ")],
89 | });
90 |
91 | if (!Array.isArray(result) || result.length !== 1) {
92 | return {
93 | width: this.info.width,
94 | height: this.info.height,
95 | blob: this.info.blob,
96 | src: URL.createObjectURL(this.info.blob),
97 | };
98 | }
99 |
100 | const blob = new Blob([result[0].file], {
101 | type: this.info.blob.type,
102 | });
103 | return {
104 | width,
105 | height,
106 | blob,
107 | src: URL.createObjectURL(blob),
108 | };
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/engines/ImageBase.ts:
--------------------------------------------------------------------------------
1 | export interface ImageInfo {
2 | key: number;
3 | name: string;
4 | width: number;
5 | height: number;
6 | blob: Blob;
7 | }
8 |
9 | export interface CompressOption {
10 | preview: {
11 | maxSize: number;
12 | };
13 | resize: {
14 | method?:
15 | | "fitWidth"
16 | | "fitHeight"
17 | | "setShort"
18 | | "setLong"
19 | | "setCropRatio"
20 | | "setCropSize";
21 | width?: number;
22 | height?: number;
23 | short?: number;
24 | long?: number;
25 | cropWidthRatio?: number;
26 | cropHeightRatio?: number;
27 | cropWidthSize?: number;
28 | cropHeightSize?: number;
29 | };
30 | format: {
31 | target?: "jpg" | "jpeg" | "png" | "webp" | "avif";
32 | transparentFill: string;
33 | };
34 | jpeg: {
35 | quality: number; // 0-1
36 | };
37 | png: {
38 | colors: number; // 2-256
39 | dithering: number; // 0-1
40 | };
41 | gif: {
42 | colors: number; // 2-256
43 | dithering: boolean; // boolean
44 | };
45 | avif: {
46 | quality: number; // 1 - 100
47 | speed: number; // 1 - 10
48 | };
49 | }
50 |
51 | export interface ProcessOutput {
52 | width: number;
53 | height: number;
54 | blob: Blob;
55 | src: string;
56 | }
57 |
58 | export interface Dimension {
59 | x: number;
60 | y: number;
61 | width: number;
62 | height: number;
63 | }
64 |
65 | export abstract class ImageBase {
66 | constructor(
67 | public info: ImageInfo,
68 | public option: CompressOption,
69 | ) {}
70 |
71 | abstract compress(): Promise;
72 |
73 | /**
74 | * Get output image dimension, based on resize param
75 | * @returns Dimension
76 | */
77 | getOutputDimension(): Dimension {
78 | const {
79 | method,
80 | width,
81 | height,
82 | short,
83 | long,
84 | cropWidthRatio,
85 | cropHeightRatio,
86 | cropWidthSize,
87 | cropHeightSize,
88 | } = this.option.resize;
89 |
90 | const originDimension = {
91 | x: 0,
92 | y: 0,
93 | width: this.info.width,
94 | height: this.info.height,
95 | };
96 |
97 | if (method === "fitWidth") {
98 | if (!width) {
99 | return originDimension;
100 | }
101 | const rate = width / this.info.width;
102 | const newHeight = rate * this.info.height;
103 | return {
104 | x: 0,
105 | y: 0,
106 | width: Math.ceil(width),
107 | height: Math.ceil(newHeight),
108 | };
109 | }
110 |
111 | if (method === "fitHeight") {
112 | if (!height) {
113 | return originDimension;
114 | }
115 | const rate = height / this.info.height;
116 | const newWidth = rate * this.info.width;
117 | return {
118 | x: 0,
119 | y: 0,
120 | width: Math.ceil(newWidth),
121 | height: Math.ceil(height),
122 | };
123 | }
124 |
125 | if (method === "setShort") {
126 | if (!short) {
127 | return originDimension;
128 | }
129 |
130 | let newWidth: number;
131 | let newHeight: number;
132 | if (this.info.width <= this.info.height) {
133 | newWidth = short;
134 | const rate = newWidth / this.info.width;
135 | newHeight = rate * this.info.height;
136 | } else {
137 | newHeight = short;
138 | const rate = newHeight / this.info.height;
139 | newWidth = rate * this.info.width;
140 | }
141 | return {
142 | x: 0,
143 | y: 0,
144 | width: Math.ceil(newWidth),
145 | height: Math.ceil(newHeight),
146 | };
147 | }
148 |
149 | if (method === "setLong") {
150 | if (!long) {
151 | return originDimension;
152 | }
153 |
154 | let newWidth: number;
155 | let newHeight: number;
156 | if (this.info.width >= this.info.height) {
157 | newWidth = long;
158 | const rate = newWidth / this.info.width;
159 | newHeight = rate * this.info.height;
160 | } else {
161 | newHeight = long;
162 | const rate = newHeight / this.info.height;
163 | newWidth = rate * this.info.width;
164 | }
165 | return {
166 | x: 0,
167 | y: 0,
168 | width: Math.ceil(newWidth),
169 | height: Math.ceil(newHeight),
170 | };
171 | }
172 |
173 | // Crop via ratio
174 | if (method === "setCropRatio") {
175 | if (!cropWidthRatio || !cropHeightRatio) {
176 | return originDimension;
177 | }
178 |
179 | let x = 0;
180 | let y = 0;
181 | let newWidth = 0;
182 | let newHeight = 0;
183 |
184 | if (
185 | cropWidthRatio / cropHeightRatio >=
186 | this.info.width / this.info.height
187 | ) {
188 | x = 0;
189 | newWidth = this.info.width;
190 | newHeight = (this.info.width * cropHeightRatio) / cropWidthRatio;
191 | y = (this.info.height - newHeight) / 2;
192 | } else {
193 | y = 0;
194 | newHeight = this.info.height;
195 | newWidth = (this.info.height * cropWidthRatio) / cropHeightRatio;
196 | x = (this.info.width - newWidth) / 2;
197 | }
198 |
199 | return {
200 | x: Math.ceil(x),
201 | y: Math.ceil(y),
202 | width: Math.ceil(newWidth),
203 | height: Math.ceil(newHeight),
204 | };
205 | }
206 |
207 | // Crop via special width and height
208 | if (method === "setCropSize") {
209 | if (!cropWidthSize || !cropHeightSize) {
210 | return originDimension;
211 | }
212 |
213 | let newWidth = cropWidthSize;
214 | let newHeight = cropHeightSize;
215 |
216 | if (cropWidthSize >= this.info.width) {
217 | newWidth = this.info.width;
218 | }
219 |
220 | if (cropHeightSize >= this.info.height) {
221 | newHeight = this.info.height;
222 | }
223 |
224 | const x = (this.info.width - newWidth) / 2;
225 | const y = (this.info.height - newHeight) / 2;
226 |
227 | return {
228 | x: Math.ceil(x),
229 | y: Math.ceil(y),
230 | width: Math.ceil(newWidth),
231 | height: Math.ceil(newHeight),
232 | };
233 | }
234 |
235 | return originDimension;
236 | }
237 |
238 | /**
239 | * Return original info when process fails
240 | * @returns
241 | */
242 | failResult(): ProcessOutput {
243 | return {
244 | width: this.info.width,
245 | height: this.info.height,
246 | blob: this.info.blob,
247 | src: URL.createObjectURL(this.info.blob),
248 | };
249 | }
250 |
251 | /**
252 | * Get preview image size via option
253 | * @returns Dimension
254 | */
255 | getPreviewDimension(): Dimension {
256 | const maxSize = this.option.preview.maxSize;
257 | if (Math.max(this.info.width, this.info.height) <= maxSize) {
258 | return {
259 | x: 0,
260 | y: 0,
261 | width: this.info.width,
262 | height: this.info.height,
263 | };
264 | }
265 |
266 | let width, height: number;
267 | if (this.info.width >= this.info.height) {
268 | const rate = maxSize / this.info.width;
269 | width = maxSize;
270 | height = rate * this.info.height;
271 | } else {
272 | const rate = maxSize / this.info.height;
273 | width = rate * this.info.width;
274 | height = maxSize;
275 | }
276 |
277 | return {
278 | x: 0,
279 | y: 0,
280 | width: Math.ceil(width),
281 | height: Math.ceil(height),
282 | };
283 | }
284 |
285 | /**
286 | * Get preview from native browser method
287 | * @returns
288 | */
289 | async preview(): Promise {
290 | const { width, height, x, y } = this.getPreviewDimension();
291 | const blob = await this.createBlob(width, height, x, y);
292 | return {
293 | width,
294 | height,
295 | blob,
296 | src: URL.createObjectURL(blob),
297 | };
298 | }
299 |
300 | async createCanvas(
301 | width: number,
302 | height: number,
303 | cropX: number = 0,
304 | cropY: number = 0,
305 | ): Promise<{
306 | canvas: OffscreenCanvas;
307 | context: OffscreenCanvasRenderingContext2D;
308 | }> {
309 | const canvas = new OffscreenCanvas(width, height);
310 | const context = canvas.getContext("2d")!;
311 | const image = await createImageBitmap(this.info.blob);
312 |
313 | const method = this.option.resize.method;
314 | if (method && ["setCropRatio", "setCropSize"].includes(method)) {
315 | // Crop mode only
316 | context?.drawImage(
317 | image,
318 | cropX,
319 | cropY,
320 | width,
321 | height,
322 | 0,
323 | 0,
324 | width,
325 | height,
326 | );
327 | } else {
328 | // Resize mode only
329 | context?.drawImage(
330 | image,
331 | 0,
332 | 0,
333 | this.info.width,
334 | this.info.height,
335 | 0,
336 | 0,
337 | width,
338 | height,
339 | );
340 | }
341 |
342 | image.close();
343 | return { canvas, context };
344 | }
345 |
346 | /**
347 | * create OffscreenCanvas from Blob
348 | * @param width
349 | * @param height
350 | * @param quality
351 | * @param cropX
352 | * @param cropY
353 | * @returns
354 | */
355 | async createBlob(
356 | width: number,
357 | height: number,
358 | quality = 0.6,
359 | cropX = 0,
360 | cropY = 0,
361 | ) {
362 | const { canvas } = await this.createCanvas(width, height, cropX, cropY);
363 | const opiton: ImageEncodeOptions = {
364 | type: this.info.blob.type,
365 | quality,
366 | };
367 | return canvas.convertToBlob(opiton);
368 | }
369 | }
370 |
--------------------------------------------------------------------------------
/src/engines/PngImage.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Reference:
3 | * https://github.com/antelle/wasm-image-compressor
4 | */
5 |
6 | import { ImageBase, ProcessOutput } from "./ImageBase";
7 | import { Module } from "./PngWasmModule";
8 |
9 | export class PngImage extends ImageBase {
10 | async compress(): Promise {
11 | const { width, height, x, y } = this.getOutputDimension();
12 | const { context } = await this.createCanvas(width, height, x, y);
13 | const imageData = context.getImageData(0, 0, width, height).data;
14 |
15 | try {
16 | const buffer = Module._malloc(imageData.byteLength);
17 | Module.HEAPU8.set(imageData, buffer);
18 | const imageDataLen = width * height * 4;
19 | if (imageData.byteLength !== imageDataLen) {
20 | return this.failResult();
21 | }
22 | const outputSizePointer = Module._malloc(4);
23 |
24 | const result = Module._compress(
25 | width,
26 | height,
27 | this.option.png.colors,
28 | this.option.png.dithering,
29 | buffer,
30 | outputSizePointer,
31 | );
32 | if (result) {
33 | return this.failResult();
34 | }
35 | const outputSize = Module.getValue(outputSizePointer, "i32", false);
36 | const output = new Uint8Array(outputSize);
37 | output.set(Module.HEAPU8.subarray(buffer, buffer + outputSize));
38 |
39 | Module._free(buffer);
40 | Module._free(outputSizePointer);
41 |
42 | const blob = new Blob([output], { type: this.info.blob.type });
43 | return {
44 | width,
45 | height,
46 | blob,
47 | src: URL.createObjectURL(blob),
48 | };
49 | } catch (error) {
50 | return this.failResult();
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/engines/Queue.ts:
--------------------------------------------------------------------------------
1 | export type Task = () => Promise;
2 |
3 | export class Queue {
4 | // Current task list
5 | list: Array = [];
6 | // Indicate whether task queue running
7 | isRunning: boolean = false;
8 |
9 | /**
10 | *
11 | * @param max Maximun concurrent task number
12 | */
13 | constructor(private max: number = 1) {}
14 |
15 | /**
16 | * Add new task for executing
17 | * @param task
18 | */
19 | public push(task: Task) {
20 | this.list.push(task);
21 | if (!this.isRunning) {
22 | this.do();
23 | }
24 | }
25 |
26 | /**
27 | * Execute a batch of tasks
28 | * @returns
29 | */
30 | private async do() {
31 | // If list is empty, end run
32 | if (this.list.length === 0) {
33 | this.isRunning = false;
34 | return;
35 | }
36 |
37 | this.isRunning = true;
38 | const takeList: Array = [];
39 | for (let i = 0; i < this.max; i++) {
40 | const task = this.list.shift();
41 | if (task) {
42 | takeList.push(task);
43 | }
44 | }
45 |
46 | // Execute all task
47 | const runningList = takeList.map((task) => task());
48 | await Promise.all(runningList);
49 |
50 | // Execute next batch
51 | await this.do();
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/engines/SvgImage.ts:
--------------------------------------------------------------------------------
1 | import { Mimes } from "@/mimes";
2 | import { ImageBase, ProcessOutput } from "./ImageBase";
3 | import { optimize } from "svgo/lib/svgo";
4 |
5 | /**
6 | * JPEG/JPG/WEBP is compatible
7 | */
8 | export class SvgImage extends ImageBase {
9 | async compress(): Promise {
10 | if (this.info.width === 0 || this.info.height === 0) {
11 | return this.failResult();
12 | }
13 |
14 | const data = await this.info.blob.text();
15 |
16 | const result = optimize(data);
17 | const blob = new Blob([result.data], { type: Mimes.svg });
18 | return {
19 | width: this.info.width,
20 | height: this.info.height,
21 | blob,
22 | src: URL.createObjectURL(blob),
23 | };
24 | }
25 |
26 | /**
27 | * We will do nothing for svg preview
28 | * @returns
29 | */
30 | async preview(): Promise {
31 | return this.failResult();
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/engines/WorkerCompress.ts:
--------------------------------------------------------------------------------
1 | import { Queue } from "./Queue";
2 | import { MessageData, convert } from "./handler";
3 | import { avifCheck } from "./support";
4 |
5 | (async () => {
6 | // Ensure avif check in worker
7 | await avifCheck();
8 | const queue = new Queue(3);
9 |
10 | globalThis.addEventListener(
11 | "message",
12 | async (event: MessageEvent) => {
13 | queue.push(async () => {
14 | const output = await convert(event.data, "compress");
15 | if (output) {
16 | globalThis.postMessage(output);
17 | }
18 | });
19 | },
20 | );
21 | })();
22 |
--------------------------------------------------------------------------------
/src/engines/WorkerPreview.ts:
--------------------------------------------------------------------------------
1 | import { Queue } from "./Queue";
2 | import { MessageData, convert } from "./handler";
3 | import { avifCheck } from "./support";
4 |
5 | (async () => {
6 | // Ensure avif check in worker
7 | await avifCheck();
8 | const queue = new Queue(3);
9 |
10 | globalThis.addEventListener(
11 | "message",
12 | async (event: MessageEvent) => {
13 | queue.push(async () => {
14 | const output = await convert(event.data, "preview");
15 | if (output) {
16 | globalThis.postMessage(output);
17 | }
18 | });
19 | },
20 | );
21 | })();
22 |
--------------------------------------------------------------------------------
/src/engines/avif.wasm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joye61/pic-smaller/7ddf0508452372d25ff9a3c197e0debafef602fb/src/engines/avif.wasm
--------------------------------------------------------------------------------
/src/engines/gif.wasm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joye61/pic-smaller/7ddf0508452372d25ff9a3c197e0debafef602fb/src/engines/gif.wasm
--------------------------------------------------------------------------------
/src/engines/handler.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CompressOption,
3 | ImageBase,
4 | ImageInfo,
5 | ProcessOutput,
6 | } from "./ImageBase";
7 | import { GifImage } from "./GifImage";
8 | import { CanvasImage } from "./CanvasImage";
9 | import { PngImage } from "./PngImage";
10 | import { AvifImage } from "./AvifImage";
11 | import { Mimes } from "@/mimes";
12 | import { SvgImage } from "./SvgImage";
13 | import { getSvgDimension } from "./svgParse";
14 |
15 | export interface MessageData {
16 | info: ImageInfo;
17 | option: CompressOption;
18 | }
19 |
20 | export interface OutputMessageData extends Omit {
21 | compress?: ProcessOutput;
22 | preview?: ProcessOutput;
23 | }
24 |
25 | export type HandleMethod = "compress" | "preview";
26 |
27 | export async function convert(
28 | data: MessageData,
29 | method: HandleMethod = "compress",
30 | ): Promise {
31 | const mime = data.info.blob.type.toLowerCase();
32 |
33 | // For SVG type, do not support type convert
34 | if (Mimes.svg === mime) {
35 | // SVG has dimension already
36 | if (data.info.width > 0 && data.info.height > 0) {
37 | return createHandler(data, method);
38 | }
39 |
40 | // If SVG has no dimension from main thread
41 | const svgData = await data.info.blob.text();
42 | let dimension = { width: 0, height: 0 };
43 | try {
44 | dimension = getSvgDimension(svgData);
45 | } catch (error) {}
46 | data.info.width = dimension.width;
47 | data.info.height = dimension.height;
48 |
49 | return createHandler(data, method);
50 | }
51 |
52 | // For JPG/JPEG/WEBP/AVIF/PNG/GIF type
53 | const bitmap = await createImageBitmap(data.info.blob);
54 | data.info.width = bitmap.width;
55 | data.info.height = bitmap.height;
56 |
57 | // Type convert logic here
58 | if (
59 | // Only compress task need convert
60 | method === "compress" &&
61 | // If there is no target type, don't need convert
62 | data.option.format.target &&
63 | // If target type is equal to original type, don't need convert
64 | data.option.format.target !== data.info.blob.type
65 | ) {
66 | const target = data.option.format.target.toLowerCase();
67 |
68 | // Currently no browsers support creation of an AVIF from a canvas
69 | // So we should encode AVIF image type using webassembly, and the
70 | // result blob don't need compress agin, return it directly
71 | if (target === "avif") {
72 | return createHandler(data, method, Mimes.avif);
73 | }
74 |
75 | const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
76 | const context = canvas.getContext("2d")!;
77 |
78 | // JPEG format don't support transparent, we should set a background
79 | if (["jpg", "jpeg"].includes(target)) {
80 | context.fillStyle = data.option.format.transparentFill;
81 | context.fillRect(0, 0, bitmap.width, bitmap.height);
82 | }
83 | context.drawImage(
84 | bitmap,
85 | 0,
86 | 0,
87 | bitmap.width,
88 | bitmap.height,
89 | 0,
90 | 0,
91 | bitmap.width,
92 | bitmap.height,
93 | );
94 |
95 | data.info.blob = await canvas.convertToBlob({
96 | type: Mimes[target],
97 | quality: 1,
98 | });
99 | }
100 |
101 | // Release bitmap
102 | bitmap.close();
103 |
104 | return createHandler(data, method);
105 | }
106 |
107 | export async function createHandler(
108 | data: MessageData,
109 | method: HandleMethod,
110 | specify?: string,
111 | ): Promise {
112 | let mime = data.info.blob.type.toLowerCase();
113 | if (specify) {
114 | mime = specify;
115 | }
116 | let image: ImageBase | null = null;
117 | if ([Mimes.jpg, Mimes.webp].includes(mime)) {
118 | image = new CanvasImage(data.info, data.option);
119 | } else if (mime === Mimes.avif) {
120 | image = new AvifImage(data.info, data.option);
121 | } else if (mime === Mimes.png) {
122 | image = new PngImage(data.info, data.option);
123 | } else if (mime === Mimes.gif) {
124 | image = new GifImage(data.info, data.option);
125 | } else if (mime === Mimes.svg) {
126 | image = new SvgImage(data.info, data.option);
127 | }
128 |
129 | // Unsupported handler type, return it
130 | if (!image) return null;
131 |
132 | const result: OutputMessageData = {
133 | key: image.info.key,
134 | width: image.info.width,
135 | height: image.info.height,
136 | };
137 |
138 | if (image && method === "preview") {
139 | result.preview = await image.preview();
140 | return result;
141 | }
142 |
143 | if (image && method === "compress") {
144 | result.compress = await image.compress();
145 | return result;
146 | }
147 |
148 | return null;
149 | }
150 |
--------------------------------------------------------------------------------
/src/engines/png.wasm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joye61/pic-smaller/7ddf0508452372d25ff9a3c197e0debafef602fb/src/engines/png.wasm
--------------------------------------------------------------------------------
/src/engines/support.ts:
--------------------------------------------------------------------------------
1 | import { Mimes } from "@/mimes";
2 |
3 | const MimeAvif = "image/avif";
4 |
5 | /**
6 | * 检测Avif图片格式是否被支持
7 | * @returns
8 | */
9 | async function isAvifSupport() {
10 | const canvas = new OffscreenCanvas(1, 1);
11 | canvas.getContext("2d");
12 | try {
13 | await canvas.convertToBlob({ type: MimeAvif });
14 | return true;
15 | } catch (error) {
16 | return false;
17 | }
18 | }
19 |
20 | export async function avifCheck() {
21 | if (await isAvifSupport()) {
22 | Mimes.avif = MimeAvif;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/engines/svgConvert.ts:
--------------------------------------------------------------------------------
1 | import { homeState } from "@/states/home";
2 | import { ProcessOutput } from "./ImageBase";
3 | import { AvifImage } from "./AvifImage";
4 | import { Mimes } from "@/mimes";
5 |
6 | /**
7 | * Convert SVG type to other, SVG convert can't do in worker
8 | * @param input SVG compress result in worker
9 | * @returns
10 | */
11 | export async function svgConvert(input: ProcessOutput): Promise {
12 | if (!homeState.option.format.target) {
13 | return input;
14 | }
15 | const target = homeState.option.format.target.toLowerCase();
16 | const canvas = document.createElement("canvas");
17 | canvas.width = input.width;
18 | canvas.height = input.height;
19 | const context = canvas.getContext("2d")!;
20 | if (["jpg", "jpeg"].includes(target)) {
21 | context.fillStyle = homeState.option.format.transparentFill;
22 | context.fillRect(0, 0, input.width, input.height);
23 | }
24 | const svg = await new Promise((resolve) => {
25 | const img = new Image();
26 | img.src = input.src;
27 | img.onload = () => resolve(img);
28 | });
29 | context.drawImage(
30 | svg,
31 | 0,
32 | 0,
33 | input.width,
34 | input.height,
35 | 0,
36 | 0,
37 | input.width,
38 | input.height,
39 | );
40 |
41 | // Convert svg to target type
42 | let blob: Blob;
43 | if (target === "avif") {
44 | blob = await AvifImage.encode(
45 | context,
46 | input.width,
47 | input.height,
48 | homeState.option.avif.quality,
49 | homeState.option.avif.speed,
50 | );
51 | } else {
52 | blob = await new Promise((resolve) => {
53 | canvas.toBlob(
54 | (result) => {
55 | resolve(result!);
56 | },
57 | Mimes[target],
58 | 1,
59 | );
60 | });
61 | }
62 | input.blob = blob;
63 | input.src = URL.createObjectURL(blob);
64 | return input;
65 | }
66 |
--------------------------------------------------------------------------------
/src/engines/svgParse.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Reference: https://github.com/image-size/image-size/blob/main/lib/types/svg.ts
3 | */
4 |
5 | import { Dimension } from "./ImageBase";
6 |
7 | type IAttributes = {
8 | width: number | null;
9 | height: number | null;
10 | viewbox?: IAttributes | null;
11 | };
12 |
13 | const svgReg = /"']|"[^"]*"|'[^']*')*>/;
14 |
15 | const extractorRegExps = {
16 | height: /\sheight=(['"])([^%]+?)\1/,
17 | root: svgReg,
18 | viewbox: /\sviewBox=(['"])(.+?)\1/i,
19 | width: /\swidth=(['"])([^%]+?)\1/,
20 | };
21 |
22 | const INCH_CM = 2.54;
23 | const units: { [unit: string]: number } = {
24 | in: 96,
25 | cm: 96 / INCH_CM,
26 | em: 16,
27 | ex: 8,
28 | m: (96 / INCH_CM) * 100,
29 | mm: 96 / INCH_CM / 10,
30 | pc: 96 / 72 / 12,
31 | pt: 96 / 72,
32 | px: 1,
33 | };
34 |
35 | const unitsReg = new RegExp(
36 | `^([0-9.]+(?:e\\d+)?)(${Object.keys(units).join("|")})?$`,
37 | );
38 |
39 | function parseLength(len: string) {
40 | const m = unitsReg.exec(len);
41 | if (!m) {
42 | return undefined;
43 | }
44 | return Math.round(Number(m[1]) * (units[m[2]] || 1));
45 | }
46 |
47 | function parseViewbox(viewbox: string): IAttributes {
48 | const bounds = viewbox.split(" ");
49 | return {
50 | height: parseLength(bounds[3]) as number,
51 | width: parseLength(bounds[2]) as number,
52 | };
53 | }
54 |
55 | function parseAttributes(root: string): IAttributes {
56 | const width = root.match(extractorRegExps.width);
57 | const height = root.match(extractorRegExps.height);
58 | const viewbox = root.match(extractorRegExps.viewbox);
59 | return {
60 | height: height && (parseLength(height[2]) as number),
61 | viewbox: viewbox && (parseViewbox(viewbox[2]) as IAttributes),
62 | width: width && (parseLength(width[2]) as number),
63 | };
64 | }
65 |
66 | function calculateByDimensions(attrs: IAttributes): Dimension {
67 | return {
68 | x: 0,
69 | y: 0,
70 | height: attrs.height as number,
71 | width: attrs.width as number,
72 | };
73 | }
74 |
75 | function calculateByViewbox(
76 | attrs: IAttributes,
77 | viewbox: IAttributes,
78 | ): Dimension {
79 | const ratio = (viewbox.width as number) / (viewbox.height as number);
80 | if (attrs.width) {
81 | return {
82 | x: 0,
83 | y: 0,
84 | height: Math.floor(attrs.width / ratio),
85 | width: attrs.width,
86 | };
87 | }
88 | if (attrs.height) {
89 | return {
90 | x: 0,
91 | y: 0,
92 | height: attrs.height,
93 | width: Math.floor(attrs.height * ratio),
94 | };
95 | }
96 | return {
97 | x: 0,
98 | y: 0,
99 | height: viewbox.height as number,
100 | width: viewbox.width as number,
101 | };
102 | }
103 |
104 | export function getSvgDimension(input: string): Dimension {
105 | const root = input.match(extractorRegExps.root);
106 | if (root) {
107 | const attrs = parseAttributes(root[0]);
108 | if (attrs.width && attrs.height) {
109 | return calculateByDimensions(attrs);
110 | }
111 | if (attrs.viewbox) {
112 | return calculateByViewbox(attrs, attrs.viewbox);
113 | }
114 | }
115 | throw new TypeError("Invalid SVG");
116 | }
117 |
--------------------------------------------------------------------------------
/src/engines/transform.ts:
--------------------------------------------------------------------------------
1 | import WorkerC from "./WorkerCompress?worker";
2 | import WorkerP from "./WorkerPreview?worker";
3 | import { useEffect } from "react";
4 | import { uniqId } from "@/functions";
5 | import { toJS } from "mobx";
6 | import { ImageItem, homeState } from "@/states/home";
7 | import { CompressOption, Dimension, ImageInfo } from "./ImageBase";
8 | import { OutputMessageData } from "./handler";
9 | import { Mimes } from "@/mimes";
10 | import { svgConvert } from "./svgConvert";
11 |
12 | export interface MessageData {
13 | info: ImageInfo;
14 | option: CompressOption;
15 | }
16 |
17 | let workerC: Worker | null = null;
18 | let workerP: Worker | null = null;
19 |
20 | async function message(event: MessageEvent) {
21 | const value = homeState.list.get(event.data.key);
22 | if (!value) return;
23 |
24 | const item = toJS(value);
25 | item.width = event.data.width;
26 | item.height = event.data.height;
27 | item.compress = event.data.compress ?? item.compress;
28 | item.preview = event.data.preview ?? item.preview;
29 |
30 | // SVG can't convert in worker,so we do converting here
31 | if (item.blob.type === Mimes.svg && event.data.compress) {
32 | await svgConvert(item.compress!);
33 | }
34 |
35 | homeState.list.set(item.key, item);
36 | }
37 |
38 | export function useWorkerHandler() {
39 | useEffect(() => {
40 | workerC = new WorkerC();
41 | workerP = new WorkerP();
42 | workerC.addEventListener("message", message);
43 | workerP.addEventListener("message", message);
44 |
45 | return () => {
46 | workerC!.removeEventListener("message", message);
47 | workerP!.removeEventListener("message", message);
48 | workerC!.terminate();
49 | workerP!.terminate();
50 | workerC = null;
51 | workerP = null;
52 | };
53 | }, []);
54 | }
55 |
56 | function createMessageData(item: ImageInfo): MessageData {
57 | return {
58 | /**
59 | * Why not use the spread operator here?
60 | * Because it causes an error when used this way,
61 | * and the exact reason is unknown at the moment.
62 | *
63 | * error: `Uncaught (in promise) DOMException: Failed to execute 'postMessage' on 'Worker': # could not be cloned.`
64 | * Reproduction method: In the second upload, include the same images as in the first.
65 | */
66 | info: {
67 | key: item.key,
68 | name: item.name,
69 | blob: item.blob,
70 | width: item.width,
71 | height: item.height,
72 | },
73 | option: toJS(homeState.option),
74 | };
75 | }
76 |
77 | export function createCompressTask(item: ImageItem) {
78 | workerC?.postMessage(createMessageData(item));
79 | }
80 |
81 | function createPreviewTask(item: ImageItem) {
82 | workerP?.postMessage(createMessageData(item));
83 | }
84 |
85 | /**
86 | * Handle image files
87 | * @param files
88 | */
89 | export async function createImageList(files: Array) {
90 | const infoListPromise = files.map(async (file) => {
91 | const info: ImageItem = {
92 | key: uniqId(),
93 | name: file.name,
94 | blob: file,
95 | width: 0,
96 | height: 0,
97 | src: URL.createObjectURL(file),
98 | };
99 |
100 | // Due to createImageBitmap do not support SVG blob,
101 | // we should get dimension of SVG via Image
102 | if (file.type === Mimes.svg) {
103 | const { width, height } = await new Promise((resolve) => {
104 | const img = new Image();
105 | img.src = info.src;
106 | img.onload = () => {
107 | resolve({
108 | x: 0,
109 | y: 0,
110 | width: img.width,
111 | height: img.height,
112 | });
113 | };
114 | });
115 | info.width = width;
116 | info.height = height;
117 | }
118 |
119 | return info;
120 | });
121 |
122 | (await Promise.all(infoListPromise)).forEach((item: ImageItem) => {
123 | homeState.list.set(item.key, item);
124 | });
125 |
126 | homeState.list.forEach((item) => {
127 | createPreviewTask(item);
128 | createCompressTask(item);
129 | });
130 | }
131 |
--------------------------------------------------------------------------------
/src/functions.ts:
--------------------------------------------------------------------------------
1 | import { filesize } from "filesize";
2 | import { Mimes } from "./mimes";
3 | import type { ImageItem } from "./states/home";
4 | import type { CompressOption } from "./engines/ImageBase";
5 |
6 | /**
7 | * Normalize pathname
8 | * @param pathname
9 | * @param base
10 | * @returns
11 | */
12 | export function normalize(pathname: string, base = import.meta.env.BASE_URL) {
13 | // Ensure starts with '/'
14 | pathname = "/" + pathname.replace(/^\/*/, "");
15 | base = "/" + base.replace(/^\/*/, "");
16 | if (!pathname.startsWith(base)) return "error404";
17 | return pathname.substring(base.length).replace(/^\/*|\/*$/g, "");
18 | }
19 |
20 | /**
21 | * Globaly uniqid in browser session lifecycle
22 | */
23 | let __UniqIdIndex = 0;
24 | export function uniqId() {
25 | __UniqIdIndex += 1;
26 | return __UniqIdIndex;
27 | }
28 |
29 | /**
30 | * Beautify byte size
31 | * @param num byte size
32 | * @returns
33 | */
34 | export function formatSize(num: number) {
35 | const result = filesize(num, { standard: "jedec", output: "array" });
36 | return result[0] + " " + result[1];
37 | }
38 |
39 | /**
40 | * Create a download dialog from browser
41 | * @param name
42 | * @param blob
43 | */
44 | export function createDownload(name: string, blob: Blob) {
45 | const anchor = document.createElement("a");
46 | anchor.href = URL.createObjectURL(blob);
47 | anchor.download = name;
48 | anchor.click();
49 | anchor.remove();
50 | }
51 |
52 | /**
53 | * If names Set already has name, add suffix '(1)' for the name
54 | * which will newly pushed to names set
55 | *
56 | * @param names will checked names Set
57 | * @param name will pushed to names
58 | */
59 | export function getUniqNameOnNames(names: Set, name: string): string {
60 | const getName = (checkName: string): string => {
61 | if (names.has(checkName)) {
62 | const nameParts = checkName.split(".");
63 | const extension = nameParts.pop();
64 | const newName = nameParts.join("") + "(1)." + extension;
65 | return getName(newName);
66 | } else {
67 | return checkName;
68 | }
69 | };
70 | return getName(name);
71 | }
72 |
73 | /**
74 | * Wait some time
75 | * @param millisecond
76 | * @returns
77 | */
78 | export async function wait(millisecond: number) {
79 | return new Promise((resolve) => {
80 | window.setTimeout(resolve, millisecond);
81 | });
82 | }
83 |
84 | /**
85 | * Preload image by src
86 | * @param src
87 | */
88 | export async function preloadImage(src: string) {
89 | return new Promise((resolve) => {
90 | const img = new Image();
91 | img.src = src;
92 | img.onload = () => resolve();
93 | img.onerror = () => resolve();
94 | });
95 | }
96 |
97 | /**
98 | * Get file list from FileSystemEntry
99 | * @param entry
100 | * @returns
101 | */
102 | export async function getFilesFromEntry(
103 | entry: FileSystemEntry,
104 | ): Promise> {
105 | // If entry is a file
106 | if (entry.isFile) {
107 | const fileEntry = entry as FileSystemFileEntry;
108 | return new Promise>((resolve) => {
109 | fileEntry.file(
110 | (result) => {
111 | const types = Object.values(Mimes);
112 | resolve(types.includes(result.type) ? [result] : []);
113 | },
114 | () => [],
115 | );
116 | });
117 | }
118 |
119 | // If entry is a directory
120 | if (entry.isDirectory) {
121 | const dirEntry = entry as FileSystemDirectoryEntry;
122 | const list = await new Promise>((resolve) => {
123 | dirEntry.createReader().readEntries(resolve, () => []);
124 | });
125 | const result: Array = [];
126 | for (const item of list) {
127 | const subList = await getFilesFromEntry(item);
128 | result.push(...subList);
129 | }
130 | return result;
131 | }
132 |
133 | // Otherwise
134 | return [];
135 | }
136 |
137 | /**
138 | * Get file list from FileSystemHandle
139 | * @param entry
140 | * @returns
141 | */
142 | export async function getFilesFromHandle(
143 | handle: FileSystemHandle,
144 | ): Promise> {
145 | // If handle is a file
146 | if (handle.kind === "file") {
147 | const fileHandle = handle as FileSystemFileHandle;
148 | const file = await fileHandle.getFile();
149 | const types = Object.values(Mimes);
150 | return types.includes(file.type) ? [file] : [];
151 | }
152 |
153 | // If handle is a directory
154 | if (handle.kind === "directory") {
155 | const result: Array = [];
156 | for await (const item of (handle as any).values()) {
157 | const subList = await getFilesFromHandle(item);
158 | result.push(...subList);
159 | }
160 | return result;
161 | }
162 |
163 | return [];
164 | }
165 |
166 | /**
167 | * Get file suffix by lowercase
168 | * @param fileName
169 | */
170 | export function splitFileName(fileName: string) {
171 | const index = fileName.lastIndexOf(".");
172 | const name = fileName.substring(0, index);
173 | const suffix = fileName.substring(index + 1).toLowerCase();
174 | return { name, suffix };
175 | }
176 |
177 | /**
178 | * Get final file name if there exists a type convert
179 | * @param item
180 | * @param option
181 | * @returns
182 | */
183 | export function getOutputFileName(item: ImageItem, option: CompressOption) {
184 | if (item.blob.type === item.compress?.blob.type) {
185 | return item.name;
186 | }
187 |
188 | const { name, suffix } = splitFileName(item.name);
189 | let resultSuffix = suffix;
190 | for (const key in Mimes) {
191 | if (item.compress!.blob.type === Mimes[key]) {
192 | resultSuffix = key;
193 | break;
194 | }
195 | }
196 |
197 | if (["jpg", "jpeg"].includes(resultSuffix)) {
198 | resultSuffix = option.format.target?.toLowerCase() || resultSuffix;
199 | }
200 |
201 | return name + "." + resultSuffix;
202 | }
203 |
--------------------------------------------------------------------------------
/src/global.tsx:
--------------------------------------------------------------------------------
1 | import { makeAutoObservable } from "mobx";
2 | import { normalize } from "./functions";
3 | import { history } from "./router";
4 | import { LocaleData } from "./type";
5 | import { Initial } from "./Initial";
6 |
7 | export class GlobalState {
8 | public pathname: string = normalize(history.location.pathname);
9 | public page: null | React.ReactNode = ( );
10 | public lang: string = "en-US";
11 | public locale: LocaleData | null = null;
12 | public loading: boolean = false;
13 | constructor() {
14 | makeAutoObservable(this);
15 | }
16 | }
17 |
18 | export const gstate = new GlobalState();
19 |
--------------------------------------------------------------------------------
/src/locale.ts:
--------------------------------------------------------------------------------
1 | // https://www.techonthenet.com/js/language_tags.php
2 | import getUserLocale from "get-user-locale";
3 | import { gstate } from "./global";
4 | import { MenuProps } from "antd";
5 | import { locales } from "./modules";
6 |
7 | const localeCacheKey = "Pic-Smaller-Locale";
8 | const defaultLang = "en-US";
9 |
10 | export const langList: NonNullable = [
11 | { key: "en-US", label: "English" },
12 | { key: "tr-TR", label: "Türkçe" },
13 | { key: "fr-FR", label: "Français" },
14 | { key: "es-ES", label: "Español" },
15 | { key: "ko-KR", label: "한국인" },
16 | { key: "ja-JP", label: "日本語" },
17 | { key: "zh-TW", label: "繁體中文" },
18 | { key: "zh-CN", label: "简体中文" },
19 | { key: "fa-IR", label: "فارسی" },
20 | ];
21 |
22 | function getLang() {
23 | let lang = window.localStorage.getItem(localeCacheKey);
24 | if (!lang) {
25 | lang = getUserLocale();
26 | }
27 | return lang ?? defaultLang;
28 | }
29 |
30 | async function setLocaleData(lang: string) {
31 | let importer = locales[`/src/locales/${lang}.ts`];
32 | if (!importer) {
33 | importer = locales[`/src/locales/${defaultLang}.ts`];
34 | }
35 | gstate.locale = (await importer()).default;
36 | }
37 |
38 | export async function changeLang(lang: string) {
39 | gstate.lang = lang;
40 | window.localStorage.setItem(localeCacheKey, lang);
41 | await setLocaleData(lang);
42 | }
43 |
44 | export async function initLang() {
45 | gstate.lang = getLang();
46 | await setLocaleData(gstate.lang);
47 | }
48 |
--------------------------------------------------------------------------------
/src/locales/en-US.ts:
--------------------------------------------------------------------------------
1 | import { LocaleData } from "@/type";
2 | import enUS from "antd/locale/en_US";
3 |
4 | const localeData: LocaleData = {
5 | antLocale: enUS,
6 | logo: "Pic Smaller",
7 | initial: "Initializing",
8 | previewHelp:
9 | "Drag the dividing line to compare the compression effect: the left is the original image, the right is the compressed image",
10 | uploadCard: {
11 | title: "Select files here, support dragging files and folders",
12 | subTitle: "Open source batch image compression tool, supports %s format",
13 | },
14 | listAction: {
15 | batchAppend: "Batch append",
16 | addFolder: "Add folder",
17 | clear: "Clear all",
18 | downloadAll: "Save all",
19 | downloadOne: "Save image",
20 | removeOne: "Remove image",
21 | reCompress: "Recompress",
22 | },
23 | columnTitle: {
24 | status: "Status",
25 | name: "Name",
26 | preview: "Preview",
27 | size: "Size",
28 | dimension: "Dimension",
29 | decrease: "Decrease",
30 | action: "Action",
31 | newSize: "New size",
32 | newDimension: "New Dimension",
33 | },
34 | optionPannel: {
35 | failTip: "Cannot be smaller, please adjust the parameters and try again.",
36 | help: "Pic Smaller is a batch image compression application. Modifications to the options will be applied to all images.",
37 | resizeLable: "Resize image",
38 | jpegLable: "JPEG/WEBP parameters",
39 | pngLable: "PNG parameters",
40 | gifLable: "GIF parameters",
41 | avifLable: "AVIF parameters",
42 | resizePlaceholder: "Select adjustment mode",
43 | fitWidth: "Set width, height automatically scales",
44 | fitHeight: "Set height, width automatically scales",
45 | setShort: "Set short side, long side automatically scale",
46 | setLong: "Set long side, short side automatically scale",
47 | setCropRatio: "Crop mode, set the crop ratio",
48 | setCropSize: "Crop mode, set the crop size",
49 | cwRatioPlaceholder: "Set width ratio",
50 | chRatioPlaceholder: "Set height ratio",
51 | cwSizePlaceholder: "Set crop width",
52 | chSizePlaceholder: "Set crop height",
53 | widthPlaceholder: "Set the width of the output image",
54 | heightPlaceholder: "Set the height of the output image",
55 | shortPlaceholder: "Set short side length of the output image",
56 | longPlaceholder: "Set long side length of the output image",
57 | resetBtn: "Reset options",
58 | confirmBtn: "Apply options",
59 | qualityTitle: "Set output image quality (0-1)",
60 | colorsDesc: "Set the number of output colors (2-256)",
61 | pngDithering: "Set dithering coefficient (0-1)",
62 | gifDithering: "Turn on dithering",
63 | avifQuality: "Set output image quality (1-100)",
64 | avifSpeed: "Set compression speed (1-10)",
65 | outputFormat: "Set output format",
66 | outputFormatPlaceholder: "Select output image format",
67 | transparentFillDesc: "Choose a transparent fill color",
68 | cropCompareWarning: "Crop mode does not support comparison preview",
69 | },
70 | error404: {
71 | backHome: "Back to home",
72 | description: "Sorry, the page you visited does not exist~",
73 | },
74 | progress: {
75 | before: "Before compression",
76 | after: "After compression",
77 | rate: "Decrease ratio",
78 | },
79 | };
80 |
81 | export default localeData;
82 |
--------------------------------------------------------------------------------
/src/locales/es-ES.ts:
--------------------------------------------------------------------------------
1 | import { LocaleData } from "@/type";
2 | import esES from "antd/locale/es_ES";
3 |
4 | const localeData: LocaleData = {
5 | antLocale: esES,
6 | logo: "Pic Smaller",
7 | initial: "Inicializando",
8 | previewHelp:
9 | "Arrastra la línea divisoria para comparar el efecto de compresión: a la izquierda es la imagen original, a la derecha es la imagen comprimida",
10 | uploadCard: {
11 | title:
12 | "Selecciona tus archivos aquí, tambien puedes arrastrar archivos y carpetas",
13 | subTitle:
14 | "Herramienta de compresión de imágenes por lotes de código abierto, compatible con los formatos %s",
15 | },
16 | listAction: {
17 | batchAppend: "Añadir imagenes",
18 | addFolder: "Añadir carpeta",
19 | clear: "Eliminar todas",
20 | downloadAll: "Guardar todas",
21 | downloadOne: "Guardar imagen",
22 | removeOne: "Eliminar imagen",
23 | reCompress: "Recomprimir",
24 | },
25 | columnTitle: {
26 | status: "Estado",
27 | name: "Nombre",
28 | preview: "Miniatura",
29 | size: "Tamaño",
30 | dimension: "Resolución",
31 | decrease: "Compresión",
32 | action: "Acciones",
33 | newSize: "Nuevo tamaño",
34 | newDimension: "Nueva resolución",
35 | },
36 | optionPannel: {
37 | failTip:
38 | "Imposible de reducir más el tamaño, por favor ajusta los parámetros e inténtalo de nuevo.",
39 | help: "Pic Smaller es una aplicación de compresión de imágenes por lotes. Las modificaciones se aplicarán a todas las imágenes.",
40 | resizeLable: "Cambia el tamaño de la imagen",
41 | jpegLable: "Parámetros JPEG/WEBP",
42 | pngLable: "Parámetros PNG",
43 | gifLable: "Parámetros GIF",
44 | avifLable: "Parámetros AVIF",
45 | resizePlaceholder: "Selecciona el ajuste de tamaño",
46 | fitWidth: "Ajusta la anchura, la altura se escala automáticamente",
47 | fitHeight: "Ajusta la altura, la anchura se escala automáticamente",
48 | setShort:
49 | "Ajusta el lado más corto, el lado más largo se adaptará automáticamente",
50 | setLong:
51 | "Ajusta el lado más largo, el lado más corto se adaptará automáticamente",
52 | setCropRatio: "Modo de recorte, establecer proporción de recorte",
53 | setCropSize: "Modo de recorte, establecer tamaño de recorte",
54 | cwRatioPlaceholder: "Establecer relación de ancho",
55 | chRatioPlaceholder: "Establecer relación de altura",
56 | cwSizePlaceholder: "Establecer ancho de recorte",
57 | chSizePlaceholder: "Establecer altura de recorte",
58 | widthPlaceholder: "Ajusta la anchura de la imagen",
59 | heightPlaceholder: "Ajusta la altura de la imagen",
60 | shortPlaceholder: "Ajusta el lado mas corto de la imagen",
61 | longPlaceholder: "Ajusta el lado mas largo de la imagen",
62 | resetBtn: "Reiniciar ajustes",
63 | confirmBtn: "Aplicar ajustes",
64 | qualityTitle: "Calidad de imagen (0-1)",
65 | colorsDesc: "Número de colores de salida (2-256)",
66 | pngDithering: "Coeficiente de difuminado (0-1)",
67 | gifDithering: "Difuminado",
68 | avifQuality: "Calidad de imagen (1-100)",
69 | avifSpeed: "Velocidad de compresión (1-10)",
70 | outputFormat: "Formato de fichero",
71 | outputFormatPlaceholder: "Selecciona el formato de imagen",
72 | transparentFillDesc: "Elige un color de relleno transparente",
73 | cropCompareWarning:
74 | "El modo de recorte no admite la vista previa de comparación",
75 | },
76 | error404: {
77 | backHome: "Volver al inicio",
78 | description: "Lo siento, la página visitada no existe~",
79 | },
80 | progress: {
81 | before: "Antes de comprimir",
82 | after: "Después de comprimir",
83 | rate: "Índice de compresión",
84 | },
85 | };
86 |
87 | export default localeData;
88 |
--------------------------------------------------------------------------------
/src/locales/fa-IR.ts:
--------------------------------------------------------------------------------
1 | import { LocaleData } from "@/type";
2 | import faIR from "antd/locale/fa_IR";
3 |
4 | const localeData: LocaleData = {
5 | antLocale: faIR,
6 | logo: "پیک کوچولو",
7 | initial: "در حال راهاندازی",
8 | previewHelp:
9 | "خط تقسیم را برای مقایسه اثر فشرده سازی بکشید: سمت چپ تصویر اصلی و سمت راست تصویر فشرده است",
10 | uploadCard: {
11 | title:
12 | "فایلها را اینجا انتخاب کنید (پشتیبانی از روش کشیدن و انداختن فایلها و پوشهها)",
13 | subTitle: "ابزار فشردهسازی هوشمند دستهای تصاویر، پشتیبانی از فرمتهای %s",
14 | },
15 | listAction: {
16 | batchAppend: "افزودن دستهای",
17 | addFolder: "افزودن پوشه",
18 | clear: "پاک کردن همه",
19 | downloadAll: "ذخیره همه",
20 | downloadOne: "بارگذاری تصویر",
21 | removeOne: "پاک کردن تصویر",
22 | reCompress: "فشردهسازی مجدد",
23 | },
24 | columnTitle: {
25 | status: "وضعیت",
26 | name: "نام",
27 | preview: "پیشنمایش",
28 | size: "اندازه",
29 | dimension: "ابعاد",
30 | decrease: "کاهش",
31 | action: "عملیات",
32 | newSize: "اندازه جدید",
33 | newDimension: "ابعاد جدید",
34 | },
35 | optionPannel: {
36 | failTip:
37 | "کوچکتر از این نمیشود! لطفا پارامترها را تغییر دهید و دوباره امتحان کنید",
38 | help: "پیک کوچولو یک برنامه فشردهسازی دستهای تصاویر است. تغییرات در گزینهها به همه تصاویر اعمال خواهد شد.",
39 | resizeLable: "تغییر اندازه تصویر",
40 | jpegLable: "پارامترهای JPEG/WEBP",
41 | pngLable: "پارامترهای PNG",
42 | gifLable: "پارامترهای GIF",
43 | avifLable: "پارامترهای AVIF",
44 | resizePlaceholder: "حالت تنظیم را انتخاب کنید",
45 | fitWidth: "تنظیم عرض، ارتفاع به طور خودکار مقیاس میشود",
46 | fitHeight: "تنظیم ارتفاع، عرض به طور خودکار مقیاس میشود",
47 | setShort: "تنظیم ضلع کوتاه، ضلع بلند به طور خودکار مقیاس میشود",
48 | setLong: "تنظیم ضلع بلند، ضلع کوتاه به طور خودکار مقیاس میشود",
49 | setCropRatio: "حالت برش، نسبت برش را تنظیم کنید",
50 | setCropSize: "حالت برش، اندازه برش را تنظیم کنید",
51 | cwRatioPlaceholder: "نسبت عرض را تنظیم کنید",
52 | chRatioPlaceholder: "نسبت ارتفاع را تنظیم کنید",
53 | cwSizePlaceholder: "عرض برش را تنظیم کنید",
54 | chSizePlaceholder: "ارتفاع برش را تنظیم کنید",
55 | widthPlaceholder: "عرض تصویر خروجی را تنظیم کنید",
56 | heightPlaceholder: "ارتفاع تصویر خروجی را تنظیم کنید",
57 | shortPlaceholder: "طول ضلع کوتاه تصویر خروجی را تنظیم کنید",
58 | longPlaceholder: "طول ضلع بلند تصویر خروجی را تنظیم کنید",
59 | resetBtn: "بازنشانی گزینهها",
60 | confirmBtn: "اعمال گزینهها",
61 | qualityTitle: "تنظیم کیفیت تصویر خروجی (0-1)",
62 | colorsDesc: "تنظیم تعداد رنگهای خروجی (2-256)",
63 | pngDithering: "تنظیم ضریب دانهبندی (0-1)",
64 | gifDithering: "فعال کردن دانهبندی",
65 | avifQuality: "تنظیم کیفیت تصویر خروجی (1-100)",
66 | avifSpeed: "تنظیم سرعت فشردهسازی (1-10)",
67 | outputFormat: "تنظیم فرمت خروجی",
68 | outputFormatPlaceholder: "فرمت تصویر خروجی را انتخاب کنید",
69 | transparentFillDesc: "انتخاب رنگ شفاف",
70 | cropCompareWarning: "حالت برش از پیشنمایش مقایسه پشتیبانی نمیکند",
71 | },
72 | error404: {
73 | backHome: "بازگشت به خانه",
74 | description: "متاسفانه صفحهای که بازدید کردید وجود ندارد",
75 | },
76 | progress: {
77 | before: "قبل از فشردهسازی",
78 | after: "بعد از فشردهسازی",
79 | rate: "نسبت کاهش",
80 | },
81 | };
82 |
83 | export default localeData;
84 |
--------------------------------------------------------------------------------
/src/locales/fr-FR.ts:
--------------------------------------------------------------------------------
1 | import { LocaleData } from "@/type";
2 | import frFR from "antd/locale/fr_FR";
3 |
4 | const localeData: LocaleData = {
5 | antLocale: frFR,
6 | logo: "Pic Smaller",
7 | initial: "Initialisation",
8 | previewHelp:
9 | "Faites glisser la ligne de séparation pour comparer l'effet de compression : l'image de gauche est l'image originale, celle de droite est l'image compressée",
10 | uploadCard: {
11 | title: "Selectionnez ou glissez-déposez vos fichiers et dossiers ici",
12 | subTitle:
13 | "Outil open source de compression d'images par lot, prend en charge les formats %s",
14 | },
15 | listAction: {
16 | batchAppend: "Ajouter des fichiers",
17 | addFolder: "Ajouter dossier",
18 | clear: "Tout retirer",
19 | downloadAll: "Tout sauvegarder",
20 | downloadOne: "Sauvegarder l'image",
21 | removeOne: "Retirer l'image",
22 | reCompress: "Relancer compression",
23 | },
24 | columnTitle: {
25 | status: "Status",
26 | name: "Nom",
27 | preview: "Aperçu",
28 | size: "Taille",
29 | dimension: "Dimensions",
30 | decrease: "Réduction",
31 | action: "Action",
32 | newSize: "Nouvelle taille",
33 | newDimension: "Nouvelles dimensions",
34 | },
35 | optionPannel: {
36 | failTip:
37 | "Impossible de réduire la taille, veuillez ajuster les paramètres et réessayer.",
38 | help: "Pic Smaller est une application de compression d'images par lot. Les modifications apportées aux options seront appliquées à toutes les images.",
39 | resizeLable: "Redimensionner l'image",
40 | jpegLable: "Paramètres JPEG/WEBP",
41 | pngLable: "Paramètres PNG",
42 | gifLable: "Paramètres GIF",
43 | avifLable: "Paramètres AVIF",
44 | resizePlaceholder: "Sélectionner le mode d'ajustement",
45 | fitWidth: "Régler la largeur, la hauteur s'ajuste automatiquement",
46 | fitHeight: "Régler la hauteur, la largeur s'ajuste automatiquement",
47 | setShort: "Régler le petit côté, le long côté s'ajuste automatiquement",
48 | setLong: "Régler le long côté, le petit côté s'ajuste automatiquement",
49 | setCropRatio: "Mode de recadrage, définir le rapport de recadrage",
50 | setCropSize: "Mode recadrage, définir la taille du recadrage",
51 | cwRatioPlaceholder: "Définir le rapport de largeur",
52 | chRatioPlaceholder: "Définir le rapport de hauteur",
53 | cwSizePlaceholder: "Définir la largeur du recadrage",
54 | chSizePlaceholder: "Définir la hauteur de recadrage",
55 | widthPlaceholder: "Largeur de l'image de sortie",
56 | heightPlaceholder: "Hauteur de l'image de sortie",
57 | shortPlaceholder: "Longueur du petit côté de l'image de sortie",
58 | longPlaceholder: "Longueur du côté long de l'image de sortie",
59 | resetBtn: "Réinitialiser",
60 | confirmBtn: "Appliquer",
61 | qualityTitle: "Qualité de l'image de sortie (0-1)",
62 | colorsDesc: "Nombre de couleurs de sortie (2-256)",
63 | pngDithering: "Coefficient de tramage (0-1)",
64 | gifDithering: "Activer le tramage",
65 | avifQuality: "Qualité de l'image de sortie (1-100)",
66 | avifSpeed: "Vitesse de compression (1-10)",
67 | outputFormat: "Format de sortie",
68 | outputFormatPlaceholder: "Format de l'image de sortie",
69 | transparentFillDesc: "Couleur de remplissage transparente",
70 | cropCompareWarning:
71 | "Le mode Recadrage ne prend pas en charge l'aperçu de comparaison",
72 | },
73 | error404: {
74 | backHome: "Retour à l'accueil",
75 | description: "Désolé, la page que vous avez visitée n'existe pas~",
76 | },
77 | progress: {
78 | before: "Avant compression",
79 | after: "Après compression",
80 | rate: "Taux de diminution",
81 | },
82 | };
83 |
84 | export default localeData;
85 |
--------------------------------------------------------------------------------
/src/locales/ja-JP.ts:
--------------------------------------------------------------------------------
1 | // 日语
2 |
3 | import { LocaleData } from "@/type";
4 | import jaJP from "antd/locale/ja_JP";
5 |
6 | const localeData: LocaleData = {
7 | antLocale: jaJP,
8 | logo: "Pic Smaller",
9 | initial: "初期化中",
10 | previewHelp:
11 | "分割線をドラッグして圧縮効果を比較します。左が元の画像、右が圧縮された画像です",
12 | uploadCard: {
13 | title:
14 | "ここでファイルを選択し、ファイルとフォルダーのドラッグをサポートします",
15 | subTitle: "オープンソースのバッチ画像圧縮ツール、%s 形式をサポート",
16 | },
17 | listAction: {
18 | batchAppend: "バッチ追加",
19 | addFolder: "フォルダーを追加",
20 | clear: "リストをクリア",
21 | downloadAll: "すべて保存",
22 | downloadOne: "画像を保存",
23 | removeOne: "画像を削除",
24 | reCompress: "再圧縮",
25 | },
26 | columnTitle: {
27 | status: "ステータス",
28 | name: "ファイル名",
29 | preview: "プレビュー",
30 | size: "サイズ",
31 | dimension: "サイズ",
32 | decrease: "圧縮率",
33 | action: "アクション",
34 | newSize: "新しいサイズ",
35 | newDimension: "新しいディメンション",
36 | },
37 | optionPannel: {
38 | failTip:
39 | "小さくすることができません。パラメータを調整して再試行してください。",
40 | help: "Pic Smaller はバッチ画像圧縮アプリケーションです。オプションの変更はすべての画像に適用されます。",
41 | resizeLable: "画像のサイズを変更する",
42 | jpegLable: "JPEG/WEBPパラメータ",
43 | pngLable: "PNG パラメータ",
44 | gifLable: "GIF パラメータ",
45 | avifLable: "AVIF パラメータ",
46 | resizePlaceholder: "調整モードの選択",
47 | fitWidth: "幅と高さを自動的に調整します",
48 | fitHeight: "高さと幅を自動的に調整します",
49 | setShort: "短辺と長辺を自動的に調整します",
50 | setLong: "長辺と短辺を自動的に調整します",
51 | setCropRatio: "クロップモード、クロップ率の設定",
52 | setCropSize: "切り抜きモード、切り抜きサイズを設定",
53 | cwRatioPlaceholder: "幅の比率を設定",
54 | chRatioPlaceholder: "高さの比率を設定",
55 | cwSizePlaceholder: "切り抜き幅を設定",
56 | chSizePlaceholder: "トリミングの高さを設定",
57 | widthPlaceholder: "出力画像の幅を設定します",
58 | heightPlaceholder: "出力画像の高さを設定します",
59 | shortPlaceholder: "出力画像の短辺の長さを設定する",
60 | longPlaceholder: "出力画像の長辺の長さを設定する",
61 | resetBtn: "オプションをリセット",
62 | confirmBtn: "オプションを適用",
63 | qualityTitle: "出力画質を設定します(0-1)",
64 | colorsDesc: "出力色の数を設定します (2-256)",
65 | pngDithering: "ディザリング係数を設定します (0-1)",
66 | gifDithering: "ディザリングをオンにする",
67 | avifQuality: "出力画質を設定します (1-100)",
68 | avifSpeed: "圧縮速度を設定します (1-10)",
69 | outputFormat: "出力形式を設定する",
70 | outputFormatPlaceholder: "出力画像フォーマットの選択",
71 | transparentFillDesc: "透明な塗りつぶしの色を選択します",
72 | cropCompareWarning: "クロップ モードは比較プレビューをサポートしていません",
73 | },
74 | error404: {
75 | backHome: "ホームページに戻る",
76 | description: "申し訳ありませんが、アクセスしたページは存在しません~",
77 | },
78 | progress: {
79 | before: "圧縮前",
80 | after: "圧縮後",
81 | rate: "圧縮率",
82 | },
83 | };
84 |
85 | export default localeData;
86 |
--------------------------------------------------------------------------------
/src/locales/ko-KR.ts:
--------------------------------------------------------------------------------
1 | // 韩语
2 |
3 | import { LocaleData } from "@/type";
4 | import koKR from "antd/locale/ko_KR";
5 |
6 | const localeData: LocaleData = {
7 | antLocale: koKR,
8 | logo: "Pic Smaller",
9 | initial: "초기화 중",
10 | previewHelp:
11 | "압축 효과를 비교하려면 구분선을 드래그하세요. 왼쪽은 원본 이미지, 오른쪽은 압축된 이미지입니다.",
12 | uploadCard: {
13 | title: "여기에서 파일을 선택하고 파일 및 폴더 끌기를 지원합니다.",
14 | subTitle: "오픈 소스 배치 이미지 압축 도구, %s 형식 지원",
15 | },
16 |
17 | listAction: {
18 | batchAppend: "일괄 추가",
19 | addFolder: "폴더 추가",
20 | clear: "목록 지우기",
21 | downloadAll: "모두 저장",
22 | downloadOne: "이미지 저장",
23 | removeOne: "사진 제거",
24 | reCompress: "재압축",
25 | },
26 | columnTitle: {
27 | status: "상태",
28 | name: "파일 이름",
29 | preview: "미리보기",
30 | size: "크기",
31 | dimension: "크기",
32 | decrease: "압축 비율",
33 | action: "액션",
34 | newSize: "새 크기",
35 | newDimension: "새 차원",
36 | },
37 | optionPannel: {
38 | failTip: "더 작게 만들 수 없습니다. 매개변수를 조정하고 다시 시도하세요.",
39 | help: "Pic Smaller는 옵션에 대한 수정 사항이 모든 이미지에 적용되는 일괄 이미지 압축 응용 프로그램입니다.",
40 | resizeLable: "이미지 크기 조정",
41 | jpegLable: "JPEG/WEBP 매개변수",
42 | pngLable: "PNG 매개변수",
43 | gifLable: "GIF 매개변수",
44 | avifLable: "AVIF 매개변수",
45 | resizePlaceholder: "조정 모드 선택",
46 | fitWidth: "너비, 높이는 자동으로 조정됩니다.",
47 | fitHeight: "높이 설정, 너비 자동 조정",
48 | setShort: "짧은 쪽, 긴 쪽은 자동으로 크기 조절 설정",
49 | setLong: "긴 쪽, 짧은 쪽 자동 크기 조정",
50 | setCropRatio: "자르기 모드, 자르기 비율 설정",
51 | setCropSize: "자르기 모드, 자르기 크기 설정",
52 | cwRatioPlaceholder: "너비 비율 설정",
53 | chRatioPlaceholder: "높이 비율 설정",
54 | cwSizePlaceholder: "자르기 너비 설정",
55 | chSizePlaceholder: "자르기 높이 설정",
56 | widthPlaceholder: "출력 이미지의 너비를 설정합니다",
57 | heightPlaceholder: "출력 이미지의 높이를 설정합니다",
58 | shortPlaceholder: "출력 이미지의 짧은 쪽 길이를 설정합니다",
59 | longPlaceholder: "출력 이미지의 긴 쪽 길이를 설정합니다",
60 | resetBtn: "재설정 옵션",
61 | confirmBtn: "옵션 적용",
62 | qualityTitle: "출력 이미지 품질 설정(0-1)",
63 | colorsDesc: "출력 색상 수 설정(2-256)",
64 | pngDithering: "디더링 계수 설정(0-1)",
65 | gifDithering: "디더링 켜기",
66 | avifQuality: "출력 이미지 품질 설정(1-100)",
67 | avifSpeed: "압축 속도 설정(1-10)",
68 | outputFormat: "출력 형식 설정",
69 | outputFormatPlaceholder: "출력 이미지 형식 선택",
70 | transparentFillDesc: "투명한 채우기 색상 선택",
71 | cropCompareWarning: "자르기 모드는 비교 미리보기를 지원하지 않습니다.",
72 | },
73 | error404: {
74 | backHome: "홈 페이지로 돌아가기",
75 | description: "죄송합니다. 방문하신 페이지는 존재하지 않습니다~",
76 | },
77 | progress: {
78 | before: "압축 전",
79 | after: "압축 후",
80 | rate: "압축률",
81 | },
82 | };
83 |
84 | export default localeData;
85 |
--------------------------------------------------------------------------------
/src/locales/tr-TR.ts:
--------------------------------------------------------------------------------
1 | import { LocaleData } from "@/type";
2 | import trTR from "antd/locale/tr_TR";
3 |
4 | const localeData: LocaleData = {
5 | antLocale: trTR,
6 | logo: "Pic Smaller",
7 | initial: "Başlatılıyor",
8 | previewHelp:
9 | "Sıkıştırma etkisini karşılaştırmak için bölme çizgisini sürükleyin: soldaki orijinal görüntü, sağdaki sıkıştırılmış görüntü",
10 | uploadCard: {
11 | title: "Dosyaları buradan seçin, dosya ve klasör sürüklemeyi destekler",
12 | subTitle:
13 | "Açık kaynaklı toplu resim sıkıştırma aracı, %s formatını destekler",
14 | },
15 | listAction: {
16 | batchAppend: "Toplu ekle",
17 | addFolder: "Klasör ekle",
18 | clear: "Hepsini temizle",
19 | downloadAll: "Hepsini İndir",
20 | downloadOne: "İndir",
21 | removeOne: "Sil",
22 | reCompress: "Yeniden sıkıştır",
23 | },
24 | columnTitle: {
25 | status: "Durum",
26 | name: "İsim",
27 | preview: "Önizleme",
28 | size: "Boyut",
29 | dimension: "Boyut",
30 | decrease: "Sıkıştır",
31 | action: "Eylem",
32 | newSize: "Yeni boyut",
33 | newDimension: "Yeni boyutlar",
34 | },
35 | optionPannel: {
36 | failTip:
37 | "Daha küçük olamaz, lütfen parametreleri ayarlayın ve tekrar deneyin.",
38 | help: "Pic Smaller, toplu resim sıkıştırma uygulamasıdır. Seçeneklerde yapılan değişiklikler tüm resimlere uygulanacaktır.",
39 | resizeLable: "Görüntüyü yeniden boyutlandır",
40 | jpegLable: "JPEG/WEBP parametreleri",
41 | pngLable: "PNG parametreleri",
42 | gifLable: "GIF parametreleri",
43 | avifLable: "AVIF parametreleri",
44 | resizePlaceholder: "Ayarlama modunu seçin",
45 | fitWidth: "Genişliği ayarla, yükseklik otomatik ayarlanır",
46 | fitHeight: "Yüksekliği ayarla, genişlik otomatik ayarlanır",
47 | setShort: "Kısa kenarı ayarla, uzun kenar otomatik ayarlanır",
48 | setLong: "Uzun kenarı ayarla, kısa kenar otomatik ayarlanır",
49 | setCropRatio: "Kırpma modu, kırpma oranını ayarlayın",
50 | setCropSize: "Kırpma modu, kırpma boyutunu ayarla",
51 | cwRatioPlaceholder: "Genişlik oranını ayarla",
52 | chRatioPlaceholder: "Yükseklik oranını ayarla",
53 | cwSizePlaceholder: "Kırpma genişliğini ayarla",
54 | chSizePlaceholder: "Kırpma yüksekliğini ayarla",
55 | widthPlaceholder: "Çıktının genişliğini ayarlayın",
56 | heightPlaceholder: "Çıktının yüksekliğini ayarlayın",
57 | shortPlaceholder: "Çıktının kısa kenar uzunluğunu ayarlayın",
58 | longPlaceholder: "Çıktının uzun kenar uzunluğunu ayarlayın",
59 | resetBtn: "Seçenekleri sıfırla",
60 | confirmBtn: "Seçenekleri uygula",
61 | qualityTitle: "Çıktının kalitesini ayarla (0-1)",
62 | colorsDesc: "Çıktınun renk sayısını ayarla (2-256)",
63 | pngDithering: "Dithering katsayısını ayarla (0-1)",
64 | gifDithering: "Dithering'i aç",
65 | avifQuality: "Çıktının kalitesini ayarla (1-100)",
66 | avifSpeed: "Sıkıştırma hızını ayarla (1-10)",
67 | outputFormat: "Çıktı formatını ayarla",
68 | outputFormatPlaceholder: "Çıktı formatını seçin",
69 | transparentFillDesc: "Şeffaflık rengini seçin",
70 | cropCompareWarning: "Kırpma modu karşılaştırma önizlemesini desteklemiyor",
71 | },
72 | error404: {
73 | backHome: "Ana sayfaya dön",
74 | description: "Üzgünüz, ziyaret ettiğiniz sayfa mevcut değil~",
75 | },
76 | progress: {
77 | before: "Sıkıştırmadan önce",
78 | after: "Sıkıştırmadan sonra",
79 | rate: "Sıkıştırma oranı",
80 | },
81 | };
82 |
83 | export default localeData;
84 |
--------------------------------------------------------------------------------
/src/locales/zh-CN.ts:
--------------------------------------------------------------------------------
1 | import { LocaleData } from "@/type";
2 | import zhCN from "antd/locale/zh_CN";
3 |
4 | const localeData: LocaleData = {
5 | antLocale: zhCN,
6 | logo: "图小小",
7 | initial: "初始化中",
8 | previewHelp: "拖动分割线对比压缩效果:左边是原始图,右边是压缩图",
9 | uploadCard: {
10 | title: "选取文件到这里,支持拖拽文件和文件夹",
11 | subTitle: "开源的批量图片压缩工具,支持 %s 格式",
12 | },
13 | listAction: {
14 | batchAppend: "批量添加",
15 | addFolder: "添加文件夹",
16 | clear: "清空列表",
17 | downloadAll: "保存全部",
18 | downloadOne: "保存图片",
19 | removeOne: "移除图片",
20 | reCompress: "重新压缩",
21 | },
22 | columnTitle: {
23 | status: "状态",
24 | name: "文件名",
25 | preview: "预览",
26 | size: "大小",
27 | dimension: "尺寸",
28 | decrease: "压缩率",
29 | action: "操作",
30 | newSize: "新大小",
31 | newDimension: "新尺寸",
32 | },
33 | optionPannel: {
34 | failTip: "无法更小,请调整参数后重试",
35 | help: "图小小是一款批量图片压缩应用程序,对选项的修改将应用到所有图片上",
36 | resizeLable: "调整图片尺寸",
37 | jpegLable: "JPEG/WEBP参数",
38 | pngLable: "PNG参数",
39 | gifLable: "GIF参数",
40 | avifLable: "AVIF参数",
41 | resizePlaceholder: "选择调整模式",
42 | fitWidth: "设置宽度,高度自动缩放",
43 | fitHeight: "设置高度,宽度自动缩放",
44 | setShort: "设置短边,长边自动缩放",
45 | setLong: "设置长边,短边自动缩放",
46 | setCropRatio: "裁剪模式,设置裁剪比例",
47 | setCropSize: "裁剪模式,设置裁剪尺寸",
48 | cwRatioPlaceholder: "设置宽度比例",
49 | chRatioPlaceholder: "设置高度比例",
50 | cwSizePlaceholder: "设置裁剪宽度",
51 | chSizePlaceholder: "设置裁剪高度",
52 | widthPlaceholder: "设置输出图片宽度",
53 | heightPlaceholder: "设置输出图片高度",
54 | shortPlaceholder: "设置输出图片短边长度",
55 | longPlaceholder: "设置输出图片长边长度",
56 | resetBtn: "重置选项",
57 | confirmBtn: "应用选项",
58 | qualityTitle: "设置输出图片质量(0-1)",
59 | colorsDesc: "设置输出颜色数量(2-256)",
60 | pngDithering: "设置抖色系数(0-1)",
61 | gifDithering: "开启抖色",
62 | avifQuality: "设置输出图片质量(1-100)",
63 | avifSpeed: "设置压缩速度(1-10)",
64 | outputFormat: "设置输出格式",
65 | outputFormatPlaceholder: "选择输出图片格式",
66 | transparentFillDesc: "选择透明填充色",
67 | cropCompareWarning: "裁剪模式不支持对比预览",
68 | },
69 | error404: {
70 | backHome: "返回首页",
71 | description: "抱歉,你访问的页面不存在~",
72 | },
73 | progress: {
74 | before: "压缩前",
75 | after: "压缩后",
76 | rate: "压缩率",
77 | },
78 | };
79 |
80 | export default localeData;
81 |
--------------------------------------------------------------------------------
/src/locales/zh-TW.ts:
--------------------------------------------------------------------------------
1 | // 台湾繁体
2 |
3 | import { LocaleData } from "@/type";
4 | import zhTW from "antd/locale/zh_TW";
5 |
6 | const localeData: LocaleData = {
7 | antLocale: zhTW,
8 | logo: "圖小小",
9 | initial: "初始化中",
10 | previewHelp: "拖曳分割線對比壓縮效果:左邊是原始圖,右邊是壓縮圖",
11 | uploadCard: {
12 | title: "選取文件到這裡,支援拖曳文件和資料夾",
13 | subTitle: "開源的批量圖片壓縮工具,支援 %s 格式",
14 | },
15 | listAction: {
16 | batchAppend: "大量新增",
17 | addFolder: "新增資料夾",
18 | clear: "清空清單",
19 | downloadAll: "儲存全部",
20 | downloadOne: "儲存圖片",
21 | removeOne: "移除圖片",
22 | reCompress: "重新壓縮",
23 | },
24 | columnTitle: {
25 | status: "狀態",
26 | name: "檔案名稱",
27 | preview: "預覽",
28 | size: "大小",
29 | dimension: "尺寸",
30 | decrease: "壓縮率",
31 | action: "操作",
32 | newSize: "新大小",
33 | newDimension: "新尺寸",
34 | },
35 | optionPannel: {
36 | failTip: "無法更小,請調整參數後重試",
37 | help: "Pic Smaller是一款大量圖片壓縮應用,對選項的修改將套用到所有圖片上",
38 | resizeLable: "調整圖片尺寸",
39 | jpegLable: "JPEG/WEBP參數",
40 | pngLable: "PNG參數",
41 | gifLable: "GIF參數",
42 | avifLable: "AVIF參數",
43 | resizePlaceholder: "選擇調整模式",
44 | fitWidth: "設定寬度,高度自動縮放",
45 | fitHeight: "設定高度,寬度自動縮放",
46 | setShort: "設定短邊,長邊自動縮放",
47 | setLong: "設定長邊,短邊自動縮放",
48 | setCropRatio: "裁切模式,設定裁切比例",
49 | setCropSize: "裁切模式,設定裁切尺寸",
50 | cwRatioPlaceholder: "設定寬度比例",
51 | chRatioPlaceholder: "設定高度比例",
52 | cwSizePlaceholder: "設定裁切寬度",
53 | chSizePlaceholder: "設定裁切高度",
54 | widthPlaceholder: "設定輸出圖片寬度",
55 | heightPlaceholder: "設定輸出圖片高度",
56 | shortPlaceholder: "設定輸出圖片短邊長度",
57 | longPlaceholder: "設定輸出圖片長邊長度",
58 | resetBtn: "重置選項",
59 | confirmBtn: "應用選項",
60 | qualityTitle: "設定輸出圖片品質(0-1)",
61 | colorsDesc: "設定輸出顏色數量(2-256)",
62 | pngDithering: "設定抖色係數(0-1)",
63 | gifDithering: "開啟抖色",
64 | avifQuality: "設定輸出圖片品質(1-100)",
65 | avifSpeed: "設定壓縮速度(1-10)",
66 | outputFormat: "設定輸出格式",
67 | outputFormatPlaceholder: "選擇輸出圖片格式",
68 | transparentFillDesc: "選擇透明填充色",
69 | cropCompareWarning: "裁切模式不支援比較預覽",
70 | },
71 | error404: {
72 | backHome: "返回首頁",
73 | description: "抱歉,你造訪的頁面不存在~",
74 | },
75 | progress: {
76 | before: "壓縮前",
77 | after: "壓縮後",
78 | rate: "壓縮率",
79 | },
80 | };
81 |
82 | export default localeData;
83 |
--------------------------------------------------------------------------------
/src/main.scss:
--------------------------------------------------------------------------------
1 | @import "antd/dist/reset.css";
2 |
3 | .__initial {
4 | height: 100vh;
5 | img {
6 | width: 30px;
7 | }
8 | span {
9 | margin-top: 10px;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import "./main.scss";
2 | import { configure } from "mobx";
3 | import ReactDOM from "react-dom/client";
4 | import { initLang } from "./locale";
5 | import { App } from "./App";
6 |
7 | window.onload = async () => {
8 | await initLang();
9 | configure({
10 | enforceActions: "never",
11 | useProxies: "ifavailable",
12 | });
13 |
14 | const root = document.getElementById("root")!;
15 | ReactDOM.createRoot(root).render( );
16 | };
17 |
--------------------------------------------------------------------------------
/src/media.ts:
--------------------------------------------------------------------------------
1 | import { useMediaQuery } from "react-responsive";
2 |
3 | export function useResponse() {
4 | const isMobile = useMediaQuery({ maxWidth: 719 });
5 | const isPad = useMediaQuery({ minWidth: 720, maxWidth: 1279 });
6 | const isPC = useMediaQuery({ minWidth: 1280 });
7 |
8 | return { isMobile, isPad, isPC };
9 | }
10 |
--------------------------------------------------------------------------------
/src/mimes.ts:
--------------------------------------------------------------------------------
1 | // 支持的图片类型
2 | export const Mimes: Record = {
3 | jpg: "image/jpeg",
4 | jpeg: "image/jpeg",
5 | png: "image/png",
6 | webp: "image/webp",
7 | gif: "image/gif",
8 | svg: "image/svg+xml",
9 | };
10 |
--------------------------------------------------------------------------------
/src/modules.ts:
--------------------------------------------------------------------------------
1 | import type { LocaleData } from "@/type";
2 | export const modules = import.meta.glob<{ default: React.FC }>(
3 | "@/pages/**/index.tsx",
4 | );
5 | export const locales = import.meta.glob<{ default: LocaleData }>(
6 | "@/locales/*.ts",
7 | );
8 |
--------------------------------------------------------------------------------
/src/pages/error404/index.module.scss:
--------------------------------------------------------------------------------
1 | .container {
2 | height: 100vh;
3 | width: 100vw;
4 | }
5 |
--------------------------------------------------------------------------------
/src/pages/error404/index.tsx:
--------------------------------------------------------------------------------
1 | import { goto } from "@/router";
2 | import style from "./index.module.scss";
3 | import { Button, Flex, Result } from "antd";
4 | import { observer } from "mobx-react-lite";
5 | import { gstate } from "@/global";
6 |
7 | const Error404 = observer(() => {
8 | const backToHome = (
9 | {
12 | goto("/", null, "replace");
13 | }}
14 | >
15 | {gstate.locale?.error404.backHome}
16 |
17 | );
18 |
19 | return (
20 |
21 |
27 |
28 | );
29 | });
30 |
31 | export default Error404;
32 |
--------------------------------------------------------------------------------
/src/pages/home/LeftContent.module.scss:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joye61/pic-smaller/7ddf0508452372d25ff9a3c197e0debafef602fb/src/pages/home/LeftContent.module.scss
--------------------------------------------------------------------------------
/src/pages/home/LeftContent.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Flex, Space, Table, Tooltip } from "antd";
2 | import style from "./index.module.scss";
3 | import { observer } from "mobx-react-lite";
4 | import {
5 | ClearOutlined,
6 | DownloadOutlined,
7 | FolderAddOutlined,
8 | PlusOutlined,
9 | ReloadOutlined,
10 | } from "@ant-design/icons";
11 | import { useCallback, useEffect, useRef, useState } from "react";
12 | import { ImageInput } from "@/components/ImageInput";
13 | import { gstate } from "@/global";
14 | import { homeState } from "@/states/home";
15 | import {
16 | createDownload,
17 | getFilesFromHandle,
18 | getOutputFileName,
19 | getUniqNameOnNames,
20 | } from "@/functions";
21 | import { ProgressHint } from "@/components/ProgressHint";
22 | import { createImageList } from "@/engines/transform";
23 | import { useColumn } from "./useColumn";
24 | import { useResponse } from "@/media";
25 |
26 | export const LeftContent = observer(() => {
27 | const { isMobile } = useResponse();
28 | const disabled = homeState.hasTaskRunning();
29 | const fileRef = useRef(null);
30 | const columns = useColumn(disabled);
31 |
32 | const scrollBoxRef = useRef(null);
33 | const [scrollHeight, setScrollHeight] = useState(0);
34 | const resize = useCallback(() => {
35 | const element = scrollBoxRef.current;
36 | if (element) {
37 | const boxHeight = element.getBoundingClientRect().height;
38 | const th = document.querySelector(".ant-table-thead");
39 | const tbody = document.querySelector(".ant-table-tbody");
40 | const thHeight = th?.getBoundingClientRect().height ?? 0;
41 | const tbodyHeight = tbody?.getBoundingClientRect().height ?? 0;
42 | if (boxHeight > thHeight + tbodyHeight) {
43 | setScrollHeight(0);
44 | } else {
45 | setScrollHeight(boxHeight - thHeight);
46 | }
47 | }
48 | }, []);
49 |
50 | /* eslint-disable react-hooks/exhaustive-deps */
51 | // Everytime list change, recalc the scroll height
52 | useEffect(resize, [homeState.list.size]);
53 |
54 | useEffect(() => {
55 | window.addEventListener("resize", resize);
56 | return () => {
57 | window.removeEventListener("resize", resize);
58 | };
59 | }, [resize]);
60 |
61 | return (
62 |
63 |
64 |
65 | }
68 | type="primary"
69 | onClick={() => {
70 | fileRef.current?.click();
71 | }}
72 | >
73 | {!isMobile && gstate.locale?.listAction.batchAppend}
74 |
75 | {window.showDirectoryPicker && (
76 | }
79 | type="primary"
80 | onClick={async () => {
81 | const handle = await window.showDirectoryPicker!();
82 | const result = await getFilesFromHandle(handle);
83 | await createImageList(result);
84 | }}
85 | >
86 | {!isMobile && gstate.locale?.listAction.addFolder}
87 |
88 | )}
89 |
90 |
91 |
92 | }
95 | onClick={async () => {
96 | homeState.reCompress();
97 | }}
98 | />
99 |
100 | }
103 | onClick={() => {
104 | homeState.clear();
105 | }}
106 | >
107 | {!isMobile && gstate.locale?.listAction.clear}
108 |
109 | }
111 | type="primary"
112 | disabled={disabled}
113 | onClick={async () => {
114 | gstate.loading = true;
115 | const jszip = await import("jszip");
116 | const zip = new jszip.default();
117 | const names: Set = new Set();
118 | /* eslint-disable @typescript-eslint/no-unused-vars */
119 | for (const [_, info] of homeState.list) {
120 | const fileName = getOutputFileName(info, homeState.option);
121 | const uniqName = getUniqNameOnNames(names, fileName);
122 | names.add(uniqName);
123 | if (info.compress?.blob) {
124 | zip.file(uniqName, info.compress.blob);
125 | }
126 | }
127 | const result = await zip.generateAsync({
128 | type: "blob",
129 | compression: "DEFLATE",
130 | compressionOptions: {
131 | level: 6,
132 | },
133 | });
134 | createDownload("picsmaller.zip", result);
135 | gstate.loading = false;
136 | }}
137 | >
138 | {!isMobile && gstate.locale?.listAction.downloadAll}
139 |
140 |
141 |
142 |
143 |
152 |
153 |
154 |
155 |
156 | );
157 | });
158 |
--------------------------------------------------------------------------------
/src/pages/home/RightOption.module.scss:
--------------------------------------------------------------------------------
1 | $border: 1px solid #dfdfdf;
2 |
3 | .sidePc {
4 | width: 380px;
5 | border-left: $border;
6 | flex-shrink: 0;
7 | }
8 |
9 | .sidePad {
10 | width: 380px;
11 | }
12 | .sideMobile {
13 | width: 100%;
14 | }
15 |
16 | .side {
17 | $h1: 60px;
18 | height: 100%;
19 | overflow: hidden;
20 | background-color: #fff;
21 | > div:nth-child(1) {
22 | height: $h1;
23 | border-bottom: $border;
24 | background-color: #fafafa;
25 | padding: 0 16px;
26 | }
27 | > div:nth-child(2) {
28 | padding: 0 16px;
29 | overflow-y: auto;
30 | flex: 1;
31 | height: calc(100% - $h1);
32 | }
33 | }
34 |
35 | .optionHelp {
36 | cursor: pointer;
37 | }
38 | .optionHelpBox {
39 | width: 320px;
40 | }
41 |
--------------------------------------------------------------------------------
/src/pages/home/RightOption.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Drawer, Flex, Popover, Space, Typography } from "antd";
2 | import style from "./RightOption.module.scss";
3 | import { observer } from "mobx-react-lite";
4 | import {
5 | CaretRightOutlined,
6 | ExclamationCircleOutlined,
7 | ReloadOutlined,
8 | } from "@ant-design/icons";
9 | import { gstate } from "@/global";
10 | import { CompressOption } from "@/components/CompressOption";
11 | import { DefaultCompressOption, homeState } from "@/states/home";
12 | import { toJS } from "mobx";
13 | import { useResponse } from "@/media";
14 | import classNames from "classnames";
15 |
16 | export const RightOption = observer(() => {
17 | const disabled = homeState.hasTaskRunning();
18 |
19 | const { isPad, isPC } = useResponse();
20 | const option = (
21 | <>
22 |
23 |
27 |
28 | {gstate.locale?.optionPannel.help}
29 |
30 |
31 | }
32 | >
33 |
34 |
35 |
36 |
37 |
38 | }
41 | onClick={async () => {
42 | homeState.showOption = false;
43 | homeState.tempOption = { ...DefaultCompressOption };
44 | homeState.option = { ...DefaultCompressOption };
45 | homeState.reCompress();
46 | }}
47 | >
48 | {gstate.locale?.optionPannel?.resetBtn}
49 |
50 | }
53 | type="primary"
54 | onClick={() => {
55 | homeState.showOption = false;
56 | homeState.option = toJS(homeState.tempOption);
57 | homeState.reCompress();
58 | }}
59 | >
60 | {gstate.locale?.optionPannel?.confirmBtn}
61 |
62 |
63 |
64 |
65 |
66 |
67 | >
68 | );
69 |
70 | if (isPC) {
71 | return {option}
;
72 | } else if (isPad) {
73 | return (
74 |
85 | {option}
86 |
87 | );
88 | } else {
89 | return (
90 |
102 | {option}
103 |
104 | );
105 | }
106 | });
107 |
--------------------------------------------------------------------------------
/src/pages/home/index.module.scss:
--------------------------------------------------------------------------------
1 | $headerHeight: 60px;
2 | $border: 1px solid #dfdfdf;
3 |
4 | .container {
5 | height: 100vh;
6 | background-color: #f5f5f5;
7 | }
8 |
9 | .header {
10 | background-color: #fff;
11 | height: $headerHeight;
12 | padding: 0 16px;
13 | border-bottom: $border;
14 | }
15 |
16 | .locale {
17 | cursor: pointer;
18 | svg {
19 | width: 18px;
20 | height: 18px;
21 | margin-right: 4px;
22 | path {
23 | fill: currentColor;
24 | }
25 | }
26 | span {
27 | white-space: nowrap;
28 | color: currentColor;
29 | }
30 | &:hover {
31 | color: #1da565;
32 | }
33 | }
34 |
35 | .main {
36 | height: calc(100% - $headerHeight);
37 | position: relative;
38 | }
39 |
40 | .status {
41 | padding-left: 16px !important;
42 | }
43 | .action {
44 | padding-right: 16px !important;
45 | }
46 |
47 | .name {
48 | overflow: hidden;
49 | text-overflow: ellipsis;
50 | display: -webkit-box;
51 | -webkit-line-clamp: 2;
52 | -webkit-box-orient: vertical;
53 | display: -moz-box;
54 | -moz-line-clamp: 2;
55 | -moz-box-orient: vertical;
56 | word-wrap: break-word;
57 | word-break: break-all;
58 | white-space: normal;
59 | }
60 |
61 | .preview {
62 | font-size: 0;
63 | width: 50px;
64 | height: 50px;
65 | position: relative;
66 | overflow: hidden;
67 | img {
68 | width: 100%;
69 | height: 100%;
70 | object-fit: cover;
71 | }
72 | &:hover {
73 | > div {
74 | display: flex;
75 | }
76 | }
77 | > div {
78 | display: none;
79 | position: absolute;
80 | top: 0;
81 | left: 0;
82 | width: 100%;
83 | height: 100%;
84 | cursor: pointer;
85 | background-color: rgba(0, 0, 0, 0.6);
86 | svg {
87 | width: 20px;
88 | path {
89 | fill: #f5f5f5;
90 | }
91 | }
92 | }
93 | }
94 |
95 | .content {
96 | $h1: 60px;
97 | $h3: 60px;
98 | height: 100%;
99 | overflow: hidden;
100 | flex-grow: 1;
101 | > div:nth-child(1) {
102 | height: $h1;
103 | border-bottom: $border;
104 | background-color: #fafafa;
105 | padding: 0 16px;
106 | }
107 | > div:nth-child(2) {
108 | flex: 1;
109 | height: calc(100% - $h1 - $h3);
110 | }
111 | > div:nth-child(3) {
112 | height: $h3;
113 | border-top: $border;
114 | padding: 0 16px;
115 | background-color: #fafafa;
116 | }
117 | }
118 |
119 | a.github {
120 | cursor: pointer;
121 | color: #000;
122 | margin-left: 16px;
123 | span {
124 | font-size: 24px;
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/pages/home/index.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Divider, Dropdown, Flex, Space, Typography } from "antd";
2 | import style from "./index.module.scss";
3 | import { observer } from "mobx-react-lite";
4 | import { Logo } from "@/components/Logo";
5 | import { GithubOutlined, MenuOutlined } from "@ant-design/icons";
6 | import { gstate } from "@/global";
7 | import { changeLang, langList } from "@/locale";
8 | import { homeState } from "@/states/home";
9 | import { wait } from "@/functions";
10 | import { UploadCard } from "@/components/UploadCard";
11 | import { useWorkerHandler } from "@/engines/transform";
12 | import { Compare } from "@/components/Compare";
13 | import { useResponse } from "@/media";
14 | import { RightOption } from "./RightOption";
15 | import { LeftContent } from "./LeftContent";
16 |
17 | function getCurentLangStr(): string | undefined {
18 | const findLang = langList.find((item) => item?.key == gstate.lang);
19 | return (findLang as any)?.label;
20 | }
21 |
22 | const Header = observer(() => {
23 | const { isPC } = useResponse();
24 |
25 | return (
26 |
27 |
28 |
29 |
39 |
40 |
41 |
42 |
43 | {getCurentLangStr()}
44 |
45 |
46 |
51 |
52 |
53 |
54 | {/* If non-PC is determined, the menu button will be displayed */}
55 | {!isPC && homeState.list.size > 0 && (
56 | <>
57 |
58 | }
60 | onClick={() => {
61 | homeState.showOption = !homeState.showOption;
62 | }}
63 | />
64 | >
65 | )}
66 |
67 |
68 | );
69 | });
70 |
71 | const Body = observer(() => {
72 | return (
73 |
74 | {homeState.list.size === 0 ? (
75 |
76 | ) : (
77 | <>
78 |
79 |
80 | >
81 | )}
82 |
83 | );
84 | });
85 |
86 | const Home = observer(() => {
87 | useWorkerHandler();
88 |
89 | return (
90 |
91 |
92 |
93 | {homeState.compareId !== null && }
94 |
95 | );
96 | });
97 |
98 | export default Home;
99 |
--------------------------------------------------------------------------------
/src/pages/home/useColumn.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, Space, Tooltip, Typography, message, theme } from "antd";
2 | import style from "./index.module.scss";
3 | import { TableProps } from "antd/es/table";
4 | import {
5 | ArrowDownOutlined,
6 | ArrowUpOutlined,
7 | CheckCircleFilled,
8 | DeleteOutlined,
9 | DownloadOutlined,
10 | } from "@ant-design/icons";
11 | import { gstate } from "@/global";
12 | import { ImageItem, homeState } from "@/states/home";
13 | import { Indicator } from "@/components/Indicator";
14 | import { createDownload, formatSize, getOutputFileName } from "@/functions";
15 | import { useResponse } from "@/media";
16 |
17 | export function useColumn(disabled: boolean) {
18 | const { token } = theme.useToken();
19 | const { isPC, isPad, isMobile } = useResponse();
20 |
21 | const columns: TableProps["columns"] = [
22 | {
23 | dataIndex: "status",
24 | title: gstate.locale?.columnTitle.status,
25 | fixed: "left",
26 | width: 80,
27 | className: style.status,
28 | render(_, row) {
29 | if (row.compress && row.preview) {
30 | return (
31 |
37 | );
38 | }
39 | return ;
40 | },
41 | },
42 | {
43 | dataIndex: "preview",
44 | title: gstate.locale?.columnTitle.preview,
45 | render(_, row) {
46 | if (!row.preview) return
;
47 | return (
48 |
54 |
55 | {row.compress && (
56 |
{
60 | if (homeState.isCropMode()) {
61 | message.warning("裁剪模式不支持预览对比");
62 | return;
63 | }
64 | homeState.compareId = row.key;
65 | }}
66 | >
67 |
68 |
69 |
70 |
71 | )}
72 |
73 | );
74 | },
75 | },
76 | ];
77 | if (isPC) {
78 | columns.push({
79 | dataIndex: "name",
80 | title: gstate.locale?.columnTitle.name,
81 | render(_, row) {
82 | return (
83 |
84 | {row.name}
85 |
86 | );
87 | },
88 | });
89 | }
90 | if (isPC || isPad) {
91 | columns.push(
92 | {
93 | dataIndex: "dimension",
94 | align: "right",
95 | className: style.nowrap,
96 | title: gstate.locale?.columnTitle.dimension,
97 | render(_, row) {
98 | if (!row.width && !row.height) return "-";
99 | return (
100 |
101 | {row.width}*{row.height}
102 |
103 | );
104 | },
105 | },
106 | {
107 | dataIndex: "newDimension",
108 | align: "right",
109 | className: style.nowrap,
110 | title: gstate.locale?.columnTitle.newDimension,
111 | render(_, row) {
112 | if (!row.compress?.width && !row.compress?.height) return "-";
113 | return (
114 |
115 | {row.compress.width}*{row.compress.height}
116 |
117 | );
118 | },
119 | },
120 | {
121 | dataIndex: "size",
122 | align: "right",
123 | className: style.nowrap,
124 | title: gstate.locale?.columnTitle.size,
125 | sorter(first, second) {
126 | return first.blob.size - second.blob.size;
127 | },
128 | render(_, row) {
129 | return (
130 |
131 | {formatSize(row.blob.size)}
132 |
133 | );
134 | },
135 | },
136 | {
137 | dataIndex: "newSize",
138 | align: "right",
139 | className: style.nowrap,
140 | title: gstate.locale?.columnTitle.newSize,
141 | sorter(first, second) {
142 | if (!first.compress || !second.compress) {
143 | return 0;
144 | }
145 | return first.compress.blob.size - second.compress.blob.size;
146 | },
147 | render(_, row) {
148 | if (!row.compress) return "-";
149 | const lower = row.blob.size > row.compress.blob.size;
150 | const format = formatSize(row.compress.blob.size);
151 | if (lower) {
152 | return {format} ;
153 | }
154 |
155 | return {format} ;
156 | },
157 | },
158 | );
159 | }
160 |
161 | // All media supported fields
162 | columns.push(
163 | {
164 | dataIndex: "decrease",
165 | className: style.nowrap,
166 | title: gstate.locale?.columnTitle.decrease,
167 | align: "right",
168 | sorter(first, second) {
169 | if (!first.compress || !second.compress) {
170 | return 0;
171 | }
172 | const firstRate =
173 | (first.blob.size - first.compress.blob.size) / first.blob.size;
174 | const secondRate =
175 | (second.blob.size - second.compress.blob.size) / second.blob.size;
176 |
177 | return firstRate - secondRate;
178 | },
179 | render(_, row) {
180 | if (!row.compress) return "-";
181 | const lower = row.blob.size > row.compress.blob.size;
182 | const rate = (row.compress.blob.size - row.blob.size) / row.blob.size;
183 | const formatRate = (Math.abs(rate) * 100).toFixed(2) + "%";
184 | if (lower) {
185 | return (
186 |
187 |
188 | {formatRate}
189 |
190 |
191 |
192 | );
193 | }
194 |
195 | return (
196 |
197 | +{formatRate}
198 |
199 |
202 |
203 |
204 | );
205 | },
206 | },
207 | {
208 | dataIndex: "action",
209 | align: "right",
210 | fixed: "right",
211 | title: gstate.locale?.columnTitle.action,
212 | className: style.action,
213 | render(_, row) {
214 | return (
215 |
216 | {
220 | homeState.list.delete(row.key);
221 | }}
222 | >
223 |
224 |
225 |
226 |
227 | {
231 | if (row.compress?.blob) {
232 | const fileName = getOutputFileName(row, homeState.option);
233 | createDownload(fileName, row.compress.blob);
234 | }
235 | }}
236 | >
237 |
238 |
239 |
240 |
241 |
242 | );
243 | },
244 | },
245 | );
246 |
247 | return columns;
248 | }
249 |
--------------------------------------------------------------------------------
/src/router.tsx:
--------------------------------------------------------------------------------
1 | import { createBrowserHistory } from "history";
2 | import { normalize } from "./functions";
3 | import { gstate } from "./global";
4 | import { modules } from "./modules";
5 |
6 | export const history = createBrowserHistory();
7 |
8 | type Params = Record | null;
9 |
10 | export function goto(
11 | pathname: string = "/",
12 | params?: Params,
13 | type: string = "push",
14 | ) {
15 | pathname += buildQueryString(params);
16 | navigate(pathname, type);
17 | }
18 |
19 | function buildQueryString(params?: Params) {
20 | if (!params) return "";
21 | const search = new URLSearchParams();
22 |
23 | Object.entries(params).forEach(([key, value]) => {
24 | search.append(key, String(value));
25 | });
26 |
27 | const query = search.toString();
28 | return query ? `?${query}` : "";
29 | }
30 |
31 | function navigate(pathname: string, type: string): void {
32 | if (type === "push") {
33 | history.push(pathname);
34 | } else if (type === "replace") {
35 | history.replace(pathname);
36 | } else {
37 | throw new Error("Error history route method");
38 | }
39 | }
40 |
41 | export function initRouter() {
42 | history.listen(({ location }) => {
43 | handleRouteChange(location.pathname);
44 | });
45 | handleRouteChange(history.location.pathname);
46 | }
47 |
48 | async function handleRouteChange(pathname: string) {
49 | gstate.pathname = normalize(pathname) || "home";
50 | gstate.page = await loadPageComponent(gstate.pathname);
51 | }
52 |
53 | async function loadPageComponent(pathname: string) {
54 | try {
55 | const importer = modules[`/src/pages/${pathname}/index.tsx`]();
56 | const result = await importer;
57 | return ;
58 | } catch (error) {
59 | const error404 = await import(`@/pages/error404/index.tsx`);
60 | return ;
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/states/home.ts:
--------------------------------------------------------------------------------
1 | import { CompressOption, ProcessOutput } from "@/engines/ImageBase";
2 | import { createCompressTask } from "@/engines/transform";
3 | import { makeAutoObservable } from "mobx";
4 |
5 | export const DefaultCompressOption: CompressOption = {
6 | preview: {
7 | maxSize: 256,
8 | },
9 | resize: {
10 | method: undefined,
11 | width: undefined,
12 | height: undefined,
13 | short: undefined,
14 | long: undefined,
15 | cropWidthRatio: undefined,
16 | cropHeightRatio: undefined,
17 | cropWidthSize: undefined,
18 | cropHeightSize: undefined,
19 | },
20 | format: {
21 | target: undefined,
22 | transparentFill: "#FFFFFF",
23 | },
24 | jpeg: {
25 | quality: 0.75,
26 | },
27 | png: {
28 | colors: 128,
29 | dithering: 0.5,
30 | },
31 | gif: {
32 | colors: 128,
33 | dithering: false,
34 | },
35 | avif: {
36 | quality: 50,
37 | speed: 8,
38 | },
39 | };
40 |
41 | export interface ProgressHintInfo {
42 | loadedNum: number;
43 | totalNum: number;
44 | percent: number;
45 | originSize: number;
46 | outputSize: number;
47 | rate: number;
48 | }
49 |
50 | export type ImageItem = {
51 | key: number;
52 | name: string;
53 | blob: Blob;
54 | src: string;
55 | width: number;
56 | height: number;
57 | preview?: ProcessOutput;
58 | compress?: ProcessOutput;
59 | };
60 |
61 | export class HomeState {
62 | public list: Map = new Map();
63 | public option: CompressOption = DefaultCompressOption;
64 | public tempOption: CompressOption = DefaultCompressOption;
65 | public compareId: number | null = null;
66 | public showOption: boolean = false;
67 |
68 | constructor() {
69 | makeAutoObservable(this);
70 | }
71 |
72 | /**
73 | * Check whether crop mode
74 | * @returns
75 | */
76 | isCropMode() {
77 | const resize = this.option.resize;
78 | return (
79 | (resize.method === "setCropRatio" &&
80 | resize.cropWidthRatio &&
81 | resize.cropHeightRatio &&
82 | resize.cropWidthRatio > 0 &&
83 | resize.cropHeightRatio > 0) ||
84 | (resize.method === "setCropSize" &&
85 | resize.cropWidthSize &&
86 | resize.cropHeightSize &&
87 | resize.cropWidthSize > 0 &&
88 | resize.cropHeightSize > 0)
89 | );
90 | }
91 |
92 | clear() {
93 | this.list.clear();
94 | this.tempOption = { ...DefaultCompressOption };
95 | this.option = { ...DefaultCompressOption };
96 | }
97 |
98 | reCompress() {
99 | this.list.forEach((info) => {
100 | URL.revokeObjectURL(info.compress!.src);
101 | info.compress = undefined;
102 | createCompressTask(info);
103 | });
104 | }
105 |
106 | hasTaskRunning() {
107 | /* eslint-disable @typescript-eslint/no-unused-vars */
108 | for (const [_, value] of this.list) {
109 | if (!value.preview || !value.compress) {
110 | return true;
111 | }
112 | }
113 | return false;
114 | }
115 |
116 | /**
117 | * 获取进度条信息
118 | * @returns
119 | */
120 | getProgressHintInfo(): ProgressHintInfo {
121 | const totalNum = this.list.size;
122 | let loadedNum = 0;
123 | let originSize = 0;
124 | let outputSize = 0;
125 | /* eslint-disable @typescript-eslint/no-unused-vars */
126 | for (const [_, info] of this.list) {
127 | originSize += info.blob.size;
128 | if (info.compress) {
129 | loadedNum++;
130 | outputSize += info.compress.blob.size;
131 | }
132 | }
133 | const percent = Math.ceil((loadedNum * 100) / totalNum);
134 | const originRate = ((outputSize - originSize) * 100) / originSize;
135 | const rate = Number(Math.abs(originRate).toFixed(2));
136 |
137 | return {
138 | totalNum,
139 | loadedNum,
140 | originSize,
141 | outputSize,
142 | percent,
143 | rate,
144 | };
145 | }
146 | }
147 |
148 | export const homeState = new HomeState();
149 |
--------------------------------------------------------------------------------
/src/type.ts:
--------------------------------------------------------------------------------
1 | import { Locale } from "antd/es/locale";
2 |
3 | export interface LocaleData {
4 | antLocale: Locale;
5 | logo: string;
6 | initial: string;
7 | previewHelp: string;
8 | error404: {
9 | backHome: string;
10 | description: string;
11 | };
12 | uploadCard: {
13 | title: string;
14 | subTitle: string;
15 | };
16 | listAction: {
17 | batchAppend: string;
18 | addFolder: string;
19 | clear: string;
20 | downloadAll: string;
21 | downloadOne: string;
22 | removeOne: string;
23 | reCompress: string;
24 | };
25 | columnTitle: {
26 | status: string;
27 | name: string;
28 | preview: string;
29 | size: string;
30 | newSize: string;
31 | dimension: string;
32 | newDimension: string;
33 | decrease: string;
34 | action: string;
35 | };
36 | optionPannel: {
37 | resizeLable: string;
38 | jpegLable: string;
39 | pngLable: string;
40 | gifLable: string;
41 | avifLable: string;
42 | avifQuality: string;
43 | avifSpeed: string;
44 | help: string;
45 | failTip: string;
46 | resizePlaceholder: string;
47 | fitWidth: string;
48 | fitHeight: string;
49 | setShort: string;
50 | setLong: string;
51 | setCropRatio: string;
52 | setCropSize: string;
53 | widthPlaceholder: string;
54 | heightPlaceholder: string;
55 | shortPlaceholder: string;
56 | longPlaceholder: string;
57 | cwRatioPlaceholder: string;
58 | chRatioPlaceholder: string;
59 | cwSizePlaceholder: string;
60 | chSizePlaceholder: string;
61 | cropCompareWarning: string;
62 | qualityTitle: string;
63 | resetBtn: string;
64 | confirmBtn: string;
65 | colorsDesc: string;
66 | pngDithering: string;
67 | gifDithering: string;
68 | outputFormat: string;
69 | outputFormatPlaceholder: string;
70 | transparentFillDesc: string;
71 | };
72 | progress: {
73 | before: string;
74 | after: string;
75 | rate: string;
76 | };
77 | }
78 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | interface DataTransferItem {
4 | getAsFileSystemHandle?: () => Promise;
5 | }
6 |
7 | interface Window {
8 | showDirectoryPicker?: (option?: {
9 | id: string;
10 | }) => Promise;
11 | }
12 |
--------------------------------------------------------------------------------
/tests/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from "vitest";
2 | import { getUniqNameOnNames, normalize } from "@/functions";
3 |
4 | test("Path normalize check", () => {
5 | expect(normalize("")).toBe("");
6 | expect(normalize("/a/b")).toBe("a/b");
7 | expect(normalize("/sub/a/b", "/sub")).toBe("a/b");
8 | expect(normalize("/a/b", "/sub")).toBe("error404");
9 | });
10 |
11 | test("Rename check", () => {
12 | const names = new Set(["a.jpg", "b.png"]);
13 | expect(getUniqNameOnNames(names, "a.jpg")).toBe("a(1).jpg");
14 | names.add("a(1).jpg");
15 | expect(getUniqNameOnNames(names, "a.jpg")).toBe("a(1)(1).jpg");
16 | });
17 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 | "allowJs": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 |
24 | "baseUrl": ".",
25 | "paths": {
26 | "@/*": ["src/*"]
27 | }
28 | },
29 | "include": ["src", "tests"],
30 | "references": [{ "path": "./tsconfig.node.json" }]
31 | }
32 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true,
8 | "strict": true
9 | },
10 | "include": ["vite.config.ts"]
11 | }
12 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import { defineConfig } from "vite";
4 | import react from "@vitejs/plugin-react";
5 | import path from "path";
6 |
7 | // https://vitejs.dev/config/
8 | export default defineConfig({
9 | plugins: [react()],
10 | base: "/",
11 | resolve: {
12 | alias: {
13 | "@": path.resolve(__dirname, "src"),
14 | },
15 | },
16 | server: {
17 | port: 3000,
18 | host: "0.0.0.0"
19 | },
20 | preview: {
21 | port: 3001,
22 | host: "0.0.0.0",
23 | },
24 | test: {
25 | include: ["tests/**/*.{test,spec}.?(c|m)[jt]s?(x)"],
26 | },
27 | });
28 |
--------------------------------------------------------------------------------