From 4a66c65b68cb65b27b81914580565f6d18a0a5a6 Mon Sep 17 00:00:00 2001 From: Osama Khalid Date: Wed, 21 Jan 2026 21:41:08 -0500 Subject: [PATCH 01/10] chore: wip, image conversion --- package-lock.json | 345 +++++++++++++++++++++----- package.json | 7 +- src/components/Sidebar.tsx | 1 + src/config/routes.config.ts | 5 + src/config/tools.config.tsx | 17 ++ src/lib/categories.ts | 3 +- src/tools/ImageConverter.tsx | 465 +++++++++++++++++++++++++++++++++++ src/types/tool.types.ts | 1 + src/workers/vips.worker.ts | 146 +++++++++++ vite.config.ts | 15 ++ 10 files changed, 946 insertions(+), 59 deletions(-) create mode 100644 src/tools/ImageConverter.tsx create mode 100644 src/workers/vips.worker.ts diff --git a/package-lock.json b/package-lock.json index f38675b..1606e22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "good.tools", - "version": "1.22.0", + "version": "1.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "good.tools", - "version": "1.22.0", + "version": "1.23.0", "dependencies": { "@algolia/autocomplete-core": "^1.19.4", "@goodtools/jdserialize": "^1.0.0", @@ -51,6 +51,9 @@ "tailwindcss": "^3.2.4", "tailwindcss-animate": "^1.0.7", "three": "^0.172.0", + "vite-plugin-top-level-await": "^1.6.0", + "vite-plugin-wasm": "^3.5.0", + "wasm-vips": "^0.0.16", "web-vitals": "^2.1.4", "xml-formatter": "^3.6.7", "zustand": "^4.1.5" @@ -942,7 +945,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -959,7 +961,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -976,7 +977,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -993,7 +993,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1010,7 +1009,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1027,7 +1025,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1044,7 +1041,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1061,7 +1057,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1078,7 +1073,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1095,7 +1089,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1112,7 +1105,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1129,7 +1121,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1146,7 +1137,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1163,7 +1153,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1180,7 +1169,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1197,7 +1185,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1214,7 +1201,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1248,7 +1234,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1282,7 +1267,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1316,7 +1300,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1333,7 +1316,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1350,7 +1332,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1367,7 +1348,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3233,6 +3213,23 @@ } } }, + "node_modules/@rollup/plugin-virtual": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-virtual/-/plugin-virtual-3.0.2.tgz", + "integrity": "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -3276,7 +3273,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3290,7 +3286,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3304,7 +3299,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3318,7 +3312,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3332,7 +3325,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3346,7 +3338,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3360,7 +3351,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3374,7 +3364,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3388,7 +3377,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3402,7 +3390,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3416,7 +3403,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3430,7 +3416,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3444,7 +3429,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3458,7 +3442,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3472,7 +3455,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3486,7 +3468,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3500,7 +3481,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3514,7 +3494,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3528,7 +3507,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3542,7 +3520,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3556,7 +3533,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3570,7 +3546,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3584,7 +3559,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3598,7 +3572,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3612,7 +3585,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4421,6 +4393,225 @@ "dev": true, "license": "MIT" }, + "node_modules/@swc/core": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.10.tgz", + "integrity": "sha512-udNofxftduMUEv7nqahl2nvodCiCDQ4Ge0ebzsEm6P8s0RC2tBM0Hqx0nNF5J/6t9uagFJyWIDjXy3IIWMHDJw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.25" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.10", + "@swc/core-darwin-x64": "1.15.10", + "@swc/core-linux-arm-gnueabihf": "1.15.10", + "@swc/core-linux-arm64-gnu": "1.15.10", + "@swc/core-linux-arm64-musl": "1.15.10", + "@swc/core-linux-x64-gnu": "1.15.10", + "@swc/core-linux-x64-musl": "1.15.10", + "@swc/core-win32-arm64-msvc": "1.15.10", + "@swc/core-win32-ia32-msvc": "1.15.10", + "@swc/core-win32-x64-msvc": "1.15.10" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.10.tgz", + "integrity": "sha512-U72pGqmJYbjrLhMndIemZ7u9Q9owcJczGxwtfJlz/WwMaGYAV/g4nkGiUVk/+QSX8sFCAjanovcU1IUsP2YulA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.10.tgz", + "integrity": "sha512-NZpDXtwHH083L40xdyj1sY31MIwLgOxKfZEAGCI8xHXdHa+GWvEiVdGiu4qhkJctoHFzAEc7ZX3GN5phuJcPuQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.10.tgz", + "integrity": "sha512-ioieF5iuRziUF1HkH1gg1r93e055dAdeBAPGAk40VjqpL5/igPJ/WxFHGvc6WMLhUubSJI4S0AiZAAhEAp1jDg==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.10.tgz", + "integrity": "sha512-tD6BClOrxSsNus9cJL7Gxdv7z7Y2hlyvZd9l0NQz+YXzmTWqnfzLpg16ovEI7gknH2AgDBB5ywOsqu8hUgSeEQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.10.tgz", + "integrity": "sha512-4uAHO3nbfbrTcmO/9YcVweTQdx5fN3l7ewwl5AEK4yoC4wXmoBTEPHAVdKNe4r9+xrTgd4BgyPsy0409OjjlMw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.10.tgz", + "integrity": "sha512-W0h9ONNw1pVIA0cN7wtboOSTl4Jk3tHq+w2cMPQudu9/+3xoCxpFb9ZdehwCAk29IsvdWzGzY6P7dDVTyFwoqg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.10.tgz", + "integrity": "sha512-XQNZlLZB62S8nAbw7pqoqwy91Ldy2RpaMRqdRN3T+tAg6Xg6FywXRKCsLh6IQOadr4p1+lGnqM/Wn35z5a/0Vw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.10.tgz", + "integrity": "sha512-qnAGrRv5Nj/DATxAmCnJQRXXQqnJwR0trxLndhoHoxGci9MuguNIjWahS0gw8YZFjgTinbTxOwzatkoySihnmw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.10.tgz", + "integrity": "sha512-i4X/q8QSvzVlaRtv1xfnfl+hVKpCfiJ+9th484rh937fiEZKxZGf51C+uO0lfKDP1FfnT6C1yBYwHy7FLBVXFw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.10.tgz", + "integrity": "sha512-HvY8XUFuoTXn6lSccDLYFlXv1SU/PzYi4PyUqGT++WfTnbw/68N/7BdUZqglGRwiSqr0qhYt/EhmBpULj0J9rA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", + "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@swc/wasm": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.10.tgz", + "integrity": "sha512-kVTb346oP16MOwDibhLr/nv+swrw/M8dcim6VjsWKZR5ZVOCZU9RrzrT5gMh72v4Y37Agilb8SCzEZ4wTvQGpQ==", + "license": "Apache-2.0" + }, "node_modules/@tailwindcss/forms": { "version": "0.5.11", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", @@ -4708,7 +4899,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/jsdom": { @@ -4754,7 +4944,7 @@ "version": "20.19.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -7679,7 +7869,6 @@ "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -14793,7 +14982,6 @@ "version": "4.55.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -16974,7 +17162,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { @@ -17209,6 +17397,19 @@ "node": ">= 4" } }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -17225,7 +17426,6 @@ "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", @@ -17298,6 +17498,30 @@ "vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/vite-plugin-top-level-await": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vite-plugin-top-level-await/-/vite-plugin-top-level-await-1.6.0.tgz", + "integrity": "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww==", + "license": "MIT", + "dependencies": { + "@rollup/plugin-virtual": "^3.0.2", + "@swc/core": "^1.12.14", + "@swc/wasm": "^1.12.14", + "uuid": "10.0.0" + }, + "peerDependencies": { + "vite": ">=2.8" + } + }, + "node_modules/vite-plugin-wasm": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.5.0.tgz", + "integrity": "sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ==", + "license": "MIT", + "peerDependencies": { + "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7" + } + }, "node_modules/vitest": { "version": "4.0.17", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", @@ -17972,6 +18196,15 @@ "node": ">=18" } }, + "node_modules/wasm-vips": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/wasm-vips/-/wasm-vips-0.0.16.tgz", + "integrity": "sha512-4/bEq8noAFt7DX3VT+Vt5AgNtnnOLwvmrDbduWfiv9AV+VYkbUU4f9Dam9e6khRqPinyClFHCqiwATTTJEiGwA==", + "license": "MIT", + "engines": { + "node": ">=16.4.0" + } + }, "node_modules/web-vitals": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz", diff --git a/package.json b/package.json index 48db003..0d41bc0 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,6 @@ "@goodtools/jdserialize": "^1.0.0", "@goodtools/meshrepair": "^0.1.1", "@goodtools/protobuf-decoder": "^1.0.0", - "@react-three/drei": "^9.92.7", - "@react-three/fiber": "^8.15.12", "@goodtools/wiregasm": "^1.9.0", "@headlessui/react": "^1.7.7", "@heroicons/react": "^2.0.13", @@ -24,6 +22,8 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", + "@react-three/drei": "^9.92.7", + "@react-three/fiber": "^8.15.12", "@tailwindcss/forms": "^0.5.3", "@tailwindcss/typography": "^0.5.8", "@tanstack/react-query": "^5.62.16", @@ -51,6 +51,9 @@ "tailwindcss": "^3.2.4", "tailwindcss-animate": "^1.0.7", "three": "^0.172.0", + "vite-plugin-top-level-await": "^1.6.0", + "vite-plugin-wasm": "^3.5.0", + "wasm-vips": "^0.0.16", "web-vitals": "^2.1.4", "xml-formatter": "^3.6.7", "zustand": "^4.1.5" diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 89dcb63..30b3a06 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -105,6 +105,7 @@ export function Sidebar() { [CATEGORIES.SECURITY]: [], [CATEGORIES.NETWORK]: [], [CATEGORIES['3D']]: [], + [CATEGORIES.IMAGE]: [], } const toolsToShow = searchQuery diff --git a/src/config/routes.config.ts b/src/config/routes.config.ts index bded078..de7f3b5 100644 --- a/src/config/routes.config.ts +++ b/src/config/routes.config.ts @@ -20,6 +20,11 @@ export const ROUTES = { 'json-escape': '/json-escape', 'xml-formatter': '/xml', 'stl-repair': '/stl-repair', + // Image tools + 'image-converter': '/image-converter', + 'image-transform': '/image-transform', + 'image-compress': '/image-compress', + 'create-transparent': '/create-transparent', } as const export type ToolKey = keyof typeof ROUTES diff --git a/src/config/tools.config.tsx b/src/config/tools.config.tsx index 2fd3c77..48fcbec 100644 --- a/src/config/tools.config.tsx +++ b/src/config/tools.config.tsx @@ -16,6 +16,7 @@ import { Code2, Quote, Box, + ImageIcon, } from 'lucide-react' import { ROUTES } from './routes.config' import { CATEGORIES, type Tool } from '@/types/tool.types' @@ -317,6 +318,22 @@ export const tools: Tool[] = [ }, ], }, + { + title: 'Image Converter', + href: ROUTES['image-converter'], + description: 'Convert images between formats: JPEG, PNG, WebP, AVIF with quality control', + icon: ImageIcon, + categories: [CATEGORIES.IMAGE], + searchTags: ['image', 'convert', 'jpeg', 'png', 'webp', 'avif', 'format', 'photo'], + component: React.lazy(() => import('@/tools/ImageConverter')), + online: false, + dependencies: [ + { + name: 'wasm-vips', + url: 'https://www.npmjs.com/package/wasm-vips', + }, + ], + }, ] /** diff --git a/src/lib/categories.ts b/src/lib/categories.ts index f266f6f..f3a7a11 100644 --- a/src/lib/categories.ts +++ b/src/lib/categories.ts @@ -1,4 +1,4 @@ -import { Layers, Code, Binary, Shield, Globe, Box, type LucideIcon } from 'lucide-react' +import { Layers, Code, Binary, Shield, Globe, Box, Image, type LucideIcon } from 'lucide-react' import { CATEGORIES, type CategoryName, type Tool, type ToolCategory } from '@/types/tool.types' // Re-export for convenience @@ -14,6 +14,7 @@ export const categories: ToolCategory[] = [ { name: CATEGORIES.SECURITY, icon: Shield }, { name: CATEGORIES.NETWORK, icon: Globe }, { name: CATEGORIES['3D'], icon: Box }, + { name: CATEGORIES.IMAGE, icon: Image }, ] /** diff --git a/src/tools/ImageConverter.tsx b/src/tools/ImageConverter.tsx new file mode 100644 index 0000000..3d1e62f --- /dev/null +++ b/src/tools/ImageConverter.tsx @@ -0,0 +1,465 @@ +import { useState, useRef, useCallback, useEffect, useMemo } from 'react' +import { Button } from '@/components/Button' +import { XCircleIcon, ArrowDownTrayIcon, TrashIcon, DocumentTextIcon } from '@heroicons/react/24/outline' +import { filesize } from 'filesize' +import type { ImageInfo } from '@/workers/vips.worker' + +type OutputFormat = 'jpeg' | 'png' | 'webp' | 'avif' + +interface ConversionResult { + buffer: ArrayBuffer + format: OutputFormat + size: number +} + +function ImageConverter() { + // File state + const [fileName, setFileName] = useState(null) + const [originalBuffer, setOriginalBuffer] = useState(null) + const [imageInfo, setImageInfo] = useState(null) + const [originalSize, setOriginalSize] = useState(0) + const [result, setResult] = useState(null) + + // UI state + const [ready, setReady] = useState(false) + const [loading, setLoading] = useState(true) + const [converting, setConverting] = useState(false) + const [error, setError] = useState(null) + const [status, setStatus] = useState('Initializing...') + + // Options + const [outputFormat, setOutputFormat] = useState('webp') + const [quality, setQuality] = useState(80) + + // Refs + const fileInputRef = useRef(null) + const previewRef = useRef(null) + const resultPreviewRef = useRef(null) + const requestIdRef = useRef(0) + + // Render image to canvas + const renderToCanvas = useCallback((canvas: HTMLCanvasElement | null, buffer: ArrayBuffer, mimeType: string) => { + if (!canvas || buffer.byteLength === 0) return + + // Create a copy of the buffer to ensure it's not neutered + const bufferCopy = buffer.slice(0) + const blob = new Blob([bufferCopy], { type: mimeType }) + const url = URL.createObjectURL(blob) + const img = new window.Image() + + img.onload = () => { + const ctx = canvas.getContext('2d') + if (!ctx) return + + // Set canvas size to match image (max 400px) + const maxSize = 400 + const scale = Math.min(1, maxSize / Math.max(img.width, img.height)) + canvas.width = img.width * scale + canvas.height = img.height * scale + + ctx.drawImage(img, 0, 0, canvas.width, canvas.height) + URL.revokeObjectURL(url) + } + + img.onerror = () => { + console.error('Failed to load image for preview:', mimeType, buffer.byteLength) + URL.revokeObjectURL(url) + } + + img.src = url + }, []) + + // Create worker once using useMemo (like Wiregasm pattern) + const worker = useMemo(() => { + const w = new Worker(new URL('../workers/vips.worker.ts', import.meta.url), { + type: 'module', + }) + w.onerror = (e) => console.error('Worker Load Error:', e) + return w + }, []) + + // Set up worker message handlers + useEffect(() => { + // Store current outputFormat in ref for use in message handler + const currentOutputFormat = outputFormat + + worker.onmessage = (event: MessageEvent) => { + const { + type, + data, + error: err, + status: workerStatus, + } = event.data as { + type: string + data?: ImageInfo | ArrayBuffer + error?: string + status?: string + } + + if (type === 'init') { + setLoading(false) + setReady(true) + setStatus('Ready') + } else if (type === 'status') { + setStatus(workerStatus || '') + } else if (type === 'error') { + setError(err || 'Unknown error') + setLoading(false) + setConverting(false) + } else if (type === 'loaded') { + setImageInfo(data as ImageInfo) + // Auto-select format based on alpha + if ((data as ImageInfo).hasAlpha) { + setOutputFormat('png') + } else { + setOutputFormat('webp') + } + } else if (type === 'converted') { + const buffer = data as ArrayBuffer + setResult({ + buffer, + format: currentOutputFormat, + size: buffer.byteLength, + }) + setConverting(false) + // Render result preview + const mimeType = currentOutputFormat === 'jpeg' ? 'image/jpeg' : `image/${currentOutputFormat}` + renderToCanvas(resultPreviewRef.current, buffer, mimeType) + } + } + + return () => { + worker.terminate() + } + }, [worker, outputFormat, renderToCanvas]) + + // Load file + const handleFile = useCallback( + async (file: File) => { + if (!ready) return + + setError(null) + setResult(null) + setFileName(file.name) + + try { + const buffer = await file.arrayBuffer() + // Store a copy for later use + const storedBuffer = buffer.slice(0) + setOriginalBuffer(storedBuffer) + setOriginalSize(storedBuffer.byteLength) + + // Render original preview directly (use stored buffer copy) + const mimeType = file.type || 'image/png' + renderToCanvas(previewRef.current, storedBuffer, mimeType) + + // Send a separate copy to worker to get image info + const workerBuffer = buffer.slice(0) + const id = ++requestIdRef.current + worker.postMessage({ type: 'load', id, buffer: workerBuffer }, [workerBuffer]) + } catch (err) { + setError(`Failed to load image: ${err instanceof Error ? err.message : String(err)}`) + } + }, + [ready, renderToCanvas, worker], + ) + + // Handle drop + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + const file = e.dataTransfer.files[0] + if (file && file.type.startsWith('image/')) { + void handleFile(file) + } else { + setError('Please drop a valid image file') + } + }, + [handleFile], + ) + + // Handle file input + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + void handleFile(file) + } + }, + [handleFile], + ) + + // Convert image + const convert = useCallback(() => { + if (!originalBuffer || !ready) return + + setConverting(true) + setError(null) + + const id = ++requestIdRef.current + // Send buffer copy to worker (original buffer can be neutered) + const bufferCopy = originalBuffer.slice(0) + worker.postMessage({ type: 'convert', id, buffer: bufferCopy, format: outputFormat, options: { quality } }, [ + bufferCopy, + ]) + }, [originalBuffer, outputFormat, quality, ready, worker]) + + // Download result + const download = useCallback(() => { + if (!result || !fileName) return + + const mimeType = result.format === 'jpeg' ? 'image/jpeg' : `image/${result.format}` + const blob = new Blob([result.buffer], { type: mimeType }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + + // Replace extension + const baseName = fileName.replace(/\.[^/.]+$/, '') + a.download = `${baseName}.${result.format}` + a.click() + URL.revokeObjectURL(url) + }, [result, fileName]) + + // Clear + const clear = useCallback(() => { + setFileName(null) + setOriginalBuffer(null) + setImageInfo(null) + setOriginalSize(0) + setResult(null) + setError(null) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + if (previewRef.current) { + const ctx = previewRef.current.getContext('2d') + ctx?.clearRect(0, 0, previewRef.current.width, previewRef.current.height) + } + if (resultPreviewRef.current) { + const ctx = resultPreviewRef.current.getContext('2d') + ctx?.clearRect(0, 0, resultPreviewRef.current.width, resultPreviewRef.current.height) + } + }, []) + + const sizeChange = result ? ((result.size - originalSize) / originalSize) * 100 : 0 + const sizeChangeText = sizeChange < 0 ? `${sizeChange.toFixed(1)}%` : `+${sizeChange.toFixed(1)}%` + const sizeChangeColor = sizeChange < 0 ? 'text-green-600' : 'text-red-600' + + return ( +
+ {/* Hidden file input */} + + + {/* Drop zone - show when loading or no file */} + {!fileName && ( +
ready && fileInputRef.current?.click()} + onDrop={handleDrop} + onDragOver={(e) => e.preventDefault()} + className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${ + ready + ? 'border-gray-300 dark:border-zinc-700 hover:border-indigo-400 dark:hover:border-indigo-600 cursor-pointer' + : 'border-gray-200 dark:border-zinc-800 cursor-wait' + }`} + > + {loading ? ( +
+ + + + + {status} +
+ ) : ( + <> +
+ + + +
+

Drop an image here, or click to select

+

Supports JPEG, PNG, WebP, AVIF

+ + )} +
+ )} + + {/* Error display */} + {error && ( +
+
+
+
+ )} + + {/* Options */} + {originalBuffer && ( +
+
+

Conversion Options

+
+ + + {fileName} + + + {filesize(originalSize, { base: 2 })} + {imageInfo && ( + <> + + + {imageInfo.width}×{imageInfo.height} + + + )} +
+
+ + {/* Format selector */} +
+ +
+ {(['jpeg', 'png', 'webp', 'avif'] as const).map((fmt) => ( + + ))} +
+
+ + {/* Quality slider (for lossy formats) */} + {(outputFormat === 'jpeg' || outputFormat === 'webp' || outputFormat === 'avif') && ( +
+ + setQuality(parseInt(e.target.value))} + className='w-full h-2 bg-gray-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-indigo-600' + /> +
+ Smaller file + Higher quality +
+
+ )} + + {/* Action buttons */} +
+
+ + + {result && ( + + )} +
+ + +
+
+ )} + + {/* Previews */} + {originalBuffer && ( +
+
+ {/* Original */} +
+
+ Original +
+
+ +
+
+ {filesize(originalSize, { base: 2 })} +
+
+ +
+ + {/* Result */} +
+
+ Converted +
+
+ {result ? ( + + ) : ( + Click Convert to see result + )} +
+
+ {result ? ( + + {filesize(result.size, { base: 2 })} ({sizeChangeText}) + + ) : ( + + )} +
+
+
+
+ )} +
+ ) +} + +export default ImageConverter diff --git a/src/types/tool.types.ts b/src/types/tool.types.ts index 071da55..9e6578f 100644 --- a/src/types/tool.types.ts +++ b/src/types/tool.types.ts @@ -10,6 +10,7 @@ export const CATEGORIES = { SECURITY: 'Security', NETWORK: 'Network', '3D': '3D & CAD', + IMAGE: 'Image', } as const export type CategoryName = (typeof CATEGORIES)[keyof typeof CATEGORIES] diff --git a/src/workers/vips.worker.ts b/src/workers/vips.worker.ts new file mode 100644 index 0000000..b581a70 --- /dev/null +++ b/src/workers/vips.worker.ts @@ -0,0 +1,146 @@ +/** + * Vips Web Worker for non-blocking image processing + * TypeScript version for type safety + */ + +import type Vips from 'wasm-vips' + +// Message types for worker communication +export interface VipsWorkerMessage { + type: 'load' | 'convert' | 'info' + id: number + buffer?: ArrayBuffer + format?: OutputFormat + options?: ConvertOptions +} + +export interface VipsWorkerResponse { + type: 'init' | 'status' | 'error' | 'loaded' | 'converted' | 'info' + id?: number + data?: ImageInfo | ArrayBuffer + error?: string + status?: string +} + +export type OutputFormat = 'jpeg' | 'png' | 'webp' | 'avif' + +export interface ConvertOptions { + quality?: number + compressionLevel?: number +} + +export interface ImageInfo { + width: number + height: number + bands: number + hasAlpha: boolean +} + +// Singleton vips instance +let vipsInstance: typeof Vips | null = null +let initPromise: Promise | null = null + +/** + * Initialize wasm-vips + */ +async function initVips(): Promise { + if (vipsInstance) { + return vipsInstance + } + + if (initPromise) { + return initPromise + } + + initPromise = (async () => { + postMessage({ type: 'status', status: 'Loading module...' } as VipsWorkerResponse) + + // Dynamic import for vips + const vips = await import('wasm-vips') + const instance = await vips.default() + + vipsInstance = instance + postMessage({ type: 'status', status: 'Ready' } as VipsWorkerResponse) + return instance + })() + + return initPromise +} + +/** + * Convert image to specified format + */ +function convertFormat( + vips: typeof Vips, + imageData: ArrayBuffer, + format: OutputFormat, + options: ConvertOptions = {}, +): Uint8Array { + const data = new Uint8Array(imageData) + const image = vips.Image.newFromBuffer(data) + + if (format === 'jpeg') { + return image.jpegsaveBuffer({ Q: options.quality || 85 }) + } else if (format === 'png') { + return image.pngsaveBuffer({ compression: options.compressionLevel || 6 }) + } else if (format === 'webp') { + return image.webpsaveBuffer({ Q: options.quality || 85 }) + } else if (format === 'avif') { + return image.heifsaveBuffer({ Q: options.quality || 50, compression: 'av1' }) + } + + throw new Error(`Unsupported format: ${format}`) +} + +/** + * Get image info (width, height, hasAlpha) + */ +function getImageInfo(vips: typeof Vips, buffer: ArrayBuffer): ImageInfo { + const data = new Uint8Array(buffer) + const image = vips.Image.newFromBuffer(data) + + return { + width: image.width, + height: image.height, + bands: image.bands, + hasAlpha: image.hasAlpha(), + } +} + +// Initialize on worker start +initVips() + .then(() => { + postMessage({ type: 'init' } as VipsWorkerResponse) + }) + .catch((e: Error) => { + postMessage({ type: 'error', error: e.message || String(e) } as VipsWorkerResponse) + }) + +// Handle messages from main thread +onmessage = async (event: MessageEvent) => { + const { type, id, buffer, format, options } = event.data + + try { + const vips = await initVips() + + if (type === 'load' && buffer) { + const info = getImageInfo(vips, buffer) + postMessage({ type: 'loaded', id, data: info } as VipsWorkerResponse) + } else if (type === 'convert' && buffer && format) { + self.postMessage({ type: 'status', status: `Converting to ${format.toUpperCase()}...` }) + + const result = convertFormat(vips, buffer, format, options || {}) + // Transfer the ArrayBuffer to avoid copying + const arrayBuffer = result.buffer.slice(result.byteOffset, result.byteOffset + result.byteLength) + self.postMessage({ type: 'converted', id, data: arrayBuffer }, { transfer: [arrayBuffer] }) + } else if (type === 'info' && buffer) { + const info = getImageInfo(vips, buffer) + postMessage({ type: 'info', id, data: info } as VipsWorkerResponse) + } + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + postMessage({ type: 'error', id, error } as VipsWorkerResponse) + } +} + +export {} diff --git a/vite.config.ts b/vite.config.ts index b26069b..0bf517a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import path from "path"; import { nodePolyfills } from 'vite-plugin-node-polyfills' +import wasm from 'vite-plugin-wasm' +import topLevelAwait from 'vite-plugin-top-level-await' @@ -9,6 +11,8 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills' export default defineConfig({ plugins: [ // mockWsPlugin, + wasm(), + topLevelAwait(), nodePolyfills(), react() ], @@ -76,16 +80,27 @@ export default defineConfig({ "pako", "buffer" ], + // Exclude wasm-vips so it can load its WASM file correctly + exclude: ["wasm-vips"], }, // Server configuration server: { port: 3000, open: true, + // Enable SharedArrayBuffer for wasm-vips (requires cross-origin isolation) + headers: { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + }, }, // Preview server configuration preview: { port: 3000, + headers: { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + }, }, }); From b3998f05086203583f4e8f99b286a9b599dfd6d0 Mon Sep 17 00:00:00 2001 From: Osama Khalid Date: Sat, 24 Jan 2026 21:55:00 -0500 Subject: [PATCH 02/10] chore: fix workers in dev mode --- src/tools/ImageConverter.tsx | 37 ++++++++-------- src/tools/PacketDissector.tsx | 82 ++++++++++++++++++----------------- 2 files changed, 62 insertions(+), 57 deletions(-) diff --git a/src/tools/ImageConverter.tsx b/src/tools/ImageConverter.tsx index 3d1e62f..9e950b2 100644 --- a/src/tools/ImageConverter.tsx +++ b/src/tools/ImageConverter.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useCallback, useEffect, useMemo } from 'react' +import { useState, useRef, useCallback, useEffect } from 'react' import { Button } from '@/components/Button' import { XCircleIcon, ArrowDownTrayIcon, TrashIcon, DocumentTextIcon } from '@heroicons/react/24/outline' import { filesize } from 'filesize' @@ -36,6 +36,7 @@ function ImageConverter() { const previewRef = useRef(null) const resultPreviewRef = useRef(null) const requestIdRef = useRef(0) + const workerRef = useRef(null) // Render image to canvas const renderToCanvas = useCallback((canvas: HTMLCanvasElement | null, buffer: ArrayBuffer, mimeType: string) => { @@ -69,17 +70,15 @@ function ImageConverter() { img.src = url }, []) - // Create worker once using useMemo (like Wiregasm pattern) - const worker = useMemo(() => { - const w = new Worker(new URL('../workers/vips.worker.ts', import.meta.url), { + // Set up worker - create inside effect for React Strict Mode compatibility + useEffect(() => { + // Create worker inside effect so React Strict Mode re-creates it on double-invoke + const worker = new Worker(new URL('../workers/vips.worker.ts', import.meta.url), { type: 'module', }) - w.onerror = (e) => console.error('Worker Load Error:', e) - return w - }, []) + worker.onerror = (e) => console.error('Worker Load Error:', e) + workerRef.current = worker - // Set up worker message handlers - useEffect(() => { // Store current outputFormat in ref for use in message handler const currentOutputFormat = outputFormat @@ -130,13 +129,14 @@ function ImageConverter() { return () => { worker.terminate() + workerRef.current = null } - }, [worker, outputFormat, renderToCanvas]) + }, [outputFormat, renderToCanvas]) // Load file const handleFile = useCallback( async (file: File) => { - if (!ready) return + if (!ready || !workerRef.current) return setError(null) setResult(null) @@ -156,12 +156,12 @@ function ImageConverter() { // Send a separate copy to worker to get image info const workerBuffer = buffer.slice(0) const id = ++requestIdRef.current - worker.postMessage({ type: 'load', id, buffer: workerBuffer }, [workerBuffer]) + workerRef.current.postMessage({ type: 'load', id, buffer: workerBuffer }, [workerBuffer]) } catch (err) { setError(`Failed to load image: ${err instanceof Error ? err.message : String(err)}`) } }, - [ready, renderToCanvas, worker], + [ready, renderToCanvas], ) // Handle drop @@ -191,7 +191,7 @@ function ImageConverter() { // Convert image const convert = useCallback(() => { - if (!originalBuffer || !ready) return + if (!originalBuffer || !ready || !workerRef.current) return setConverting(true) setError(null) @@ -199,10 +199,11 @@ function ImageConverter() { const id = ++requestIdRef.current // Send buffer copy to worker (original buffer can be neutered) const bufferCopy = originalBuffer.slice(0) - worker.postMessage({ type: 'convert', id, buffer: bufferCopy, format: outputFormat, options: { quality } }, [ - bufferCopy, - ]) - }, [originalBuffer, outputFormat, quality, ready, worker]) + workerRef.current.postMessage( + { type: 'convert', id, buffer: bufferCopy, format: outputFormat, options: { quality } }, + [bufferCopy], + ) + }, [originalBuffer, outputFormat, quality, ready]) // Download result const download = useCallback(() => { diff --git a/src/tools/PacketDissector.tsx b/src/tools/PacketDissector.tsx index a4ca5c4..3bf855b 100644 --- a/src/tools/PacketDissector.tsx +++ b/src/tools/PacketDissector.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import FileButton from '@/components/FileButton' import TextInput from '@/components/TextInput' import { Buffer } from 'buffer' @@ -198,16 +198,7 @@ const applyPreferencesToWorker = (worker: Worker): Promise => }) function PacketDissector() { - const worker = useMemo(() => { - const w = new Worker(new URL('../workers/wiregasm.worker.js', import.meta.url), { - type: 'module', - }) - - // Good practice: Add an error listener immediately - w.onerror = (e) => console.error('Worker Load Error:', e) - - return w - }, []) + const workerRef = useRef(null) const queryClient = new QueryClient() const [version, setVersion] = useState(null) @@ -251,9 +242,9 @@ function PacketDissector() { clear() setSummary(null) setFinishedProcessing(false) - worker.postMessage({ type: 'process-data', name: name, data: data }) + workerRef.current?.postMessage({ type: 'process-data', name: name, data: data }) }, - [clear, worker], + [clear], ) const loadExample = useMemo( @@ -324,34 +315,41 @@ function PacketDissector() { ) useEffect(() => { - if (!initialized) { + if (!initialized || !workerRef.current) { return } - checkFilter(worker, filter) + checkFilter(workerRef.current, filter) .then(() => { setFilterError(null) }) .catch((e) => { setFilterError(String(e)) }) - }, [filter, worker, initialized]) + }, [filter, initialized]) useEffect(() => { - if (!initialized) { + if (!initialized || !workerRef.current) { return } - getVersion(worker) + getVersion(workerRef.current) .then((version) => { setVersion(version) }) .catch((_e) => { // Silent error }) - }, [worker, initialized]) + }, [initialized]) useEffect(() => { + // Create worker inside effect so React Strict Mode re-creates it on double-invoke + const worker = new Worker(new URL('../workers/wiregasm.worker.js', import.meta.url), { + type: 'module', + }) + worker.onerror = (e) => console.error('Worker Load Error:', e) + workerRef.current = worker + clear() if (window.Worker) { worker.onmessage = (e: MessageEvent) => { @@ -391,35 +389,36 @@ function PacketDissector() { return () => { worker.terminate() + workerRef.current = null } - }, [worker, preparePositions, clear]) + }, [preparePositions, clear]) useEffect(() => { - if (finishedProcessing && selectedFrame >= 1 && selectedFrame <= totalFrames) { - worker.postMessage({ type: 'select', number: selectedFrame }) + if (finishedProcessing && selectedFrame >= 1 && selectedFrame <= totalFrames && workerRef.current) { + workerRef.current.postMessage({ type: 'select', number: selectedFrame }) } - }, [selectedFrame, totalFrames, worker, finishedProcessing, dissectionNonce]) + }, [selectedFrame, totalFrames, finishedProcessing, dissectionNonce]) const process = useMemo( () => (f: File) => { clear() setFinishedProcessing(false) - worker.postMessage({ type: 'process', file: f }) + workerRef.current?.postMessage({ type: 'process', file: f }) }, - [worker, clear], + [clear], ) const fetchPackets = useMemo( () => async (filter: string, skip: number, limit: number) => { - if (initialized && finishedProcessing) { - const res = await getFrames(worker, filter, skip, limit) + if (initialized && finishedProcessing && workerRef.current) { + const res = await getFrames(workerRef.current, filter, skip, limit) setMatchedFrames(res.matched) return res.frames } return [] }, - [worker, initialized, finishedProcessing], + [initialized, finishedProcessing], ) const loadFile = useMemo( @@ -437,39 +436,44 @@ function PacketDissector() { const loadModuleTree = useMemo( () => async () => { - return await loadModuleTreeFromWorker(worker) + if (!workerRef.current) throw new Error('Worker not initialized') + return await loadModuleTreeFromWorker(workerRef.current) }, - [worker], + [], ) const loadPreferences = useMemo( () => async (name: string) => { - return await loadPreferencesFromWorker(worker, name) + if (!workerRef.current) throw new Error('Worker not initialized') + return await loadPreferencesFromWorker(workerRef.current, name) }, - [worker], + [], ) const uploadFile = useMemo( () => async (file: File) => { - return await uploadFileToWorker(worker, file) + if (!workerRef.current) throw new Error('Worker not initialized') + return await uploadFileToWorker(workerRef.current, file) }, - [worker], + [], ) const updatePreference = useMemo( () => async (module: string, key: string, value: string) => { - return await updatePreferenceToWorker(worker, module, key, value) + if (!workerRef.current) throw new Error('Worker not initialized') + return await updatePreferenceToWorker(workerRef.current, module, key, value) }, - [worker], + [], ) const applyPreferences = useMemo( () => async () => { - const res = await applyPreferencesToWorker(worker) - worker.postMessage({ type: 'reload-quick', name: fileName }) + if (!workerRef.current) throw new Error('Worker not initialized') + const res = await applyPreferencesToWorker(workerRef.current) + workerRef.current.postMessage({ type: 'reload-quick', name: fileName }) return res }, - [worker, fileName], + [fileName], ) return ( From 6d4c2aff65a0a7c99471c5f6c77a735c6ddb2cf2 Mon Sep 17 00:00:00 2001 From: Osama Khalid Date: Sat, 24 Jan 2026 22:11:40 -0500 Subject: [PATCH 03/10] fix: ImageConverter preview rendering and add resize options - Fix empty canvas issue by using useEffect to render after state updates - Add resize options (percentage, width, height, custom dimensions) --- package-lock.json | 4 +- src/tools/ImageConverter.tsx | 237 ++++++++++++++++++++++++++++++++--- src/workers/vips.worker.ts | 86 +++++++++++-- 3 files changed, 296 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2581f00..27b74c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "good.tools", - "version": "1.23.0", + "version": "1.23.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "good.tools", - "version": "1.23.0", + "version": "1.23.1", "dependencies": { "@algolia/autocomplete-core": "^1.19.4", "@goodtools/jdserialize": "^1.0.0", diff --git a/src/tools/ImageConverter.tsx b/src/tools/ImageConverter.tsx index 9e950b2..f032fff 100644 --- a/src/tools/ImageConverter.tsx +++ b/src/tools/ImageConverter.tsx @@ -1,8 +1,8 @@ -import { useState, useRef, useCallback, useEffect } from 'react' +import { useState, useRef, useCallback, useEffect, useMemo } from 'react' import { Button } from '@/components/Button' import { XCircleIcon, ArrowDownTrayIcon, TrashIcon, DocumentTextIcon } from '@heroicons/react/24/outline' import { filesize } from 'filesize' -import type { ImageInfo } from '@/workers/vips.worker' +import type { ImageInfo, ResizeMode, ResizeOptions } from '@/workers/vips.worker' type OutputFormat = 'jpeg' | 'png' | 'webp' | 'avif' @@ -16,6 +16,7 @@ function ImageConverter() { // File state const [fileName, setFileName] = useState(null) const [originalBuffer, setOriginalBuffer] = useState(null) + const [originalMimeType, setOriginalMimeType] = useState('image/png') const [imageInfo, setImageInfo] = useState(null) const [originalSize, setOriginalSize] = useState(0) const [result, setResult] = useState(null) @@ -31,6 +32,12 @@ function ImageConverter() { const [outputFormat, setOutputFormat] = useState('webp') const [quality, setQuality] = useState(80) + // Resize options + const [resizeMode, setResizeMode] = useState('none') + const [resizePercentage, setResizePercentage] = useState(50) + const [resizeWidth, setResizeWidth] = useState('') + const [resizeHeight, setResizeHeight] = useState('') + // Refs const fileInputRef = useRef(null) const previewRef = useRef(null) @@ -70,6 +77,65 @@ function ImageConverter() { img.src = url }, []) + // Store outputFormat in a ref so worker message handler can access current value + const outputFormatRef = useRef(outputFormat) + useEffect(() => { + outputFormatRef.current = outputFormat + }, [outputFormat]) + + // Calculate preview dimensions based on resize settings + const previewDimensions = useMemo(() => { + if (!imageInfo) return null + + const { width, height } = imageInfo + const aspectRatio = width / height + + switch (resizeMode) { + case 'none': + return { width, height } + case 'percentage': { + const scale = resizePercentage / 100 + return { + width: Math.round(width * scale), + height: Math.round(height * scale), + } + } + case 'width': { + const targetWidth = resizeWidth || width + return { + width: targetWidth, + height: Math.round(targetWidth / aspectRatio), + } + } + case 'height': { + const targetHeight = resizeHeight || height + return { + width: Math.round(targetHeight * aspectRatio), + height: targetHeight, + } + } + case 'dimensions': + return { + width: resizeWidth || width, + height: resizeHeight || height, + } + default: + return { width, height } + } + }, [imageInfo, resizeMode, resizePercentage, resizeWidth, resizeHeight]) + + // Build resize options for the worker + const buildResizeOptions = useCallback((): ResizeOptions | undefined => { + if (resizeMode === 'none') return undefined + + return { + mode: resizeMode, + percentage: resizeMode === 'percentage' ? resizePercentage : undefined, + width: resizeMode === 'width' || resizeMode === 'dimensions' ? resizeWidth || undefined : undefined, + height: resizeMode === 'height' || resizeMode === 'dimensions' ? resizeHeight || undefined : undefined, + } + }, [resizeMode, resizePercentage, resizeWidth, resizeHeight]) + // Set up worker - create inside effect for React Strict Mode compatibility useEffect(() => { // Create worker inside effect so React Strict Mode re-creates it on double-invoke @@ -79,9 +145,6 @@ function ImageConverter() { worker.onerror = (e) => console.error('Worker Load Error:', e) workerRef.current = worker - // Store current outputFormat in ref for use in message handler - const currentOutputFormat = outputFormat - worker.onmessage = (event: MessageEvent) => { const { type, @@ -115,15 +178,13 @@ function ImageConverter() { } } else if (type === 'converted') { const buffer = data as ArrayBuffer + const currentFormat = outputFormatRef.current setResult({ buffer, - format: currentOutputFormat, + format: currentFormat, size: buffer.byteLength, }) setConverting(false) - // Render result preview - const mimeType = currentOutputFormat === 'jpeg' ? 'image/jpeg' : `image/${currentOutputFormat}` - renderToCanvas(resultPreviewRef.current, buffer, mimeType) } } @@ -131,7 +192,22 @@ function ImageConverter() { worker.terminate() workerRef.current = null } - }, [outputFormat, renderToCanvas]) + }, []) + + // Render original preview when buffer changes and canvas is mounted + useEffect(() => { + if (originalBuffer && previewRef.current) { + renderToCanvas(previewRef.current, originalBuffer, originalMimeType) + } + }, [originalBuffer, originalMimeType, renderToCanvas]) + + // Render result preview when result changes and canvas is mounted + useEffect(() => { + if (result && resultPreviewRef.current) { + const mimeType = result.format === 'jpeg' ? 'image/jpeg' : `image/${result.format}` + renderToCanvas(resultPreviewRef.current, result.buffer, mimeType) + } + }, [result, renderToCanvas]) // Load file const handleFile = useCallback( @@ -146,13 +222,12 @@ function ImageConverter() { const buffer = await file.arrayBuffer() // Store a copy for later use const storedBuffer = buffer.slice(0) + const mimeType = file.type || 'image/png' + setOriginalBuffer(storedBuffer) + setOriginalMimeType(mimeType) setOriginalSize(storedBuffer.byteLength) - // Render original preview directly (use stored buffer copy) - const mimeType = file.type || 'image/png' - renderToCanvas(previewRef.current, storedBuffer, mimeType) - // Send a separate copy to worker to get image info const workerBuffer = buffer.slice(0) const id = ++requestIdRef.current @@ -161,7 +236,7 @@ function ImageConverter() { setError(`Failed to load image: ${err instanceof Error ? err.message : String(err)}`) } }, - [ready, renderToCanvas], + [ready], ) // Handle drop @@ -199,11 +274,12 @@ function ImageConverter() { const id = ++requestIdRef.current // Send buffer copy to worker (original buffer can be neutered) const bufferCopy = originalBuffer.slice(0) + const resizeOptions = buildResizeOptions() workerRef.current.postMessage( - { type: 'convert', id, buffer: bufferCopy, format: outputFormat, options: { quality } }, + { type: 'convert', id, buffer: bufferCopy, format: outputFormat, options: { quality, resize: resizeOptions } }, [bufferCopy], ) - }, [originalBuffer, outputFormat, quality, ready]) + }, [originalBuffer, outputFormat, quality, ready, buildResizeOptions]) // Download result const download = useCallback(() => { @@ -226,10 +302,15 @@ function ImageConverter() { const clear = useCallback(() => { setFileName(null) setOriginalBuffer(null) + setOriginalMimeType('image/png') setImageInfo(null) setOriginalSize(0) setResult(null) setError(null) + setResizeMode('none') + setResizePercentage(50) + setResizeWidth('') + setResizeHeight('') if (fileInputRef.current) { fileInputRef.current.value = '' } @@ -374,6 +455,128 @@ function ImageConverter() {
)} + {/* Resize options */} +
+ +
+ {[ + { mode: 'none' as const, label: 'None' }, + { mode: 'percentage' as const, label: '%' }, + { mode: 'width' as const, label: 'Width' }, + { mode: 'height' as const, label: 'Height' }, + { mode: 'dimensions' as const, label: 'Custom' }, + ].map(({ mode, label }) => ( + + ))} +
+ + {/* Percentage slider */} + {resizeMode === 'percentage' && ( +
+ + setResizePercentage(parseInt(e.target.value))} + className='w-full h-2 bg-gray-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-indigo-600' + /> +
+ 1% + 100% + 200% +
+
+ )} + + {/* Width input */} + {resizeMode === 'width' && ( +
+ + setResizeWidth(e.target.value ? parseInt(e.target.value) : '')} + className='w-full px-3 py-2 text-sm border border-gray-300 dark:border-zinc-600 rounded-md bg-white dark:bg-zinc-900 focus:outline-none focus:ring-2 focus:ring-indigo-500' + /> +

Height will be calculated to maintain aspect ratio

+
+ )} + + {/* Height input */} + {resizeMode === 'height' && ( +
+ + setResizeHeight(e.target.value ? parseInt(e.target.value) : '')} + className='w-full px-3 py-2 text-sm border border-gray-300 dark:border-zinc-600 rounded-md bg-white dark:bg-zinc-900 focus:outline-none focus:ring-2 focus:ring-indigo-500' + /> +

Width will be calculated to maintain aspect ratio

+
+ )} + + {/* Custom dimensions */} + {resizeMode === 'dimensions' && ( +
+
+ + setResizeWidth(e.target.value ? parseInt(e.target.value) : '')} + className='w-full px-3 py-2 text-sm border border-gray-300 dark:border-zinc-600 rounded-md bg-white dark:bg-zinc-900 focus:outline-none focus:ring-2 focus:ring-indigo-500' + /> +
+
×
+
+ + setResizeHeight(e.target.value ? parseInt(e.target.value) : '')} + className='w-full px-3 py-2 text-sm border border-gray-300 dark:border-zinc-600 rounded-md bg-white dark:bg-zinc-900 focus:outline-none focus:ring-2 focus:ring-indigo-500' + /> +
+
+ )} + + {/* Preview dimensions */} + {resizeMode !== 'none' && previewDimensions && imageInfo && ( +
+ Output:{' '} + + {previewDimensions.width}×{previewDimensions.height} + {' '} + + (original: {imageInfo.width}×{imageInfo.height}) + +
+ )} +
+ {/* Action buttons */}
diff --git a/src/workers/vips.worker.ts b/src/workers/vips.worker.ts index b581a70..0294e8c 100644 --- a/src/workers/vips.worker.ts +++ b/src/workers/vips.worker.ts @@ -24,9 +24,19 @@ export interface VipsWorkerResponse { export type OutputFormat = 'jpeg' | 'png' | 'webp' | 'avif' +export type ResizeMode = 'none' | 'percentage' | 'width' | 'height' | 'dimensions' + +export interface ResizeOptions { + mode: ResizeMode + percentage?: number + width?: number + height?: number +} + export interface ConvertOptions { quality?: number compressionLevel?: number + resize?: ResizeOptions } export interface ImageInfo { @@ -67,6 +77,52 @@ async function initVips(): Promise { return initPromise } +/** + * Resize image based on options + */ +function resizeImage(vips: typeof Vips, image: Vips.Image, options?: ResizeOptions): Vips.Image { + if (!options || options.mode === 'none') { + return image + } + + const originalWidth = image.width + const originalHeight = image.height + const aspectRatio = originalWidth / originalHeight + + let targetWidth = originalWidth + let targetHeight = originalHeight + + if (options.mode === 'percentage') { + const scale = (options.percentage || 100) / 100 + targetWidth = Math.round(originalWidth * scale) + targetHeight = Math.round(originalHeight * scale) + } else if (options.mode === 'width') { + targetWidth = options.width || originalWidth + targetHeight = Math.round(targetWidth / aspectRatio) + } else if (options.mode === 'height') { + targetHeight = options.height || originalHeight + targetWidth = Math.round(targetHeight * aspectRatio) + } else if (options.mode === 'dimensions') { + targetWidth = options.width || originalWidth + targetHeight = options.height || originalHeight + } + + // Ensure minimum dimensions + targetWidth = Math.max(1, targetWidth) + targetHeight = Math.max(1, targetHeight) + + // No resize needed if same dimensions + if (targetWidth === originalWidth && targetHeight === originalHeight) { + return image + } + + // Use resize with scale factor + const hScale = targetWidth / originalWidth + const vScale = targetHeight / originalHeight + + return image.resize(hScale, { vscale: vScale }) +} + /** * Convert image to specified format */ @@ -77,19 +133,25 @@ function convertFormat( options: ConvertOptions = {}, ): Uint8Array { const data = new Uint8Array(imageData) - const image = vips.Image.newFromBuffer(data) - - if (format === 'jpeg') { - return image.jpegsaveBuffer({ Q: options.quality || 85 }) - } else if (format === 'png') { - return image.pngsaveBuffer({ compression: options.compressionLevel || 6 }) - } else if (format === 'webp') { - return image.webpsaveBuffer({ Q: options.quality || 85 }) - } else if (format === 'avif') { - return image.heifsaveBuffer({ Q: options.quality || 50, compression: 'av1' }) + let image = vips.Image.newFromBuffer(data) + + // Apply resize if requested + image = resizeImage(vips, image, options.resize) + + switch (format) { + case 'jpeg': + return image.jpegsaveBuffer({ Q: options.quality || 85 }) + case 'png': + return image.pngsaveBuffer({ compression: options.compressionLevel || 6 }) + case 'webp': + return image.webpsaveBuffer({ Q: options.quality || 85 }) + case 'avif': + return image.heifsaveBuffer({ Q: options.quality || 50, compression: 'av1' }) + default: { + const _exhaustiveCheck: never = format + throw new Error(`Unsupported format: ${String(_exhaustiveCheck)}`) + } } - - throw new Error(`Unsupported format: ${format}`) } /** From 706c4031505aaa39df0155e8f5ec1e7adfa6506a Mon Sep 17 00:00:00 2001 From: Osama Khalid Date: Sat, 24 Jan 2026 22:13:37 -0500 Subject: [PATCH 04/10] fix: prefix unused vips parameter with underscore --- src/workers/vips.worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workers/vips.worker.ts b/src/workers/vips.worker.ts index 0294e8c..21b03fe 100644 --- a/src/workers/vips.worker.ts +++ b/src/workers/vips.worker.ts @@ -80,7 +80,7 @@ async function initVips(): Promise { /** * Resize image based on options */ -function resizeImage(vips: typeof Vips, image: Vips.Image, options?: ResizeOptions): Vips.Image { +function resizeImage(_vips: typeof Vips, image: Vips.Image, options?: ResizeOptions): Vips.Image { if (!options || options.mode === 'none') { return image } From 60ad261750613e2b6f025b7d6f0aaf7a7ec36024 Mon Sep 17 00:00:00 2001 From: Osama Khalid Date: Sat, 24 Jan 2026 22:16:45 -0500 Subject: [PATCH 05/10] chore: remove COOP/COEP headers and create-transparent route --- src/config/routes.config.ts | 1 - vite.config.ts | 9 --------- 2 files changed, 10 deletions(-) diff --git a/src/config/routes.config.ts b/src/config/routes.config.ts index de7f3b5..936944d 100644 --- a/src/config/routes.config.ts +++ b/src/config/routes.config.ts @@ -24,7 +24,6 @@ export const ROUTES = { 'image-converter': '/image-converter', 'image-transform': '/image-transform', 'image-compress': '/image-compress', - 'create-transparent': '/create-transparent', } as const export type ToolKey = keyof typeof ROUTES diff --git a/vite.config.ts b/vite.config.ts index 0bf517a..5af6a49 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -88,19 +88,10 @@ export default defineConfig({ server: { port: 3000, open: true, - // Enable SharedArrayBuffer for wasm-vips (requires cross-origin isolation) - headers: { - 'Cross-Origin-Opener-Policy': 'same-origin', - 'Cross-Origin-Embedder-Policy': 'require-corp', - }, }, // Preview server configuration preview: { port: 3000, - headers: { - 'Cross-Origin-Opener-Policy': 'same-origin', - 'Cross-Origin-Embedder-Policy': 'require-corp', - }, }, }); From f7dc83355e43a4369cebba2e2ea5378593543f43 Mon Sep 17 00:00:00 2001 From: Osama Khalid Date: Sat, 24 Jan 2026 22:33:24 -0500 Subject: [PATCH 06/10] fix: add COOP/COEP headers to dev/preview --- src/tools/ImageConverter.tsx | 8 ++++---- src/workers/vips.worker.ts | 11 +++++++---- vite.config.ts | 10 ++++++++++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/tools/ImageConverter.tsx b/src/tools/ImageConverter.tsx index f032fff..dbbe9a9 100644 --- a/src/tools/ImageConverter.tsx +++ b/src/tools/ImageConverter.tsx @@ -4,7 +4,7 @@ import { XCircleIcon, ArrowDownTrayIcon, TrashIcon, DocumentTextIcon } from '@he import { filesize } from 'filesize' import type { ImageInfo, ResizeMode, ResizeOptions } from '@/workers/vips.worker' -type OutputFormat = 'jpeg' | 'png' | 'webp' | 'avif' +type OutputFormat = 'jpeg' | 'png' | 'webp' interface ConversionResult { buffer: ArrayBuffer @@ -370,7 +370,7 @@ function ImageConverter() {

Drop an image here, or click to select

-

Supports JPEG, PNG, WebP, AVIF

+

Supports JPEG, PNG, WebP

)}
@@ -418,7 +418,7 @@ function ImageConverter() {
- {(['jpeg', 'png', 'webp', 'avif'] as const).map((fmt) => ( + {(['jpeg', 'png', 'webp'] as const).map((fmt) => (
{/* Quality slider (for lossy formats) */} - {(outputFormat === 'jpeg' || outputFormat === 'webp' || outputFormat === 'avif') && ( + {(outputFormat === 'jpeg' || outputFormat === 'webp') && (