chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.vscode
|
||||
docs
|
||||
node_modules
|
||||
|
||||
.DS_Store
|
||||
@@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
ignorePatterns: ["dist", ".eslintrc.cjs"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["react-refresh"],
|
||||
rules: {
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
"no-empty": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
};
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
RUN set -eux \
|
||||
&& npm install --ignore-scripts \
|
||||
&& npm run build:preview
|
||||
CMD [ "npm", "run", "preview" ]
|
||||
EXPOSE 3001
|
||||
@@ -0,0 +1,22 @@
|
||||
# -------------------Desciption---------------------
|
||||
|
||||
# FROM nginx:alpine-slim: Uses the alpine-slim nginx image as the base image.
|
||||
# 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.
|
||||
# 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.
|
||||
# EXPOSE 3000: Declares that the container is listening on port 3000.
|
||||
# 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.
|
||||
|
||||
# -------------------Usage--------------------
|
||||
|
||||
# $ docker build -f Dockerfile.slim -t picsmaller-slim .
|
||||
# $ docker run -d -p 9000:3000 picsmaller-slim
|
||||
|
||||
FROM nginx:alpine-slim
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
COPY dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Joye
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Pic Smaller (图小小)
|
||||
|
||||
**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.
|
||||
|
||||
<br/>
|
||||
|
||||
<div><img src="./docs/demo1.png"></div>
|
||||
Figure 1: Pic Smaller's landing page, where users can upload their images for compression
|
||||
<br/>
|
||||
<br/>
|
||||
<div><img src="./docs/demo2.png"></div>
|
||||
Figure 2: Example pictures uploaded for compression shown on the left, and Pic Smaller's customizable compression and editing features shown on the right
|
||||
<br/>
|
||||
<br/>
|
||||
<div><img src="./docs/demo3.png"></div>
|
||||
Figure 3: Pic Smaller's comparison tool, that the user can drag to see the difference between the original and compressed image
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
## Usage
|
||||
|
||||
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/)
|
||||
|
||||
> [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.
|
||||
|
||||
## Preqrequisites
|
||||
|
||||
Node.js
|
||||
1. Navigate to the Node.js website: https://nodejs.org/en/
|
||||
2. Download the recommended version (which is currently v20.17.0).
|
||||
3. Follow the steps on your computer to finish its installation.
|
||||
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.
|
||||
```
|
||||
node -v
|
||||
```
|
||||
|
||||
## Develop
|
||||
|
||||
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.
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://github.com/joye61/pic-smaller.git
|
||||
|
||||
# Change cwd
|
||||
cd ./pic-smaller
|
||||
|
||||
# Install dependences
|
||||
npm install
|
||||
|
||||
# Start to develop
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Hold control and left click the URL next to "Local:" to open the website on your local machine.
|
||||
|
||||

|
||||
<br/>
|
||||
Figure 4: Where to open the localhost website link
|
||||
|
||||
|
||||
## Deploy
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
# Build docker image from Dockerfile
|
||||
docker build -t picsmaller .
|
||||
|
||||
# Start a container
|
||||
docker run -p 3001:3001 -d picsmaller
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Ensure all required dependency installations have been properly followed to accurately test your changes.
|
||||
2. Update the README.md with information about changes to the interface, including new environment variables, important file locations, and container parameters.
|
||||
4. Increase the version numbers in all example files and the README.md to reflect the new version represented by your changes.
|
||||
5. Create a Pull Request with an appropriate and descriptive title and description.
|
||||
6. You can reach out to other developers to review and merge the Pull Request if appropriate.
|
||||
|
||||
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.
|
||||
|
||||
## Project Structure
|
||||
|
||||
The src folder stores in all the files and components used in the react application like App.tsx.
|
||||
<br/>
|
||||
The tests folder includes code to test particular features during the development process.
|
||||
<br/>
|
||||
The docs folder includes the pictures used for this README documentation.
|
||||
|
||||
## License
|
||||
|
||||
This project is under [MIT](LICENSE) license.
|
||||
|
||||
## Contact
|
||||
|
||||
Please contact the repository owner joye61's email for any questions: 89065495@qq.com
|
||||
|
||||
## Thanks
|
||||
|
||||
- [ant-design](https://github.com/ant-design/ant-design) Provides React-based UI solutions
|
||||
- [wasm-image-compressor](https://github.com/antelle/wasm-image-compressor) Provides PNG image compression implementation based on Webassembly
|
||||
- [gifsicle-wasm-browser](https://github.com/renzhezhilu/gifsicle-wasm-browser) Provides GIF image compression implementation based on Webassembly
|
||||
- [wasm_avif](https://github.com/packurl/wasm_avif) Provides AVIF image compression implementation based on Webassembly
|
||||
- [svgo](https://github.com/svg/svgo) Provides SVG vector compression
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`joye61/pic-smaller`
|
||||
- 原始仓库:https://github.com/joye61/pic-smaller
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
+13
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Pic Smaller – Compress JPEG, PNG, WEBP, AVIF, SVG and GIF images intelligently</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
server {
|
||||
listen 3000;
|
||||
server_name localhost;
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+5455
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "pic-smaller",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build:preview": "tsc && vite build --mode preview",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"format": "prettier --write --no-error-on-unmatched-pattern --ignore-unknown src/**/*",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.3.6",
|
||||
"@vercel/analytics": "^1.2.2",
|
||||
"antd": "^5.16.4",
|
||||
"classnames": "^2.5.1",
|
||||
"filesize": "^10.1.1",
|
||||
"get-user-locale": "^2.3.2",
|
||||
"history": "^5.3.0",
|
||||
"jszip": "^3.10.1",
|
||||
"mobx": "^6.12.3",
|
||||
"mobx-react-lite": "^4.0.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-responsive": "^10.0.0",
|
||||
"sprintf-js": "^1.1.3",
|
||||
"svgo": "^3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.0",
|
||||
"@types/node": "^20.12.7",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@types/sprintf-js": "^1.1.4",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"prettier": "^3.2.5",
|
||||
"sass": "^1.75.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vconsole": "^3.15.1",
|
||||
"vite": "^5.2.0",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="512" height="511.9999694824219" viewBox="0 0 512 511.9999694824219"><defs><clipPath id="master_svg0_38_06"><rect x="0" y="0" width="512" height="511.9999694824219" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_38_06)"><g><g><rect x="0" y="0" width="512" height="512" rx="0" fill="#1BA565" fill-opacity="1"/></g><g><g><path d="M512.000002861023,511.9999084472656L512.000002861023,391.5459084472656L318.00000286102295,205.99990844726562L200.00000286102295,343.5679084472656L200.00000286102295,511.9999084472656L512.000002861023,511.9999084472656L512.000002861023,511.9999084472656Z" fill-rule="evenodd" fill="#47C88B" fill-opacity="1"/></g><g><path d="M0.00000286102294921875,272.56793896484373L0.00000286102294921875,511.99993896484375L512.000002861023,511.99993896484375L512.000002861023,511.82993896484373L118.00000286102295,134.99993896484375L0.00000286102294921875,272.56793896484373Z" fill-rule="evenodd" fill="#50E19D" fill-opacity="1"/></g><g><ellipse cx="356.00000286102295" cy="118" rx="70" ry="70" fill="#FFFFFF" fill-opacity="1"/></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
+45
@@ -0,0 +1,45 @@
|
||||
import { ConfigProvider, App as AntApp } from "antd";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { gstate } from "./global";
|
||||
import { ContextAction } from "./ContextAction";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { Loading } from "./components/Loading";
|
||||
import { useResponse } from "./media";
|
||||
import { useEffect } from "react";
|
||||
|
||||
function useMobileVConsole() {
|
||||
const { isMobile } = useResponse();
|
||||
useEffect(() => {
|
||||
if (!isMobile || !import.meta.env.DEV) return;
|
||||
let vConsole: any = null;
|
||||
import("vconsole").then((result) => {
|
||||
vConsole = new result.default({ theme: "dark" });
|
||||
});
|
||||
return () => vConsole?.destroy();
|
||||
}, [isMobile]);
|
||||
}
|
||||
|
||||
export const App = observer(() => {
|
||||
useMobileVConsole();
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={gstate.locale?.antLocale}
|
||||
theme={{
|
||||
token: {
|
||||
borderRadius: 0,
|
||||
colorPrimary: "#078249",
|
||||
colorLink: "#078249",
|
||||
colorSuccess: "#078249",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AntApp>
|
||||
<ContextAction />
|
||||
</AntApp>
|
||||
{import.meta.env.MODE === "production" && <Analytics />}
|
||||
{gstate.page}
|
||||
{gstate.loading && <Loading />}
|
||||
</ConfigProvider>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { App } from "antd";
|
||||
import type { MessageInstance } from "antd/es/message/interface";
|
||||
import type { ModalStaticFunctions } from "antd/es/modal/confirm";
|
||||
import type { NotificationInstance } from "antd/es/notification/interface";
|
||||
|
||||
let message: MessageInstance;
|
||||
let notification: NotificationInstance;
|
||||
let modal: Omit<ModalStaticFunctions, "warn">;
|
||||
|
||||
export function ContextAction() {
|
||||
const staticFunction = App.useApp();
|
||||
message = staticFunction.message;
|
||||
modal = staticFunction.modal;
|
||||
notification = staticFunction.notification;
|
||||
return null;
|
||||
}
|
||||
|
||||
export { message, notification, modal };
|
||||
@@ -0,0 +1,50 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Flex, Typography } from "antd";
|
||||
import { useEffect } from "react";
|
||||
import { locales, modules } from "./modules";
|
||||
import { initRouter } from "./router";
|
||||
import { gstate } from "./global";
|
||||
import { Indicator } from "./components/Indicator";
|
||||
import { avifCheck } from "./engines/support";
|
||||
|
||||
const loadResources = () => {
|
||||
const loadList: Array<Promise<any>> = [
|
||||
import("jszip"),
|
||||
fetch(new URL("./engines/png.wasm", import.meta.url)),
|
||||
fetch(new URL("./engines/gif.wasm", import.meta.url)),
|
||||
fetch(new URL("./engines/avif.wasm", import.meta.url)),
|
||||
import("./engines/WorkerPreview?worker"),
|
||||
import("./engines/WorkerCompress?worker"),
|
||||
];
|
||||
const langs = Object.values(locales);
|
||||
const pages = Object.values(modules);
|
||||
for (const load of [...langs, ...pages]) {
|
||||
loadList.push(load());
|
||||
}
|
||||
loadList.push(avifCheck());
|
||||
return Promise.all(loadList);
|
||||
};
|
||||
|
||||
const useInit = () => {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await loadResources();
|
||||
initRouter();
|
||||
})();
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const Initial = observer(() => {
|
||||
useInit();
|
||||
|
||||
return (
|
||||
<Flex align="center" justify="center" className="__initial">
|
||||
<Flex vertical align="center">
|
||||
<Indicator size="large" />
|
||||
<Typography.Text type="secondary">
|
||||
{gstate.locale?.initial}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
@keyframes BoxShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes BoxHide {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 9;
|
||||
user-select: none;
|
||||
background-color: #fff;
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
#e0e0e0 25%,
|
||||
transparent 25%,
|
||||
transparent 75%,
|
||||
#e0e0e0 75%
|
||||
),
|
||||
linear-gradient(
|
||||
45deg,
|
||||
#e0e0e0 25%,
|
||||
transparent 25%,
|
||||
transparent 75%,
|
||||
#e0e0e0 75%
|
||||
);
|
||||
background-size: 20px 20px;
|
||||
background-position:
|
||||
0 0,
|
||||
10px 10px;
|
||||
|
||||
&.show {
|
||||
animation: BoxShow 0.3s ease-in forwards;
|
||||
}
|
||||
&.hide {
|
||||
animation: BoxHide 0.3s ease-out forwards;
|
||||
}
|
||||
&.moving {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
> div:nth-child(1),
|
||||
> div:nth-child(2) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
img {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
> div:nth-child(1) {
|
||||
left: 0;
|
||||
}
|
||||
> div:nth-child(2) {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
> div:nth-child(3) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
background-color: #000;
|
||||
> div {
|
||||
position: absolute;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
path {
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.before {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
}
|
||||
.after {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
}
|
||||
|
||||
.help {
|
||||
width: 240px;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import style from "./index.module.scss";
|
||||
import { Button, Flex, Popover, Space } from "antd";
|
||||
import { CloseOutlined, QuestionCircleOutlined } from "@ant-design/icons";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ImageItem, homeState } from "@/states/home";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import classNames from "classnames";
|
||||
import { gstate } from "@/global";
|
||||
|
||||
export interface CompareState {
|
||||
x: number;
|
||||
xrate: number;
|
||||
scale: number;
|
||||
moving: boolean;
|
||||
status: "show" | "hide";
|
||||
dividerWidth: number;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
}
|
||||
|
||||
export const Compare = observer(() => {
|
||||
const infoRef = useRef<Required<ImageItem>>(
|
||||
homeState.list.get(homeState.compareId!) as Required<ImageItem>,
|
||||
);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const [state, setState] = useState<CompareState>({
|
||||
x: 0,
|
||||
xrate: 0.5,
|
||||
scale: 0.8,
|
||||
moving: false,
|
||||
status: "show",
|
||||
dividerWidth: 2,
|
||||
containerWidth: 0,
|
||||
containerHeight: 0,
|
||||
imageWidth: 0,
|
||||
imageHeight: 0,
|
||||
});
|
||||
const [oldLoaded, setOldLoaded] = useState<boolean>(false);
|
||||
const [newLoaded, setNewLoaded] = useState<boolean>(false);
|
||||
|
||||
const update = useCallback(
|
||||
(newState: Partial<CompareState>) => {
|
||||
setState({
|
||||
...state,
|
||||
...newState,
|
||||
});
|
||||
},
|
||||
[state],
|
||||
);
|
||||
|
||||
const getState = useCallback(() => {
|
||||
return state;
|
||||
}, [state]);
|
||||
|
||||
const updateRef = useRef<(newState: Partial<CompareState>) => void>(update);
|
||||
const stateRef = useRef<() => CompareState>(getState);
|
||||
useEffect(() => {
|
||||
updateRef.current = update;
|
||||
stateRef.current = getState;
|
||||
}, [update, getState]);
|
||||
|
||||
useEffect(() => {
|
||||
gstate.loading = true;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (oldLoaded && newLoaded) {
|
||||
gstate.loading = false;
|
||||
}
|
||||
}, [oldLoaded, newLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
const doc = document.documentElement;
|
||||
const bar = barRef.current!;
|
||||
|
||||
let isControl = false;
|
||||
let cursorX = 0;
|
||||
|
||||
const resize = () => {
|
||||
const states = stateRef.current();
|
||||
const rect = containerRef.current!.getBoundingClientRect();
|
||||
let imageWidth: number;
|
||||
let imageHeight: number;
|
||||
if (
|
||||
infoRef.current.width / infoRef.current.height >
|
||||
rect.width / rect.height
|
||||
) {
|
||||
imageWidth = rect.width * states.scale;
|
||||
imageHeight =
|
||||
(imageWidth * infoRef.current.height) / infoRef.current.width;
|
||||
} else {
|
||||
imageHeight = rect.height * states.scale;
|
||||
imageWidth =
|
||||
(imageHeight * infoRef.current.width) / infoRef.current.height;
|
||||
}
|
||||
updateRef.current({
|
||||
x: rect.width * states.xrate,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
containerWidth: rect.width,
|
||||
containerHeight: rect.height,
|
||||
});
|
||||
};
|
||||
|
||||
const mousedown = (event: MouseEvent) => {
|
||||
isControl = true;
|
||||
cursorX = event.clientX;
|
||||
updateRef.current({ moving: true });
|
||||
};
|
||||
|
||||
const mouseup = () => {
|
||||
isControl = false;
|
||||
cursorX = 0;
|
||||
updateRef.current({ moving: false });
|
||||
};
|
||||
|
||||
const mousemove = (event: MouseEvent) => {
|
||||
if (isControl) {
|
||||
const states = stateRef.current();
|
||||
let x = states.x + event.clientX - cursorX;
|
||||
const min = (states.containerWidth - states.imageWidth) / 2;
|
||||
const max = (states.containerWidth + states.imageWidth) / 2;
|
||||
if (x < min) {
|
||||
x = min;
|
||||
}
|
||||
if (x > max) {
|
||||
x = max;
|
||||
}
|
||||
cursorX = event.clientX;
|
||||
updateRef.current({ x, xrate: x / states.containerWidth });
|
||||
}
|
||||
};
|
||||
|
||||
const wheel = (event: WheelEvent) => {
|
||||
const states = stateRef.current();
|
||||
let scale = -0.001 * event.deltaY + states.scale;
|
||||
if (scale > 1) {
|
||||
scale = 1;
|
||||
}
|
||||
if (scale < 0.1) {
|
||||
scale = 0.1;
|
||||
}
|
||||
|
||||
let imageWidth: number;
|
||||
let imageHeight: number;
|
||||
if (
|
||||
infoRef.current.width / infoRef.current.height >
|
||||
states.containerWidth / states.containerHeight
|
||||
) {
|
||||
imageWidth = states.containerWidth * scale;
|
||||
imageHeight =
|
||||
(imageWidth * infoRef.current.height) / infoRef.current.width;
|
||||
} else {
|
||||
imageHeight = states.containerHeight * scale;
|
||||
imageWidth =
|
||||
(imageHeight * infoRef.current.width) / infoRef.current.height;
|
||||
}
|
||||
|
||||
const innerRate =
|
||||
(states.x - (states.containerWidth - states.imageWidth) / 2) /
|
||||
states.imageWidth;
|
||||
const x =
|
||||
innerRate * imageWidth + (states.containerWidth - imageWidth) / 2;
|
||||
|
||||
updateRef.current({ scale, imageWidth, imageHeight, x });
|
||||
};
|
||||
|
||||
window.addEventListener("resize", resize);
|
||||
window.addEventListener("wheel", wheel);
|
||||
bar.addEventListener("mousedown", mousedown);
|
||||
doc.addEventListener("mousemove", mousemove);
|
||||
doc.addEventListener("mouseup", mouseup);
|
||||
|
||||
resize();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", resize);
|
||||
window.removeEventListener("wheel", wheel);
|
||||
bar.removeEventListener("mousedown", mousedown);
|
||||
doc.removeEventListener("mousemove", mousemove);
|
||||
doc.removeEventListener("mouseup", mouseup);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const leftStyle: React.CSSProperties = {
|
||||
width: `${state.x}px`,
|
||||
};
|
||||
const rightStyle: React.CSSProperties = {
|
||||
width: `${state.containerWidth - state.x}px`,
|
||||
};
|
||||
const barStyle: React.CSSProperties = {
|
||||
width: `${state.dividerWidth}px`,
|
||||
left: `${state.x - state.dividerWidth / 2}px`,
|
||||
opacity: state.x === 0 ? 0 : 1,
|
||||
};
|
||||
const imageStyle: React.CSSProperties = {
|
||||
opacity: newLoaded && oldLoaded ? 1 : 0,
|
||||
};
|
||||
const leftImageStyle: React.CSSProperties = {
|
||||
width: state.imageWidth,
|
||||
height: state.imageHeight,
|
||||
left: (state.containerWidth - state.imageWidth) / 2 + "px",
|
||||
...imageStyle,
|
||||
};
|
||||
const rightImageStyle: React.CSSProperties = {
|
||||
width: state.imageWidth,
|
||||
height: state.imageHeight,
|
||||
right: (state.containerWidth - state.imageWidth) / 2 + "px",
|
||||
...imageStyle,
|
||||
};
|
||||
|
||||
let statusClass: string | undefined = undefined;
|
||||
if (state.status === "show") {
|
||||
statusClass = style.show;
|
||||
}
|
||||
if (state.status === "hide") {
|
||||
statusClass = style.hide;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={classNames(
|
||||
style.container,
|
||||
state.moving && style.moving,
|
||||
statusClass,
|
||||
)}
|
||||
ref={containerRef}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.animationName === style.BoxHide) {
|
||||
homeState.compareId = null;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={leftStyle}>
|
||||
<img
|
||||
src={infoRef.current.src}
|
||||
style={leftImageStyle}
|
||||
onLoad={() => {
|
||||
setOldLoaded(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={rightStyle}>
|
||||
<img
|
||||
src={infoRef.current.compress.src}
|
||||
style={rightImageStyle}
|
||||
onLoad={() => {
|
||||
setNewLoaded(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={barStyle}>
|
||||
<Flex align="center" justify="center" ref={barRef}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M6.45,17.45L1,12L6.45,6.55L7.86,7.96L4.83,11H19.17L16.14,7.96L17.55,6.55L23,12L17.55,17.45L16.14,16.04L19.17,13H4.83L7.86,16.04L6.45,17.45Z" />
|
||||
</svg>
|
||||
</Flex>
|
||||
</div>
|
||||
<Space className={style.action}>
|
||||
<Popover
|
||||
content={
|
||||
<div className={style.help}>{gstate.locale?.previewHelp}</div>
|
||||
}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button icon={<QuestionCircleOutlined />} />
|
||||
</Popover>
|
||||
<Button
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => {
|
||||
updateRef.current?.({ status: "hide" });
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
.resizeInput {
|
||||
margin-top: 16px;
|
||||
> div {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.cropInput {
|
||||
margin-top: 16px;
|
||||
> div:nth-child(1),
|
||||
> div:nth-child(3) {
|
||||
flex-grow: 1;
|
||||
}
|
||||
> div:nth-child(2) {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
width: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.olabel {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.presetCropInput {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.presetRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
> span {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
min-width: 70px;
|
||||
}
|
||||
> div {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.presetWarning {
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
background: #fffbe6;
|
||||
border: 1px solid #ffe58f;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #ad8b00;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
.warningActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
ColorPicker,
|
||||
Divider,
|
||||
Flex,
|
||||
InputNumber,
|
||||
Select,
|
||||
Slider,
|
||||
} from "antd";
|
||||
import style from "./index.module.scss";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { DefaultCompressOption, homeState } from "@/states/home";
|
||||
import { gstate } from "@/global";
|
||||
import { OptionItem } from "../OptionItem";
|
||||
import { Mimes } from "@/mimes";
|
||||
import { PAPER_SIZES } from "@/engines/ImageBase";
|
||||
|
||||
export const CompressOption = observer(() => {
|
||||
const disabled = homeState.hasTaskRunning();
|
||||
const resizeMethod = homeState.tempOption.resize.method;
|
||||
const resizeOptions = [
|
||||
{
|
||||
value: "fitWidth",
|
||||
label: gstate.locale?.optionPannel?.fitWidth,
|
||||
},
|
||||
{
|
||||
value: "fitHeight",
|
||||
label: gstate.locale?.optionPannel?.fitHeight,
|
||||
},
|
||||
{
|
||||
value: "setShort",
|
||||
label: gstate.locale?.optionPannel?.setShort,
|
||||
},
|
||||
{
|
||||
value: "setLong",
|
||||
label: gstate.locale?.optionPannel?.setLong,
|
||||
},
|
||||
{
|
||||
value: "setCropRatio",
|
||||
label: gstate.locale?.optionPannel?.setCropRatio,
|
||||
},
|
||||
{
|
||||
value: "setCropSize",
|
||||
label: gstate.locale?.optionPannel?.setCropSize,
|
||||
},
|
||||
{
|
||||
value: "presetCrop",
|
||||
label: gstate.locale?.optionPannel?.presetCrop,
|
||||
},
|
||||
];
|
||||
|
||||
const getFormatOptions = () => {
|
||||
const options: { label: string; value: string }[] = [];
|
||||
Object.keys(Mimes).forEach((mime) => {
|
||||
if (!["svg", "gif"].includes(mime)) {
|
||||
options.push({
|
||||
value: mime,
|
||||
label: mime.toUpperCase(),
|
||||
});
|
||||
}
|
||||
});
|
||||
return options;
|
||||
};
|
||||
|
||||
const paperSizeOptions = Object.entries(PAPER_SIZES).map(([key, val]) => ({
|
||||
value: key,
|
||||
label: val.label,
|
||||
}));
|
||||
|
||||
const pc = resizeMethod === "presetCrop" ? (homeState.tempOption.resize.presetCrop || {
|
||||
paperSize: "a4",
|
||||
orientation: "portrait" as const,
|
||||
reference: "width" as const,
|
||||
cropPx: 0,
|
||||
offsetPx: 0,
|
||||
}) : null;
|
||||
|
||||
let presetCropWarning = false;
|
||||
if (pc && homeState.list.size > 0) {
|
||||
for (const [_, item] of homeState.list) {
|
||||
const paper = PAPER_SIZES[pc.paperSize];
|
||||
if (!paper) { presetCropWarning = true; break; }
|
||||
let ratioW = paper.width;
|
||||
let ratioH = paper.height;
|
||||
if (pc.orientation === "landscape") {
|
||||
ratioW = paper.height;
|
||||
ratioH = paper.width;
|
||||
}
|
||||
const refIsWidth = pc.reference === "width";
|
||||
const refDim = refIsWidth ? item.width : item.height;
|
||||
const otherDim = refIsWidth ? item.height : item.width;
|
||||
const ratioRef = refIsWidth ? ratioW : ratioH;
|
||||
const ratioOther = refIsWidth ? ratioH : ratioW;
|
||||
const cropStart = Math.max(0, (pc.cropPx || 0) + (pc.offsetPx || 0));
|
||||
const cropEnd = Math.max(0, (pc.cropPx || 0) - (pc.offsetPx || 0));
|
||||
const newRefDim = refDim - cropStart - cropEnd;
|
||||
if (newRefDim <= 0) { presetCropWarning = true; break; }
|
||||
const newOtherDim = Math.round(newRefDim * (ratioOther / ratioRef));
|
||||
if (newOtherDim > otherDim) { presetCropWarning = true; break; }
|
||||
}
|
||||
}
|
||||
|
||||
// You should only allow to resize a side
|
||||
let input: React.ReactNode = null;
|
||||
if (resizeMethod === "fitWidth") {
|
||||
input = (
|
||||
<div className={style.resizeInput}>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
placeholder={gstate.locale?.optionPannel?.widthPlaceholder}
|
||||
value={homeState.tempOption.resize.width}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.resize.width = value!;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (resizeMethod === "fitHeight") {
|
||||
input = (
|
||||
<div className={style.resizeInput}>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
placeholder={gstate.locale?.optionPannel?.heightPlaceholder}
|
||||
value={homeState.tempOption.resize.height}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.resize.height = value!;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (resizeMethod === "setShort") {
|
||||
input = (
|
||||
<div className={style.resizeInput}>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
placeholder={gstate.locale?.optionPannel?.shortPlaceholder}
|
||||
value={homeState.tempOption.resize.short}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.resize.short = value!;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (resizeMethod === "setLong") {
|
||||
input = (
|
||||
<div className={style.resizeInput}>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
placeholder={gstate.locale?.optionPannel?.longPlaceholder}
|
||||
value={homeState.tempOption.resize.long}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.resize.long = value!;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (resizeMethod === "setCropRatio") {
|
||||
input = (
|
||||
<Flex align="center" className={style.cropInput}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
placeholder={gstate.locale?.optionPannel?.cwRatioPlaceholder}
|
||||
value={homeState.tempOption.resize.cropWidthRatio}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.resize.cropWidthRatio = value!;
|
||||
}}
|
||||
/>
|
||||
<div>:</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
placeholder={gstate.locale?.optionPannel?.chRatioPlaceholder}
|
||||
value={homeState.tempOption.resize.cropHeightRatio}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.resize.cropHeightRatio = value!;
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
} else if (resizeMethod === "setCropSize") {
|
||||
input = (
|
||||
<Flex align="center" className={style.cropInput}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
placeholder={gstate.locale?.optionPannel?.cwSizePlaceholder}
|
||||
value={homeState.tempOption.resize.cropWidthSize}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.resize.cropWidthSize = value!;
|
||||
}}
|
||||
/>
|
||||
<div>×</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
placeholder={gstate.locale?.optionPannel?.chSizePlaceholder}
|
||||
value={homeState.tempOption.resize.cropHeightSize}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.resize.cropHeightSize = value!;
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
} else if (resizeMethod === "presetCrop" && pc) {
|
||||
input = (
|
||||
<div className={style.presetCropInput}>
|
||||
<div className={style.presetRow}>
|
||||
<span>{gstate.locale?.optionPannel?.presetPaperSize}</span>
|
||||
<Select
|
||||
value={pc.paperSize}
|
||||
options={paperSizeOptions}
|
||||
disabled={disabled}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.resize = {
|
||||
...homeState.tempOption.resize,
|
||||
presetCrop: { ...pc, paperSize: value },
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.presetRow}>
|
||||
<span>{gstate.locale?.optionPannel?.presetOrientation}</span>
|
||||
<Select
|
||||
value={pc.orientation}
|
||||
options={[
|
||||
{ value: "portrait", label: gstate.locale?.optionPannel?.presetPortrait },
|
||||
{ value: "landscape", label: gstate.locale?.optionPannel?.presetLandscape },
|
||||
]}
|
||||
disabled={disabled}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.resize = {
|
||||
...homeState.tempOption.resize,
|
||||
presetCrop: { ...pc, orientation: value },
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.presetRow}>
|
||||
<span>{gstate.locale?.optionPannel?.presetRefWidth}</span>
|
||||
<Select
|
||||
value={pc.reference}
|
||||
options={[
|
||||
{ value: "width", label: gstate.locale?.optionPannel?.presetRefWidth },
|
||||
{ value: "height", label: gstate.locale?.optionPannel?.presetRefHeight },
|
||||
]}
|
||||
disabled={disabled}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.resize = {
|
||||
...homeState.tempOption.resize,
|
||||
presetCrop: { ...pc, reference: value },
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.presetRow}>
|
||||
<span>{gstate.locale?.optionPannel?.presetCropPx}</span>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1000}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
value={pc.cropPx}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.resize = {
|
||||
...homeState.tempOption.resize,
|
||||
presetCrop: { ...pc, cropPx: value ?? 0 },
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.presetRow}>
|
||||
<span>{gstate.locale?.optionPannel?.presetOffsetPx}</span>
|
||||
<InputNumber
|
||||
min={-500}
|
||||
max={500}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
value={pc.offsetPx}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.resize = {
|
||||
...homeState.tempOption.resize,
|
||||
presetCrop: { ...pc, offsetPx: value ?? 0 },
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{presetCropWarning && (
|
||||
<div className={style.presetWarning}>
|
||||
<span>{gstate.locale?.optionPannel?.presetCropWarning}</span>
|
||||
<div className={style.warningActions}>
|
||||
<Button size="small" onClick={() => {
|
||||
homeState.tempOption.resize = {
|
||||
...homeState.tempOption.resize,
|
||||
presetCrop: { ...pc, reference: pc.reference === "width" ? "height" : "width" },
|
||||
};
|
||||
}}>
|
||||
{gstate.locale?.optionPannel?.presetSwitchRef}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => {
|
||||
homeState.tempOption.resize = {
|
||||
method: undefined,
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
short: undefined,
|
||||
long: undefined,
|
||||
cropWidthRatio: undefined,
|
||||
cropHeightRatio: undefined,
|
||||
cropWidthSize: undefined,
|
||||
cropHeightSize: undefined,
|
||||
};
|
||||
}}>
|
||||
{gstate.locale?.optionPannel?.presetCancelCrop}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// JPEG dont't support transparent, when convert to JPEG,
|
||||
// we should give an option to choose transparent fill color
|
||||
let colorPicker: React.ReactNode = null;
|
||||
const target = homeState.tempOption.format.target;
|
||||
if (target && ["jpg", "jpeg"].includes(target)) {
|
||||
colorPicker = (
|
||||
<OptionItem desc={gstate.locale?.optionPannel.transparentFillDesc}>
|
||||
<ColorPicker
|
||||
showText
|
||||
disabledAlpha
|
||||
disabled={disabled}
|
||||
value={homeState.tempOption.format.transparentFill}
|
||||
onChangeComplete={(value) => {
|
||||
homeState.tempOption.format.transparentFill =
|
||||
"#" + value.toHex().toUpperCase();
|
||||
}}
|
||||
/>
|
||||
</OptionItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={style.olabel}>
|
||||
{gstate.locale?.optionPannel.resizeLable}
|
||||
</div>
|
||||
<OptionItem>
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
value={resizeMethod}
|
||||
options={resizeOptions}
|
||||
disabled={disabled}
|
||||
placeholder={gstate.locale?.optionPannel.resizePlaceholder}
|
||||
allowClear
|
||||
onChange={(value) => {
|
||||
const base = {
|
||||
method: value,
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
short: undefined,
|
||||
long: undefined,
|
||||
cropWidthRatio: undefined,
|
||||
cropHeightRatio: undefined,
|
||||
cropWidthSize: undefined,
|
||||
cropHeightSize: undefined,
|
||||
} as any;
|
||||
if (value === "presetCrop") {
|
||||
homeState.tempOption.resize = {
|
||||
...base,
|
||||
presetCrop: {
|
||||
paperSize: "a4",
|
||||
orientation: "portrait",
|
||||
reference: "width",
|
||||
cropPx: 0,
|
||||
offsetPx: 0,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
homeState.tempOption.resize = base;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{input}
|
||||
</OptionItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className={style.olabel}>
|
||||
{gstate.locale?.optionPannel.outputFormat}
|
||||
</div>
|
||||
<OptionItem>
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
value={homeState.tempOption.format.target}
|
||||
options={getFormatOptions()}
|
||||
disabled={disabled}
|
||||
placeholder={gstate.locale?.optionPannel.outputFormatPlaceholder}
|
||||
allowClear
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.format.target = value;
|
||||
}}
|
||||
/>
|
||||
</OptionItem>
|
||||
{/* Colorpicker option */}
|
||||
{colorPicker}
|
||||
|
||||
<Divider />
|
||||
<div className={style.olabel}>
|
||||
{gstate.locale?.optionPannel.jpegLable}
|
||||
</div>
|
||||
|
||||
<OptionItem desc={gstate.locale?.optionPannel?.qualityTitle}>
|
||||
<Slider
|
||||
defaultValue={DefaultCompressOption.jpeg.quality}
|
||||
value={homeState.tempOption.jpeg.quality}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
disabled={disabled}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.jpeg.quality = value;
|
||||
}}
|
||||
/>
|
||||
</OptionItem>
|
||||
|
||||
<Divider />
|
||||
<div className={style.olabel}>{gstate.locale?.optionPannel.pngLable}</div>
|
||||
|
||||
<OptionItem desc={gstate.locale?.optionPannel.colorsDesc}>
|
||||
<Slider
|
||||
defaultValue={DefaultCompressOption.png.colors}
|
||||
value={homeState.tempOption.png.colors}
|
||||
min={2}
|
||||
max={256}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.png.colors = value;
|
||||
}}
|
||||
/>
|
||||
</OptionItem>
|
||||
|
||||
<OptionItem desc={gstate.locale?.optionPannel.pngDithering}>
|
||||
<Slider
|
||||
defaultValue={DefaultCompressOption.png.dithering}
|
||||
value={homeState.tempOption.png.dithering}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
disabled={disabled}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.png.dithering = value;
|
||||
}}
|
||||
/>
|
||||
</OptionItem>
|
||||
|
||||
<Divider />
|
||||
<div className={style.olabel}>{gstate.locale?.optionPannel.gifLable}</div>
|
||||
|
||||
<OptionItem>
|
||||
<Checkbox
|
||||
checked={homeState.tempOption.gif.dithering}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
homeState.tempOption.gif.dithering = event.target.checked;
|
||||
}}
|
||||
>
|
||||
{gstate.locale?.optionPannel.gifDithering}
|
||||
</Checkbox>
|
||||
</OptionItem>
|
||||
|
||||
<OptionItem desc={gstate.locale?.optionPannel.colorsDesc}>
|
||||
<Slider
|
||||
defaultValue={DefaultCompressOption.gif.colors}
|
||||
value={homeState.tempOption.gif.colors}
|
||||
min={2}
|
||||
max={256}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.gif.colors = value;
|
||||
}}
|
||||
/>
|
||||
</OptionItem>
|
||||
|
||||
{Mimes.avif && (
|
||||
<>
|
||||
<Divider />
|
||||
<div className={style.olabel}>
|
||||
{gstate.locale?.optionPannel.avifLable}
|
||||
</div>
|
||||
|
||||
<OptionItem desc={gstate.locale?.optionPannel.avifQuality}>
|
||||
<Slider
|
||||
defaultValue={DefaultCompressOption.png.colors}
|
||||
value={homeState.tempOption.avif.quality}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.avif.quality = value;
|
||||
}}
|
||||
/>
|
||||
</OptionItem>
|
||||
|
||||
<OptionItem desc={gstate.locale?.optionPannel.avifSpeed}>
|
||||
<Slider
|
||||
defaultValue={DefaultCompressOption.avif.speed}
|
||||
value={homeState.tempOption.avif.speed}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
onChange={(value) => {
|
||||
homeState.tempOption.avif.speed = value;
|
||||
}}
|
||||
/>
|
||||
</OptionItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
.file {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ForwardedRef, forwardRef } from "react";
|
||||
import style from "./index.module.scss";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { createImageList } from "@/engines/transform";
|
||||
import { Mimes } from "@/mimes";
|
||||
|
||||
export const ImageInput = observer(
|
||||
forwardRef((_, ref: ForwardedRef<HTMLInputElement>) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={style.file}
|
||||
type="file"
|
||||
multiple
|
||||
accept={Object.keys(Mimes)
|
||||
.map((item) => "." + item)
|
||||
.join(",")}
|
||||
onChange={async (event) => {
|
||||
const files = event.target.files;
|
||||
if (!files?.length) {
|
||||
event.target.value = "";
|
||||
return;
|
||||
}
|
||||
const list = Array.from(files).filter((file) => !!file);
|
||||
await createImageList(list);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
@use "sass:math";
|
||||
|
||||
$size: 16px;
|
||||
$barNum: 10;
|
||||
$barWidth: 2px;
|
||||
$barHeight: 4px;
|
||||
$barColor: #444;
|
||||
|
||||
@keyframes IndicatorRun {
|
||||
from {
|
||||
transform: rotate(0);
|
||||
}
|
||||
to {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
width: $size;
|
||||
height: $size;
|
||||
animation: IndicatorRun 0.8s steps($barNum, jump-end) infinite;
|
||||
&.large {
|
||||
width: $size + 4px;
|
||||
height: $size + 4px;
|
||||
> div {
|
||||
&::after,
|
||||
&:after {
|
||||
height: $barHeight + 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.white {
|
||||
> div {
|
||||
&::after,
|
||||
&:after {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
> div {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
&::after,
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
margin-left: math.div(-$barWidth, 2);
|
||||
width: $barWidth;
|
||||
height: $barHeight;
|
||||
background-color: $barColor;
|
||||
}
|
||||
@for $index from 1 through $barNum {
|
||||
$rotate: math.div(360deg, $barNum) * ($index - 1);
|
||||
$opacity: math.div($index, $barNum);
|
||||
&:nth-child(#{$index}) {
|
||||
transform: rotate($rotate);
|
||||
&::after,
|
||||
&:after {
|
||||
opacity: $opacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import style from "./index.module.scss";
|
||||
import clsx from "classnames";
|
||||
|
||||
export interface IndicatorProps {
|
||||
size?: "large";
|
||||
white?: boolean;
|
||||
}
|
||||
|
||||
export function Indicator({ size, white = false }: IndicatorProps) {
|
||||
const bars = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
bars.push(<div key={i} />);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
style.container,
|
||||
size === "large" && style.large,
|
||||
white && style.white,
|
||||
)}
|
||||
>
|
||||
{bars}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
> div {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import style from "./index.module.scss";
|
||||
import { Flex, theme } from "antd";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Indicator } from "../Indicator";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export const Loading = observer(() => {
|
||||
const { token } = theme.useToken();
|
||||
|
||||
return createPortal(
|
||||
<Flex align="center" justify="center" className={style.container}>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{
|
||||
borderRadius: token.borderRadius,
|
||||
}}
|
||||
>
|
||||
<Indicator size="large" white />
|
||||
</Flex>
|
||||
</Flex>,
|
||||
document.body,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
.container {
|
||||
line-height: 1;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-weight: 600;
|
||||
span {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Typography } from "antd";
|
||||
import style from "./index.module.scss";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
interface LogoProps {
|
||||
iconSize?: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const Logo = observer(({ title = "Pic Smaller" }: LogoProps) => {
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<Typography.Text>{title}</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
.container {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:global(.ant-slider) {
|
||||
margin: 11px 1px !important;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { HTMLProps } from "react";
|
||||
import style from "./index.module.scss";
|
||||
import { Typography } from "antd";
|
||||
|
||||
export interface OptionItemProps extends HTMLProps<HTMLDivElement> {
|
||||
desc?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function OptionItem(props: OptionItemProps) {
|
||||
/* eslint-disable prefer-const */
|
||||
let { desc, children, ...extra } = props;
|
||||
if (desc && typeof desc === "string") {
|
||||
desc = <Typography.Text className={style.desc}>{desc}</Typography.Text>;
|
||||
}
|
||||
return (
|
||||
<div className={style.container} {...extra}>
|
||||
<div className={style.desc}>{desc}</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.progress {
|
||||
margin-left: 8px;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import style from "./index.module.scss";
|
||||
import { Flex, Progress, Typography } from "antd";
|
||||
import { gstate } from "@/global";
|
||||
import { ArrowDownOutlined, ArrowUpOutlined } from "@ant-design/icons";
|
||||
import { homeState } from "@/states/home";
|
||||
import { formatSize } from "@/functions";
|
||||
import { useResponse } from "@/media";
|
||||
|
||||
export const ProgressHint = observer(() => {
|
||||
const info = homeState.getProgressHintInfo();
|
||||
const { isMobile } = useResponse();
|
||||
|
||||
let rate: React.ReactNode = null;
|
||||
if (info.originSize > info.outputSize) {
|
||||
rate = (
|
||||
<Typography.Text type="success" strong>
|
||||
{info.rate}%
|
||||
<ArrowDownOutlined />
|
||||
</Typography.Text>
|
||||
);
|
||||
} else {
|
||||
rate = (
|
||||
<Typography.Text type="danger" strong>
|
||||
{info.rate}%
|
||||
<ArrowUpOutlined />
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex align="center">
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={info.percent}
|
||||
// strokeColor={info.percent === 100 ? token.colorPrimary : token.colorInfo}
|
||||
strokeWidth={20}
|
||||
size={14}
|
||||
/>
|
||||
<div className={style.progress}>
|
||||
<Typography.Text strong type="success">
|
||||
{info.loadedNum}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
/ {info.totalNum}
|
||||
</Typography.Text>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Typography.Text type="secondary">
|
||||
{gstate.locale?.progress.before}:
|
||||
<Typography.Text>{formatSize(info.originSize)}</Typography.Text>
|
||||
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{gstate.locale?.progress.after}:
|
||||
<Typography.Text>{formatSize(info.outputSize)}</Typography.Text>
|
||||
|
||||
</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
<Typography.Text type="secondary">
|
||||
{gstate.locale?.progress.rate}:{rate}
|
||||
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background-color: #f7f7f7;
|
||||
border: 2px dashed #e0e0e0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
min-height: 320px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #078249;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: darken($color: #f9f9f9, $amount: 3);
|
||||
}
|
||||
|
||||
> input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #078249;
|
||||
background-color: rgba(7, 130, 73, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.inner {
|
||||
line-height: 1;
|
||||
padding: 32px 20px;
|
||||
|
||||
svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
path {
|
||||
fill: #078249;
|
||||
transition: fill 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
> span:nth-child(2) {
|
||||
margin-top: 24px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
padding: 0 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
> div:nth-child(3) {
|
||||
margin-top: 12px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
padding: 0 16px;
|
||||
line-height: 1.6;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.pasteHint {
|
||||
margin-top: 24px;
|
||||
color: #078249;
|
||||
text-align: center;
|
||||
padding: 12px 24px;
|
||||
background-color: rgba(7, 130, 73, 0.08);
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
border-left: 4px solid #078249;
|
||||
}
|
||||
}
|
||||
|
||||
.mask {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Flex, Typography, theme } from "antd";
|
||||
import style from "./index.module.scss";
|
||||
import { useEffect, useRef } from "react";
|
||||
import classNames from "classnames";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { gstate } from "@/global";
|
||||
import { ImageInput } from "../ImageInput";
|
||||
import { state } from "./state";
|
||||
import { createImageList } from "@/engines/transform";
|
||||
import { getFilesFromEntry, getFilesFromHandle } from "@/functions";
|
||||
import { sprintf } from "sprintf-js";
|
||||
import { Mimes } from "@/mimes";
|
||||
|
||||
export const UploadCard = observer(() => {
|
||||
const { token } = theme.useToken();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dragLeave = () => {
|
||||
state.dragActive = false;
|
||||
};
|
||||
const dragOver = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
state.dragActive = true;
|
||||
};
|
||||
const drop = async (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
state.dragActive = false;
|
||||
const files: Array<File> = [];
|
||||
if (event.dataTransfer?.items) {
|
||||
// https://stackoverflow.com/questions/55658851/javascript-datatransfer-items-not-persisting-through-async-calls
|
||||
const list: Array<Promise<void>> = [];
|
||||
for (const item of event.dataTransfer.items) {
|
||||
if (typeof item.getAsFileSystemHandle === "function") {
|
||||
list.push(
|
||||
(async () => {
|
||||
const handle = await item.getAsFileSystemHandle!();
|
||||
const result = await getFilesFromHandle(handle);
|
||||
files.push(...result);
|
||||
})(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (typeof item.webkitGetAsEntry === "function") {
|
||||
list.push(
|
||||
(async () => {
|
||||
const entry = await item.webkitGetAsEntry();
|
||||
if (entry) {
|
||||
const result = await getFilesFromEntry(entry);
|
||||
files.push(...result);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(list);
|
||||
} else if (event.dataTransfer?.files) {
|
||||
const list = event.dataTransfer?.files;
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
const file = list.item(index);
|
||||
if (file) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
files.length > 0 && createImageList(files);
|
||||
};
|
||||
|
||||
const target = dragRef.current!;
|
||||
target.addEventListener("dragover", dragOver);
|
||||
target.addEventListener("dragleave", dragLeave);
|
||||
target.addEventListener("drop", drop);
|
||||
|
||||
return () => {
|
||||
target.removeEventListener("dragover", dragOver);
|
||||
target.removeEventListener("dragleave", dragLeave);
|
||||
target.removeEventListener("drop", drop);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
className={classNames(style.container, state.dragActive && style.active)}
|
||||
style={{ borderRadius: token.borderRadiusLG }}
|
||||
>
|
||||
<Flex vertical align="center" className={style.inner}>
|
||||
<svg viewBox="0 0 1024 1024">
|
||||
<path d="M128 256l0 640 896 0L1024 256 128 256zM960 789.344 832 576l-145.056 120.896L576 512 192 832 192 320l768 0L960 789.344zM256 480A3 3 7560 1 0 448 480 3 3 7560 1 0 256 480zM896 128 0 128 0 768 64 768 64 192 896 192z" />
|
||||
</svg>
|
||||
<Typography.Text>{gstate.locale?.uploadCard.title}</Typography.Text>
|
||||
<div>
|
||||
{sprintf(
|
||||
gstate.locale?.uploadCard.subTitle ?? "",
|
||||
Object.keys(Mimes)
|
||||
.map((item) => item.toUpperCase())
|
||||
.join("/"),
|
||||
)}
|
||||
</div>
|
||||
<div className={style.pasteHint}>
|
||||
{gstate.locale?.uploadCard.pasteHint}
|
||||
</div>
|
||||
</Flex>
|
||||
<ImageInput ref={fileRef} />
|
||||
<div
|
||||
className={style.mask}
|
||||
ref={dragRef}
|
||||
onClick={() => {
|
||||
fileRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { observable } from "mobx";
|
||||
|
||||
export interface UploadCardState {
|
||||
dragActive: boolean;
|
||||
}
|
||||
|
||||
export const state = observable.object<UploadCardState>({
|
||||
dragActive: false,
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Reference:
|
||||
* https://github.com/packurl/wasm_avif
|
||||
*/
|
||||
|
||||
import { Mimes } from "@/mimes";
|
||||
import { avif } from "./AvifWasmModule";
|
||||
import { ImageBase, ProcessOutput } from "./ImageBase";
|
||||
|
||||
export class AvifImage extends ImageBase {
|
||||
/**
|
||||
* Encode avif image with canvas context
|
||||
* @param context
|
||||
* @param width
|
||||
* @param height
|
||||
* @param quality
|
||||
* @param speed
|
||||
* @returns
|
||||
*/
|
||||
static async encode(
|
||||
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
quality: number = 50,
|
||||
speed: number = 8,
|
||||
): Promise<Blob> {
|
||||
const imageData = context.getImageData(0, 0, width, height).data;
|
||||
const bytes = new Uint8Array(imageData);
|
||||
const result: Uint8Array = await avif(bytes, width, height, quality, speed);
|
||||
return new Blob([result], { type: Mimes.avif });
|
||||
}
|
||||
|
||||
async compress(): Promise<ProcessOutput> {
|
||||
const { width, height, x, y } = this.getOutputDimension();
|
||||
try {
|
||||
const { context } = await this.createCanvas(width, height, x, y);
|
||||
const blob = await AvifImage.encode(
|
||||
context,
|
||||
width,
|
||||
height,
|
||||
this.option.avif.quality,
|
||||
this.option.avif.speed,
|
||||
);
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
blob,
|
||||
src: URL.createObjectURL(blob),
|
||||
};
|
||||
} catch (error) {
|
||||
return this.failResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import avifWasmBinaryFile from "./avif.wasm?url";
|
||||
|
||||
/**
|
||||
* Encodes the supplied ImageData rgba array.
|
||||
* @param {Uint8Array} bytes
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @param {number} quality (1 to 100)
|
||||
* @param {number} speed (1 to 10)
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
export const avif = async (bytes, width, height, quality = 50, speed = 6) => {
|
||||
const imports = {
|
||||
wbg: {
|
||||
__wbg_log_12edb8942696c207: (p, n) => {
|
||||
new TextDecoder().decode(
|
||||
new Uint8Array(wasm.memory.buffer).subarray(p, p + n),
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
const {
|
||||
instance: { exports: wasm },
|
||||
} = await WebAssembly.instantiateStreaming(
|
||||
await fetch(avifWasmBinaryFile, { cache: "force-cache" }),
|
||||
imports,
|
||||
);
|
||||
const malloc = wasm.__wbindgen_malloc;
|
||||
const free = wasm.__wbindgen_free;
|
||||
const pointer = wasm.__wbindgen_add_to_stack_pointer;
|
||||
|
||||
const n1 = bytes.length;
|
||||
const p1 = malloc(n1, 1);
|
||||
const r = pointer(-16);
|
||||
try {
|
||||
new Uint8Array(wasm.memory.buffer).set(bytes, p1);
|
||||
wasm.avif_from_imagedata(r, p1, n1, width, height, quality, speed);
|
||||
const arr = new Int32Array(wasm.memory.buffer);
|
||||
const p2 = arr[r / 4];
|
||||
const n2 = arr[r / 4 + 1];
|
||||
const res = new Uint8Array(wasm.memory.buffer)
|
||||
.subarray(p2, p2 + n2)
|
||||
.slice();
|
||||
free(p2, n2);
|
||||
return res;
|
||||
} finally {
|
||||
pointer(16);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ImageBase, ProcessOutput } from "./ImageBase";
|
||||
|
||||
/**
|
||||
* JPEG/JPG/WEBP is compatible
|
||||
*/
|
||||
export class CanvasImage extends ImageBase {
|
||||
async compress(): Promise<ProcessOutput> {
|
||||
const dimension = this.getOutputDimension();
|
||||
const blob = await this.createBlob(
|
||||
dimension.width,
|
||||
dimension.height,
|
||||
this.option.jpeg.quality,
|
||||
dimension.x,
|
||||
dimension.y,
|
||||
);
|
||||
return {
|
||||
...dimension,
|
||||
blob,
|
||||
src: URL.createObjectURL(blob),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Reference:
|
||||
* https://github.com/renzhezhilu/gifsicle-wasm-browser
|
||||
* https://www.lcdf.org/gifsicle/man.html
|
||||
*/
|
||||
|
||||
import { gifsicle } from "./GifWasmModule";
|
||||
import { ImageBase, ProcessOutput } from "./ImageBase";
|
||||
|
||||
export class GifImage extends ImageBase {
|
||||
async compress(): Promise<ProcessOutput> {
|
||||
try {
|
||||
const { width, height, x, y } = this.getOutputDimension();
|
||||
|
||||
const commands: string[] = [
|
||||
`--optimize=3`,
|
||||
// `--resize=${width}x${height}`,
|
||||
`--colors=${this.option.gif.colors}`,
|
||||
];
|
||||
|
||||
// Crop mode
|
||||
if (width !== this.info.width || height !== this.info.height) {
|
||||
commands.push(`--crop=${x},${y}+${width}x${height}`);
|
||||
} else {
|
||||
commands.push(`--resize=${width}x${height}`);
|
||||
}
|
||||
|
||||
if (this.option.gif.dithering) {
|
||||
commands.push(`--dither=floyd-steinberg`);
|
||||
}
|
||||
commands.push(`--output=/out/${this.info.name}`);
|
||||
commands.push(this.info.name);
|
||||
const buffer = await this.info.blob.arrayBuffer();
|
||||
const result = await gifsicle({
|
||||
data: [
|
||||
{
|
||||
file: buffer,
|
||||
name: this.info.name,
|
||||
},
|
||||
],
|
||||
command: [commands.join(" ")],
|
||||
});
|
||||
|
||||
if (!Array.isArray(result) || result.length !== 1) {
|
||||
return this.failResult();
|
||||
}
|
||||
|
||||
const blob = new Blob([result[0].file], {
|
||||
type: this.info.blob.type,
|
||||
});
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
blob,
|
||||
src: URL.createObjectURL(blob),
|
||||
};
|
||||
} catch (error) {
|
||||
return this.failResult();
|
||||
}
|
||||
}
|
||||
|
||||
async preview(): Promise<ProcessOutput> {
|
||||
const { width, height, x, y } = this.getPreviewDimension();
|
||||
|
||||
const commands: string[] = [
|
||||
`--colors=${this.option.gif.colors}`,
|
||||
// `--resize=${width}x${height}`,
|
||||
`--output=/out/${this.info.name}`,
|
||||
];
|
||||
|
||||
// Crop mode
|
||||
if (width !== this.info.width || height !== this.info.height) {
|
||||
commands.push(`--crop=${x},${y}+${width}x${height}`);
|
||||
} else {
|
||||
commands.push(`--resize=${width}x${height}`);
|
||||
}
|
||||
|
||||
commands.push(this.info.name);
|
||||
|
||||
const buffer = await this.info.blob.arrayBuffer();
|
||||
const result = await gifsicle({
|
||||
data: [
|
||||
{
|
||||
file: buffer,
|
||||
name: this.info.name,
|
||||
},
|
||||
],
|
||||
command: [commands.join(" ")],
|
||||
});
|
||||
|
||||
if (!Array.isArray(result) || result.length !== 1) {
|
||||
return {
|
||||
width: this.info.width,
|
||||
height: this.info.height,
|
||||
blob: this.info.blob,
|
||||
src: URL.createObjectURL(this.info.blob),
|
||||
};
|
||||
}
|
||||
|
||||
const blob = new Blob([result[0].file], {
|
||||
type: this.info.blob.type,
|
||||
});
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
blob,
|
||||
src: URL.createObjectURL(blob),
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,450 @@
|
||||
export interface ImageInfo {
|
||||
key: number;
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
export interface CompressOption {
|
||||
preview: {
|
||||
maxSize: number;
|
||||
};
|
||||
resize: {
|
||||
method?:
|
||||
| "fitWidth"
|
||||
| "fitHeight"
|
||||
| "setShort"
|
||||
| "setLong"
|
||||
| "setCropRatio"
|
||||
| "setCropSize"
|
||||
| "presetCrop";
|
||||
width?: number;
|
||||
height?: number;
|
||||
short?: number;
|
||||
long?: number;
|
||||
cropWidthRatio?: number;
|
||||
cropHeightRatio?: number;
|
||||
cropWidthSize?: number;
|
||||
cropHeightSize?: number;
|
||||
presetCrop?: {
|
||||
paperSize: string;
|
||||
orientation: "portrait" | "landscape";
|
||||
reference: "width" | "height";
|
||||
cropPx: number;
|
||||
offsetPx: number;
|
||||
};
|
||||
};
|
||||
format: {
|
||||
target?: "jpg" | "jpeg" | "png" | "webp" | "avif";
|
||||
transparentFill: string;
|
||||
};
|
||||
jpeg: {
|
||||
quality: number; // 0-1
|
||||
};
|
||||
png: {
|
||||
colors: number; // 2-256
|
||||
dithering: number; // 0-1
|
||||
};
|
||||
gif: {
|
||||
colors: number; // 2-256
|
||||
dithering: boolean; // boolean
|
||||
};
|
||||
avif: {
|
||||
quality: number; // 1 - 100
|
||||
speed: number; // 1 - 10
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProcessOutput {
|
||||
width: number;
|
||||
height: number;
|
||||
blob: Blob;
|
||||
src: string;
|
||||
}
|
||||
|
||||
export interface Dimension {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const PAPER_SIZES: Record<
|
||||
string,
|
||||
{ label: string; width: number; height: number }
|
||||
> = {
|
||||
a3: { label: "A3", width: 297, height: 420 },
|
||||
a4: { label: "A4", width: 210, height: 297 },
|
||||
a5: { label: "A5", width: 148, height: 210 },
|
||||
letter: { label: "US Letter", width: 216, height: 279 },
|
||||
legal: { label: "US Legal", width: 216, height: 356 },
|
||||
b4: { label: "B4", width: 257, height: 364 },
|
||||
b5: { label: "B5", width: 182, height: 257 },
|
||||
};
|
||||
|
||||
export abstract class ImageBase {
|
||||
constructor(
|
||||
public info: ImageInfo,
|
||||
public option: CompressOption,
|
||||
) {}
|
||||
|
||||
abstract compress(): Promise<ProcessOutput>;
|
||||
|
||||
/**
|
||||
* Get output image dimension, based on resize param
|
||||
* @returns Dimension
|
||||
*/
|
||||
getOutputDimension(): Dimension {
|
||||
const {
|
||||
method,
|
||||
width,
|
||||
height,
|
||||
short,
|
||||
long,
|
||||
cropWidthRatio,
|
||||
cropHeightRatio,
|
||||
cropWidthSize,
|
||||
cropHeightSize,
|
||||
} = this.option.resize;
|
||||
|
||||
const originDimension = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: this.info.width,
|
||||
height: this.info.height,
|
||||
};
|
||||
|
||||
if (method === "fitWidth") {
|
||||
if (!width) {
|
||||
return originDimension;
|
||||
}
|
||||
const rate = width / this.info.width;
|
||||
const newHeight = rate * this.info.height;
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: Math.ceil(width),
|
||||
height: Math.ceil(newHeight),
|
||||
};
|
||||
}
|
||||
|
||||
if (method === "fitHeight") {
|
||||
if (!height) {
|
||||
return originDimension;
|
||||
}
|
||||
const rate = height / this.info.height;
|
||||
const newWidth = rate * this.info.width;
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: Math.ceil(newWidth),
|
||||
height: Math.ceil(height),
|
||||
};
|
||||
}
|
||||
|
||||
if (method === "setShort") {
|
||||
if (!short) {
|
||||
return originDimension;
|
||||
}
|
||||
|
||||
let newWidth: number;
|
||||
let newHeight: number;
|
||||
if (this.info.width <= this.info.height) {
|
||||
newWidth = short;
|
||||
const rate = newWidth / this.info.width;
|
||||
newHeight = rate * this.info.height;
|
||||
} else {
|
||||
newHeight = short;
|
||||
const rate = newHeight / this.info.height;
|
||||
newWidth = rate * this.info.width;
|
||||
}
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: Math.ceil(newWidth),
|
||||
height: Math.ceil(newHeight),
|
||||
};
|
||||
}
|
||||
|
||||
if (method === "setLong") {
|
||||
if (!long) {
|
||||
return originDimension;
|
||||
}
|
||||
|
||||
let newWidth: number;
|
||||
let newHeight: number;
|
||||
if (this.info.width >= this.info.height) {
|
||||
newWidth = long;
|
||||
const rate = newWidth / this.info.width;
|
||||
newHeight = rate * this.info.height;
|
||||
} else {
|
||||
newHeight = long;
|
||||
const rate = newHeight / this.info.height;
|
||||
newWidth = rate * this.info.width;
|
||||
}
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: Math.ceil(newWidth),
|
||||
height: Math.ceil(newHeight),
|
||||
};
|
||||
}
|
||||
|
||||
// Crop via ratio
|
||||
if (method === "setCropRatio") {
|
||||
if (!cropWidthRatio || !cropHeightRatio) {
|
||||
return originDimension;
|
||||
}
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let newWidth = 0;
|
||||
let newHeight = 0;
|
||||
|
||||
if (
|
||||
cropWidthRatio / cropHeightRatio >=
|
||||
this.info.width / this.info.height
|
||||
) {
|
||||
x = 0;
|
||||
newWidth = this.info.width;
|
||||
newHeight = (this.info.width * cropHeightRatio) / cropWidthRatio;
|
||||
y = (this.info.height - newHeight) / 2;
|
||||
} else {
|
||||
y = 0;
|
||||
newHeight = this.info.height;
|
||||
newWidth = (this.info.height * cropWidthRatio) / cropHeightRatio;
|
||||
x = (this.info.width - newWidth) / 2;
|
||||
}
|
||||
|
||||
return {
|
||||
x: Math.ceil(x),
|
||||
y: Math.ceil(y),
|
||||
width: Math.ceil(newWidth),
|
||||
height: Math.ceil(newHeight),
|
||||
};
|
||||
}
|
||||
|
||||
// Crop via special width and height
|
||||
if (method === "setCropSize") {
|
||||
if (!cropWidthSize || !cropHeightSize) {
|
||||
return originDimension;
|
||||
}
|
||||
|
||||
let newWidth = cropWidthSize;
|
||||
let newHeight = cropHeightSize;
|
||||
|
||||
if (cropWidthSize >= this.info.width) {
|
||||
newWidth = this.info.width;
|
||||
}
|
||||
|
||||
if (cropHeightSize >= this.info.height) {
|
||||
newHeight = this.info.height;
|
||||
}
|
||||
|
||||
const x = (this.info.width - newWidth) / 2;
|
||||
const y = (this.info.height - newHeight) / 2;
|
||||
|
||||
return {
|
||||
x: Math.ceil(x),
|
||||
y: Math.ceil(y),
|
||||
width: Math.ceil(newWidth),
|
||||
height: Math.ceil(newHeight),
|
||||
};
|
||||
}
|
||||
|
||||
// Crop via preset paper size
|
||||
if (method === "presetCrop") {
|
||||
if (!this.option.resize.presetCrop) {
|
||||
return originDimension;
|
||||
}
|
||||
|
||||
const { paperSize, orientation, reference, cropPx, offsetPx } =
|
||||
this.option.resize.presetCrop;
|
||||
const paper = PAPER_SIZES[paperSize];
|
||||
if (!paper || cropPx == null || offsetPx == null) {
|
||||
return originDimension;
|
||||
}
|
||||
|
||||
let ratioW = paper.width;
|
||||
let ratioH = paper.height;
|
||||
if (orientation === "landscape") {
|
||||
ratioW = paper.height;
|
||||
ratioH = paper.width;
|
||||
}
|
||||
|
||||
const refIsWidth = reference === "width";
|
||||
const refDim = refIsWidth ? this.info.width : this.info.height;
|
||||
const otherDim = refIsWidth ? this.info.height : this.info.width;
|
||||
const ratioRef = refIsWidth ? ratioW : ratioH;
|
||||
const ratioOther = refIsWidth ? ratioH : ratioW;
|
||||
|
||||
const cropStart = Math.max(0, cropPx + offsetPx);
|
||||
const cropEnd = Math.max(0, cropPx - offsetPx);
|
||||
const newRefDim = refDim - cropStart - cropEnd;
|
||||
|
||||
if (newRefDim <= 0) {
|
||||
return originDimension;
|
||||
}
|
||||
|
||||
const newOtherDim = Math.round(newRefDim * (ratioOther / ratioRef));
|
||||
|
||||
if (newOtherDim > otherDim) {
|
||||
return originDimension;
|
||||
}
|
||||
|
||||
const refOffset = cropStart;
|
||||
const otherOffset = Math.round((otherDim - newOtherDim) / 2);
|
||||
|
||||
if (refIsWidth) {
|
||||
return {
|
||||
x: refOffset,
|
||||
y: otherOffset,
|
||||
width: Math.ceil(newRefDim),
|
||||
height: Math.ceil(newOtherDim),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
x: otherOffset,
|
||||
y: refOffset,
|
||||
width: Math.ceil(newOtherDim),
|
||||
height: Math.ceil(newRefDim),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return originDimension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return original info when process fails
|
||||
* @returns
|
||||
*/
|
||||
failResult(): ProcessOutput {
|
||||
return {
|
||||
width: this.info.width,
|
||||
height: this.info.height,
|
||||
blob: this.info.blob,
|
||||
src: URL.createObjectURL(this.info.blob),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preview image size via option
|
||||
* @returns Dimension
|
||||
*/
|
||||
getPreviewDimension(): Dimension {
|
||||
const maxSize = this.option.preview.maxSize;
|
||||
if (Math.max(this.info.width, this.info.height) <= maxSize) {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: this.info.width,
|
||||
height: this.info.height,
|
||||
};
|
||||
}
|
||||
|
||||
let width, height: number;
|
||||
if (this.info.width >= this.info.height) {
|
||||
const rate = maxSize / this.info.width;
|
||||
width = maxSize;
|
||||
height = rate * this.info.height;
|
||||
} else {
|
||||
const rate = maxSize / this.info.height;
|
||||
width = rate * this.info.width;
|
||||
height = maxSize;
|
||||
}
|
||||
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: Math.ceil(width),
|
||||
height: Math.ceil(height),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preview from native browser method
|
||||
* @returns
|
||||
*/
|
||||
async preview(): Promise<ProcessOutput> {
|
||||
const { width, height, x, y } = this.getPreviewDimension();
|
||||
const blob = await this.createBlob(width, height, x, y);
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
blob,
|
||||
src: URL.createObjectURL(blob),
|
||||
};
|
||||
}
|
||||
|
||||
async createCanvas(
|
||||
width: number,
|
||||
height: number,
|
||||
cropX: number = 0,
|
||||
cropY: number = 0,
|
||||
): Promise<{
|
||||
canvas: OffscreenCanvas;
|
||||
context: OffscreenCanvasRenderingContext2D;
|
||||
}> {
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const context = canvas.getContext("2d")!;
|
||||
const image = await createImageBitmap(this.info.blob);
|
||||
|
||||
const method = this.option.resize.method;
|
||||
if (method && ["setCropRatio", "setCropSize", "presetCrop"].includes(method)) {
|
||||
// Crop mode only
|
||||
context?.drawImage(
|
||||
image,
|
||||
cropX,
|
||||
cropY,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
} else {
|
||||
// Resize mode only
|
||||
context?.drawImage(
|
||||
image,
|
||||
0,
|
||||
0,
|
||||
this.info.width,
|
||||
this.info.height,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
}
|
||||
|
||||
image.close();
|
||||
return { canvas, context };
|
||||
}
|
||||
|
||||
/**
|
||||
* create OffscreenCanvas from Blob
|
||||
* @param width
|
||||
* @param height
|
||||
* @param quality
|
||||
* @param cropX
|
||||
* @param cropY
|
||||
* @returns
|
||||
*/
|
||||
async createBlob(
|
||||
width: number,
|
||||
height: number,
|
||||
quality = 0.6,
|
||||
cropX = 0,
|
||||
cropY = 0,
|
||||
) {
|
||||
const { canvas } = await this.createCanvas(width, height, cropX, cropY);
|
||||
const opiton: ImageEncodeOptions = {
|
||||
type: this.info.blob.type,
|
||||
quality,
|
||||
};
|
||||
return canvas.convertToBlob(opiton);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Reference:
|
||||
* https://github.com/antelle/wasm-image-compressor
|
||||
*/
|
||||
|
||||
import { ImageBase, ProcessOutput } from "./ImageBase";
|
||||
import { Module } from "./PngWasmModule";
|
||||
|
||||
export class PngImage extends ImageBase {
|
||||
async compress(): Promise<ProcessOutput> {
|
||||
const { width, height, x, y } = this.getOutputDimension();
|
||||
const { context } = await this.createCanvas(width, height, x, y);
|
||||
const imageData = context.getImageData(0, 0, width, height).data;
|
||||
|
||||
try {
|
||||
const buffer = Module._malloc(imageData.byteLength);
|
||||
Module.HEAPU8.set(imageData, buffer);
|
||||
const imageDataLen = width * height * 4;
|
||||
if (imageData.byteLength !== imageDataLen) {
|
||||
return this.failResult();
|
||||
}
|
||||
const outputSizePointer = Module._malloc(4);
|
||||
|
||||
const result = Module._compress(
|
||||
width,
|
||||
height,
|
||||
this.option.png.colors,
|
||||
this.option.png.dithering,
|
||||
buffer,
|
||||
outputSizePointer,
|
||||
);
|
||||
if (result) {
|
||||
return this.failResult();
|
||||
}
|
||||
const outputSize = Module.getValue(outputSizePointer, "i32", false);
|
||||
const output = new Uint8Array(outputSize);
|
||||
output.set(Module.HEAPU8.subarray(buffer, buffer + outputSize));
|
||||
|
||||
Module._free(buffer);
|
||||
Module._free(outputSizePointer);
|
||||
|
||||
const blob = new Blob([output], { type: this.info.blob.type });
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
blob,
|
||||
src: URL.createObjectURL(blob),
|
||||
};
|
||||
} catch (error) {
|
||||
return this.failResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
export type Task = () => Promise<void>;
|
||||
|
||||
export class Queue {
|
||||
// Current task list
|
||||
list: Array<Task> = [];
|
||||
// Indicate whether task queue running
|
||||
isRunning: boolean = false;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param max Maximun concurrent task number
|
||||
*/
|
||||
constructor(private max: number = 1) {}
|
||||
|
||||
/**
|
||||
* Add new task for executing
|
||||
* @param task
|
||||
*/
|
||||
public push(task: Task) {
|
||||
this.list.push(task);
|
||||
if (!this.isRunning) {
|
||||
this.do();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a batch of tasks
|
||||
* @returns
|
||||
*/
|
||||
private async do() {
|
||||
// If list is empty, end run
|
||||
if (this.list.length === 0) {
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
const takeList: Array<Task> = [];
|
||||
for (let i = 0; i < this.max; i++) {
|
||||
const task = this.list.shift();
|
||||
if (task) {
|
||||
takeList.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute all task
|
||||
const runningList = takeList.map((task) => task());
|
||||
await Promise.all(runningList);
|
||||
|
||||
// Execute next batch
|
||||
await this.do();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Mimes } from "@/mimes";
|
||||
import { ImageBase, ProcessOutput } from "./ImageBase";
|
||||
import { optimize } from "svgo/lib/svgo";
|
||||
|
||||
/**
|
||||
* JPEG/JPG/WEBP is compatible
|
||||
*/
|
||||
export class SvgImage extends ImageBase {
|
||||
async compress(): Promise<ProcessOutput> {
|
||||
if (this.info.width === 0 || this.info.height === 0) {
|
||||
return this.failResult();
|
||||
}
|
||||
|
||||
const data = await this.info.blob.text();
|
||||
|
||||
const result = optimize(data);
|
||||
const blob = new Blob([result.data], { type: Mimes.svg });
|
||||
return {
|
||||
width: this.info.width,
|
||||
height: this.info.height,
|
||||
blob,
|
||||
src: URL.createObjectURL(blob),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* We will do nothing for svg preview
|
||||
* @returns
|
||||
*/
|
||||
async preview(): Promise<ProcessOutput> {
|
||||
return this.failResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Queue } from "./Queue";
|
||||
import { MessageData, convert } from "./handler";
|
||||
import { avifCheck } from "./support";
|
||||
|
||||
(async () => {
|
||||
// Ensure avif check in worker
|
||||
await avifCheck();
|
||||
const queue = new Queue(3);
|
||||
|
||||
globalThis.addEventListener(
|
||||
"message",
|
||||
async (event: MessageEvent<MessageData>) => {
|
||||
queue.push(async () => {
|
||||
const output = await convert(event.data, "compress");
|
||||
if (output) {
|
||||
globalThis.postMessage(output);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
})();
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Queue } from "./Queue";
|
||||
import { MessageData, convert } from "./handler";
|
||||
import { avifCheck } from "./support";
|
||||
|
||||
(async () => {
|
||||
// Ensure avif check in worker
|
||||
await avifCheck();
|
||||
const queue = new Queue(3);
|
||||
|
||||
globalThis.addEventListener(
|
||||
"message",
|
||||
async (event: MessageEvent<MessageData>) => {
|
||||
queue.push(async () => {
|
||||
const output = await convert(event.data, "preview");
|
||||
if (output) {
|
||||
globalThis.postMessage(output);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
})();
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
CompressOption,
|
||||
ImageBase,
|
||||
ImageInfo,
|
||||
ProcessOutput,
|
||||
} from "./ImageBase";
|
||||
import { GifImage } from "./GifImage";
|
||||
import { CanvasImage } from "./CanvasImage";
|
||||
import { PngImage } from "./PngImage";
|
||||
import { AvifImage } from "./AvifImage";
|
||||
import { Mimes } from "@/mimes";
|
||||
import { SvgImage } from "./SvgImage";
|
||||
import { getSvgDimension } from "./svgParse";
|
||||
|
||||
export interface MessageData {
|
||||
info: ImageInfo;
|
||||
option: CompressOption;
|
||||
}
|
||||
|
||||
export interface OutputMessageData extends Omit<ImageInfo, "name" | "blob"> {
|
||||
compress?: ProcessOutput;
|
||||
preview?: ProcessOutput;
|
||||
}
|
||||
|
||||
export type HandleMethod = "compress" | "preview";
|
||||
|
||||
export async function convert(
|
||||
data: MessageData,
|
||||
method: HandleMethod = "compress",
|
||||
): Promise<OutputMessageData | null> {
|
||||
const mime = data.info.blob.type.toLowerCase();
|
||||
|
||||
// For SVG type, do not support type convert
|
||||
if (Mimes.svg === mime) {
|
||||
// SVG has dimension already
|
||||
if (data.info.width > 0 && data.info.height > 0) {
|
||||
return createHandler(data, method);
|
||||
}
|
||||
|
||||
// If SVG has no dimension from main thread
|
||||
const svgData = await data.info.blob.text();
|
||||
let dimension = { width: 0, height: 0 };
|
||||
try {
|
||||
dimension = getSvgDimension(svgData);
|
||||
} catch (error) {}
|
||||
data.info.width = dimension.width;
|
||||
data.info.height = dimension.height;
|
||||
|
||||
return createHandler(data, method);
|
||||
}
|
||||
|
||||
// For JPG/JPEG/WEBP/AVIF/PNG/GIF type
|
||||
const bitmap = await createImageBitmap(data.info.blob);
|
||||
data.info.width = bitmap.width;
|
||||
data.info.height = bitmap.height;
|
||||
|
||||
// Type convert logic here
|
||||
if (
|
||||
// Only compress task need convert
|
||||
method === "compress" &&
|
||||
// If there is no target type, don't need convert
|
||||
data.option.format.target &&
|
||||
// If target type is equal to original type, don't need convert
|
||||
data.option.format.target !== data.info.blob.type
|
||||
) {
|
||||
const target = data.option.format.target.toLowerCase();
|
||||
|
||||
// Currently no browsers support creation of an AVIF from a canvas
|
||||
// So we should encode AVIF image type using webassembly, and the
|
||||
// result blob don't need compress agin, return it directly
|
||||
if (target === "avif") {
|
||||
return createHandler(data, method, Mimes.avif);
|
||||
}
|
||||
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
const context = canvas.getContext("2d")!;
|
||||
|
||||
// JPEG format don't support transparent, we should set a background
|
||||
if (["jpg", "jpeg"].includes(target)) {
|
||||
context.fillStyle = data.option.format.transparentFill;
|
||||
context.fillRect(0, 0, bitmap.width, bitmap.height);
|
||||
}
|
||||
context.drawImage(
|
||||
bitmap,
|
||||
0,
|
||||
0,
|
||||
bitmap.width,
|
||||
bitmap.height,
|
||||
0,
|
||||
0,
|
||||
bitmap.width,
|
||||
bitmap.height,
|
||||
);
|
||||
|
||||
data.info.blob = await canvas.convertToBlob({
|
||||
type: Mimes[target],
|
||||
quality: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Release bitmap
|
||||
bitmap.close();
|
||||
|
||||
return createHandler(data, method);
|
||||
}
|
||||
|
||||
export async function createHandler(
|
||||
data: MessageData,
|
||||
method: HandleMethod,
|
||||
specify?: string,
|
||||
): Promise<OutputMessageData | null> {
|
||||
let mime = data.info.blob.type.toLowerCase();
|
||||
if (specify) {
|
||||
mime = specify;
|
||||
}
|
||||
let image: ImageBase | null = null;
|
||||
if ([Mimes.jpg, Mimes.webp].includes(mime)) {
|
||||
image = new CanvasImage(data.info, data.option);
|
||||
} else if (mime === Mimes.avif) {
|
||||
image = new AvifImage(data.info, data.option);
|
||||
} else if (mime === Mimes.png) {
|
||||
image = new PngImage(data.info, data.option);
|
||||
} else if (mime === Mimes.gif) {
|
||||
image = new GifImage(data.info, data.option);
|
||||
} else if (mime === Mimes.svg) {
|
||||
image = new SvgImage(data.info, data.option);
|
||||
}
|
||||
|
||||
// Unsupported handler type, return it
|
||||
if (!image) return null;
|
||||
|
||||
const result: OutputMessageData = {
|
||||
key: image.info.key,
|
||||
width: image.info.width,
|
||||
height: image.info.height,
|
||||
};
|
||||
|
||||
if (image && method === "preview") {
|
||||
result.preview = await image.preview();
|
||||
return result;
|
||||
}
|
||||
|
||||
if (image && method === "compress") {
|
||||
result.compress = await image.compress();
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,24 @@
|
||||
import { Mimes } from "@/mimes";
|
||||
|
||||
const MimeAvif = "image/avif";
|
||||
|
||||
/**
|
||||
* 检测Avif图片格式是否被支持
|
||||
* @returns
|
||||
*/
|
||||
async function isAvifSupport() {
|
||||
const canvas = new OffscreenCanvas(1, 1);
|
||||
canvas.getContext("2d");
|
||||
try {
|
||||
await canvas.convertToBlob({ type: MimeAvif });
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function avifCheck() {
|
||||
if (await isAvifSupport()) {
|
||||
Mimes.avif = MimeAvif;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { homeState } from "@/states/home";
|
||||
import { ProcessOutput } from "./ImageBase";
|
||||
import { AvifImage } from "./AvifImage";
|
||||
import { Mimes } from "@/mimes";
|
||||
|
||||
/**
|
||||
* Convert SVG type to other, SVG convert can't do in worker
|
||||
* @param input SVG compress result in worker
|
||||
* @returns
|
||||
*/
|
||||
export async function svgConvert(input: ProcessOutput): Promise<ProcessOutput> {
|
||||
if (!homeState.option.format.target) {
|
||||
return input;
|
||||
}
|
||||
const target = homeState.option.format.target.toLowerCase();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = input.width;
|
||||
canvas.height = input.height;
|
||||
const context = canvas.getContext("2d")!;
|
||||
if (["jpg", "jpeg"].includes(target)) {
|
||||
context.fillStyle = homeState.option.format.transparentFill;
|
||||
context.fillRect(0, 0, input.width, input.height);
|
||||
}
|
||||
const svg = await new Promise<HTMLImageElement>((resolve) => {
|
||||
const img = new Image();
|
||||
img.src = input.src;
|
||||
img.onload = () => resolve(img);
|
||||
});
|
||||
context.drawImage(
|
||||
svg,
|
||||
0,
|
||||
0,
|
||||
input.width,
|
||||
input.height,
|
||||
0,
|
||||
0,
|
||||
input.width,
|
||||
input.height,
|
||||
);
|
||||
|
||||
// Convert svg to target type
|
||||
let blob: Blob;
|
||||
if (target === "avif") {
|
||||
blob = await AvifImage.encode(
|
||||
context,
|
||||
input.width,
|
||||
input.height,
|
||||
homeState.option.avif.quality,
|
||||
homeState.option.avif.speed,
|
||||
);
|
||||
} else {
|
||||
blob = await new Promise<Blob>((resolve) => {
|
||||
canvas.toBlob(
|
||||
(result) => {
|
||||
resolve(result!);
|
||||
},
|
||||
Mimes[target],
|
||||
1,
|
||||
);
|
||||
});
|
||||
}
|
||||
input.blob = blob;
|
||||
input.src = URL.createObjectURL(blob);
|
||||
return input;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Reference: https://github.com/image-size/image-size/blob/main/lib/types/svg.ts
|
||||
*/
|
||||
|
||||
import { Dimension } from "./ImageBase";
|
||||
|
||||
type IAttributes = {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
viewbox?: IAttributes | null;
|
||||
};
|
||||
|
||||
const svgReg = /<svg\s([^>"']|"[^"]*"|'[^']*')*>/;
|
||||
|
||||
const extractorRegExps = {
|
||||
height: /\sheight=(['"])([^%]+?)\1/,
|
||||
root: svgReg,
|
||||
viewbox: /\sviewBox=(['"])(.+?)\1/i,
|
||||
width: /\swidth=(['"])([^%]+?)\1/,
|
||||
};
|
||||
|
||||
const INCH_CM = 2.54;
|
||||
const units: { [unit: string]: number } = {
|
||||
in: 96,
|
||||
cm: 96 / INCH_CM,
|
||||
em: 16,
|
||||
ex: 8,
|
||||
m: (96 / INCH_CM) * 100,
|
||||
mm: 96 / INCH_CM / 10,
|
||||
pc: 96 / 72 / 12,
|
||||
pt: 96 / 72,
|
||||
px: 1,
|
||||
};
|
||||
|
||||
const unitsReg = new RegExp(
|
||||
`^([0-9.]+(?:e\\d+)?)(${Object.keys(units).join("|")})?$`,
|
||||
);
|
||||
|
||||
function parseLength(len: string) {
|
||||
const m = unitsReg.exec(len);
|
||||
if (!m) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.round(Number(m[1]) * (units[m[2]] || 1));
|
||||
}
|
||||
|
||||
function parseViewbox(viewbox: string): IAttributes {
|
||||
const bounds = viewbox.split(" ");
|
||||
return {
|
||||
height: parseLength(bounds[3]) as number,
|
||||
width: parseLength(bounds[2]) as number,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAttributes(root: string): IAttributes {
|
||||
const width = root.match(extractorRegExps.width);
|
||||
const height = root.match(extractorRegExps.height);
|
||||
const viewbox = root.match(extractorRegExps.viewbox);
|
||||
return {
|
||||
height: height && (parseLength(height[2]) as number),
|
||||
viewbox: viewbox && (parseViewbox(viewbox[2]) as IAttributes),
|
||||
width: width && (parseLength(width[2]) as number),
|
||||
};
|
||||
}
|
||||
|
||||
function calculateByDimensions(attrs: IAttributes): Dimension {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
height: attrs.height as number,
|
||||
width: attrs.width as number,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateByViewbox(
|
||||
attrs: IAttributes,
|
||||
viewbox: IAttributes,
|
||||
): Dimension {
|
||||
const ratio = (viewbox.width as number) / (viewbox.height as number);
|
||||
if (attrs.width) {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
height: Math.floor(attrs.width / ratio),
|
||||
width: attrs.width,
|
||||
};
|
||||
}
|
||||
if (attrs.height) {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
height: attrs.height,
|
||||
width: Math.floor(attrs.height * ratio),
|
||||
};
|
||||
}
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
height: viewbox.height as number,
|
||||
width: viewbox.width as number,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSvgDimension(input: string): Dimension {
|
||||
const root = input.match(extractorRegExps.root);
|
||||
if (root) {
|
||||
const attrs = parseAttributes(root[0]);
|
||||
if (attrs.width && attrs.height) {
|
||||
return calculateByDimensions(attrs);
|
||||
}
|
||||
if (attrs.viewbox) {
|
||||
return calculateByViewbox(attrs, attrs.viewbox);
|
||||
}
|
||||
}
|
||||
throw new TypeError("Invalid SVG");
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import WorkerC from "./WorkerCompress?worker";
|
||||
import WorkerP from "./WorkerPreview?worker";
|
||||
import { useEffect } from "react";
|
||||
import { uniqId } from "@/functions";
|
||||
import { toJS } from "mobx";
|
||||
import { ImageItem, homeState } from "@/states/home";
|
||||
import { CompressOption, Dimension, ImageInfo } from "./ImageBase";
|
||||
import { OutputMessageData } from "./handler";
|
||||
import { Mimes } from "@/mimes";
|
||||
import { svgConvert } from "./svgConvert";
|
||||
|
||||
export interface MessageData {
|
||||
info: ImageInfo;
|
||||
option: CompressOption;
|
||||
}
|
||||
|
||||
let workerC: Worker | null = null;
|
||||
let workerP: Worker | null = null;
|
||||
|
||||
async function message(event: MessageEvent<OutputMessageData>) {
|
||||
const value = homeState.list.get(event.data.key);
|
||||
if (!value) return;
|
||||
|
||||
const item = toJS(value);
|
||||
item.width = event.data.width;
|
||||
item.height = event.data.height;
|
||||
item.compress = event.data.compress ?? item.compress;
|
||||
item.preview = event.data.preview ?? item.preview;
|
||||
|
||||
// SVG can't convert in worker,so we do converting here
|
||||
if (item.blob.type === Mimes.svg && event.data.compress) {
|
||||
await svgConvert(item.compress!);
|
||||
}
|
||||
|
||||
homeState.list.set(item.key, item);
|
||||
}
|
||||
|
||||
export function useWorkerHandler() {
|
||||
useEffect(() => {
|
||||
workerC = new WorkerC();
|
||||
workerP = new WorkerP();
|
||||
workerC.addEventListener("message", message);
|
||||
workerP.addEventListener("message", message);
|
||||
|
||||
return () => {
|
||||
workerC!.removeEventListener("message", message);
|
||||
workerP!.removeEventListener("message", message);
|
||||
workerC!.terminate();
|
||||
workerP!.terminate();
|
||||
workerC = null;
|
||||
workerP = null;
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
function createMessageData(item: ImageInfo): MessageData {
|
||||
return {
|
||||
/**
|
||||
* Why not use the spread operator here?
|
||||
* Because it causes an error when used this way,
|
||||
* and the exact reason is unknown at the moment.
|
||||
*
|
||||
* error: `Uncaught (in promise) DOMException: Failed to execute 'postMessage' on 'Worker': #<Object> could not be cloned.`
|
||||
* Reproduction method: In the second upload, include the same images as in the first.
|
||||
*/
|
||||
info: {
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
blob: item.blob,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
},
|
||||
option: toJS(homeState.option),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCompressTask(item: ImageItem) {
|
||||
workerC?.postMessage(createMessageData(item));
|
||||
}
|
||||
|
||||
function createPreviewTask(item: ImageItem) {
|
||||
workerP?.postMessage(createMessageData(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle image files
|
||||
* @param files
|
||||
*/
|
||||
export async function createImageList(files: Array<File>) {
|
||||
const infoListPromise = files.map(async (file) => {
|
||||
const info: ImageItem = {
|
||||
key: uniqId(),
|
||||
name: file.name,
|
||||
blob: file,
|
||||
width: 0,
|
||||
height: 0,
|
||||
src: URL.createObjectURL(file),
|
||||
};
|
||||
|
||||
// Due to createImageBitmap do not support SVG blob,
|
||||
// we should get dimension of SVG via Image
|
||||
if (file.type === Mimes.svg) {
|
||||
const { width, height } = await new Promise<Dimension>((resolve) => {
|
||||
const img = new Image();
|
||||
img.src = info.src;
|
||||
img.onload = () => {
|
||||
resolve({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
});
|
||||
};
|
||||
});
|
||||
info.width = width;
|
||||
info.height = height;
|
||||
}
|
||||
|
||||
return info;
|
||||
});
|
||||
|
||||
(await Promise.all(infoListPromise)).forEach((item: ImageItem) => {
|
||||
homeState.list.set(item.key, item);
|
||||
});
|
||||
|
||||
homeState.option = toJS(homeState.tempOption);
|
||||
homeState.list.forEach((item) => {
|
||||
createPreviewTask(item);
|
||||
createCompressTask(item);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { filesize } from "filesize";
|
||||
import { Mimes } from "./mimes";
|
||||
import type { ImageItem } from "./states/home";
|
||||
import type { CompressOption } from "./engines/ImageBase";
|
||||
|
||||
/**
|
||||
* Normalize pathname
|
||||
* @param pathname
|
||||
* @param base
|
||||
* @returns
|
||||
*/
|
||||
export function normalize(pathname: string, base = import.meta.env.BASE_URL) {
|
||||
// Ensure starts with '/'
|
||||
pathname = "/" + pathname.replace(/^\/*/, "");
|
||||
base = "/" + base.replace(/^\/*/, "");
|
||||
if (!pathname.startsWith(base)) return "error404";
|
||||
return pathname.substring(base.length).replace(/^\/*|\/*$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Globaly uniqid in browser session lifecycle
|
||||
*/
|
||||
let __UniqIdIndex = 0;
|
||||
export function uniqId() {
|
||||
__UniqIdIndex += 1;
|
||||
return __UniqIdIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beautify byte size
|
||||
* @param num byte size
|
||||
* @returns
|
||||
*/
|
||||
export function formatSize(num: number) {
|
||||
const result = filesize(num, { standard: "jedec", output: "array" });
|
||||
return result[0] + " " + result[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a download dialog from browser
|
||||
* @param name
|
||||
* @param blob
|
||||
*/
|
||||
export function createDownload(name: string, blob: Blob) {
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = URL.createObjectURL(blob);
|
||||
anchor.download = name;
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* If names Set already has name, add suffix '(1)' for the name
|
||||
* which will newly pushed to names set
|
||||
*
|
||||
* @param names will checked names Set
|
||||
* @param name will pushed to names
|
||||
*/
|
||||
export function getUniqNameOnNames(names: Set<string>, name: string): string {
|
||||
const getName = (checkName: string): string => {
|
||||
if (names.has(checkName)) {
|
||||
const nameParts = checkName.split(".");
|
||||
const extension = nameParts.pop();
|
||||
const newName = nameParts.join("") + "(1)." + extension;
|
||||
return getName(newName);
|
||||
} else {
|
||||
return checkName;
|
||||
}
|
||||
};
|
||||
return getName(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait some time
|
||||
* @param millisecond
|
||||
* @returns
|
||||
*/
|
||||
export async function wait(millisecond: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, millisecond);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload image by src
|
||||
* @param src
|
||||
*/
|
||||
export async function preloadImage(src: string) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const img = new Image();
|
||||
img.src = src;
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file list from FileSystemEntry
|
||||
* @param entry
|
||||
* @returns
|
||||
*/
|
||||
export async function getFilesFromEntry(
|
||||
entry: FileSystemEntry,
|
||||
): Promise<Array<File>> {
|
||||
// If entry is a file
|
||||
if (entry.isFile) {
|
||||
const fileEntry = entry as FileSystemFileEntry;
|
||||
return new Promise<Array<File>>((resolve) => {
|
||||
fileEntry.file(
|
||||
(result) => {
|
||||
const types = Object.values(Mimes);
|
||||
resolve(types.includes(result.type) ? [result] : []);
|
||||
},
|
||||
() => [],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// If entry is a directory
|
||||
if (entry.isDirectory) {
|
||||
const dirEntry = entry as FileSystemDirectoryEntry;
|
||||
const list = await new Promise<Array<FileSystemEntry>>((resolve) => {
|
||||
dirEntry.createReader().readEntries(resolve, () => []);
|
||||
});
|
||||
const result: Array<File> = [];
|
||||
for (const item of list) {
|
||||
const subList = await getFilesFromEntry(item);
|
||||
result.push(...subList);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Otherwise
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file list from FileSystemHandle
|
||||
* @param entry
|
||||
* @returns
|
||||
*/
|
||||
export async function getFilesFromHandle(
|
||||
handle: FileSystemHandle,
|
||||
): Promise<Array<File>> {
|
||||
// If handle is a file
|
||||
if (handle.kind === "file") {
|
||||
const fileHandle = handle as FileSystemFileHandle;
|
||||
const file = await fileHandle.getFile();
|
||||
const types = Object.values(Mimes);
|
||||
return types.includes(file.type) ? [file] : [];
|
||||
}
|
||||
|
||||
// If handle is a directory
|
||||
if (handle.kind === "directory") {
|
||||
const result: Array<File> = [];
|
||||
for await (const item of (handle as any).values()) {
|
||||
const subList = await getFilesFromHandle(item);
|
||||
result.push(...subList);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file suffix by lowercase
|
||||
* @param fileName
|
||||
*/
|
||||
export function splitFileName(fileName: string) {
|
||||
const index = fileName.lastIndexOf(".");
|
||||
const name = fileName.substring(0, index);
|
||||
const suffix = fileName.substring(index + 1).toLowerCase();
|
||||
return { name, suffix };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get final file name if there exists a type convert
|
||||
* @param item
|
||||
* @param option
|
||||
* @returns
|
||||
*/
|
||||
export function getOutputFileName(item: ImageItem, option: CompressOption) {
|
||||
if (item.blob.type === item.compress?.blob.type) {
|
||||
return item.name;
|
||||
}
|
||||
|
||||
const { name, suffix } = splitFileName(item.name);
|
||||
let resultSuffix = suffix;
|
||||
for (const key in Mimes) {
|
||||
if (item.compress!.blob.type === Mimes[key]) {
|
||||
resultSuffix = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (["jpg", "jpeg"].includes(resultSuffix)) {
|
||||
resultSuffix = option.format.target?.toLowerCase() || resultSuffix;
|
||||
}
|
||||
|
||||
return name + "." + resultSuffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files from clipboard paste event
|
||||
* @param event ClipboardEvent
|
||||
* @returns Array of File objects
|
||||
*/
|
||||
export async function getFilesFromClipboard(event: ClipboardEvent): Promise<Array<File>> {
|
||||
const files: Array<File> = [];
|
||||
|
||||
if (!event.clipboardData) {
|
||||
return files;
|
||||
}
|
||||
|
||||
const items = event.clipboardData.items;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
// Check if the item is an image
|
||||
if (item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
// Check if the image type is supported
|
||||
const types = Object.values(Mimes);
|
||||
if (types.includes(file.type)) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if clipboard contains image data
|
||||
* @param event ClipboardEvent
|
||||
* @returns boolean
|
||||
*/
|
||||
export function hasImageInClipboard(event: ClipboardEvent): boolean {
|
||||
if (!event.clipboardData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const items = event.clipboardData.items;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.type.startsWith('image/')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { makeAutoObservable } from "mobx";
|
||||
import { normalize } from "./functions";
|
||||
import { history } from "./router";
|
||||
import { LocaleData } from "./type";
|
||||
import { Initial } from "./Initial";
|
||||
|
||||
export class GlobalState {
|
||||
public pathname: string = normalize(history.location.pathname);
|
||||
public page: null | React.ReactNode = (<Initial />);
|
||||
public lang: string = "en-US";
|
||||
public locale: LocaleData | null = null;
|
||||
public loading: boolean = false;
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
}
|
||||
|
||||
export const gstate = new GlobalState();
|
||||
@@ -0,0 +1,47 @@
|
||||
// https://www.techonthenet.com/js/language_tags.php
|
||||
import getUserLocale from "get-user-locale";
|
||||
import { gstate } from "./global";
|
||||
import { MenuProps } from "antd";
|
||||
import { locales } from "./modules";
|
||||
|
||||
const localeCacheKey = "Pic-Smaller-Locale";
|
||||
const defaultLang = "en-US";
|
||||
|
||||
export const langList: NonNullable<MenuProps["items"]> = [
|
||||
{ key: "en-US", label: "English" },
|
||||
{ key: "tr-TR", label: "Türkçe" },
|
||||
{ key: "fr-FR", label: "Français" },
|
||||
{ key: "es-ES", label: "Español" },
|
||||
{ key: "ko-KR", label: "한국인" },
|
||||
{ key: "ja-JP", label: "日本語" },
|
||||
{ key: "zh-TW", label: "繁體中文" },
|
||||
{ key: "zh-CN", label: "简体中文" },
|
||||
{ key: "fa-IR", label: "فارسی" },
|
||||
];
|
||||
|
||||
function getLang() {
|
||||
let lang = window.localStorage.getItem(localeCacheKey);
|
||||
if (!lang) {
|
||||
lang = getUserLocale();
|
||||
}
|
||||
return lang ?? defaultLang;
|
||||
}
|
||||
|
||||
async function setLocaleData(lang: string) {
|
||||
let importer = locales[`/src/locales/${lang}.ts`];
|
||||
if (!importer) {
|
||||
importer = locales[`/src/locales/${defaultLang}.ts`];
|
||||
}
|
||||
gstate.locale = (await importer()).default;
|
||||
}
|
||||
|
||||
export async function changeLang(lang: string) {
|
||||
gstate.lang = lang;
|
||||
window.localStorage.setItem(localeCacheKey, lang);
|
||||
await setLocaleData(lang);
|
||||
}
|
||||
|
||||
export async function initLang() {
|
||||
gstate.lang = getLang();
|
||||
await setLocaleData(gstate.lang);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { LocaleData } from "@/type";
|
||||
import enUS from "antd/locale/en_US";
|
||||
|
||||
const localeData: LocaleData = {
|
||||
antLocale: enUS,
|
||||
logo: "Pic Smaller",
|
||||
initial: "Initializing",
|
||||
previewHelp:
|
||||
"Drag the dividing line to compare the compression effect: the left is the original image, the right is the compressed image",
|
||||
uploadCard: {
|
||||
title: "Select files here, support dragging files and folders",
|
||||
subTitle: "Open source batch image compression tool, supports %s format",
|
||||
pasteHint: "💡 Tip: You can also paste image with Ctrl+V (Cmd+V), or drag and drop images here",
|
||||
},
|
||||
listAction: {
|
||||
batchAppend: "Batch append",
|
||||
addFolder: "Add folder",
|
||||
clear: "Clear all",
|
||||
downloadAll: "Save all",
|
||||
downloadOne: "Save image",
|
||||
removeOne: "Remove image",
|
||||
reCompress: "Recompress",
|
||||
},
|
||||
columnTitle: {
|
||||
status: "Status",
|
||||
name: "Name",
|
||||
preview: "Preview",
|
||||
size: "Size",
|
||||
dimension: "Dimension",
|
||||
decrease: "Decrease",
|
||||
action: "Action",
|
||||
newSize: "New size",
|
||||
newDimension: "New Dimension",
|
||||
},
|
||||
optionPannel: {
|
||||
failTip: "Cannot be smaller, please adjust the parameters and try again.",
|
||||
help: "Pic Smaller is a batch image compression application. Modifications to the options will be applied to all images.",
|
||||
resizeLable: "Resize image",
|
||||
jpegLable: "JPEG/WEBP parameters",
|
||||
pngLable: "PNG parameters",
|
||||
gifLable: "GIF parameters",
|
||||
avifLable: "AVIF parameters",
|
||||
resizePlaceholder: "Select adjustment mode",
|
||||
fitWidth: "Set width, height automatically scales",
|
||||
fitHeight: "Set height, width automatically scales",
|
||||
setShort: "Set short side, long side automatically scale",
|
||||
setLong: "Set long side, short side automatically scale",
|
||||
setCropRatio: "Crop mode, set the crop ratio",
|
||||
setCropSize: "Crop mode, set the crop size",
|
||||
cwRatioPlaceholder: "Set width ratio",
|
||||
chRatioPlaceholder: "Set height ratio",
|
||||
cwSizePlaceholder: "Set crop width",
|
||||
chSizePlaceholder: "Set crop height",
|
||||
widthPlaceholder: "Set the width of the output image",
|
||||
heightPlaceholder: "Set the height of the output image",
|
||||
shortPlaceholder: "Set short side length of the output image",
|
||||
longPlaceholder: "Set long side length of the output image",
|
||||
resetBtn: "Reset options",
|
||||
confirmBtn: "Apply options",
|
||||
qualityTitle: "Set output image quality (0-1)",
|
||||
colorsDesc: "Set the number of output colors (2-256)",
|
||||
pngDithering: "Set dithering coefficient (0-1)",
|
||||
gifDithering: "Turn on dithering",
|
||||
avifQuality: "Set output image quality (1-100)",
|
||||
avifSpeed: "Set compression speed (1-10)",
|
||||
outputFormat: "Set output format",
|
||||
outputFormatPlaceholder: "Select output image format",
|
||||
transparentFillDesc: "Choose a transparent fill color",
|
||||
cropCompareWarning: "Crop mode does not support comparison preview",
|
||||
presetCrop: "Preset Crop (Paper)",
|
||||
presetPaperSize: "Paper Size",
|
||||
presetOrientation: "Orientation",
|
||||
presetPortrait: "Portrait",
|
||||
presetLandscape: "Landscape",
|
||||
presetRefWidth: "Based on width",
|
||||
presetRefHeight: "Based on height",
|
||||
presetCropPx: "Crop per side (px)",
|
||||
presetOffsetPx: "Offset (px)",
|
||||
presetCropWarning: "Image's {axis} is insufficient for {paper} ratio",
|
||||
presetSwitchRef: "Switch reference edge",
|
||||
presetCancelCrop: "Cancel preset crop",
|
||||
},
|
||||
error404: {
|
||||
backHome: "Back to home",
|
||||
description: "Sorry, the page you visited does not exist~",
|
||||
},
|
||||
progress: {
|
||||
before: "Before compression",
|
||||
after: "After compression",
|
||||
rate: "Decrease ratio",
|
||||
},
|
||||
};
|
||||
|
||||
export default localeData;
|
||||
@@ -0,0 +1,98 @@
|
||||
import { LocaleData } from "@/type";
|
||||
import esES from "antd/locale/es_ES";
|
||||
|
||||
const localeData: LocaleData = {
|
||||
antLocale: esES,
|
||||
logo: "Pic Smaller",
|
||||
initial: "Inicializando",
|
||||
previewHelp:
|
||||
"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",
|
||||
uploadCard: {
|
||||
title: "Selecciona o arrastra tus imágenes aquí",
|
||||
subTitle: "Formatos soportados: %s",
|
||||
pasteHint: "💡 Pega con Ctrl+V o arrastra imágenes aquí",
|
||||
},
|
||||
listAction: {
|
||||
batchAppend: "Añadir imagenes",
|
||||
addFolder: "Añadir carpeta",
|
||||
clear: "Eliminar todas",
|
||||
downloadAll: "Guardar todas",
|
||||
downloadOne: "Guardar imagen",
|
||||
removeOne: "Eliminar imagen",
|
||||
reCompress: "Recomprimir",
|
||||
},
|
||||
columnTitle: {
|
||||
status: "Estado",
|
||||
name: "Nombre",
|
||||
preview: "Miniatura",
|
||||
size: "Tamaño",
|
||||
dimension: "Resolución",
|
||||
decrease: "Compresión",
|
||||
action: "Acciones",
|
||||
newSize: "Nuevo tamaño",
|
||||
newDimension: "Nueva resolución",
|
||||
},
|
||||
optionPannel: {
|
||||
failTip:
|
||||
"Imposible de reducir más el tamaño, por favor ajusta los parámetros e inténtalo de nuevo.",
|
||||
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.",
|
||||
resizeLable: "Cambia el tamaño de la imagen",
|
||||
jpegLable: "Parámetros JPEG/WEBP",
|
||||
pngLable: "Parámetros PNG",
|
||||
gifLable: "Parámetros GIF",
|
||||
avifLable: "Parámetros AVIF",
|
||||
resizePlaceholder: "Selecciona el ajuste de tamaño",
|
||||
fitWidth: "Ajusta la anchura, la altura se escala automáticamente",
|
||||
fitHeight: "Ajusta la altura, la anchura se escala automáticamente",
|
||||
setShort:
|
||||
"Ajusta el lado más corto, el lado más largo se adaptará automáticamente",
|
||||
setLong:
|
||||
"Ajusta el lado más largo, el lado más corto se adaptará automáticamente",
|
||||
setCropRatio: "Modo de recorte, establecer proporción de recorte",
|
||||
setCropSize: "Modo de recorte, establecer tamaño de recorte",
|
||||
cwRatioPlaceholder: "Establecer relación de ancho",
|
||||
chRatioPlaceholder: "Establecer relación de altura",
|
||||
cwSizePlaceholder: "Establecer ancho de recorte",
|
||||
chSizePlaceholder: "Establecer altura de recorte",
|
||||
widthPlaceholder: "Ajusta la anchura de la imagen",
|
||||
heightPlaceholder: "Ajusta la altura de la imagen",
|
||||
shortPlaceholder: "Ajusta el lado mas corto de la imagen",
|
||||
longPlaceholder: "Ajusta el lado mas largo de la imagen",
|
||||
resetBtn: "Reiniciar ajustes",
|
||||
confirmBtn: "Aplicar ajustes",
|
||||
qualityTitle: "Calidad de imagen (0-1)",
|
||||
colorsDesc: "Número de colores de salida (2-256)",
|
||||
pngDithering: "Coeficiente de difuminado (0-1)",
|
||||
gifDithering: "Difuminado",
|
||||
avifQuality: "Calidad de imagen (1-100)",
|
||||
avifSpeed: "Velocidad de compresión (1-10)",
|
||||
outputFormat: "Formato de fichero",
|
||||
outputFormatPlaceholder: "Selecciona el formato de imagen",
|
||||
transparentFillDesc: "Elige un color de relleno transparente",
|
||||
cropCompareWarning:
|
||||
"El modo de recorte no admite la vista previa de comparación",
|
||||
presetCrop: "Preset Crop (Paper)",
|
||||
presetPaperSize: "Paper Size",
|
||||
presetOrientation: "Orientation",
|
||||
presetPortrait: "Portrait",
|
||||
presetLandscape: "Landscape",
|
||||
presetRefWidth: "Based on width",
|
||||
presetRefHeight: "Based on height",
|
||||
presetCropPx: "Crop per side (px)",
|
||||
presetOffsetPx: "Offset (px)",
|
||||
presetCropWarning: "Image's {axis} is insufficient for {paper} ratio",
|
||||
presetSwitchRef: "Switch reference edge",
|
||||
presetCancelCrop: "Cancel preset crop",
|
||||
},
|
||||
error404: {
|
||||
backHome: "Volver al inicio",
|
||||
description: "Lo siento, la página visitada no existe~",
|
||||
},
|
||||
progress: {
|
||||
before: "Antes de comprimir",
|
||||
after: "Después de comprimir",
|
||||
rate: "Índice de compresión",
|
||||
},
|
||||
};
|
||||
|
||||
export default localeData;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { LocaleData } from "@/type";
|
||||
import faIR from "antd/locale/fa_IR";
|
||||
|
||||
const localeData: LocaleData = {
|
||||
antLocale: faIR,
|
||||
logo: "پیک کوچولو",
|
||||
initial: "در حال راهاندازی",
|
||||
previewHelp:
|
||||
"خط تقسیم را برای مقایسه اثر فشرده سازی بکشید: سمت چپ تصویر اصلی و سمت راست تصویر فشرده است",
|
||||
uploadCard: {
|
||||
title: "تصاویر خود را اینجا بکشید یا انتخاب کنید",
|
||||
subTitle: "فرمتهای پشتیبانی شده: %s",
|
||||
pasteHint: "💡 با Ctrl+V بچسبانید یا تصاویر را بکشید",
|
||||
},
|
||||
listAction: {
|
||||
batchAppend: "افزودن دستهای",
|
||||
addFolder: "افزودن پوشه",
|
||||
clear: "پاک کردن همه",
|
||||
downloadAll: "ذخیره همه",
|
||||
downloadOne: "بارگذاری تصویر",
|
||||
removeOne: "پاک کردن تصویر",
|
||||
reCompress: "فشردهسازی مجدد",
|
||||
},
|
||||
columnTitle: {
|
||||
status: "وضعیت",
|
||||
name: "نام",
|
||||
preview: "پیشنمایش",
|
||||
size: "اندازه",
|
||||
dimension: "ابعاد",
|
||||
decrease: "کاهش",
|
||||
action: "عملیات",
|
||||
newSize: "اندازه جدید",
|
||||
newDimension: "ابعاد جدید",
|
||||
},
|
||||
optionPannel: {
|
||||
failTip:
|
||||
"کوچکتر از این نمیشود! لطفا پارامترها را تغییر دهید و دوباره امتحان کنید",
|
||||
help: "پیک کوچولو یک برنامه فشردهسازی دستهای تصاویر است. تغییرات در گزینهها به همه تصاویر اعمال خواهد شد.",
|
||||
resizeLable: "تغییر اندازه تصویر",
|
||||
jpegLable: "پارامترهای JPEG/WEBP",
|
||||
pngLable: "پارامترهای PNG",
|
||||
gifLable: "پارامترهای GIF",
|
||||
avifLable: "پارامترهای AVIF",
|
||||
resizePlaceholder: "حالت تنظیم را انتخاب کنید",
|
||||
fitWidth: "تنظیم عرض، ارتفاع به طور خودکار مقیاس میشود",
|
||||
fitHeight: "تنظیم ارتفاع، عرض به طور خودکار مقیاس میشود",
|
||||
setShort: "تنظیم ضلع کوتاه، ضلع بلند به طور خودکار مقیاس میشود",
|
||||
setLong: "تنظیم ضلع بلند، ضلع کوتاه به طور خودکار مقیاس میشود",
|
||||
setCropRatio: "حالت برش، نسبت برش را تنظیم کنید",
|
||||
setCropSize: "حالت برش، اندازه برش را تنظیم کنید",
|
||||
cwRatioPlaceholder: "نسبت عرض را تنظیم کنید",
|
||||
chRatioPlaceholder: "نسبت ارتفاع را تنظیم کنید",
|
||||
cwSizePlaceholder: "عرض برش را تنظیم کنید",
|
||||
chSizePlaceholder: "ارتفاع برش را تنظیم کنید",
|
||||
widthPlaceholder: "عرض تصویر خروجی را تنظیم کنید",
|
||||
heightPlaceholder: "ارتفاع تصویر خروجی را تنظیم کنید",
|
||||
shortPlaceholder: "طول ضلع کوتاه تصویر خروجی را تنظیم کنید",
|
||||
longPlaceholder: "طول ضلع بلند تصویر خروجی را تنظیم کنید",
|
||||
resetBtn: "بازنشانی گزینهها",
|
||||
confirmBtn: "اعمال گزینهها",
|
||||
qualityTitle: "تنظیم کیفیت تصویر خروجی (0-1)",
|
||||
colorsDesc: "تنظیم تعداد رنگهای خروجی (2-256)",
|
||||
pngDithering: "تنظیم ضریب دانهبندی (0-1)",
|
||||
gifDithering: "فعال کردن دانهبندی",
|
||||
avifQuality: "تنظیم کیفیت تصویر خروجی (1-100)",
|
||||
avifSpeed: "تنظیم سرعت فشردهسازی (1-10)",
|
||||
outputFormat: "تنظیم فرمت خروجی",
|
||||
outputFormatPlaceholder: "فرمت تصویر خروجی را انتخاب کنید",
|
||||
transparentFillDesc: "انتخاب رنگ شفاف",
|
||||
cropCompareWarning: "حالت برش از پیشنمایش مقایسه پشتیبانی نمیکند",
|
||||
presetCrop: "Preset Crop (Paper)",
|
||||
presetPaperSize: "Paper Size",
|
||||
presetOrientation: "Orientation",
|
||||
presetPortrait: "Portrait",
|
||||
presetLandscape: "Landscape",
|
||||
presetRefWidth: "Based on width",
|
||||
presetRefHeight: "Based on height",
|
||||
presetCropPx: "Crop per side (px)",
|
||||
presetOffsetPx: "Offset (px)",
|
||||
presetCropWarning: "Image's {axis} is insufficient for {paper} ratio",
|
||||
presetSwitchRef: "Switch reference edge",
|
||||
presetCancelCrop: "Cancel preset crop",
|
||||
},
|
||||
error404: {
|
||||
backHome: "بازگشت به خانه",
|
||||
description: "متاسفانه صفحهای که بازدید کردید وجود ندارد",
|
||||
},
|
||||
progress: {
|
||||
before: "قبل از فشردهسازی",
|
||||
after: "بعد از فشردهسازی",
|
||||
rate: "نسبت کاهش",
|
||||
},
|
||||
};
|
||||
|
||||
export default localeData;
|
||||
@@ -0,0 +1,96 @@
|
||||
import { LocaleData } from "@/type";
|
||||
import frFR from "antd/locale/fr_FR";
|
||||
|
||||
const localeData: LocaleData = {
|
||||
antLocale: frFR,
|
||||
logo: "Pic Smaller",
|
||||
initial: "Initialisation",
|
||||
previewHelp:
|
||||
"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",
|
||||
uploadCard: {
|
||||
title: "Déposez vos images ici",
|
||||
subTitle: "Formats supportés : %s",
|
||||
pasteHint: "💡 Collez avec Ctrl+V ou glissez vos images ici",
|
||||
},
|
||||
listAction: {
|
||||
batchAppend: "Ajouter des fichiers",
|
||||
addFolder: "Ajouter dossier",
|
||||
clear: "Tout retirer",
|
||||
downloadAll: "Tout sauvegarder",
|
||||
downloadOne: "Sauvegarder l'image",
|
||||
removeOne: "Retirer l'image",
|
||||
reCompress: "Relancer compression",
|
||||
},
|
||||
columnTitle: {
|
||||
status: "Status",
|
||||
name: "Nom",
|
||||
preview: "Aperçu",
|
||||
size: "Taille",
|
||||
dimension: "Dimensions",
|
||||
decrease: "Réduction",
|
||||
action: "Action",
|
||||
newSize: "Nouvelle taille",
|
||||
newDimension: "Nouvelles dimensions",
|
||||
},
|
||||
optionPannel: {
|
||||
failTip:
|
||||
"Impossible de réduire la taille, veuillez ajuster les paramètres et réessayer.",
|
||||
help: "Pic Smaller est une application de compression d'images par lot. Les modifications apportées aux options seront appliquées à toutes les images.",
|
||||
resizeLable: "Redimensionner l'image",
|
||||
jpegLable: "Paramètres JPEG/WEBP",
|
||||
pngLable: "Paramètres PNG",
|
||||
gifLable: "Paramètres GIF",
|
||||
avifLable: "Paramètres AVIF",
|
||||
resizePlaceholder: "Sélectionner le mode d'ajustement",
|
||||
fitWidth: "Régler la largeur, la hauteur s'ajuste automatiquement",
|
||||
fitHeight: "Régler la hauteur, la largeur s'ajuste automatiquement",
|
||||
setShort: "Régler le petit côté, le long côté s'ajuste automatiquement",
|
||||
setLong: "Régler le long côté, le petit côté s'ajuste automatiquement",
|
||||
setCropRatio: "Mode de recadrage, définir le rapport de recadrage",
|
||||
setCropSize: "Mode recadrage, définir la taille du recadrage",
|
||||
cwRatioPlaceholder: "Définir le rapport de largeur",
|
||||
chRatioPlaceholder: "Définir le rapport de hauteur",
|
||||
cwSizePlaceholder: "Définir la largeur du recadrage",
|
||||
chSizePlaceholder: "Définir la hauteur de recadrage",
|
||||
widthPlaceholder: "Largeur de l'image de sortie",
|
||||
heightPlaceholder: "Hauteur de l'image de sortie",
|
||||
shortPlaceholder: "Longueur du petit côté de l'image de sortie",
|
||||
longPlaceholder: "Longueur du côté long de l'image de sortie",
|
||||
resetBtn: "Réinitialiser",
|
||||
confirmBtn: "Appliquer",
|
||||
qualityTitle: "Qualité de l'image de sortie (0-1)",
|
||||
colorsDesc: "Nombre de couleurs de sortie (2-256)",
|
||||
pngDithering: "Coefficient de tramage (0-1)",
|
||||
gifDithering: "Activer le tramage",
|
||||
avifQuality: "Qualité de l'image de sortie (1-100)",
|
||||
avifSpeed: "Vitesse de compression (1-10)",
|
||||
outputFormat: "Format de sortie",
|
||||
outputFormatPlaceholder: "Format de l'image de sortie",
|
||||
transparentFillDesc: "Couleur de remplissage transparente",
|
||||
cropCompareWarning:
|
||||
"Le mode Recadrage ne prend pas en charge l'aperçu de comparaison",
|
||||
presetCrop: "Preset Crop (Paper)",
|
||||
presetPaperSize: "Paper Size",
|
||||
presetOrientation: "Orientation",
|
||||
presetPortrait: "Portrait",
|
||||
presetLandscape: "Landscape",
|
||||
presetRefWidth: "Based on width",
|
||||
presetRefHeight: "Based on height",
|
||||
presetCropPx: "Crop per side (px)",
|
||||
presetOffsetPx: "Offset (px)",
|
||||
presetCropWarning: "Image's {axis} is insufficient for {paper} ratio",
|
||||
presetSwitchRef: "Switch reference edge",
|
||||
presetCancelCrop: "Cancel preset crop",
|
||||
},
|
||||
error404: {
|
||||
backHome: "Retour à l'accueil",
|
||||
description: "Désolé, la page que vous avez visitée n'existe pas~",
|
||||
},
|
||||
progress: {
|
||||
before: "Avant compression",
|
||||
after: "Après compression",
|
||||
rate: "Taux de diminution",
|
||||
},
|
||||
};
|
||||
|
||||
export default localeData;
|
||||
@@ -0,0 +1,97 @@
|
||||
// 日语
|
||||
|
||||
import { LocaleData } from "@/type";
|
||||
import jaJP from "antd/locale/ja_JP";
|
||||
|
||||
const localeData: LocaleData = {
|
||||
antLocale: jaJP,
|
||||
logo: "Pic Smaller",
|
||||
initial: "初期化中",
|
||||
previewHelp:
|
||||
"分割線をドラッグして圧縮効果を比較します。左が元の画像、右が圧縮された画像です",
|
||||
uploadCard: {
|
||||
title: "画像をドラッグまたは選択",
|
||||
subTitle: "対応フォーマット:%s",
|
||||
pasteHint: "💡 Ctrl+V で画像を貼り付け、またはドラッグ&ドロップ可能",
|
||||
},
|
||||
listAction: {
|
||||
batchAppend: "バッチ追加",
|
||||
addFolder: "フォルダーを追加",
|
||||
clear: "リストをクリア",
|
||||
downloadAll: "すべて保存",
|
||||
downloadOne: "画像を保存",
|
||||
removeOne: "画像を削除",
|
||||
reCompress: "再圧縮",
|
||||
},
|
||||
columnTitle: {
|
||||
status: "ステータス",
|
||||
name: "ファイル名",
|
||||
preview: "プレビュー",
|
||||
size: "サイズ",
|
||||
dimension: "サイズ",
|
||||
decrease: "圧縮率",
|
||||
action: "アクション",
|
||||
newSize: "新しいサイズ",
|
||||
newDimension: "新しいディメンション",
|
||||
},
|
||||
optionPannel: {
|
||||
failTip:
|
||||
"小さくすることができません。パラメータを調整して再試行してください。",
|
||||
help: "Pic Smaller はバッチ画像圧縮アプリケーションです。オプションの変更はすべての画像に適用されます。",
|
||||
resizeLable: "画像のサイズを変更する",
|
||||
jpegLable: "JPEG/WEBPパラメータ",
|
||||
pngLable: "PNG パラメータ",
|
||||
gifLable: "GIF パラメータ",
|
||||
avifLable: "AVIF パラメータ",
|
||||
resizePlaceholder: "調整モードの選択",
|
||||
fitWidth: "幅と高さを自動的に調整します",
|
||||
fitHeight: "高さと幅を自動的に調整します",
|
||||
setShort: "短辺と長辺を自動的に調整します",
|
||||
setLong: "長辺と短辺を自動的に調整します",
|
||||
setCropRatio: "クロップモード、クロップ率の設定",
|
||||
setCropSize: "切り抜きモード、切り抜きサイズを設定",
|
||||
cwRatioPlaceholder: "幅の比率を設定",
|
||||
chRatioPlaceholder: "高さの比率を設定",
|
||||
cwSizePlaceholder: "切り抜き幅を設定",
|
||||
chSizePlaceholder: "トリミングの高さを設定",
|
||||
widthPlaceholder: "出力画像の幅を設定します",
|
||||
heightPlaceholder: "出力画像の高さを設定します",
|
||||
shortPlaceholder: "出力画像の短辺の長さを設定する",
|
||||
longPlaceholder: "出力画像の長辺の長さを設定する",
|
||||
resetBtn: "オプションをリセット",
|
||||
confirmBtn: "オプションを適用",
|
||||
qualityTitle: "出力画質を設定します(0-1)",
|
||||
colorsDesc: "出力色の数を設定します (2-256)",
|
||||
pngDithering: "ディザリング係数を設定します (0-1)",
|
||||
gifDithering: "ディザリングをオンにする",
|
||||
avifQuality: "出力画質を設定します (1-100)",
|
||||
avifSpeed: "圧縮速度を設定します (1-10)",
|
||||
outputFormat: "出力形式を設定する",
|
||||
outputFormatPlaceholder: "出力画像フォーマットの選択",
|
||||
transparentFillDesc: "透明な塗りつぶしの色を選択します",
|
||||
cropCompareWarning: "クロップ モードは比較プレビューをサポートしていません",
|
||||
presetCrop: "Preset Crop (Paper)",
|
||||
presetPaperSize: "Paper Size",
|
||||
presetOrientation: "Orientation",
|
||||
presetPortrait: "Portrait",
|
||||
presetLandscape: "Landscape",
|
||||
presetRefWidth: "Based on width",
|
||||
presetRefHeight: "Based on height",
|
||||
presetCropPx: "Crop per side (px)",
|
||||
presetOffsetPx: "Offset (px)",
|
||||
presetCropWarning: "Image's {axis} is insufficient for {paper} ratio",
|
||||
presetSwitchRef: "Switch reference edge",
|
||||
presetCancelCrop: "Cancel preset crop",
|
||||
},
|
||||
error404: {
|
||||
backHome: "ホームページに戻る",
|
||||
description: "申し訳ありませんが、アクセスしたページは存在しません~",
|
||||
},
|
||||
progress: {
|
||||
before: "圧縮前",
|
||||
after: "圧縮後",
|
||||
rate: "圧縮率",
|
||||
},
|
||||
};
|
||||
|
||||
export default localeData;
|
||||
@@ -0,0 +1,97 @@
|
||||
// 韩语
|
||||
|
||||
import { LocaleData } from "@/type";
|
||||
import koKR from "antd/locale/ko_KR";
|
||||
|
||||
const localeData: LocaleData = {
|
||||
antLocale: koKR,
|
||||
logo: "Pic Smaller",
|
||||
initial: "초기화 중",
|
||||
previewHelp:
|
||||
"압축 효과를 비교하려면 구분선을 드래그하세요. 왼쪽은 원본 이미지, 오른쪽은 압축된 이미지입니다.",
|
||||
uploadCard: {
|
||||
title: "이미지 파일을 여기에 넣기",
|
||||
subTitle: "지원 형식: %s",
|
||||
pasteHint: "💡 Ctrl+V로 붙여넣기 또는 이미지를 끌어다 놓기",
|
||||
},
|
||||
|
||||
listAction: {
|
||||
batchAppend: "일괄 추가",
|
||||
addFolder: "폴더 추가",
|
||||
clear: "목록 지우기",
|
||||
downloadAll: "모두 저장",
|
||||
downloadOne: "이미지 저장",
|
||||
removeOne: "사진 제거",
|
||||
reCompress: "재압축",
|
||||
},
|
||||
columnTitle: {
|
||||
status: "상태",
|
||||
name: "파일 이름",
|
||||
preview: "미리보기",
|
||||
size: "크기",
|
||||
dimension: "크기",
|
||||
decrease: "압축 비율",
|
||||
action: "액션",
|
||||
newSize: "새 크기",
|
||||
newDimension: "새 차원",
|
||||
},
|
||||
optionPannel: {
|
||||
failTip: "더 작게 만들 수 없습니다. 매개변수를 조정하고 다시 시도하세요.",
|
||||
help: "Pic Smaller는 옵션에 대한 수정 사항이 모든 이미지에 적용되는 일괄 이미지 압축 응용 프로그램입니다.",
|
||||
resizeLable: "이미지 크기 조정",
|
||||
jpegLable: "JPEG/WEBP 매개변수",
|
||||
pngLable: "PNG 매개변수",
|
||||
gifLable: "GIF 매개변수",
|
||||
avifLable: "AVIF 매개변수",
|
||||
resizePlaceholder: "조정 모드 선택",
|
||||
fitWidth: "너비, 높이는 자동으로 조정됩니다.",
|
||||
fitHeight: "높이 설정, 너비 자동 조정",
|
||||
setShort: "짧은 쪽, 긴 쪽은 자동으로 크기 조절 설정",
|
||||
setLong: "긴 쪽, 짧은 쪽 자동 크기 조정",
|
||||
setCropRatio: "자르기 모드, 자르기 비율 설정",
|
||||
setCropSize: "자르기 모드, 자르기 크기 설정",
|
||||
cwRatioPlaceholder: "너비 비율 설정",
|
||||
chRatioPlaceholder: "높이 비율 설정",
|
||||
cwSizePlaceholder: "자르기 너비 설정",
|
||||
chSizePlaceholder: "자르기 높이 설정",
|
||||
widthPlaceholder: "출력 이미지의 너비를 설정합니다",
|
||||
heightPlaceholder: "출력 이미지의 높이를 설정합니다",
|
||||
shortPlaceholder: "출력 이미지의 짧은 쪽 길이를 설정합니다",
|
||||
longPlaceholder: "출력 이미지의 긴 쪽 길이를 설정합니다",
|
||||
resetBtn: "재설정 옵션",
|
||||
confirmBtn: "옵션 적용",
|
||||
qualityTitle: "출력 이미지 품질 설정(0-1)",
|
||||
colorsDesc: "출력 색상 수 설정(2-256)",
|
||||
pngDithering: "디더링 계수 설정(0-1)",
|
||||
gifDithering: "디더링 켜기",
|
||||
avifQuality: "출력 이미지 품질 설정(1-100)",
|
||||
avifSpeed: "압축 속도 설정(1-10)",
|
||||
outputFormat: "출력 형식 설정",
|
||||
outputFormatPlaceholder: "출력 이미지 형식 선택",
|
||||
transparentFillDesc: "투명한 채우기 색상 선택",
|
||||
cropCompareWarning: "자르기 모드는 비교 미리보기를 지원하지 않습니다.",
|
||||
presetCrop: "Preset Crop (Paper)",
|
||||
presetPaperSize: "Paper Size",
|
||||
presetOrientation: "Orientation",
|
||||
presetPortrait: "Portrait",
|
||||
presetLandscape: "Landscape",
|
||||
presetRefWidth: "Based on width",
|
||||
presetRefHeight: "Based on height",
|
||||
presetCropPx: "Crop per side (px)",
|
||||
presetOffsetPx: "Offset (px)",
|
||||
presetCropWarning: "Image's {axis} is insufficient for {paper} ratio",
|
||||
presetSwitchRef: "Switch reference edge",
|
||||
presetCancelCrop: "Cancel preset crop",
|
||||
},
|
||||
error404: {
|
||||
backHome: "홈 페이지로 돌아가기",
|
||||
description: "죄송합니다. 방문하신 페이지는 존재하지 않습니다~",
|
||||
},
|
||||
progress: {
|
||||
before: "압축 전",
|
||||
after: "압축 후",
|
||||
rate: "압축률",
|
||||
},
|
||||
};
|
||||
|
||||
export default localeData;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { LocaleData } from "@/type";
|
||||
import trTR from "antd/locale/tr_TR";
|
||||
|
||||
const localeData: LocaleData = {
|
||||
antLocale: trTR,
|
||||
logo: "Pic Smaller",
|
||||
initial: "Başlatılıyor",
|
||||
previewHelp:
|
||||
"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ü",
|
||||
uploadCard: {
|
||||
title: "Resimlerinizi buraya bırakın",
|
||||
subTitle: "Desteklenen formatlar: %s",
|
||||
pasteHint: "💡 Ctrl+V ile yapıştırın veya resimleri sürükleyin",
|
||||
},
|
||||
listAction: {
|
||||
batchAppend: "Toplu ekle",
|
||||
addFolder: "Klasör ekle",
|
||||
clear: "Hepsini temizle",
|
||||
downloadAll: "Hepsini İndir",
|
||||
downloadOne: "İndir",
|
||||
removeOne: "Sil",
|
||||
reCompress: "Yeniden sıkıştır",
|
||||
},
|
||||
columnTitle: {
|
||||
status: "Durum",
|
||||
name: "İsim",
|
||||
preview: "Önizleme",
|
||||
size: "Boyut",
|
||||
dimension: "Boyut",
|
||||
decrease: "Sıkıştır",
|
||||
action: "Eylem",
|
||||
newSize: "Yeni boyut",
|
||||
newDimension: "Yeni boyutlar",
|
||||
},
|
||||
optionPannel: {
|
||||
failTip:
|
||||
"Daha küçük olamaz, lütfen parametreleri ayarlayın ve tekrar deneyin.",
|
||||
help: "Pic Smaller, toplu resim sıkıştırma uygulamasıdır. Seçeneklerde yapılan değişiklikler tüm resimlere uygulanacaktır.",
|
||||
resizeLable: "Görüntüyü yeniden boyutlandır",
|
||||
jpegLable: "JPEG/WEBP parametreleri",
|
||||
pngLable: "PNG parametreleri",
|
||||
gifLable: "GIF parametreleri",
|
||||
avifLable: "AVIF parametreleri",
|
||||
resizePlaceholder: "Ayarlama modunu seçin",
|
||||
fitWidth: "Genişliği ayarla, yükseklik otomatik ayarlanır",
|
||||
fitHeight: "Yüksekliği ayarla, genişlik otomatik ayarlanır",
|
||||
setShort: "Kısa kenarı ayarla, uzun kenar otomatik ayarlanır",
|
||||
setLong: "Uzun kenarı ayarla, kısa kenar otomatik ayarlanır",
|
||||
setCropRatio: "Kırpma modu, kırpma oranını ayarlayın",
|
||||
setCropSize: "Kırpma modu, kırpma boyutunu ayarla",
|
||||
cwRatioPlaceholder: "Genişlik oranını ayarla",
|
||||
chRatioPlaceholder: "Yükseklik oranını ayarla",
|
||||
cwSizePlaceholder: "Kırpma genişliğini ayarla",
|
||||
chSizePlaceholder: "Kırpma yüksekliğini ayarla",
|
||||
widthPlaceholder: "Çıktının genişliğini ayarlayın",
|
||||
heightPlaceholder: "Çıktının yüksekliğini ayarlayın",
|
||||
shortPlaceholder: "Çıktının kısa kenar uzunluğunu ayarlayın",
|
||||
longPlaceholder: "Çıktının uzun kenar uzunluğunu ayarlayın",
|
||||
resetBtn: "Seçenekleri sıfırla",
|
||||
confirmBtn: "Seçenekleri uygula",
|
||||
qualityTitle: "Çıktının kalitesini ayarla (0-1)",
|
||||
colorsDesc: "Çıktınun renk sayısını ayarla (2-256)",
|
||||
pngDithering: "Dithering katsayısını ayarla (0-1)",
|
||||
gifDithering: "Dithering'i aç",
|
||||
avifQuality: "Çıktının kalitesini ayarla (1-100)",
|
||||
avifSpeed: "Sıkıştırma hızını ayarla (1-10)",
|
||||
outputFormat: "Çıktı formatını ayarla",
|
||||
outputFormatPlaceholder: "Çıktı formatını seçin",
|
||||
transparentFillDesc: "Şeffaflık rengini seçin",
|
||||
cropCompareWarning: "Kırpma modu karşılaştırma önizlemesini desteklemiyor",
|
||||
presetCrop: "Preset Crop (Paper)",
|
||||
presetPaperSize: "Paper Size",
|
||||
presetOrientation: "Orientation",
|
||||
presetPortrait: "Portrait",
|
||||
presetLandscape: "Landscape",
|
||||
presetRefWidth: "Based on width",
|
||||
presetRefHeight: "Based on height",
|
||||
presetCropPx: "Crop per side (px)",
|
||||
presetOffsetPx: "Offset (px)",
|
||||
presetCropWarning: "Image's {axis} is insufficient for {paper} ratio",
|
||||
presetSwitchRef: "Switch reference edge",
|
||||
presetCancelCrop: "Cancel preset crop",
|
||||
},
|
||||
error404: {
|
||||
backHome: "Ana sayfaya dön",
|
||||
description: "Üzgünüz, ziyaret ettiğiniz sayfa mevcut değil~",
|
||||
},
|
||||
progress: {
|
||||
before: "Sıkıştırmadan önce",
|
||||
after: "Sıkıştırmadan sonra",
|
||||
rate: "Sıkıştırma oranı",
|
||||
},
|
||||
};
|
||||
|
||||
export default localeData;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { LocaleData } from "@/type";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
|
||||
const localeData: LocaleData = {
|
||||
antLocale: zhCN,
|
||||
logo: "图小小",
|
||||
initial: "初始化中",
|
||||
previewHelp: "拖动分割线对比压缩效果:左边是原始图,右边是压缩图",
|
||||
uploadCard: {
|
||||
title: "选取图片到这里,支持拖拽图片和文件夹",
|
||||
subTitle: "开源的批量图片压缩工具,支持 %s 格式",
|
||||
pasteHint: "💡 提示:您也可以复制图片后按 Ctrl+V (Cmd+V) 粘贴",
|
||||
},
|
||||
listAction: {
|
||||
batchAppend: "批量添加",
|
||||
addFolder: "添加文件夹",
|
||||
clear: "清空列表",
|
||||
downloadAll: "保存全部",
|
||||
downloadOne: "保存图片",
|
||||
removeOne: "移除图片",
|
||||
reCompress: "重新压缩",
|
||||
},
|
||||
columnTitle: {
|
||||
status: "状态",
|
||||
name: "文件名",
|
||||
preview: "预览",
|
||||
size: "大小",
|
||||
dimension: "尺寸",
|
||||
decrease: "压缩率",
|
||||
action: "操作",
|
||||
newSize: "新大小",
|
||||
newDimension: "新尺寸",
|
||||
},
|
||||
optionPannel: {
|
||||
failTip: "无法更小,请调整参数后重试",
|
||||
help: "图小小是一款批量图片压缩应用程序,对选项的修改将应用到所有图片上",
|
||||
resizeLable: "调整图片尺寸",
|
||||
jpegLable: "JPEG/WEBP参数",
|
||||
pngLable: "PNG参数",
|
||||
gifLable: "GIF参数",
|
||||
avifLable: "AVIF参数",
|
||||
resizePlaceholder: "选择调整模式",
|
||||
fitWidth: "设置宽度,高度自动缩放",
|
||||
fitHeight: "设置高度,宽度自动缩放",
|
||||
setShort: "设置短边,长边自动缩放",
|
||||
setLong: "设置长边,短边自动缩放",
|
||||
setCropRatio: "裁剪模式,设置裁剪比例",
|
||||
setCropSize: "裁剪模式,设置裁剪尺寸",
|
||||
cwRatioPlaceholder: "设置宽度比例",
|
||||
chRatioPlaceholder: "设置高度比例",
|
||||
cwSizePlaceholder: "设置裁剪宽度",
|
||||
chSizePlaceholder: "设置裁剪高度",
|
||||
widthPlaceholder: "设置输出图片宽度",
|
||||
heightPlaceholder: "设置输出图片高度",
|
||||
shortPlaceholder: "设置输出图片短边长度",
|
||||
longPlaceholder: "设置输出图片长边长度",
|
||||
resetBtn: "重置选项",
|
||||
confirmBtn: "应用选项",
|
||||
qualityTitle: "设置输出图片质量(0-1)",
|
||||
colorsDesc: "设置输出颜色数量(2-256)",
|
||||
pngDithering: "设置抖色系数(0-1)",
|
||||
gifDithering: "开启抖色",
|
||||
avifQuality: "设置输出图片质量(1-100)",
|
||||
avifSpeed: "设置压缩速度(1-10)",
|
||||
outputFormat: "设置输出格式",
|
||||
outputFormatPlaceholder: "选择输出图片格式",
|
||||
transparentFillDesc: "选择透明填充色",
|
||||
cropCompareWarning: "裁剪模式不支持对比预览",
|
||||
presetCrop: "预设裁切(打印纸)",
|
||||
presetPaperSize: "纸张尺寸",
|
||||
presetOrientation: "方向",
|
||||
presetPortrait: "竖版",
|
||||
presetLandscape: "横版",
|
||||
presetRefWidth: "以宽边为准",
|
||||
presetRefHeight: "以长边为准",
|
||||
presetCropPx: "每侧裁剪(px)",
|
||||
presetOffsetPx: "偏移(px)",
|
||||
presetCropWarning: "图片{axis}不足以达到{paper}比例",
|
||||
presetSwitchRef: "切换基准边",
|
||||
presetCancelCrop: "取消预设裁切",
|
||||
},
|
||||
error404: {
|
||||
backHome: "返回首页",
|
||||
description: "抱歉,你访问的页面不存在~",
|
||||
},
|
||||
progress: {
|
||||
before: "压缩前",
|
||||
after: "压缩后",
|
||||
rate: "压缩率",
|
||||
},
|
||||
};
|
||||
|
||||
export default localeData;
|
||||
@@ -0,0 +1,95 @@
|
||||
// 台湾繁体
|
||||
|
||||
import { LocaleData } from "@/type";
|
||||
import zhTW from "antd/locale/zh_TW";
|
||||
|
||||
const localeData: LocaleData = {
|
||||
antLocale: zhTW,
|
||||
logo: "圖小小",
|
||||
initial: "初始化中",
|
||||
previewHelp: "拖曳分割線對比壓縮效果:左邊是原始圖,右邊是壓縮圖",
|
||||
uploadCard: {
|
||||
title: "選取文件到這裡,支援拖曳文件和資料夾",
|
||||
subTitle: "開源的批量圖片壓縮工具,支援 %s 格式",
|
||||
pasteHint: "💡 提示:您也可以複製圖片後按 Ctrl+V (Cmd+V) 貼上",
|
||||
},
|
||||
listAction: {
|
||||
batchAppend: "大量新增",
|
||||
addFolder: "新增資料夾",
|
||||
clear: "清空清單",
|
||||
downloadAll: "儲存全部",
|
||||
downloadOne: "儲存圖片",
|
||||
removeOne: "移除圖片",
|
||||
reCompress: "重新壓縮",
|
||||
},
|
||||
columnTitle: {
|
||||
status: "狀態",
|
||||
name: "檔案名稱",
|
||||
preview: "預覽",
|
||||
size: "大小",
|
||||
dimension: "尺寸",
|
||||
decrease: "壓縮率",
|
||||
action: "操作",
|
||||
newSize: "新大小",
|
||||
newDimension: "新尺寸",
|
||||
},
|
||||
optionPannel: {
|
||||
failTip: "無法更小,請調整參數後重試",
|
||||
help: "Pic Smaller是一款大量圖片壓縮應用,對選項的修改將套用到所有圖片上",
|
||||
resizeLable: "調整圖片尺寸",
|
||||
jpegLable: "JPEG/WEBP參數",
|
||||
pngLable: "PNG參數",
|
||||
gifLable: "GIF參數",
|
||||
avifLable: "AVIF參數",
|
||||
resizePlaceholder: "選擇調整模式",
|
||||
fitWidth: "設定寬度,高度自動縮放",
|
||||
fitHeight: "設定高度,寬度自動縮放",
|
||||
setShort: "設定短邊,長邊自動縮放",
|
||||
setLong: "設定長邊,短邊自動縮放",
|
||||
setCropRatio: "裁切模式,設定裁切比例",
|
||||
setCropSize: "裁切模式,設定裁切尺寸",
|
||||
cwRatioPlaceholder: "設定寬度比例",
|
||||
chRatioPlaceholder: "設定高度比例",
|
||||
cwSizePlaceholder: "設定裁切寬度",
|
||||
chSizePlaceholder: "設定裁切高度",
|
||||
widthPlaceholder: "設定輸出圖片寬度",
|
||||
heightPlaceholder: "設定輸出圖片高度",
|
||||
shortPlaceholder: "設定輸出圖片短邊長度",
|
||||
longPlaceholder: "設定輸出圖片長邊長度",
|
||||
resetBtn: "重置選項",
|
||||
confirmBtn: "應用選項",
|
||||
qualityTitle: "設定輸出圖片品質(0-1)",
|
||||
colorsDesc: "設定輸出顏色數量(2-256)",
|
||||
pngDithering: "設定抖色係數(0-1)",
|
||||
gifDithering: "開啟抖色",
|
||||
avifQuality: "設定輸出圖片品質(1-100)",
|
||||
avifSpeed: "設定壓縮速度(1-10)",
|
||||
outputFormat: "設定輸出格式",
|
||||
outputFormatPlaceholder: "選擇輸出圖片格式",
|
||||
transparentFillDesc: "選擇透明填充色",
|
||||
cropCompareWarning: "裁切模式不支援比較預覽",
|
||||
presetCrop: "Preset Crop (Paper)",
|
||||
presetPaperSize: "Paper Size",
|
||||
presetOrientation: "Orientation",
|
||||
presetPortrait: "Portrait",
|
||||
presetLandscape: "Landscape",
|
||||
presetRefWidth: "Based on width",
|
||||
presetRefHeight: "Based on height",
|
||||
presetCropPx: "Crop per side (px)",
|
||||
presetOffsetPx: "Offset (px)",
|
||||
presetCropWarning: "Image's {axis} is insufficient for {paper} ratio",
|
||||
presetSwitchRef: "Switch reference edge",
|
||||
presetCancelCrop: "Cancel preset crop",
|
||||
},
|
||||
error404: {
|
||||
backHome: "返回首頁",
|
||||
description: "抱歉,你造訪的頁面不存在~",
|
||||
},
|
||||
progress: {
|
||||
before: "壓縮前",
|
||||
after: "壓縮後",
|
||||
rate: "壓縮率",
|
||||
},
|
||||
};
|
||||
|
||||
export default localeData;
|
||||
@@ -0,0 +1,11 @@
|
||||
@import "antd/dist/reset.css";
|
||||
|
||||
.__initial {
|
||||
height: 100vh;
|
||||
img {
|
||||
width: 30px;
|
||||
}
|
||||
span {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import "./main.scss";
|
||||
import { configure } from "mobx";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { initLang } from "./locale";
|
||||
import { App } from "./App";
|
||||
|
||||
window.onload = async () => {
|
||||
await initLang();
|
||||
configure({
|
||||
enforceActions: "never",
|
||||
useProxies: "ifavailable",
|
||||
});
|
||||
|
||||
const root = document.getElementById("root")!;
|
||||
ReactDOM.createRoot(root).render(<App />);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
|
||||
export function useResponse() {
|
||||
const isMobile = useMediaQuery({ maxWidth: 719 });
|
||||
const isPad = useMediaQuery({ minWidth: 720, maxWidth: 1279 });
|
||||
const isPC = useMediaQuery({ minWidth: 1280 });
|
||||
|
||||
return { isMobile, isPad, isPC };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// 支持的图片类型
|
||||
export const Mimes: Record<string, string> = {
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
webp: "image/webp",
|
||||
gif: "image/gif",
|
||||
svg: "image/svg+xml",
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { LocaleData } from "@/type";
|
||||
export const modules = import.meta.glob<{ default: React.FC }>(
|
||||
"@/pages/**/index.tsx",
|
||||
);
|
||||
export const locales = import.meta.glob<{ default: LocaleData }>(
|
||||
"@/locales/*.ts",
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
.container {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { goto } from "@/router";
|
||||
import style from "./index.module.scss";
|
||||
import { Button, Flex, Result } from "antd";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { gstate } from "@/global";
|
||||
|
||||
const Error404 = observer(() => {
|
||||
const backToHome = (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
goto("/", null, "replace");
|
||||
}}
|
||||
>
|
||||
{gstate.locale?.error404.backHome}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex className={style.container} align="center" justify="center">
|
||||
<Result
|
||||
status="404"
|
||||
title="404"
|
||||
subTitle={gstate.locale?.error404.description}
|
||||
extra={backToHome}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
export default Error404;
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Button, Flex, Space, Table } from "antd";
|
||||
import style from "./index.module.scss";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import {
|
||||
ClearOutlined,
|
||||
DownloadOutlined,
|
||||
FolderAddOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ImageInput } from "@/components/ImageInput";
|
||||
import { gstate } from "@/global";
|
||||
import { homeState } from "@/states/home";
|
||||
import {
|
||||
createDownload,
|
||||
getFilesFromHandle,
|
||||
getOutputFileName,
|
||||
getUniqNameOnNames,
|
||||
} from "@/functions";
|
||||
import { ProgressHint } from "@/components/ProgressHint";
|
||||
import { createImageList } from "@/engines/transform";
|
||||
import { useColumn } from "./useColumn";
|
||||
import { useResponse } from "@/media";
|
||||
|
||||
export const LeftContent = observer(() => {
|
||||
const { isMobile } = useResponse();
|
||||
const disabled = homeState.hasTaskRunning();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const columns = useColumn(disabled);
|
||||
|
||||
const scrollBoxRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollHeight, setScrollHeight] = useState<number>(0);
|
||||
const resize = useCallback(() => {
|
||||
const element = scrollBoxRef.current;
|
||||
if (element) {
|
||||
const boxHeight = element.getBoundingClientRect().height;
|
||||
const th = document.querySelector(".ant-table-thead");
|
||||
const tbody = document.querySelector(".ant-table-tbody");
|
||||
const thHeight = th?.getBoundingClientRect().height ?? 0;
|
||||
const tbodyHeight = tbody?.getBoundingClientRect().height ?? 0;
|
||||
if (boxHeight > thHeight + tbodyHeight) {
|
||||
setScrollHeight(0);
|
||||
} else {
|
||||
setScrollHeight(boxHeight - thHeight);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
// Everytime list change, recalc the scroll height
|
||||
useEffect(resize, [homeState.list.size]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", resize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, [resize]);
|
||||
|
||||
return (
|
||||
<Flex align="stretch" vertical className={style.content}>
|
||||
<Flex align="center" justify="space-between" className={style.menu}>
|
||||
<Space>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
fileRef.current?.click();
|
||||
}}
|
||||
>
|
||||
{!isMobile && gstate.locale?.listAction.batchAppend}
|
||||
</Button>
|
||||
{window.showDirectoryPicker && (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
icon={<FolderAddOutlined />}
|
||||
type="primary"
|
||||
onClick={async () => {
|
||||
const handle = await window.showDirectoryPicker!();
|
||||
const result = await getFilesFromHandle(handle);
|
||||
await createImageList(result);
|
||||
}}
|
||||
>
|
||||
{!isMobile && gstate.locale?.listAction.addFolder}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
icon={<ClearOutlined />}
|
||||
danger
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
homeState.clear();
|
||||
}}
|
||||
>
|
||||
{!isMobile && gstate.locale?.listAction.clear}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
icon={<ReloadOutlined />}
|
||||
type="primary"
|
||||
onClick={async () => {
|
||||
homeState.reCompress();
|
||||
}}
|
||||
>
|
||||
{gstate.locale?.listAction.reCompress}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
type="primary"
|
||||
disabled={disabled}
|
||||
onClick={async () => {
|
||||
gstate.loading = true;
|
||||
const jszip = await import("jszip");
|
||||
const zip = new jszip.default();
|
||||
const names: Set<string> = new Set();
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
for (const [_, info] of homeState.list) {
|
||||
const fileName = getOutputFileName(info, homeState.option);
|
||||
const uniqName = getUniqNameOnNames(names, fileName);
|
||||
names.add(uniqName);
|
||||
if (info.compress?.blob) {
|
||||
zip.file(uniqName, info.compress.blob);
|
||||
}
|
||||
}
|
||||
const result = await zip.generateAsync({
|
||||
type: "blob",
|
||||
compression: "DEFLATE",
|
||||
compressionOptions: {
|
||||
level: 6,
|
||||
},
|
||||
});
|
||||
createDownload("picsmaller.zip", result);
|
||||
gstate.loading = false;
|
||||
}}
|
||||
>
|
||||
{!isMobile && gstate.locale?.listAction.downloadAll}
|
||||
</Button>
|
||||
</Space>
|
||||
<ImageInput ref={fileRef} />
|
||||
</Flex>
|
||||
<div ref={scrollBoxRef}>
|
||||
<Table
|
||||
columns={columns}
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={scrollHeight ? { y: scrollHeight } : undefined}
|
||||
dataSource={Array.from(homeState.list.values())}
|
||||
/>
|
||||
</div>
|
||||
<Flex align="center">
|
||||
<ProgressHint />
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
$border: 1px solid #dfdfdf;
|
||||
|
||||
.sidePc {
|
||||
width: 380px;
|
||||
border-left: $border;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidePad {
|
||||
width: 380px;
|
||||
}
|
||||
.sideMobile {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.side {
|
||||
$h1: 60px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: #fff;
|
||||
> div:nth-child(1) {
|
||||
height: $h1;
|
||||
border-bottom: $border;
|
||||
background-color: #fafafa;
|
||||
padding: 0 16px;
|
||||
}
|
||||
> div:nth-child(2) {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
height: calc(100% - $h1);
|
||||
}
|
||||
}
|
||||
|
||||
.optionHelp {
|
||||
cursor: pointer;
|
||||
font-size: 0;
|
||||
> span {
|
||||
font-size: 0;
|
||||
}
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
.optionHelpBox {
|
||||
width: 320px;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Button, Drawer, Flex, Popover, Space, Typography } from "antd";
|
||||
import style from "./RightOption.module.scss";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import {
|
||||
CaretRightOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
ReloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { gstate } from "@/global";
|
||||
import { CompressOption } from "@/components/CompressOption";
|
||||
import { DefaultCompressOption, homeState } from "@/states/home";
|
||||
import { toJS } from "mobx";
|
||||
import { useResponse } from "@/media";
|
||||
import classNames from "classnames";
|
||||
|
||||
export const RightOption = observer(() => {
|
||||
const disabled = homeState.hasTaskRunning();
|
||||
|
||||
const { isPad, isPC } = useResponse();
|
||||
const option = (
|
||||
<>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Popover
|
||||
placement="bottom"
|
||||
content={
|
||||
<div className={style.optionHelpBox}>
|
||||
<Typography.Text>
|
||||
{gstate.locale?.optionPannel.help}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Typography.Text type="secondary" className={style.optionHelp}>
|
||||
<ExclamationCircleOutlined/>
|
||||
</Typography.Text>
|
||||
</Popover>
|
||||
<Space>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={async () => {
|
||||
homeState.showOption = false;
|
||||
homeState.tempOption = { ...DefaultCompressOption };
|
||||
homeState.option = { ...DefaultCompressOption };
|
||||
homeState.reCompress();
|
||||
}}
|
||||
>
|
||||
{gstate.locale?.optionPannel?.resetBtn}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
icon={<CaretRightOutlined />}
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
homeState.showOption = false;
|
||||
homeState.option = toJS(homeState.tempOption);
|
||||
homeState.reCompress();
|
||||
}}
|
||||
>
|
||||
{gstate.locale?.optionPannel?.confirmBtn}
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
<div>
|
||||
<CompressOption />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isPC) {
|
||||
return <div className={classNames(style.side, style.sidePc)}>{option}</div>;
|
||||
} else if (isPad) {
|
||||
return (
|
||||
<Drawer
|
||||
placement="right"
|
||||
getContainer={false}
|
||||
open={homeState.showOption}
|
||||
closable={false}
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className={classNames(style.side, style.sidePad)}>{option}</div>
|
||||
</Drawer>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Drawer
|
||||
placement="right"
|
||||
getContainer={false}
|
||||
open={homeState.showOption}
|
||||
closable={false}
|
||||
width="100%"
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className={classNames(style.side, style.sideMobile)}>{option}</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
$headerHeight: 60px;
|
||||
$border: 1px solid #dfdfdf;
|
||||
|
||||
.container {
|
||||
height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #fff;
|
||||
height: $headerHeight;
|
||||
padding: 0 16px;
|
||||
border-bottom: $border;
|
||||
}
|
||||
|
||||
.locale {
|
||||
cursor: pointer;
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 6px;
|
||||
path {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
span {
|
||||
white-space: nowrap;
|
||||
color: currentColor;
|
||||
}
|
||||
&:hover {
|
||||
color: #1da565;
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
height: calc(100% - $headerHeight);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding-left: 16px !important;
|
||||
}
|
||||
.action {
|
||||
padding-right: 16px !important;
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
display: -moz-box;
|
||||
-moz-line-clamp: 2;
|
||||
-moz-box-orient: vertical;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.preview {
|
||||
font-size: 0;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
&:hover {
|
||||
> div {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
> div {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
svg {
|
||||
width: 20px;
|
||||
path {
|
||||
fill: #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
$h1: 60px;
|
||||
$h3: 60px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
> div:nth-child(1) {
|
||||
height: $h1;
|
||||
border-bottom: $border;
|
||||
background-color: #fafafa;
|
||||
padding: 0 16px;
|
||||
}
|
||||
> div:nth-child(2) {
|
||||
flex: 1;
|
||||
height: calc(100% - $h1 - $h3);
|
||||
}
|
||||
> div:nth-child(3) {
|
||||
height: $h3;
|
||||
border-top: $border;
|
||||
padding: 0 16px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
}
|
||||
|
||||
a.github {
|
||||
cursor: pointer;
|
||||
color: #000;
|
||||
margin-left: 16px;
|
||||
span {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.fileok {
|
||||
font-size: 0;
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
path {
|
||||
fill: #078249;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actionlink {
|
||||
a,
|
||||
:global(.ant-space-item) {
|
||||
font-size: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Button, Divider, Dropdown, Flex, Space, Typography } from "antd";
|
||||
import style from "./index.module.scss";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { GithubOutlined, MenuOutlined } from "@ant-design/icons";
|
||||
import { gstate } from "@/global";
|
||||
import { changeLang, langList } from "@/locale";
|
||||
import { homeState } from "@/states/home";
|
||||
import { wait, getFilesFromClipboard, hasImageInClipboard } from "@/functions";
|
||||
import { UploadCard } from "@/components/UploadCard";
|
||||
import { useWorkerHandler } from "@/engines/transform";
|
||||
import { createImageList } from "@/engines/transform";
|
||||
import { Compare } from "@/components/Compare";
|
||||
import { useResponse } from "@/media";
|
||||
import { RightOption } from "./RightOption";
|
||||
import { LeftContent } from "./LeftContent";
|
||||
import { useEffect } from "react";
|
||||
|
||||
function getCurentLangStr(): string | undefined {
|
||||
const findLang = langList.find((item) => item?.key == gstate.lang);
|
||||
return (findLang as any)?.label;
|
||||
}
|
||||
|
||||
const Header = observer(() => {
|
||||
const { isPC } = useResponse();
|
||||
|
||||
return (
|
||||
<Flex align="center" justify="space-between" className={style.header}>
|
||||
<Logo title={gstate.locale?.logo} />
|
||||
<Space>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: langList,
|
||||
selectedKeys: [gstate.lang],
|
||||
async onClick({ key }) {
|
||||
await wait(300);
|
||||
changeLang(key);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Flex className={style.locale} align="center">
|
||||
{/* <svg viewBox="0 0 24 24" style={{ color: "currentcolor" }}>
|
||||
<path d="M12.87,15.07L10.33,12.56L10.36,12.53C12.1,10.59 13.34,8.36 14.07,6H17V4H10V2H8V4H1V6H12.17C11.5,7.92 10.44,9.75 9,11.35C8.07,10.32 7.3,9.19 6.69,8H4.69C5.42,9.63 6.42,11.17 7.67,12.56L2.58,17.58L4,19L9,14L12.11,17.11L12.87,15.07M18.5,10H16.5L12,22H14L15.12,19H19.87L21,22H23L18.5,10M15.88,17L17.5,12.67L19.12,17H15.88Z" />
|
||||
</svg> */}
|
||||
|
||||
<svg viewBox="0 0 1024 1024" style={{ color: "currentcolor" }}>
|
||||
<path d="M640 416h256c35.36 0 64 28.48 64 64v416c0 35.36-28.48 64-64 64H480c-35.36 0-64-28.48-64-64V640h128c53.312 0 96-42.976 96-96V416zM64 128c0-35.36 28.48-64 64-64h416c35.36 0 64 28.48 64 64v416c0 35.36-28.48 64-64 64H128c-35.36 0-64-28.48-64-64V128z m128 276.256h46.72v-24.768h67.392V497.76h49.504V379.488h68.768v20.64h50.88V243.36H355.616v-34.368c0-10.08 1.376-18.784 4.16-26.112a10.56 10.56 0 0 0 1.344-4.16c0-0.896-3.2-1.792-9.6-2.72h-46.816v67.36H192v160.896z m46.72-122.368h67.392v60.48h-67.36V281.92z m185.664 60.48h-68.768V281.92h68.768v60.48z m203.84 488l19.264-53.632h100.384l19.264 53.632h54.976L732.736 576h-64.64L576 830.4h52.256z m33.024-96.256l37.12-108.608h1.376l34.368 108.608h-72.864zM896 320h-64a128 128 0 0 0-128-128v-64a192 192 0 0 1 192 192zM128 704h64a128 128 0 0 0 128 128v64a192 192 0 0 1-192-192z" />
|
||||
</svg>
|
||||
<Typography.Text>{getCurentLangStr()}</Typography.Text>
|
||||
</Flex>
|
||||
</Dropdown>
|
||||
<Typography.Link
|
||||
className={style.github}
|
||||
target="_blank"
|
||||
href="https://github.com/joye61/pic-smaller"
|
||||
>
|
||||
<GithubOutlined />
|
||||
</Typography.Link>
|
||||
|
||||
{/* If non-PC is determined, the menu button will be displayed */}
|
||||
{!isPC && homeState.list.size > 0 && (
|
||||
<>
|
||||
<Divider type="vertical" style={{ background: "#dfdfdf" }} />
|
||||
<Button
|
||||
icon={<MenuOutlined />}
|
||||
onClick={() => {
|
||||
homeState.showOption = !homeState.showOption;
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
const Body = observer(() => {
|
||||
return (
|
||||
<Flex align="stretch" className={style.main}>
|
||||
{homeState.list.size === 0 ? <UploadCard /> : <LeftContent />}
|
||||
<RightOption />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
const Home = observer(() => {
|
||||
useWorkerHandler();
|
||||
|
||||
// 全局粘贴事件处理
|
||||
useEffect(() => {
|
||||
const handlePaste = async (event: ClipboardEvent) => {
|
||||
if (hasImageInClipboard(event)) {
|
||||
event.preventDefault();
|
||||
const files = await getFilesFromClipboard(event);
|
||||
if (files.length > 0) {
|
||||
createImageList(files);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("paste", handlePaste);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("paste", handlePaste);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<Header />
|
||||
<Body />
|
||||
{homeState.compareId !== null && <Compare />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Home;
|
||||
@@ -0,0 +1,260 @@
|
||||
import { Flex, Space, Tooltip, Typography, message, theme } from "antd";
|
||||
import style from "./index.module.scss";
|
||||
import { TableProps } from "antd/es/table";
|
||||
import { ArrowDownOutlined, ArrowUpOutlined } from "@ant-design/icons";
|
||||
import { gstate } from "@/global";
|
||||
import { ImageItem, homeState } from "@/states/home";
|
||||
import { Indicator } from "@/components/Indicator";
|
||||
import { createDownload, formatSize, getOutputFileName } from "@/functions";
|
||||
import { useResponse } from "@/media";
|
||||
|
||||
export function useColumn(disabled: boolean) {
|
||||
const { token } = theme.useToken();
|
||||
const { isPC, isPad, isMobile } = useResponse();
|
||||
|
||||
const columns: TableProps<ImageItem>["columns"] = [
|
||||
{
|
||||
dataIndex: "status",
|
||||
title: gstate.locale?.columnTitle.status,
|
||||
fixed: "left",
|
||||
width: 80,
|
||||
className: style.status,
|
||||
render(_, row) {
|
||||
if (row.compress && row.preview) {
|
||||
return (
|
||||
<div className={style.fileok}>
|
||||
<svg viewBox="0 0 1024 1024">
|
||||
<path d="M128 128l0 768 768 0L896 128 128 128zM424.704 768 198.464 541.696 288.96 451.2l135.744 135.744 316.8-316.8L832 360.704 424.704 768z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Indicator />;
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: "preview",
|
||||
title: gstate.locale?.columnTitle.preview,
|
||||
render(_, row) {
|
||||
if (!row.preview) return <div className={style.preview} />;
|
||||
return (
|
||||
<div
|
||||
className={style.preview}
|
||||
style={{
|
||||
borderRadius: token.borderRadius,
|
||||
}}
|
||||
>
|
||||
<img src={row.preview.src} />
|
||||
{row.compress && (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
onClick={async () => {
|
||||
if (homeState.isCropMode()) {
|
||||
message.warning("裁剪模式不支持预览对比");
|
||||
return;
|
||||
}
|
||||
homeState.compareId = row.key;
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M13,23H11V1H13V23M9,19H5V5H9V3H5C3.89,3 3,3.89 3,5V19C3,20.11 3.9,21 5,21H9V19M19,7V9H21V7H19M19,5H21C21,3.89 20.1,3 19,3V5M21,15H19V17H21V15M19,11V13H21V11H19M17,3H15V5H17V3M19,21C20.11,21 21,20.11 21,19H19V21M17,19H15V21H17V19Z" />
|
||||
</svg>
|
||||
</Flex>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
if (isPC) {
|
||||
columns.push({
|
||||
dataIndex: "name",
|
||||
title: gstate.locale?.columnTitle.name,
|
||||
render(_, row) {
|
||||
return (
|
||||
<Typography.Text title={row.name} className={style.name}>
|
||||
{row.name}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (isPC || isPad) {
|
||||
columns.push(
|
||||
{
|
||||
dataIndex: "dimension",
|
||||
align: "right",
|
||||
className: style.nowrap,
|
||||
title: gstate.locale?.columnTitle.dimension,
|
||||
render(_, row) {
|
||||
if (!row.width && !row.height) return "-";
|
||||
return (
|
||||
<Typography.Text type="secondary">
|
||||
{row.width}*{row.height}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: "newDimension",
|
||||
align: "right",
|
||||
className: style.nowrap,
|
||||
title: gstate.locale?.columnTitle.newDimension,
|
||||
render(_, row) {
|
||||
if (!row.compress?.width && !row.compress?.height) return "-";
|
||||
return (
|
||||
<Typography.Text>
|
||||
{row.compress.width}*{row.compress.height}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: "size",
|
||||
align: "right",
|
||||
className: style.nowrap,
|
||||
title: gstate.locale?.columnTitle.size,
|
||||
sorter(first, second) {
|
||||
return first.blob.size - second.blob.size;
|
||||
},
|
||||
render(_, row) {
|
||||
return (
|
||||
<Typography.Text type="secondary">
|
||||
{formatSize(row.blob.size)}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: "newSize",
|
||||
align: "right",
|
||||
className: style.nowrap,
|
||||
title: gstate.locale?.columnTitle.newSize,
|
||||
sorter(first, second) {
|
||||
if (!first.compress || !second.compress) {
|
||||
return 0;
|
||||
}
|
||||
return first.compress.blob.size - second.compress.blob.size;
|
||||
},
|
||||
render(_, row) {
|
||||
if (!row.compress) return "-";
|
||||
const lower = row.blob.size > row.compress.blob.size;
|
||||
const format = formatSize(row.compress.blob.size);
|
||||
if (lower) {
|
||||
return <Typography.Text type="success">{format}</Typography.Text>;
|
||||
}
|
||||
|
||||
return <Typography.Text type="danger">{format}</Typography.Text>;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// All media supported fields
|
||||
columns.push(
|
||||
{
|
||||
dataIndex: "decrease",
|
||||
className: style.nowrap,
|
||||
title: gstate.locale?.columnTitle.decrease,
|
||||
align: "right",
|
||||
sorter(first, second) {
|
||||
if (!first.compress || !second.compress) {
|
||||
return 0;
|
||||
}
|
||||
const firstRate =
|
||||
(first.blob.size - first.compress.blob.size) / first.blob.size;
|
||||
const secondRate =
|
||||
(second.blob.size - second.compress.blob.size) / second.blob.size;
|
||||
|
||||
return firstRate - secondRate;
|
||||
},
|
||||
render(_, row) {
|
||||
if (!row.compress) return "-";
|
||||
const lower = row.blob.size > row.compress.blob.size;
|
||||
const rate = (row.compress.blob.size - row.blob.size) / row.blob.size;
|
||||
const formatRate = (Math.abs(rate) * 100).toFixed(2) + "%";
|
||||
if (lower) {
|
||||
return (
|
||||
<Flex align="center" justify="flex-end">
|
||||
<Typography.Text type="success" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatRate}
|
||||
</Typography.Text>
|
||||
<ArrowDownOutlined style={{ color: token.colorSuccess }} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex align="center" justify="flex-end">
|
||||
<Typography.Text type="danger">+{formatRate} </Typography.Text>
|
||||
<Tooltip title={gstate.locale?.optionPannel.failTip}>
|
||||
<ArrowUpOutlined
|
||||
style={{ color: token.colorError, cursor: "pointer" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: "action",
|
||||
align: "right",
|
||||
fixed: "right",
|
||||
title: gstate.locale?.columnTitle.action,
|
||||
className: style.action,
|
||||
render(_, row) {
|
||||
return (
|
||||
<Space
|
||||
size={isMobile ? "large" : undefined}
|
||||
className={style.actionlink}
|
||||
>
|
||||
<Typography.Link
|
||||
type="secondary"
|
||||
disabled={disabled}
|
||||
style={{ fontSize: 0 }}
|
||||
onClick={() => {
|
||||
homeState.list.delete(row.key);
|
||||
}}
|
||||
>
|
||||
<Tooltip title={gstate.locale?.listAction.removeOne}>
|
||||
<svg viewBox="0 0 1024 1024" width="20" height="20">
|
||||
<path
|
||||
d="M912 239.2H112a5.333333 5.333333 0 0 0-5.333333 5.333333v64a5.333333 5.333333 0 0 0 5.333333 5.333334h88.32a5.333333 5.333333 0 0 1 5.333333 5.333333V912a5.333333 5.333333 0 0 0 5.333334 5.333333h601.973333a5.333333 5.333333 0 0 0 5.333333-5.333333V319.2a5.333333 5.333333 0 0 1 5.333334-5.333333H912a5.333333 5.333333 0 0 0 5.333333-5.333334v-64a5.333333 5.333333 0 0 0-5.333333-5.333333z m-470.24 486.506667a5.333333 5.333333 0 0 1-5.333333 5.333333h-64a5.333333 5.333333 0 0 1-5.333334-5.333333V430.826667a5.333333 5.333333 0 0 1 5.333334-5.333334h64a5.333333 5.333333 0 0 1 5.333333 5.333334z m215.146667 0a5.333333 5.333333 0 0 1-5.333334 5.333333h-64a5.333333 5.333333 0 0 1-5.333333-5.333333V430.826667a5.333333 5.333333 0 0 1 5.333333-5.333334h64a5.333333 5.333333 0 0 1 5.333334 5.333334z"
|
||||
fill="#ff4d4f"
|
||||
></path>
|
||||
<path
|
||||
d="M320 106.666667m5.333333 0l373.333334 0q5.333333 0 5.333333 5.333333l0 64q0 5.333333-5.333333 5.333333l-373.333334 0q-5.333333 0-5.333333-5.333333l0-64q0-5.333333 5.333333-5.333333Z"
|
||||
fill="#ff4d4f"
|
||||
></path>
|
||||
</svg>
|
||||
</Tooltip>
|
||||
</Typography.Link>
|
||||
<Typography.Link
|
||||
type="secondary"
|
||||
disabled={disabled}
|
||||
style={{ fontSize: 0 }}
|
||||
onClick={() => {
|
||||
if (row.compress?.blob) {
|
||||
const fileName = getOutputFileName(row, homeState.option);
|
||||
createDownload(fileName, row.compress.blob);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip title={gstate.locale?.listAction.downloadOne}>
|
||||
<svg viewBox="0 0 1024 1024" width="18" height="20">
|
||||
<path
|
||||
d="M736 448l-256 256-256-256 160 0 0-384 192 0 0 384zM480 704l-480 0 0 256 960 0 0-256-480 0zM896 832l-128 0 0-64 128 0 0 64z"
|
||||
fill="#707070"
|
||||
/>
|
||||
</svg>
|
||||
</Tooltip>
|
||||
</Typography.Link>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return columns;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createBrowserHistory } from "history";
|
||||
import { normalize } from "./functions";
|
||||
import { gstate } from "./global";
|
||||
import { modules } from "./modules";
|
||||
|
||||
export const history = createBrowserHistory();
|
||||
|
||||
type Params = Record<string, string | number> | null;
|
||||
|
||||
export function goto(
|
||||
pathname: string = "/",
|
||||
params?: Params,
|
||||
type: string = "push",
|
||||
) {
|
||||
pathname += buildQueryString(params);
|
||||
navigate(pathname, type);
|
||||
}
|
||||
|
||||
function buildQueryString(params?: Params) {
|
||||
if (!params) return "";
|
||||
const search = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
search.append(key, String(value));
|
||||
});
|
||||
|
||||
const query = search.toString();
|
||||
return query ? `?${query}` : "";
|
||||
}
|
||||
|
||||
function navigate(pathname: string, type: string): void {
|
||||
if (type === "push") {
|
||||
history.push(pathname);
|
||||
} else if (type === "replace") {
|
||||
history.replace(pathname);
|
||||
} else {
|
||||
throw new Error("Error history route method");
|
||||
}
|
||||
}
|
||||
|
||||
export function initRouter() {
|
||||
history.listen(({ location }) => {
|
||||
handleRouteChange(location.pathname);
|
||||
});
|
||||
handleRouteChange(history.location.pathname);
|
||||
}
|
||||
|
||||
async function handleRouteChange(pathname: string) {
|
||||
gstate.pathname = normalize(pathname) || "home";
|
||||
gstate.page = await loadPageComponent(gstate.pathname);
|
||||
}
|
||||
|
||||
async function loadPageComponent(pathname: string) {
|
||||
try {
|
||||
const importer = modules[`/src/pages/${pathname}/index.tsx`]();
|
||||
const result = await importer;
|
||||
return <result.default />;
|
||||
} catch (error) {
|
||||
const error404 = await import(`@/pages/error404/index.tsx`);
|
||||
return <error404.default />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { CompressOption, ProcessOutput } from "@/engines/ImageBase";
|
||||
import { createCompressTask } from "@/engines/transform";
|
||||
import { makeAutoObservable } from "mobx";
|
||||
|
||||
export const DefaultCompressOption: CompressOption = {
|
||||
preview: {
|
||||
maxSize: 256,
|
||||
},
|
||||
resize: {
|
||||
method: undefined,
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
short: undefined,
|
||||
long: undefined,
|
||||
cropWidthRatio: undefined,
|
||||
cropHeightRatio: undefined,
|
||||
cropWidthSize: undefined,
|
||||
cropHeightSize: undefined,
|
||||
presetCrop: {
|
||||
paperSize: "a4",
|
||||
orientation: "portrait",
|
||||
reference: "width",
|
||||
cropPx: 0,
|
||||
offsetPx: 0,
|
||||
},
|
||||
},
|
||||
format: {
|
||||
target: undefined,
|
||||
transparentFill: "#FFFFFF",
|
||||
},
|
||||
jpeg: {
|
||||
quality: 0.75,
|
||||
},
|
||||
png: {
|
||||
colors: 128,
|
||||
dithering: 0.5,
|
||||
},
|
||||
gif: {
|
||||
colors: 128,
|
||||
dithering: false,
|
||||
},
|
||||
avif: {
|
||||
quality: 50,
|
||||
speed: 8,
|
||||
},
|
||||
};
|
||||
|
||||
export interface ProgressHintInfo {
|
||||
loadedNum: number;
|
||||
totalNum: number;
|
||||
percent: number;
|
||||
originSize: number;
|
||||
outputSize: number;
|
||||
rate: number;
|
||||
}
|
||||
|
||||
export type ImageItem = {
|
||||
key: number;
|
||||
name: string;
|
||||
blob: Blob;
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
preview?: ProcessOutput;
|
||||
compress?: ProcessOutput;
|
||||
};
|
||||
|
||||
export class HomeState {
|
||||
public list: Map<number, ImageItem> = new Map();
|
||||
public option: CompressOption = DefaultCompressOption;
|
||||
public tempOption: CompressOption = DefaultCompressOption;
|
||||
public compareId: number | null = null;
|
||||
public showOption: boolean = false;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether crop mode
|
||||
* @returns
|
||||
*/
|
||||
isCropMode() {
|
||||
const resize = this.option.resize;
|
||||
return (
|
||||
(resize.method === "presetCrop" &&
|
||||
resize.presetCrop?.paperSize != null) ||
|
||||
(resize.method === "setCropRatio" &&
|
||||
resize.cropWidthRatio &&
|
||||
resize.cropHeightRatio &&
|
||||
resize.cropWidthRatio > 0 &&
|
||||
resize.cropHeightRatio > 0) ||
|
||||
(resize.method === "setCropSize" &&
|
||||
resize.cropWidthSize &&
|
||||
resize.cropHeightSize &&
|
||||
resize.cropWidthSize > 0 &&
|
||||
resize.cropHeightSize > 0)
|
||||
);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.list.clear();
|
||||
this.tempOption = { ...DefaultCompressOption };
|
||||
this.option = { ...DefaultCompressOption };
|
||||
}
|
||||
|
||||
reCompress() {
|
||||
this.list.forEach((info) => {
|
||||
URL.revokeObjectURL(info.compress!.src);
|
||||
info.compress = undefined;
|
||||
createCompressTask(info);
|
||||
});
|
||||
}
|
||||
|
||||
hasTaskRunning() {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
for (const [_, value] of this.list) {
|
||||
if (!value.preview || !value.compress) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取进度条信息
|
||||
* @returns
|
||||
*/
|
||||
getProgressHintInfo(): ProgressHintInfo {
|
||||
const totalNum = this.list.size;
|
||||
let loadedNum = 0;
|
||||
let originSize = 0;
|
||||
let outputSize = 0;
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
for (const [_, info] of this.list) {
|
||||
originSize += info.blob.size;
|
||||
if (info.compress) {
|
||||
loadedNum++;
|
||||
outputSize += info.compress.blob.size;
|
||||
}
|
||||
}
|
||||
const percent = Math.ceil((loadedNum * 100) / totalNum);
|
||||
const originRate = ((outputSize - originSize) * 100) / originSize;
|
||||
const rate = Number(Math.abs(originRate).toFixed(2));
|
||||
|
||||
return {
|
||||
totalNum,
|
||||
loadedNum,
|
||||
originSize,
|
||||
outputSize,
|
||||
percent,
|
||||
rate,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const homeState = new HomeState();
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { Locale } from "antd/es/locale";
|
||||
|
||||
export interface LocaleData {
|
||||
antLocale: Locale;
|
||||
logo: string;
|
||||
initial: string;
|
||||
previewHelp: string;
|
||||
error404: {
|
||||
backHome: string;
|
||||
description: string;
|
||||
};
|
||||
uploadCard: {
|
||||
title: string;
|
||||
subTitle: string;
|
||||
pasteHint: string;
|
||||
};
|
||||
listAction: {
|
||||
batchAppend: string;
|
||||
addFolder: string;
|
||||
clear: string;
|
||||
downloadAll: string;
|
||||
downloadOne: string;
|
||||
removeOne: string;
|
||||
reCompress: string;
|
||||
};
|
||||
columnTitle: {
|
||||
status: string;
|
||||
name: string;
|
||||
preview: string;
|
||||
size: string;
|
||||
newSize: string;
|
||||
dimension: string;
|
||||
newDimension: string;
|
||||
decrease: string;
|
||||
action: string;
|
||||
};
|
||||
optionPannel: {
|
||||
resizeLable: string;
|
||||
jpegLable: string;
|
||||
pngLable: string;
|
||||
gifLable: string;
|
||||
avifLable: string;
|
||||
avifQuality: string;
|
||||
avifSpeed: string;
|
||||
help: string;
|
||||
failTip: string;
|
||||
resizePlaceholder: string;
|
||||
fitWidth: string;
|
||||
fitHeight: string;
|
||||
setShort: string;
|
||||
setLong: string;
|
||||
setCropRatio: string;
|
||||
setCropSize: string;
|
||||
widthPlaceholder: string;
|
||||
heightPlaceholder: string;
|
||||
shortPlaceholder: string;
|
||||
longPlaceholder: string;
|
||||
cwRatioPlaceholder: string;
|
||||
chRatioPlaceholder: string;
|
||||
cwSizePlaceholder: string;
|
||||
chSizePlaceholder: string;
|
||||
cropCompareWarning: string;
|
||||
presetCrop: string;
|
||||
presetPaperSize: string;
|
||||
presetOrientation: string;
|
||||
presetPortrait: string;
|
||||
presetLandscape: string;
|
||||
presetRefWidth: string;
|
||||
presetRefHeight: string;
|
||||
presetCropPx: string;
|
||||
presetOffsetPx: string;
|
||||
presetCropWarning: string;
|
||||
presetSwitchRef: string;
|
||||
presetCancelCrop: string;
|
||||
qualityTitle: string;
|
||||
resetBtn: string;
|
||||
confirmBtn: string;
|
||||
colorsDesc: string;
|
||||
pngDithering: string;
|
||||
gifDithering: string;
|
||||
outputFormat: string;
|
||||
outputFormatPlaceholder: string;
|
||||
transparentFillDesc: string;
|
||||
};
|
||||
progress: {
|
||||
before: string;
|
||||
after: string;
|
||||
rate: string;
|
||||
};
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface DataTransferItem {
|
||||
getAsFileSystemHandle?: () => Promise<FileSystemHandle>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
showDirectoryPicker?: (option?: {
|
||||
id: string;
|
||||
}) => Promise<FileSystemDirectoryHandle>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { getUniqNameOnNames, normalize } from "@/functions";
|
||||
|
||||
test("Path normalize check", () => {
|
||||
expect(normalize("")).toBe("");
|
||||
expect(normalize("/a/b")).toBe("a/b");
|
||||
expect(normalize("/sub/a/b", "/sub")).toBe("a/b");
|
||||
expect(normalize("/a/b", "/sub")).toBe("error404");
|
||||
});
|
||||
|
||||
test("Rename check", () => {
|
||||
const names = new Set<string>(["a.jpg", "b.png"]);
|
||||
expect(getUniqNameOnNames(names, "a.jpg")).toBe("a(1).jpg");
|
||||
names.add("a(1).jpg");
|
||||
expect(getUniqNameOnNames(names, "a.jpg")).toBe("a(1)(1).jpg");
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"allowJs": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "tests"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/// <reference types="vitest" />
|
||||
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: "/",
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
host: "0.0.0.0"
|
||||
},
|
||||
preview: {
|
||||
port: 3001,
|
||||
host: "0.0.0.0",
|
||||
},
|
||||
test: {
|
||||
include: ["tests/**/*.{test,spec}.?(c|m)[jt]s?(x)"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user