diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index f4e95777..b32791ca 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -28,10 +28,11 @@ jobs:
- id: meta
uses: docker/metadata-action@v5
with:
- images: ghcr.io/cooksleep/gpt_image_playground
+ images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
+ type=sha,format=short
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- uses: docker/build-push-action@v6
diff --git a/deploy/Dockerfile b/deploy/Dockerfile
index 0929096e..a7a08894 100644
--- a/deploy/Dockerfile
+++ b/deploy/Dockerfile
@@ -1,12 +1,19 @@
# ---- Build stage ----
-FROM node:20-alpine AS build
+# Alpine + npm ci 在 Rollup optional native package 上经常丢依赖,改用 glibc 基础镜像规避。
+FROM node:20-bookworm-slim AS build
WORKDIR /app
+ARG TARGETARCH
ENV VITE_DEFAULT_API_URL=__VITE_DEFAULT_API_URL_PLACEHOLDER__
COPY package.json package-lock.json ./
RUN npm ci
+RUN case "$TARGETARCH" in \
+ "amd64") npm install --no-save @rollup/rollup-linux-x64-gnu ;; \
+ "arm64") npm install --no-save @rollup/rollup-linux-arm64-gnu ;; \
+ *) echo "Unsupported TARGETARCH: $TARGETARCH" && exit 1 ;; \
+ esac
COPY . .
RUN npm run build
diff --git a/package-lock.json b/package-lock.json
index cf8b243c..e820dbb6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "gpt-image-playground",
"version": "0.2.15",
"dependencies": {
+ "fabric": "^7.3.1",
"fflate": "^0.8.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
@@ -38,6 +39,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "license": "ISC",
+ "optional": true
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -320,129 +342,125 @@
"node": ">=6.9.0"
}
},
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
- "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
- "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
- "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
- "cpu": [
- "arm64"
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
],
- "dev": true,
- "license": "MIT",
+ "license": "MIT-0",
"optional": true,
- "os": [
- "android"
- ],
"engines": {
"node": ">=18"
}
},
- "node_modules/@esbuild/android-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
- "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
- "cpu": [
- "x64"
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
],
- "dev": true,
"license": "MIT",
"optional": true,
- "os": [
- "android"
- ],
"engines": {
"node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
}
},
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
- "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
- "cpu": [
- "arm64"
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
],
- "dev": true,
"license": "MIT",
"optional": true,
- "os": [
- "darwin"
- ],
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
"engines": {
"node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
}
},
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
- "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
- "cpu": [
- "x64"
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
],
- "dev": true,
"license": "MIT",
"optional": true,
- "os": [
- "darwin"
- ],
"engines": {
"node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
}
},
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
- "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
- "cpu": [
- "arm64"
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
],
- "dev": true,
"license": "MIT",
"optional": true,
- "os": [
- "freebsd"
- ],
"engines": {
"node": ">=18"
}
},
- "node_modules/@esbuild/freebsd-x64": {
+ "node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
- "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
@@ -450,734 +468,106 @@
"license": "MIT",
"optional": true,
"os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
- "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
- "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
- "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
+ "win32"
],
"engines": {
"node": ">=18"
}
},
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
- "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
- "cpu": [
- "loong64"
- ],
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
}
},
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
- "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
- "cpu": [
- "mips64el"
- ],
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
}
},
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
- "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
- "cpu": [
- "ppc64"
- ],
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
"engines": {
- "node": ">=18"
+ "node": ">=6.0.0"
}
},
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
- "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
- "cpu": [
- "riscv64"
- ],
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
+ "license": "MIT"
},
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
- "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
- "cpu": [
- "s390x"
- ],
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
}
},
- "node_modules/@esbuild/linux-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
- "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
- "cpu": [
- "x64"
- ],
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
"engines": {
- "node": ">=18"
+ "node": ">= 8"
}
},
- "node_modules/@esbuild/netbsd-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
- "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
"engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
- "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
- "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
- "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openharmony-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
- "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
- "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
- "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
- "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
- "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.13",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
- "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.0",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/remapping": {
- "version": "2.3.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
- "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
- "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.31",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
- "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
- "node_modules/@nodelib/fs.scandir": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
- "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "2.0.5",
- "run-parallel": "^1.1.9"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.stat": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
- "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
+ "node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
- "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-beta.27",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
- "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
- "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-android-arm64": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
- "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
- "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
- "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
- "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
- "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
- "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
- "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
- "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
- "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
- "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-musl": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
- "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
- "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-musl": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
- "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
- "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
- "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
- "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
- "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
- "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-openbsd-x64": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
- "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ]
- },
- "node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
- "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ]
- },
- "node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
- "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
- "cpu": [
- "arm64"
- ],
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
},
- "node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.60.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
- "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
- "cpu": [
- "ia32"
- ],
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
"dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
+ "license": "MIT"
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.2",
@@ -1438,6 +828,16 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -1513,6 +913,27 @@
"postcss": "^8.1.0"
}
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.21",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz",
@@ -1539,6 +960,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -1586,6 +1019,31 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -1617,6 +1075,21 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/canvas": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.3.tgz",
+ "integrity": "sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "node-addon-api": "^7.0.0",
+ "prebuild-install": "^7.1.3"
+ },
+ "engines": {
+ "node": "^18.12.0 || >= 20.9.0"
+ }
+ },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -1665,6 +1138,13 @@
"node": ">= 6"
}
},
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC",
+ "optional": true
+ },
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -1695,6 +1175,20 @@
"node": ">=4"
}
},
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1702,11 +1196,25 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -1720,6 +1228,49 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -1741,6 +1292,29 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
@@ -1820,6 +1394,16 @@
"@types/estree": "^1.0.0"
}
},
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "optional": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -1830,6 +1414,19 @@
"node": ">=12.0.0"
}
},
+ "node_modules/fabric": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/fabric/-/fabric-7.3.1.tgz",
+ "integrity": "sha512-RoLAQzUX+/3RNMYKliuN0P2HXdSDEGzyjS7FnmEbo3nhb8LFh59T+l3f6ApIu5LT4YB49YfMNrEajeIbutmD7Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "optionalDependencies": {
+ "canvas": "^3.2.0",
+ "jsdom": "^26.1.0"
+ }
+ },
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -1903,20 +1500,12 @@
"url": "https://github.com/sponsors/rawify"
}
},
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
- "hasInstallScript": true,
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
+ "optional": true
},
"node_modules/function-bind": {
"version": "1.1.2",
@@ -1938,6 +1527,13 @@
"node": ">=6.9.0"
}
},
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -1964,6 +1560,95 @@
"node": ">= 0.4"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause",
+ "optional": true
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC",
+ "optional": true
+ },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -2026,6 +1711,13 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@@ -2043,6 +1735,46 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/jsdom": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "cssstyle": "^4.2.1",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.5.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.16",
+ "parse5": "^7.2.1",
+ "rrweb-cssom": "^0.8.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.1.1",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.1.1",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -2133,11 +1865,41 @@
"node": ">=8.6"
}
},
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "optional": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/mz": {
@@ -2165,12 +1927,52 @@
],
"license": "MIT",
"bin": {
- "nanoid": "bin/nanoid.cjs"
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/node-abi": {
+ "version": "3.89.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
+ "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-abi/node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
},
"engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ "node": ">=10"
}
},
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/node-releases": {
"version": "2.0.38",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
@@ -2188,6 +1990,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/nwsapi": {
+ "version": "2.2.23",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
+ "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -2219,6 +2028,29 @@
],
"license": "MIT"
},
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -2436,6 +2268,55 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -2457,6 +2338,22 @@
],
"license": "MIT"
},
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "optional": true,
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
"node_modules/react": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
@@ -2498,6 +2395,21 @@
"pify": "^2.3.0"
}
},
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -2589,6 +2501,13 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -2613,6 +2532,47 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -2636,6 +2596,53 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2660,6 +2667,26 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -2696,6 +2723,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -2734,6 +2768,36 @@
"node": ">=14.0.0"
}
},
+ "node_modules/tar-fs": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
+ "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -2832,6 +2896,26 @@
"node": ">=14.0.0"
}
},
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -2845,6 +2929,32 @@
"node": ">=8.0"
}
},
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -2852,6 +2962,19 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2901,7 +3024,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/vite": {
@@ -3113,6 +3236,67 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -3130,6 +3314,52 @@
"node": ">=8"
}
},
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/ws": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/package.json b/package.json
index e2d586b0..974db824 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"test:watch": "vitest"
},
"dependencies": {
+ "fabric": "^7.3.1",
"fflate": "^0.8.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
diff --git a/src/App.tsx b/src/App.tsx
index 0ce19a56..7e9c3e83 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -14,16 +14,14 @@ import ConfirmDialog from './components/ConfirmDialog'
import Toast from './components/Toast'
import MaskEditorModal from './components/MaskEditorModal'
import ImageContextMenu from './components/ImageContextMenu'
+import { isApplyingWebDavSnapshot, scheduleWebDavSync, setupWebDavAutoSync, syncWebDavOnLaunch } from './lib/webdavSync'
export default function App() {
const setSettings = useStore((s) => s.setSettings)
useEffect(() => {
const searchParams = new URLSearchParams(window.location.search)
- const nextSettings: { baseUrl?: string; apiKey?: string; codexCli?: boolean; apiMode?: ApiMode } = {
- codexCli: false,
- apiMode: 'images',
- }
+ const nextSettings: { baseUrl?: string; apiKey?: string; codexCli?: boolean; apiMode?: ApiMode } = {}
const apiUrlParam = searchParams.get('apiUrl')
if (apiUrlParam !== null) {
@@ -45,9 +43,8 @@ export default function App() {
nextSettings.apiMode = apiModeParam
}
- setSettings(nextSettings)
-
if (searchParams.has('apiUrl') || searchParams.has('apiKey') || searchParams.has('codexCli') || searchParams.has('apiMode')) {
+ setSettings(nextSettings)
searchParams.delete('apiUrl')
searchParams.delete('apiKey')
searchParams.delete('codexCli')
@@ -58,7 +55,50 @@ export default function App() {
window.history.replaceState(null, '', nextUrl)
}
- initStore()
+ let disposed = false
+ let unsubscribeStore: (() => void) | null = null
+ let cleanupAutoSync: (() => void) | null = null
+
+ void (async () => {
+ await initStore()
+ await syncWebDavOnLaunch()
+
+ if (disposed) return
+
+ cleanupAutoSync = setupWebDavAutoSync()
+ unsubscribeStore = useStore.subscribe((state, prevState) => {
+ if (isApplyingWebDavSnapshot()) return
+ if (state.settings.storageMode !== 'webdav') return
+ if (!state.settings.webdav.url.trim()) return
+
+ const tasksChanged = state.tasks !== prevState.tasks
+ const settingsChanged =
+ state.settings !== prevState.settings &&
+ (
+ state.settings.baseUrl !== prevState.settings.baseUrl ||
+ state.settings.apiKey !== prevState.settings.apiKey ||
+ state.settings.model !== prevState.settings.model ||
+ state.settings.timeout !== prevState.settings.timeout ||
+ state.settings.apiMode !== prevState.settings.apiMode ||
+ state.settings.codexCli !== prevState.settings.codexCli ||
+ state.settings.storageMode !== prevState.settings.storageMode ||
+ state.settings.webdav.url !== prevState.settings.webdav.url ||
+ state.settings.webdav.username !== prevState.settings.webdav.username ||
+ state.settings.webdav.password !== prevState.settings.webdav.password ||
+ state.settings.webdav.syncOnStartup !== prevState.settings.webdav.syncOnStartup
+ )
+
+ if (tasksChanged || settingsChanged) {
+ scheduleWebDavSync()
+ }
+ })
+ })()
+
+ return () => {
+ disposed = true
+ unsubscribeStore?.()
+ cleanupAutoSync?.()
+ }
}, [setSettings])
useEffect(() => {
diff --git a/src/components/DetailModal.tsx b/src/components/DetailModal.tsx
index 4b1fa5bd..44cf99e2 100644
--- a/src/components/DetailModal.tsx
+++ b/src/components/DetailModal.tsx
@@ -11,6 +11,7 @@ export default function DetailModal() {
const detailTaskId = useStore((s) => s.detailTaskId)
const setDetailTaskId = useStore((s) => s.setDetailTaskId)
const setLightboxImageId = useStore((s) => s.setLightboxImageId)
+ const setLightboxStartEditor = useStore((s) => s.setLightboxStartEditor)
const setMaskEditorImageId = useStore((s) => s.setMaskEditorImageId)
const setConfirmDialog = useStore((s) => s.setConfirmDialog)
const showToast = useStore((s) => s.showToast)
@@ -193,6 +194,13 @@ export default function DetailModal() {
setDetailTaskId(null)
}
+ const handleQuickEditOutput = () => {
+ if (!currentOutputImageId) return
+ setDetailTaskId(null)
+ setLightboxStartEditor(true)
+ setLightboxImageId(currentOutputImageId, task.outputImages)
+ }
+
const handleMaskEditCurrentOutput = () => {
const imgId = task.outputImages?.[imageIndex]
if (!imgId) return
@@ -350,6 +358,26 @@ export default function DetailModal() {
>
)}
+
+
+
+
>
)}
{task.status === 'running' && (
diff --git a/src/components/ImageContextMenu.tsx b/src/components/ImageContextMenu.tsx
index bd50ebf1..8469251a 100644
--- a/src/components/ImageContextMenu.tsx
+++ b/src/components/ImageContextMenu.tsx
@@ -21,6 +21,9 @@ export default function ImageContextMenu() {
// 忽略没有 src 或空的 img
if (!imgTarget.src) return
+ // 当前页面不是安全上下文时,放行浏览器原生右键菜单,避免图片复制失效。
+ if (!window.isSecureContext) return
+
e.preventDefault()
setMenuInfo({
src: imgTarget.src,
diff --git a/src/components/InputBar.tsx b/src/components/InputBar.tsx
index 786146ad..d450d041 100644
--- a/src/components/InputBar.tsx
+++ b/src/components/InputBar.tsx
@@ -23,11 +23,21 @@ function ButtonTooltip({ visible, text }: { visible: boolean; text: string }) {
const API_MAX_IMAGES = 16
function useIsMobile() {
- const [isMobile, setIsMobile] = useState(window.innerWidth < 640)
+ const getIsMobile = () => {
+ const ua = navigator.userAgent || ''
+ const platform = navigator.platform || ''
+ const isIpadOS = platform === 'MacIntel' && navigator.maxTouchPoints > 1
+ return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|Tablet/i.test(ua) || isIpadOS
+ }
+ const [isMobile, setIsMobile] = useState(getIsMobile)
useEffect(() => {
- const onResize = () => setIsMobile(window.innerWidth < 640)
+ const onResize = () => setIsMobile(getIsMobile())
window.addEventListener('resize', onResize)
- return () => window.removeEventListener('resize', onResize)
+ window.addEventListener('orientationchange', onResize)
+ return () => {
+ window.removeEventListener('resize', onResize)
+ window.removeEventListener('orientationchange', onResize)
+ }
}, [])
return isMobile
}
@@ -43,6 +53,7 @@ export default function InputBar() {
const settings = useStore((s) => s.settings)
const setShowSettings = useStore((s) => s.setShowSettings)
const setLightboxImageId = useStore((s) => s.setLightboxImageId)
+ const setLightboxStartEditor = useStore((s) => s.setLightboxStartEditor)
const setConfirmDialog = useStore((s) => s.setConfirmDialog)
const selectedTaskIds = useStore((s) => s.selectedTaskIds)
const setSelectedTaskIds = useStore((s) => s.setSelectedTaskIds)
@@ -121,6 +132,7 @@ export default function InputBar() {
const [moderationHintVisible, setModerationHintVisible] = useState(false)
const [qualityHintVisible, setQualityHintVisible] = useState(false)
const [mobileCollapsed, setMobileCollapsed] = useState(false)
+ const [selectedInputImageId, setSelectedInputImageId] = useState(null)
const [showSizePicker, setShowSizePicker] = useState(false)
const [maskPreviewUrl, setMaskPreviewUrl] = useState('')
const handleRef = useRef(null)
@@ -144,6 +156,26 @@ export default function InputBar() {
? inputImages.filter((img) => img.id !== maskTargetImage.id)
: inputImages
+ useEffect(() => {
+ if (!selectedInputImageId) return
+ if (!inputImages.some((image) => image.id === selectedInputImageId)) {
+ setSelectedInputImageId(null)
+ }
+ }, [inputImages, selectedInputImageId])
+
+ useEffect(() => {
+ if (!isMobile || !selectedInputImageId) return
+
+ const handleOutsidePointer = (event: PointerEvent) => {
+ const target = event.target
+ if (target instanceof Node && imagesRef.current?.contains(target)) return
+ setSelectedInputImageId(null)
+ }
+
+ document.addEventListener('pointerdown', handleOutsidePointer, true)
+ return () => document.removeEventListener('pointerdown', handleOutsidePointer, true)
+ }, [isMobile, selectedInputImageId])
+
useEffect(() => {
setOutputCompressionInput(
params.output_compression == null ? '' : String(params.output_compression),
@@ -344,6 +376,7 @@ export default function InputBar() {
// 粘贴图片
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
+ if (document.body.dataset.referenceEditorActive === '1') return
const items = e.clipboardData?.items
if (!items) return
const imageFiles: File[] = []
@@ -487,19 +520,51 @@ export default function InputBar() {
const selectClass = 'px-3 py-1.5 rounded-xl border border-gray-200/60 dark:border-white/[0.08] bg-white/50 dark:bg-white/[0.03] hover:bg-white dark:hover:bg-white/[0.06] text-xs transition-all duration-200 shadow-sm'
+ const handleInputImageClick = (imgId: string) => {
+ if (isMobile) {
+ if (selectedInputImageId === imgId) {
+ setLightboxStartEditor(true)
+ setLightboxImageId(imgId, inputImages.map((image) => image.id))
+ } else {
+ setSelectedInputImageId(imgId)
+ }
+ return
+ }
+ setLightboxImageId(imgId, inputImages.map((image) => image.id))
+ }
+
+ const handleRemoveInputImage = (idx: number, imgId: string) => {
+ removeInputImage(idx)
+ if (selectedInputImageId === imgId) {
+ setSelectedInputImageId(null)
+ }
+ }
const renderImageThumb = (img: (typeof inputImages)[number]) => {
const originalIndex = inputImages.findIndex((i) => i.id === img.id)
const isMaskTarget = maskDraft?.targetImageId === img.id
const canEdit = !maskTargetImage || isMaskTarget
const displaySrc = isMaskTarget && maskPreviewUrl ? maskPreviewUrl : img.dataUrl
+ const selected = selectedInputImageId === img.id
+ const deleteButtonClass = isMobile
+ ? selected
+ ? 'h-7 w-7 opacity-100'
+ : 'h-7 w-7 pointer-events-none opacity-0'
+ : 'h-[22px] w-[22px] opacity-0 group-hover:opacity-100'
return (
-
setLightboxImageId(img.id, inputImages.map((i) => i.id))}
+ onClick={() => handleInputImageClick(img.id)}
+ aria-pressed={selected}
+ aria-label={selected ? '取消选择参考图' : '选择参考图'}
>

)}
{canEdit && (
-
)}
-
-
+
+
)
}
diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx
index 1f3e35fc..e34fdcb6 100644
--- a/src/components/Lightbox.tsx
+++ b/src/components/Lightbox.tsx
@@ -1,8 +1,10 @@
-import { useEffect, useState, useRef, useCallback } from 'react'
+import { Suspense, lazy, useEffect, useState, useRef, useCallback } from 'react'
import { useStore, getCachedImage, ensureImageCached } from '../store'
import { useCloseOnEscape } from '../hooks/useCloseOnEscape'
import { createMaskPreviewDataUrl } from '../lib/canvasImage'
+const ReferenceImageEditorModal = lazy(() => import('./ReferenceImageEditorModal'))
+
const MIN_SCALE = 1
const MAX_SCALE = 10
@@ -10,14 +12,22 @@ function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v))
}
+function isLightboxControl(target: EventTarget | null) {
+ return target instanceof Element && Boolean(target.closest('[data-lightbox-control]'))
+}
+
export default function Lightbox() {
const lightboxImageId = useStore((s) => s.lightboxImageId)
const lightboxImageList = useStore((s) => s.lightboxImageList)
+ const lightboxStartEditor = useStore((s) => s.lightboxStartEditor)
const setLightboxImageId = useStore((s) => s.setLightboxImageId)
+ const setLightboxStartEditor = useStore((s) => s.setLightboxStartEditor)
+ const inputImages = useStore((s) => s.inputImages)
+ const [src, setSrc] = useState('')
+ const [showEditor, setShowEditor] = useState(false)
const maskDraft = useStore((s) => s.maskDraft)
const tasks = useStore((s) => s.tasks)
- const [src, setSrc] = useState('')
const [maskImageSrc, setMaskImageSrc] = useState('')
const [maskPreviewSrc, setMaskPreviewSrc] = useState('')
@@ -28,6 +38,8 @@ export default function Lightbox() {
useEffect(() => {
if (!lightboxImageId) {
setSrc('')
+ setShowEditor(false)
+ setLightboxStartEditor(false)
return
}
const cached = getCachedImage(lightboxImageId)
@@ -38,7 +50,15 @@ export default function Lightbox() {
if (url) setSrc(url)
})
}
- }, [lightboxImageId])
+ }, [lightboxImageId, setLightboxStartEditor])
+
+ const isReferenceImage = Boolean(lightboxImageId && inputImages.some((image) => image.id === lightboxImageId))
+
+ useEffect(() => {
+ if (!lightboxImageId || !src || !lightboxStartEditor) return
+ setShowEditor(true)
+ setLightboxStartEditor(false)
+ }, [lightboxImageId, lightboxStartEditor, setLightboxStartEditor, src])
// 遮罩图加载
useEffect(() => {
@@ -116,16 +136,36 @@ export default function Lightbox() {
if (!lightboxImageId || !src) return null
return (
-
+ <>
+ setShowEditor(true)}
+ />
+ {showEditor && lightboxImageId && src ? (
+
+ 正在加载编辑器...
+
+ }
+ >
+ setShowEditor(false)}
+ />
+
+ ) : null}
+ >
)
}
@@ -138,10 +178,12 @@ interface LightboxInnerProps {
total: number
onPrev: () => void
onNext: () => void
+ isReferenceImage: boolean
+ onEdit: () => void
}
/** 内部组件:保证挂载时 DOM 已经存在,所有 ref / effect 都可靠 */
-function LightboxInner({ src, maskPreviewSrc, onClose, showNav, currentIndex, total, onPrev, onNext }: LightboxInnerProps) {
+function LightboxInner({ src, maskPreviewSrc, onClose, showNav, currentIndex, total, onPrev, onNext, isReferenceImage, onEdit }: LightboxInnerProps) {
const containerRef = useRef(null)
// 用 ref 追踪最新变换,避免闭包过期
@@ -295,6 +337,7 @@ function LightboxInner({ src, maskPreviewSrc, onClose, showNav, currentIndex, to
// ====== 单击关闭(仅未缩放且非拖拽) ======
const onClick = useCallback((e: React.MouseEvent) => {
+ if (isLightboxControl(e.target)) return
if (suppressNextClickRef.current) {
suppressNextClickRef.current = false
e.stopPropagation()
@@ -324,6 +367,12 @@ function LightboxInner({ src, maskPreviewSrc, onClose, showNav, currentIndex, to
if (!el) return
const onTouchStart = (e: TouchEvent) => {
+ if (isLightboxControl(e.target)) {
+ tapRef.current = { time: 0, x: 0, y: 0 }
+ touchStartedOnImageRef.current = false
+ return
+ }
+
if (e.touches.length === 2) {
e.preventDefault()
hadMultiTouchRef.current = true
@@ -398,6 +447,11 @@ function LightboxInner({ src, maskPreviewSrc, onClose, showNav, currentIndex, to
}
const onTouchEnd = (e: TouchEvent) => {
+ if (isLightboxControl(e.target)) {
+ tapRef.current = { time: 0, x: 0, y: 0 }
+ return
+ }
+
if (e.touches.length < 2) pinchRef.current.active = false
if (e.touches.length === 0) {
dragRef.current.active = false
@@ -450,6 +504,19 @@ function LightboxInner({ src, maskPreviewSrc, onClose, showNav, currentIndex, to
onDoubleClick={onDoubleClick}
>
+
,
+ document.body,
+ )
+ : null}
>
)
}
diff --git a/src/components/ReferenceImageEditorModal.tsx b/src/components/ReferenceImageEditorModal.tsx
new file mode 100644
index 00000000..e752f5cb
--- /dev/null
+++ b/src/components/ReferenceImageEditorModal.tsx
@@ -0,0 +1,1343 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Canvas, Ellipse, FabricImage, IText, PencilBrush, Rect, Triangle, type FabricObject } from 'fabric'
+import { addInputImageWithDataUrl, replaceInputImageWithDataUrl, useStore } from '../store'
+import { useCloseOnEscape } from '../hooks/useCloseOnEscape'
+
+interface ReferenceImageEditorModalProps {
+ imageId: string
+ src: string
+ saveMode: 'replace-input' | 'append-input'
+ onClose: () => void
+ onSaved?: (nextImageId: string, nextDataUrl: string) => void
+}
+
+type ToolMode = 'select' | 'mask-brush'
+type MaskShapeType = 'rect' | 'ellipse' | 'triangle'
+
+interface TextStyleState {
+ text: string
+ fill: string
+ fontSize: number
+ fontWeight: 'normal' | 'bold'
+ fontStyle: 'normal' | 'italic'
+}
+
+type BaseImageData = {
+ editorKind: 'base-image'
+ flipX: boolean
+ flipY: boolean
+}
+
+const DEFAULT_TEXT_STYLE: TextStyleState = {
+ text: '输入文字',
+ fill: '#ffffff',
+ fontSize: 48,
+ fontWeight: 'bold',
+ fontStyle: 'normal',
+}
+
+const DEFAULT_MASK_HUE = 0
+
+function clamp(value: number, min: number, max: number) {
+ return Math.max(min, Math.min(max, value))
+}
+
+function buildMaskColor(hue: number, alpha: number) {
+ return `hsla(${Math.round(hue)}, 85%, 48%, ${clamp(alpha, 0.05, 1)})`
+}
+
+function getEditorKind(object: FabricObject | null): string | undefined {
+ if (!object || typeof object !== 'object') return undefined
+ return (object as FabricObject & { data?: { editorKind?: string } }).data?.editorKind
+}
+
+function getBaseImageData(object: FabricObject | null): BaseImageData | undefined {
+ if (getEditorKind(object) !== 'base-image') return undefined
+ return (object as FabricObject & { data?: BaseImageData }).data
+}
+
+function getMaskShapeTypeFromObject(object: FabricObject | null): MaskShapeType | undefined {
+ if (!object || typeof object !== 'object') return undefined
+ return (object as FabricObject & { data?: { shapeType?: MaskShapeType } }).data?.shapeType
+}
+
+function useIsMobileDevice() {
+ const getIsMobileDevice = () => {
+ const ua = navigator.userAgent || ''
+ const platform = navigator.platform || ''
+ const isIpadOS = platform === 'MacIntel' && navigator.maxTouchPoints > 1
+ return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|Tablet/i.test(ua) || isIpadOS
+ }
+ const [isMobileDevice, setIsMobileDevice] = useState(getIsMobileDevice)
+ useEffect(() => {
+ const update = () => setIsMobileDevice(getIsMobileDevice())
+ window.addEventListener('resize', update)
+ window.addEventListener('orientationchange', update)
+ return () => {
+ window.removeEventListener('resize', update)
+ window.removeEventListener('orientationchange', update)
+ }
+ }, [])
+ return isMobileDevice
+}
+
+export default function ReferenceImageEditorModal({ imageId, src, saveMode, onClose, onSaved }: ReferenceImageEditorModalProps) {
+ const setLightboxImageId = useStore((s) => s.setLightboxImageId)
+ const inputImages = useStore((s) => s.inputImages)
+ const lightboxImageList = useStore((s) => s.lightboxImageList)
+ const showToast = useStore((s) => s.showToast)
+ const backgroundCanvasRef = useRef
(null)
+ const canvasElementRef = useRef(null)
+ const canvasViewportRef = useRef(null)
+ const fileInputRef = useRef(null)
+ const canvasRef = useRef
diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts
index 0d81926e..11c8b30c 100644
--- a/src/lib/clipboard.ts
+++ b/src/lib/clipboard.ts
@@ -16,16 +16,25 @@ export async function copyTextToClipboard(text: string) {
}
export async function copyBlobToClipboard(blob: Blob) {
- if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
- throw new Error('Clipboard image API is not available')
+ const mimeType = normalizeImageMimeType(blob.type)
+
+ if (window.isSecureContext && navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') {
+ await navigator.clipboard.write([
+ new ClipboardItem({ [mimeType]: blob }, { presentationStyle: 'inline' }),
+ ])
+ return
}
- await navigator.clipboard.write([
- new ClipboardItem({ [blob.type]: blob }),
- ])
+ if (await copyBlobWithExecCommand(blob, mimeType)) return
+
+ throw new Error('当前页面不是安全上下文,无法直接复制图片,请改用 HTTPS 或 localhost 访问后重试')
}
export function getClipboardFailureMessage(fallback: string, err: unknown) {
+ if (!window.isSecureContext) {
+ return '复制失败:当前页面不是安全上下文,请改用 HTTPS 或 localhost 访问后重试'
+ }
+
if (isEmbeddedPage() && isClipboardPermissionError(err)) {
return '复制失败:内嵌页面未授予剪贴板权限'
}
@@ -54,6 +63,79 @@ function copyTextWithExecCommand(text: string) {
}
}
+async function copyBlobWithExecCommand(blob: Blob, mimeType: string) {
+ const objectUrl = URL.createObjectURL(blob)
+ const host = document.createElement('div')
+ const image = document.createElement('img')
+ const selection = window.getSelection()
+ const fileName = `image-${Date.now()}.${getImageFileExtension(mimeType)}`
+ const file = new File([blob], fileName, { type: mimeType })
+
+ host.contentEditable = 'true'
+ host.setAttribute('aria-hidden', 'true')
+ host.style.position = 'fixed'
+ host.style.left = '-9999px'
+ host.style.top = '0'
+ host.style.width = '1px'
+ host.style.height = '1px'
+ host.style.overflow = 'hidden'
+ host.style.opacity = '0'
+
+ image.src = objectUrl
+ image.alt = ''
+ image.draggable = false
+
+ host.appendChild(image)
+ document.body.appendChild(host)
+
+ const onCopy = (event: ClipboardEvent) => {
+ const clipboardData = event.clipboardData
+ if (!clipboardData) return
+
+ try {
+ clipboardData.items.add(file)
+ } catch {
+ clipboardData.setData('text/html', `
`)
+ clipboardData.setData('text/plain', objectUrl)
+ }
+
+ event.preventDefault()
+ }
+
+ try {
+ document.addEventListener('copy', onCopy)
+ const range = document.createRange()
+ range.selectNode(image)
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ host.focus()
+
+ return document.execCommand('copy')
+ } catch {
+ return false
+ } finally {
+ document.removeEventListener('copy', onCopy)
+ selection?.removeAllRanges()
+ document.body.removeChild(host)
+ URL.revokeObjectURL(objectUrl)
+ }
+}
+
+function normalizeImageMimeType(mimeType: string) {
+ if (mimeType) return mimeType
+ return 'image/png'
+}
+
+function getImageFileExtension(mimeType: string) {
+ const type = mimeType.toLowerCase()
+ if (type === 'image/jpeg') return 'jpg'
+ if (type === 'image/webp') return 'webp'
+ if (type === 'image/gif') return 'gif'
+ if (type === 'image/bmp') return 'bmp'
+ if (type === 'image/svg+xml') return 'svg'
+ return 'png'
+}
+
function isEmbeddedPage() {
try {
return window.self !== window.top
diff --git a/src/lib/db.ts b/src/lib/db.ts
index 38182c11..17e83ea5 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -116,7 +116,8 @@ export async function storeImage(dataUrl: string, source: NonNullable
+ deletedImageIds: Record
+}
+
+export interface SnapshotImageFile {
+ path: string
+ ext: string
+ bytes: Uint8Array
+ createdAt: number
+ updatedAt: number
+ source?: 'upload' | 'generated' | 'mask'
+}
+
+const SNAPSHOT_VERSION = 3
+const TOMBSTONE_STORAGE_KEY = 'gpt-image-playground-sync-tombstones'
+
+export interface SyncTombstones {
+ deletedTaskIds: Record
+ deletedImageIds: Record
+}
+
+export async function buildLocalSnapshot(settings: AppSettings): Promise {
+ const exportedAt = Date.now()
+ const tasks = (await getAllTasks()).map((task) => normalizeTask(task, exportedAt))
+ const images = (await getAllImages()).map((image) => normalizeImage(image, exportedAt))
+ const tombstones = readSyncTombstones()
+
+ return {
+ version: SNAPSHOT_VERSION,
+ exportedAt,
+ settings: normalizeSettings(settings, exportedAt),
+ tasks,
+ images,
+ deletedTaskIds: { ...tombstones.deletedTaskIds },
+ deletedImageIds: { ...tombstones.deletedImageIds },
+ }
+}
+
+export function mergeSnapshots(local: SyncSnapshot, remote: SyncSnapshot): SyncSnapshot {
+ const exportedAt = Math.max(local.exportedAt, remote.exportedAt)
+ const settings = pickNewerSettings(local.settings, remote.settings)
+ const deletedTaskIds = mergeTombstones(local.deletedTaskIds, remote.deletedTaskIds)
+ const deletedImageIds = mergeTombstones(local.deletedImageIds, remote.deletedImageIds)
+ const tasks = mergeRecords(local.tasks, remote.tasks, getTaskTimestamp).filter((task) => {
+ const deletedAt = deletedTaskIds[task.id]
+ return deletedAt == null || deletedAt < getTaskTimestamp(task)
+ })
+ const images = mergeRecords(local.images, remote.images, getImageTimestamp).filter((image) => {
+ const deletedAt = deletedImageIds[image.id]
+ return deletedAt == null || deletedAt < getImageTimestamp(image)
+ })
+
+ return {
+ version: SNAPSHOT_VERSION,
+ exportedAt,
+ settings,
+ tasks,
+ images,
+ deletedTaskIds,
+ deletedImageIds,
+ }
+}
+
+export async function replaceLocalData(snapshot: SyncSnapshot) {
+ await clearTasks()
+ await clearImages()
+
+ for (const image of snapshot.images) {
+ await putImage(image)
+ }
+
+ for (const task of snapshot.tasks) {
+ await putTask(task)
+ }
+}
+
+export function snapshotToExportData(snapshot: SyncSnapshot): ExportData {
+ const imageFiles: ExportData['imageFiles'] = {}
+
+ for (const img of snapshot.images) {
+ const { ext } = parseDataUrl(img.dataUrl)
+ imageFiles[img.id] = {
+ path: getRemoteImagePath(img.id, ext),
+ createdAt: img.createdAt,
+ updatedAt: img.updatedAt,
+ source: img.source,
+ }
+ }
+
+ return {
+ version: SNAPSHOT_VERSION,
+ exportedAt: new Date(snapshot.exportedAt).toISOString(),
+ settings: snapshot.settings,
+ tasks: snapshot.tasks,
+ deletedTaskIds: { ...snapshot.deletedTaskIds },
+ deletedImageIds: { ...snapshot.deletedImageIds },
+ imageFiles,
+ }
+}
+
+export function snapshotToZipBlob(snapshot: SyncSnapshot): Blob {
+ const exportedAt = snapshot.exportedAt
+ const exportData = snapshotToExportData(snapshot)
+ const zipFiles: Record = {}
+
+ for (const img of snapshot.images) {
+ const { bytes, ext } = parseDataUrl(img.dataUrl)
+ const path = `images/${img.id}.${ext}`
+ zipFiles[path] = [bytes, { mtime: new Date(img.updatedAt ?? img.createdAt ?? exportedAt) }]
+ }
+
+ zipFiles['manifest.json'] = [strToU8(JSON.stringify(exportData, null, 2)), { mtime: new Date(exportedAt) }]
+
+ const zipped = zipSync(zipFiles, { level: 6 })
+ return new Blob([zipped.buffer as ArrayBuffer], { type: 'application/zip' })
+}
+
+export function snapshotToDirectoryManifest(snapshot: SyncSnapshot): ExportData {
+ return snapshotToExportData(snapshot)
+}
+
+export function snapshotToDirectoryFiles(snapshot: SyncSnapshot): SnapshotImageFile[] {
+ const exportedAt = snapshot.exportedAt
+ return snapshot.images.map((img) => {
+ const { bytes, ext } = parseDataUrl(img.dataUrl)
+ return {
+ path: getRemoteImagePath(img.id, ext),
+ ext,
+ bytes,
+ createdAt: img.updatedAt ?? img.createdAt ?? exportedAt,
+ updatedAt: img.updatedAt ?? img.createdAt ?? exportedAt,
+ source: img.source,
+ }
+ })
+}
+
+export function snapshotToManifestJson(snapshot: SyncSnapshot) {
+ return JSON.stringify(snapshotToDirectoryManifest(snapshot), null, 2)
+}
+
+export async function readSnapshotFromBlob(blob: Blob): Promise {
+ const buffer = await blob.arrayBuffer()
+ return readSnapshotFromBuffer(buffer)
+}
+
+export function dataUrlToBinary(dataUrl: string) {
+ return parseDataUrl(dataUrl)
+}
+
+export function binaryToDataUrl(bytes: Uint8Array, filePath: string): string {
+ return bytesToDataUrl(bytes, filePath)
+}
+
+export async function readSnapshotFromFile(file: File): Promise {
+ return readSnapshotFromBlob(file)
+}
+
+export async function snapshotFromManifest(
+ manifest: unknown,
+ readImageBytes: (path: string) => Promise,
+): Promise {
+ if (!isPlainObject(manifest)) {
+ throw new Error('无效的数据格式')
+ }
+
+ const raw = manifest as Partial & { version?: number }
+ if (!raw.tasks || !raw.imageFiles || typeof raw.imageFiles !== 'object') {
+ throw new Error('无效的数据格式')
+ }
+
+ const exportedAt = parseExportedAt(raw.exportedAt)
+ const settings = normalizeSettings(raw.settings ?? {}, exportedAt)
+ const tasks = (raw.tasks ?? []).map((task) => normalizeTask(task, exportedAt))
+ const deletedTaskIds = normalizeTombstones(raw.deletedTaskIds)
+ const deletedImageIds = normalizeTombstones(raw.deletedImageIds)
+ const images: StoredImage[] = []
+
+ for (const [id, info] of Object.entries(raw.imageFiles)) {
+ const bytes = await readImageBytes(info.path)
+ if (!bytes) continue
+ images.push(
+ normalizeImage(
+ {
+ id,
+ dataUrl: bytesToDataUrl(bytes, info.path),
+ createdAt: info.createdAt,
+ updatedAt: info.updatedAt ?? info.createdAt,
+ source: info.source,
+ },
+ exportedAt,
+ ),
+ )
+ }
+
+ return {
+ version: typeof raw.version === 'number' ? raw.version : SNAPSHOT_VERSION,
+ exportedAt,
+ settings,
+ tasks,
+ images,
+ deletedTaskIds,
+ deletedImageIds,
+ }
+}
+
+export async function importSnapshotIntoLocalData(file: File) {
+ const snapshot = await readSnapshotFromFile(file)
+ await replaceLocalData(snapshot)
+ return snapshot
+}
+
+export function sortTasksForDisplay(tasks: TaskRecord[]) {
+ return [...tasks].sort((a, b) => {
+ const createdDiff = (b.createdAt ?? 0) - (a.createdAt ?? 0)
+ if (createdDiff !== 0) return createdDiff
+
+ const updatedDiff = (b.updatedAt ?? 0) - (a.updatedAt ?? 0)
+ if (updatedDiff !== 0) return updatedDiff
+
+ return b.id.localeCompare(a.id)
+ })
+}
+
+function readSnapshotFromBuffer(buffer: ArrayBuffer): SyncSnapshot {
+ const unzipped = unzipSync(new Uint8Array(buffer))
+ const manifestBytes = unzipped['manifest.json']
+ if (!manifestBytes) throw new Error('ZIP 中缺少 manifest.json')
+
+ const raw = JSON.parse(strFromU8(manifestBytes)) as Partial & { version?: number }
+ if (!raw.tasks || !raw.imageFiles) {
+ throw new Error('无效的数据格式')
+ }
+
+ const exportedAt = parseExportedAt(raw.exportedAt)
+ const settings = normalizeSettings(raw.settings ?? {}, exportedAt)
+ const tasks = (raw.tasks ?? []).map((task) => normalizeTask(task, exportedAt))
+ const deletedTaskIds = normalizeTombstones(raw.deletedTaskIds)
+ const deletedImageIds = normalizeTombstones(raw.deletedImageIds)
+ const images: StoredImage[] = []
+
+ for (const [id, info] of Object.entries(raw.imageFiles)) {
+ const bytes = unzipped[info.path]
+ if (!bytes) continue
+ images.push(
+ normalizeImage(
+ {
+ id,
+ dataUrl: bytesToDataUrl(bytes, info.path),
+ createdAt: info.createdAt,
+ updatedAt: info.updatedAt ?? info.createdAt,
+ source: info.source,
+ },
+ exportedAt,
+ ),
+ )
+ }
+
+ return {
+ version: typeof raw.version === 'number' ? raw.version : SNAPSHOT_VERSION,
+ exportedAt,
+ settings,
+ tasks,
+ images,
+ deletedTaskIds,
+ deletedImageIds,
+ }
+}
+
+function mergeRecords(
+ local: T[],
+ remote: T[],
+ getTimestamp: (item: T) => number,
+) {
+ const merged = new Map()
+
+ for (const item of [...local, ...remote]) {
+ const current = merged.get(item.id)
+ if (!current || getTimestamp(item) >= getTimestamp(current)) {
+ merged.set(item.id, item)
+ }
+ }
+
+ return Array.from(merged.values())
+}
+
+export function readSyncTombstones(): SyncTombstones {
+ try {
+ const raw = localStorage.getItem(TOMBSTONE_STORAGE_KEY)
+ if (!raw) {
+ return { deletedTaskIds: {}, deletedImageIds: {} }
+ }
+ const parsed = JSON.parse(raw) as Partial
+ return {
+ deletedTaskIds: normalizeTombstones(parsed.deletedTaskIds),
+ deletedImageIds: normalizeTombstones(parsed.deletedImageIds),
+ }
+ } catch {
+ return { deletedTaskIds: {}, deletedImageIds: {} }
+ }
+}
+
+export function replaceSyncTombstones(tombstones: SyncTombstones) {
+ try {
+ localStorage.setItem(TOMBSTONE_STORAGE_KEY, JSON.stringify({
+ deletedTaskIds: normalizeTombstones(tombstones.deletedTaskIds),
+ deletedImageIds: normalizeTombstones(tombstones.deletedImageIds),
+ }))
+ } catch {
+ /* 忽略本地存储失败 */
+ }
+}
+
+export function markTaskDeleted(taskId: string, deletedAt = Date.now()) {
+ const tombstones = readSyncTombstones()
+ tombstones.deletedTaskIds[taskId] = deletedAt
+ replaceSyncTombstones(tombstones)
+}
+
+export function markImageDeleted(imageId: string, deletedAt = Date.now()) {
+ const tombstones = readSyncTombstones()
+ tombstones.deletedImageIds[imageId] = deletedAt
+ replaceSyncTombstones(tombstones)
+}
+
+export function clearSyncTombstones() {
+ replaceSyncTombstones({ deletedTaskIds: {}, deletedImageIds: {} })
+}
+
+function pickNewerSettings(local: AppSettings, remote: AppSettings) {
+ const remoteIsNewer = getSettingsTimestamp(remote) >= getSettingsTimestamp(local)
+ const primary = remoteIsNewer ? remote : local
+ const secondary = remoteIsNewer ? local : remote
+
+ return {
+ ...primary,
+ baseUrl: pickApiStringSetting(local.baseUrl, remote.baseUrl, DEFAULT_SETTINGS.baseUrl, remoteIsNewer),
+ apiKey: pickApiStringSetting(local.apiKey, remote.apiKey, DEFAULT_SETTINGS.apiKey, remoteIsNewer),
+ model: pickApiStringSetting(local.model, remote.model, DEFAULT_SETTINGS.model, remoteIsNewer),
+ timeout: pickNumericSetting(local.timeout, remote.timeout, DEFAULT_SETTINGS.timeout, remoteIsNewer),
+ apiMode: pickEnumSetting(local.apiMode, remote.apiMode, DEFAULT_SETTINGS.apiMode, remoteIsNewer),
+ storageMode: pickEnumSetting(local.storageMode, remote.storageMode, DEFAULT_SETTINGS.storageMode, remoteIsNewer),
+ webdav: {
+ url: pickCredentialSetting(local.webdav.url, remote.webdav.url),
+ username: pickCredentialSetting(local.webdav.username, remote.webdav.username),
+ password: pickCredentialSetting(local.webdav.password, remote.webdav.password),
+ syncOnStartup: pickBooleanSetting(local.webdav.syncOnStartup, remote.webdav.syncOnStartup, remoteIsNewer),
+ },
+ updatedAt: Math.max(getSettingsTimestamp(local), getSettingsTimestamp(remote)),
+ }
+}
+
+function getSettingsTimestamp(settings: AppSettings) {
+ return settings.updatedAt ?? 0
+}
+
+function pickApiStringSetting(local: string, remote: string, defaultValue: string, remoteIsNewer: boolean) {
+ const localMeaningful = isMeaningfulStringSetting(local, defaultValue)
+ const remoteMeaningful = isMeaningfulStringSetting(remote, defaultValue)
+
+ if (localMeaningful && !remoteMeaningful) return local
+ if (remoteMeaningful && !localMeaningful) return remote
+ return remoteIsNewer ? remote : local
+}
+
+function pickNumericSetting(local: number, remote: number, defaultValue: number, remoteIsNewer: boolean) {
+ const localMeaningful = local !== defaultValue
+ const remoteMeaningful = remote !== defaultValue
+
+ if (localMeaningful && !remoteMeaningful) return local
+ if (remoteMeaningful && !localMeaningful) return remote
+ return remoteIsNewer ? remote : local
+}
+
+function pickEnumSetting(local: T, remote: T, defaultValue: T, remoteIsNewer: boolean) {
+ const localMeaningful = local !== defaultValue
+ const remoteMeaningful = remote !== defaultValue
+
+ if (localMeaningful && !remoteMeaningful) return local
+ if (remoteMeaningful && !localMeaningful) return remote
+ return remoteIsNewer ? remote : local
+}
+
+function pickCredentialSetting(local: string, remote: string) {
+ if (local.trim()) return local
+ return remote
+}
+
+function pickBooleanSetting(local: boolean, remote: boolean, remoteIsNewer: boolean) {
+ return remoteIsNewer ? remote : local
+}
+
+function isMeaningfulStringSetting(value: string, defaultValue: string) {
+ return value.trim() !== '' && value !== defaultValue
+}
+
+function getTaskTimestamp(task: TaskRecord) {
+ return task.updatedAt ?? task.finishedAt ?? task.createdAt ?? 0
+}
+
+function getImageTimestamp(image: StoredImage) {
+ return image.updatedAt ?? image.createdAt ?? 0
+}
+
+function mergeTombstones(local: Record, remote: Record) {
+ const merged: Record = { ...local }
+ for (const [id, deletedAt] of Object.entries(remote)) {
+ const current = merged[id]
+ if (current == null || deletedAt >= current) {
+ merged[id] = deletedAt
+ }
+ }
+ return merged
+}
+
+function normalizeSettings(settings: Partial, fallback: number): AppSettings {
+ const isDefaultLike =
+ (settings.baseUrl ?? DEFAULT_SETTINGS.baseUrl) === DEFAULT_SETTINGS.baseUrl &&
+ (settings.apiKey ?? DEFAULT_SETTINGS.apiKey) === DEFAULT_SETTINGS.apiKey &&
+ (settings.model ?? DEFAULT_SETTINGS.model) === DEFAULT_SETTINGS.model &&
+ (settings.timeout ?? DEFAULT_SETTINGS.timeout) === DEFAULT_SETTINGS.timeout &&
+ (settings.apiMode === 'responses' ? 'responses' : 'images') === DEFAULT_SETTINGS.apiMode &&
+ (settings.storageMode === 'webdav' ? 'webdav' : 'local') === DEFAULT_SETTINGS.storageMode &&
+ (settings.webdav?.url ?? DEFAULT_SETTINGS.webdav.url) === DEFAULT_SETTINGS.webdav.url &&
+ (settings.webdav?.username ?? DEFAULT_SETTINGS.webdav.username) === DEFAULT_SETTINGS.webdav.username &&
+ (settings.webdav?.password ?? DEFAULT_SETTINGS.webdav.password) === DEFAULT_SETTINGS.webdav.password &&
+ (settings.webdav?.syncOnStartup ?? DEFAULT_SETTINGS.webdav.syncOnStartup) === DEFAULT_SETTINGS.webdav.syncOnStartup
+
+ return {
+ ...(settings as AppSettings),
+ baseUrl: settings.baseUrl ?? '',
+ apiKey: settings.apiKey ?? '',
+ model: settings.model ?? '',
+ timeout: settings.timeout ?? 0,
+ apiMode: settings.apiMode === 'responses' ? 'responses' : 'images',
+ storageMode: settings.storageMode === 'webdav' ? 'webdav' : 'local',
+ webdav: {
+ url: settings.webdav?.url ?? '',
+ username: settings.webdav?.username ?? '',
+ password: settings.webdav?.password ?? '',
+ syncOnStartup: settings.webdav?.syncOnStartup ?? true,
+ },
+ updatedAt: settings.updatedAt ?? (isDefaultLike ? 0 : fallback),
+ }
+}
+
+function normalizeTombstones(tombstones: unknown) {
+ if (!tombstones || typeof tombstones !== 'object') return {}
+ const record = tombstones as Record
+ const normalized: Record = {}
+ for (const [id, value] of Object.entries(record)) {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ normalized[id] = value
+ }
+ }
+ return normalized
+}
+
+function normalizeTask(task: Partial, fallback: number): TaskRecord {
+ return {
+ ...(task as TaskRecord),
+ id: task.id ?? `task-${fallback}`,
+ prompt: task.prompt ?? '',
+ params: task.params ?? {
+ size: 'auto',
+ quality: 'auto',
+ output_format: 'png',
+ output_compression: null,
+ moderation: 'auto',
+ n: 1,
+ },
+ inputImageIds: task.inputImageIds ?? [],
+ outputImages: task.outputImages ?? [],
+ status: task.status === 'done' || task.status === 'error' ? task.status : 'running',
+ error: task.error ?? null,
+ createdAt: task.createdAt ?? fallback,
+ finishedAt: task.finishedAt ?? null,
+ elapsed: task.elapsed ?? null,
+ updatedAt: task.updatedAt ?? task.finishedAt ?? task.createdAt ?? fallback,
+ }
+}
+
+function normalizeImage(image: Partial, fallback: number): StoredImage {
+ return {
+ ...(image as StoredImage),
+ id: image.id ?? `image-${fallback}`,
+ dataUrl: image.dataUrl ?? '',
+ createdAt: image.createdAt ?? fallback,
+ updatedAt: image.updatedAt ?? image.createdAt ?? fallback,
+ source: image.source,
+ }
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return !!value && typeof value === 'object' && !Array.isArray(value)
+}
+
+function parseExportedAt(value: unknown) {
+ if (typeof value === 'string') {
+ const parsed = Date.parse(value)
+ if (!Number.isNaN(parsed)) return parsed
+ }
+ return Date.now()
+}
+
+function parseDataUrl(dataUrl: string): { ext: string; bytes: Uint8Array } {
+ const match = dataUrl.match(/^data:([^;]+);base64,(.*)$/)
+ const mime = match?.[1] ?? 'image/png'
+ const b64 = match?.[2] ?? dataUrl.replace(/^data:[^;]+;base64,/, '')
+ const binary = atob(b64)
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
+ return {
+ ext: mimeToExt(mime),
+ bytes,
+ }
+}
+
+function bytesToDataUrl(bytes: Uint8Array, filePath: string): string {
+ const ext = filePath.split('.').pop()?.toLowerCase() ?? 'png'
+ const mimeMap: Record = {
+ png: 'image/png',
+ jpg: 'image/jpeg',
+ jpeg: 'image/jpeg',
+ webp: 'image/webp',
+ gif: 'image/gif',
+ bmp: 'image/bmp',
+ svg: 'image/svg+xml',
+ }
+ const mime = mimeMap[ext] ?? 'image/png'
+ let binary = ''
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
+ return `data:${mime};base64,${btoa(binary)}`
+}
+
+function getRemoteImagePath(imageId: string, ext: string) {
+ return `${imageId}.${ext}`
+}
+
+function mimeToExt(mime: string) {
+ const value = mime.toLowerCase()
+ if (value === 'image/jpeg') return 'jpg'
+ if (value === 'image/webp') return 'webp'
+ if (value === 'image/gif') return 'gif'
+ if (value === 'image/bmp') return 'bmp'
+ if (value === 'image/svg+xml') return 'svg'
+ return 'png'
+}
diff --git a/src/lib/webdavSync.ts b/src/lib/webdavSync.ts
new file mode 100644
index 00000000..c4e44a70
--- /dev/null
+++ b/src/lib/webdavSync.ts
@@ -0,0 +1,772 @@
+import { primeImageCache, useStore } from '../store'
+import {
+ buildLocalSnapshot,
+ mergeSnapshots,
+ dataUrlToBinary,
+ snapshotFromManifest,
+ snapshotToDirectoryFiles,
+ snapshotToDirectoryManifest,
+ replaceLocalData,
+ replaceSyncTombstones,
+ sortTasksForDisplay,
+} from './snapshot'
+
+const REMOTE_STATE_FILE_NAME = 'sync-state.json'
+const LOCAL_REMOTE_STATE_STORAGE_KEY = 'gpt-image-playground-webdav-remote-state'
+let syncInFlight: Promise | null = null
+let backgroundSyncTimer: number | null = null
+let applyingSnapshotDepth = 0
+let lastBackgroundSyncStartedAt = 0
+let lastBackgroundErrorMessage = ''
+let lastBackgroundErrorAt = 0
+const MANIFEST_FILE_NAME = 'manifest.json'
+const TEST_FILE_PREFIX = '.gpt-image-playground-test-'
+const BACKGROUND_SYNC_DEBOUNCE_MS = 2500
+const BACKGROUND_SYNC_INTERVAL_MS = 30000
+const BACKGROUND_SYNC_MIN_GAP_MS = 8000
+const SYNC_RETRY_LIMIT = 3
+
+interface WebDavRemoteState {
+ remoteId: string
+ generation: number
+ initializedAt: number
+ updatedAt: number
+}
+
+interface LocalRemoteStateRecord extends WebDavRemoteState {
+ rootUrl: string
+ lastSeenAt: number
+}
+
+interface WebDavSyncOptions {
+ allowRemoteReinitialize?: boolean
+ silentSuccess?: boolean
+ silentInfo?: boolean
+}
+
+interface RemoteReadResult {
+ state: WebDavRemoteState | null
+ snapshot: Awaited>
+ version: string
+}
+
+export class WebDavRemoteResetError extends Error {
+ rootUrl: string
+
+ constructor(message: string, rootUrl: string) {
+ super(message)
+ this.name = 'WebDavRemoteResetError'
+ this.rootUrl = rootUrl
+ }
+}
+
+export async function syncWithWebDav(options: WebDavSyncOptions = {}) {
+ return runWebDavSync(options)
+}
+
+export async function syncWithWebDavSilently() {
+ return runWebDavSync({ silentSuccess: true, silentInfo: true })
+}
+
+export async function overwriteLocalWithWebDav() {
+ const { settings, setSettings, setTasks } = useStore.getState()
+ const webdav = settings.webdav
+
+ if (settings.storageMode !== 'webdav') {
+ throw new Error('当前是本地存储模式,未启用 WebDAV')
+ }
+
+ if (!webdav.url.trim()) {
+ throw new Error('请先填写 WebDAV 目录地址')
+ }
+
+ const rootUrl = resolveWebDavRoot(webdav.url.trim())
+ const remoteState = await readRemoteState(rootUrl, webdav.username, webdav.password)
+ const remoteSnapshot = await readRemoteSnapshot(rootUrl, webdav.username, webdav.password)
+ if (!remoteSnapshot) {
+ throw new Error('远端 WebDAV 目录中没有 manifest.json,无法覆盖本地')
+ }
+
+ applyingSnapshotDepth++
+ try {
+ await replaceLocalData(remoteSnapshot)
+ replaceSyncTombstones({
+ deletedTaskIds: remoteSnapshot.deletedTaskIds,
+ deletedImageIds: remoteSnapshot.deletedImageIds,
+ })
+ primeImageCache(remoteSnapshot.images)
+ setSettings(remoteSnapshot.settings)
+ setTasks(sortTasksForDisplay(remoteSnapshot.tasks))
+ if (remoteState) {
+ saveLocalRemoteState(rootUrl, remoteState)
+ }
+ } finally {
+ applyingSnapshotDepth--
+ }
+}
+
+export async function overwriteWebDavWithLocal() {
+ const { settings } = useStore.getState()
+ const webdav = settings.webdav
+
+ if (settings.storageMode !== 'webdav') {
+ throw new Error('当前是本地存储模式,未启用 WebDAV')
+ }
+
+ if (!webdav.url.trim()) {
+ throw new Error('请先填写 WebDAV 目录地址')
+ }
+
+ const rootUrl = resolveWebDavRoot(webdav.url.trim())
+ const localSnapshot = await buildLocalSnapshot(settings)
+ const localRemoteState = readLocalRemoteState(rootUrl)
+ const remoteState = await readRemoteState(rootUrl, webdav.username, webdav.password)
+ const remoteSnapshot = await readRemoteSnapshot(rootUrl, webdav.username, webdav.password)
+ const manifest = snapshotToDirectoryManifest(localSnapshot)
+ const files = snapshotToDirectoryFiles(localSnapshot)
+ const nextRemoteState = buildNextRemoteState({
+ rootUrl,
+ remoteState,
+ localRemoteState,
+ hasRemoteSnapshot: Boolean(remoteSnapshot),
+ allowRemoteReinitialize: true,
+ })
+
+ await uploadSnapshotDirectory(rootUrl, webdav.username, webdav.password, manifest, files, remoteSnapshot)
+ await writeRemoteState(rootUrl, webdav.username, webdav.password, nextRemoteState)
+ saveLocalRemoteState(rootUrl, nextRemoteState)
+}
+
+export function isWebDavRemoteResetError(err: unknown): err is WebDavRemoteResetError {
+ return err instanceof WebDavRemoteResetError
+}
+
+export function scheduleWebDavSync(delayMs = BACKGROUND_SYNC_DEBOUNCE_MS) {
+ if (typeof window === 'undefined') return
+ clearBackgroundSyncTimer()
+ backgroundSyncTimer = window.setTimeout(() => {
+ backgroundSyncTimer = null
+ void triggerBackgroundSync()
+ }, Math.max(0, delayMs))
+}
+
+export function setupWebDavAutoSync() {
+ if (typeof window === 'undefined') {
+ return () => {}
+ }
+
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === 'visible') {
+ void triggerBackgroundSync(true)
+ }
+ }
+
+ const handleFocus = () => {
+ void triggerBackgroundSync(true)
+ }
+
+ const handleOnline = () => {
+ void triggerBackgroundSync(true)
+ }
+
+ const handlePageShow = () => {
+ void triggerBackgroundSync(true)
+ }
+
+ document.addEventListener('visibilitychange', handleVisibilityChange)
+ window.addEventListener('focus', handleFocus)
+ window.addEventListener('online', handleOnline)
+ window.addEventListener('pageshow', handlePageShow)
+
+ const intervalId = window.setInterval(() => {
+ if (document.visibilityState !== 'visible') return
+ if (typeof navigator !== 'undefined' && 'onLine' in navigator && !navigator.onLine) return
+ void triggerBackgroundSync()
+ }, BACKGROUND_SYNC_INTERVAL_MS)
+
+ return () => {
+ clearBackgroundSyncTimer()
+ document.removeEventListener('visibilitychange', handleVisibilityChange)
+ window.removeEventListener('focus', handleFocus)
+ window.removeEventListener('online', handleOnline)
+ window.removeEventListener('pageshow', handlePageShow)
+ window.clearInterval(intervalId)
+ }
+}
+
+export function isApplyingWebDavSnapshot() {
+ return applyingSnapshotDepth > 0
+}
+
+async function runWebDavSync(options: WebDavSyncOptions = {}) {
+ if (syncInFlight) return syncInFlight
+
+ syncInFlight = (async () => {
+ const { settings, setSettings, setTasks, showToast } = useStore.getState()
+ const webdav = settings.webdav
+
+ if (settings.storageMode !== 'webdav') {
+ if (!options.silentInfo) {
+ showToast('当前是本地存储模式,无需同步', 'info')
+ }
+ return
+ }
+
+ if (!webdav.url.trim()) {
+ throw new Error('请先填写 WebDAV 目录地址')
+ }
+
+ const rootUrl = resolveWebDavRoot(webdav.url.trim())
+ let syncResult: {
+ snapshot: Awaited>
+ remoteState: WebDavRemoteState
+ hadRemoteSnapshot: boolean
+ } | null = null
+
+ for (let attempt = 1; attempt <= SYNC_RETRY_LIMIT; attempt++) {
+ const localSnapshot = await buildLocalSnapshot(settings)
+ const localRemoteState = readLocalRemoteState(rootUrl)
+ const remoteBefore = await readRemote(rootUrl, webdav.username, webdav.password)
+ if (!remoteBefore.snapshot && hasMeaningfulSnapshotData(localSnapshot) && !options.allowRemoteReinitialize) {
+ throw new WebDavRemoteResetError('检测到远端 WebDAV 目录已被清空,已阻止自动用本地数据重新写回。若确认要重建远端,请在设置中再次手动同步并确认。', rootUrl)
+ }
+
+ const mergedSnapshot = remoteBefore.snapshot ? mergeSnapshots(localSnapshot, remoteBefore.snapshot) : localSnapshot
+ const manifest = snapshotToDirectoryManifest(mergedSnapshot)
+ const files = snapshotToDirectoryFiles(mergedSnapshot)
+ const nextRemoteState = buildNextRemoteState({
+ rootUrl,
+ remoteState: remoteBefore.state,
+ localRemoteState,
+ hasRemoteSnapshot: Boolean(remoteBefore.snapshot),
+ allowRemoteReinitialize: Boolean(options.allowRemoteReinitialize),
+ })
+
+ const remoteBeforeWrite = await readRemote(rootUrl, webdav.username, webdav.password)
+ if (remoteBeforeWrite.version !== remoteBefore.version) {
+ continue
+ }
+
+ await uploadSnapshotDirectory(rootUrl, webdav.username, webdav.password, manifest, files, remoteBefore.snapshot)
+ await writeRemoteState(rootUrl, webdav.username, webdav.password, nextRemoteState)
+
+ const remoteAfterWrite = await readRemote(rootUrl, webdav.username, webdav.password)
+ if (!remoteVersionsMatch(remoteAfterWrite, nextRemoteState, mergedSnapshot)) {
+ continue
+ }
+
+ syncResult = {
+ snapshot: mergedSnapshot,
+ remoteState: nextRemoteState,
+ hadRemoteSnapshot: Boolean(remoteBefore.snapshot),
+ }
+ break
+ }
+
+ if (!syncResult) {
+ throw new Error('WebDAV 同步期间远端持续变化,请稍后重试')
+ }
+
+ saveLocalRemoteState(rootUrl, syncResult.remoteState)
+ applyingSnapshotDepth++
+ try {
+ await replaceLocalData(syncResult.snapshot)
+ replaceSyncTombstones({
+ deletedTaskIds: syncResult.snapshot.deletedTaskIds,
+ deletedImageIds: syncResult.snapshot.deletedImageIds,
+ })
+ primeImageCache(syncResult.snapshot.images)
+ setSettings(syncResult.snapshot.settings)
+ setTasks(sortTasksForDisplay(syncResult.snapshot.tasks))
+ } finally {
+ applyingSnapshotDepth--
+ }
+ if (!options.silentSuccess) {
+ showToast(syncResult.hadRemoteSnapshot ? '已与 WebDAV 目录同步' : '已写入 WebDAV 目录', 'success')
+ }
+ })()
+
+ try {
+ await syncInFlight
+ } finally {
+ syncInFlight = null
+ }
+}
+
+export async function syncWebDavOnLaunch() {
+ const { settings, showToast } = useStore.getState()
+ if (settings.storageMode !== 'webdav' || !settings.webdav.syncOnStartup) return
+
+ try {
+ await syncWithWebDavSilently()
+ } catch (err) {
+ showToast(formatWebDavError('WebDAV 启动同步失败', err), 'error')
+ }
+}
+
+export async function testWebDavDirectory() {
+ const { settings, showToast } = useStore.getState()
+ if (settings.storageMode !== 'webdav') {
+ showToast('当前是本地存储模式,无需测试 WebDAV', 'info')
+ return
+ }
+
+ const webdav = settings.webdav
+ if (!webdav.url.trim()) {
+ throw new Error('请先填写 WebDAV 目录地址')
+ }
+
+ const rootUrl = resolveWebDavRoot(webdav.url.trim())
+ const testUrl = resolveRemoteUrl(rootUrl, `${TEST_FILE_PREFIX}${Date.now()}.txt`)
+ const imageTestUrl = resolveRemoteUrl(rootUrl, `.${TEST_FILE_PREFIX}${Date.now()}.png`)
+ const body = new Blob([`ok-${Date.now()}`], { type: 'text/plain' })
+
+ await putRemoteFile(testUrl, body, 'text/plain', webdav.username, webdav.password)
+ await putRemoteFile(imageTestUrl, body, 'image/png', webdav.username, webdav.password)
+
+ try {
+ await deleteRemoteFile(testUrl, webdav.username, webdav.password)
+ } catch {
+ /* 测试文件删除失败不影响可用性判断 */
+ }
+
+ try {
+ await deleteRemoteFile(imageTestUrl, webdav.username, webdav.password)
+ } catch {
+ /* 测试文件删除失败不影响可用性判断 */
+ }
+
+ showToast('WebDAV 目录可用', 'success')
+}
+
+export async function clearWebDavDirectory() {
+ const { settings, showToast } = useStore.getState()
+ if (settings.storageMode !== 'webdav') {
+ showToast('当前是本地存储模式,未启用 WebDAV', 'info')
+ return
+ }
+
+ const webdav = settings.webdav
+ if (!webdav.url.trim()) {
+ throw new Error('请先填写 WebDAV 目录地址')
+ }
+
+ const rootUrl = resolveWebDavRoot(webdav.url.trim())
+ const remoteSnapshot = await readRemoteSnapshot(rootUrl, webdav.username, webdav.password)
+
+ if (remoteSnapshot) {
+ const files = snapshotToDirectoryFiles(remoteSnapshot)
+ for (const file of files) {
+ try {
+ await deleteRemoteFile(resolveRemoteUrl(rootUrl, file.path), webdav.username, webdav.password)
+ } catch {
+ /* 忽略单个图片删除失败,继续尝试删除其他文件 */
+ }
+ }
+ }
+
+ try {
+ await deleteRemoteFile(resolveRemoteUrl(rootUrl, MANIFEST_FILE_NAME), webdav.username, webdav.password)
+ } catch {
+ /* 忽略 manifest 删除失败 */
+ }
+
+ try {
+ await deleteRemoteFile(resolveRemoteUrl(rootUrl, REMOTE_STATE_FILE_NAME), webdav.username, webdav.password)
+ } catch {
+ /* 忽略状态文件删除失败 */
+ }
+
+ clearLocalRemoteState(rootUrl)
+}
+
+function resolveWebDavRoot(url: string) {
+ const trimmed = url.trim()
+ return trimmed.endsWith('/') ? trimmed : `${trimmed}/`
+}
+
+function resolveRemoteUrl(rootUrl: string, relativePath: string) {
+ return new URL(relativePath.replace(/^\/+/, ''), rootUrl).toString()
+}
+
+function readLocalRemoteState(rootUrl: string): LocalRemoteStateRecord | null {
+ if (typeof localStorage === 'undefined') return null
+ try {
+ const raw = localStorage.getItem(LOCAL_REMOTE_STATE_STORAGE_KEY)
+ if (!raw) return null
+ const parsed = JSON.parse(raw) as Record
+ const record = parsed[rootUrl]
+ if (!record || typeof record !== 'object') return null
+ if (typeof record.remoteId !== 'string' || !record.remoteId.trim()) return null
+ if (typeof record.generation !== 'number' || !Number.isFinite(record.generation)) return null
+ if (typeof record.initializedAt !== 'number' || !Number.isFinite(record.initializedAt)) return null
+ if (typeof record.updatedAt !== 'number' || !Number.isFinite(record.updatedAt)) return null
+ return { ...record, rootUrl }
+ } catch {
+ return null
+ }
+}
+
+function saveLocalRemoteState(rootUrl: string, remoteState: WebDavRemoteState) {
+ if (typeof localStorage === 'undefined') return
+ try {
+ const raw = localStorage.getItem(LOCAL_REMOTE_STATE_STORAGE_KEY)
+ const parsed = raw ? JSON.parse(raw) as Record : {}
+ parsed[rootUrl] = {
+ rootUrl,
+ remoteId: remoteState.remoteId,
+ generation: remoteState.generation,
+ initializedAt: remoteState.initializedAt,
+ updatedAt: remoteState.updatedAt,
+ lastSeenAt: Date.now(),
+ }
+ localStorage.setItem(LOCAL_REMOTE_STATE_STORAGE_KEY, JSON.stringify(parsed))
+ } catch {
+ /* 忽略本地存储失败 */
+ }
+}
+
+function clearLocalRemoteState(rootUrl: string) {
+ if (typeof localStorage === 'undefined') return
+ try {
+ const raw = localStorage.getItem(LOCAL_REMOTE_STATE_STORAGE_KEY)
+ if (!raw) return
+ const parsed = JSON.parse(raw) as Record
+ if (!(rootUrl in parsed)) return
+ delete parsed[rootUrl]
+ localStorage.setItem(LOCAL_REMOTE_STATE_STORAGE_KEY, JSON.stringify(parsed))
+ } catch {
+ /* 忽略本地存储失败 */
+ }
+}
+
+function hasMeaningfulSnapshotData(snapshot: Awaited>) {
+ return (
+ snapshot.tasks.length > 0 ||
+ snapshot.images.length > 0 ||
+ Object.keys(snapshot.deletedTaskIds).length > 0 ||
+ Object.keys(snapshot.deletedImageIds).length > 0
+ )
+}
+
+function buildNextRemoteState(options: {
+ rootUrl: string
+ remoteState: WebDavRemoteState | null
+ localRemoteState: LocalRemoteStateRecord | null
+ hasRemoteSnapshot: boolean
+ allowRemoteReinitialize: boolean
+}) {
+ const now = Date.now()
+
+ if (options.remoteState && options.hasRemoteSnapshot) {
+ return {
+ ...options.remoteState,
+ updatedAt: now,
+ }
+ }
+
+ if (!options.hasRemoteSnapshot && options.allowRemoteReinitialize) {
+ return {
+ remoteId: options.localRemoteState?.remoteId ?? createRemoteId(),
+ generation: (options.localRemoteState?.generation ?? 0) + 1,
+ initializedAt: now,
+ updatedAt: now,
+ }
+ }
+
+ return {
+ remoteId: options.remoteState?.remoteId ?? options.localRemoteState?.remoteId ?? createRemoteId(),
+ generation: options.remoteState?.generation ?? options.localRemoteState?.generation ?? 1,
+ initializedAt: options.remoteState?.initializedAt ?? options.localRemoteState?.initializedAt ?? now,
+ updatedAt: now,
+ }
+}
+
+function createRemoteId() {
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID()
+ }
+ return `remote-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
+}
+
+
+function buildAuthHeaders(username: string, password: string) {
+ const headers: Record = {}
+ if (username || password) {
+ headers.Authorization = `Basic ${btoa(`${username}:${password}`)}`
+ }
+ return headers
+}
+
+function clearBackgroundSyncTimer() {
+ if (backgroundSyncTimer != null) {
+ window.clearTimeout(backgroundSyncTimer)
+ backgroundSyncTimer = null
+ }
+}
+
+async function triggerBackgroundSync(force = false) {
+ const { settings } = useStore.getState()
+ if (settings.storageMode !== 'webdav') return
+ if (!settings.webdav.url.trim()) return
+ if (typeof navigator !== 'undefined' && 'onLine' in navigator && !navigator.onLine) return
+
+ const now = Date.now()
+ if (!force && now - lastBackgroundSyncStartedAt < BACKGROUND_SYNC_MIN_GAP_MS) {
+ return
+ }
+
+ lastBackgroundSyncStartedAt = now
+ try {
+ await syncWithWebDavSilently()
+ } catch (err) {
+ const message = formatWebDavError('WebDAV 自动同步失败', err)
+ const now = Date.now()
+ if (message !== lastBackgroundErrorMessage || now - lastBackgroundErrorAt > BACKGROUND_SYNC_INTERVAL_MS) {
+ lastBackgroundErrorMessage = message
+ lastBackgroundErrorAt = now
+ useStore.getState().showToast(message, 'error')
+ }
+ }
+}
+
+async function readRemote(rootUrl: string, username: string, password: string): Promise {
+ const [state, snapshot] = await Promise.all([
+ readRemoteState(rootUrl, username, password),
+ readRemoteSnapshot(rootUrl, username, password),
+ ])
+
+ return {
+ state,
+ snapshot,
+ version: buildRemoteVersion(state, snapshot),
+ }
+}
+
+function buildRemoteVersion(
+ state: WebDavRemoteState | null,
+ snapshot: Awaited>,
+) {
+ return JSON.stringify({
+ state: state
+ ? {
+ remoteId: state.remoteId,
+ generation: state.generation,
+ initializedAt: state.initializedAt,
+ updatedAt: state.updatedAt,
+ }
+ : null,
+ manifest: snapshot ? snapshotToDirectoryManifest(snapshot) : null,
+ })
+}
+
+function remoteVersionsMatch(
+ remote: RemoteReadResult,
+ expectedState: WebDavRemoteState,
+ expectedSnapshot: Awaited>,
+) {
+ return remote.version === buildRemoteVersion(expectedState, expectedSnapshot)
+}
+
+async function readRemoteSnapshot(rootUrl: string, username: string, password: string) {
+ const manifestUrl = resolveRemoteUrl(rootUrl, MANIFEST_FILE_NAME)
+ const response = await fetch(manifestUrl, {
+ method: 'GET',
+ cache: 'no-store',
+ headers: {
+ ...buildAuthHeaders(username, password),
+ },
+ })
+
+ if (response.status === 404) return null
+ if (!response.ok) {
+ throw new Error(await readWebDavError(response))
+ }
+
+ const manifestText = await response.text()
+ if (!manifestText.trim()) {
+ return null
+ }
+
+ let manifest: unknown
+ try {
+ manifest = JSON.parse(manifestText)
+ } catch (error) {
+ throw new Error(`远端 manifest.json 不是有效 JSON:${error instanceof Error ? error.message : String(error)}`)
+ }
+
+ return snapshotFromManifest(manifest, async (path) => {
+ const fileResponse = await fetch(resolveRemoteUrl(rootUrl, path), {
+ method: 'GET',
+ cache: 'no-store',
+ headers: {
+ ...buildAuthHeaders(username, password),
+ },
+ })
+
+ if (fileResponse.status === 404) return null
+ if (!fileResponse.ok) {
+ throw new Error(await readWebDavError(fileResponse))
+ }
+
+ return new Uint8Array(await fileResponse.arrayBuffer())
+ })
+}
+
+async function readRemoteState(rootUrl: string, username: string, password: string) {
+ const stateUrl = resolveRemoteUrl(rootUrl, REMOTE_STATE_FILE_NAME)
+ const response = await fetch(stateUrl, {
+ method: 'GET',
+ cache: 'no-store',
+ headers: {
+ ...buildAuthHeaders(username, password),
+ },
+ })
+
+ if (response.status === 404) return null
+ if (!response.ok) {
+ throw new Error(await readWebDavError(response))
+ }
+
+ const text = await response.text()
+ if (!text.trim()) return null
+
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(text)
+ } catch (error) {
+ throw new Error(`远端 sync-state.json 不是有效 JSON:${error instanceof Error ? error.message : String(error)}`)
+ }
+
+ if (!parsed || typeof parsed !== 'object') {
+ throw new Error('远端 sync-state.json 格式无效')
+ }
+
+ const state = parsed as Partial
+ if (
+ typeof state.remoteId !== 'string' ||
+ !state.remoteId.trim() ||
+ typeof state.generation !== 'number' ||
+ !Number.isFinite(state.generation) ||
+ typeof state.initializedAt !== 'number' ||
+ !Number.isFinite(state.initializedAt) ||
+ typeof state.updatedAt !== 'number' ||
+ !Number.isFinite(state.updatedAt)
+ ) {
+ throw new Error('远端 sync-state.json 缺少必要字段')
+ }
+
+ return {
+ remoteId: state.remoteId,
+ generation: state.generation,
+ initializedAt: state.initializedAt,
+ updatedAt: state.updatedAt,
+ }
+}
+
+async function uploadSnapshotDirectory(
+ rootUrl: string,
+ username: string,
+ password: string,
+ manifest: ReturnType,
+ files: ReturnType,
+ previousSnapshot: Awaited>,
+) {
+ for (const file of files) {
+ const fileUrl = resolveRemoteUrl(rootUrl, file.path)
+ const bytes = file.bytes.slice().buffer
+ await putRemoteFile(fileUrl, new Blob([bytes], { type: extToMime(file.ext) }), extToMime(file.ext), username, password)
+ }
+
+ const manifestUrl = resolveRemoteUrl(rootUrl, MANIFEST_FILE_NAME)
+ await putRemoteFile(
+ manifestUrl,
+ new Blob([JSON.stringify(manifest, null, 2)], { type: 'application/json' }),
+ 'application/json',
+ username,
+ password,
+ )
+
+ if (previousSnapshot) {
+ const currentPaths = new Set(files.map((file) => file.path))
+ const stalePaths = previousSnapshot.images
+ .map((item) => {
+ const { ext } = dataUrlToBinary(item.dataUrl)
+ return `${item.id}.${ext}`
+ })
+ .filter((path) => !currentPaths.has(path))
+
+ for (const path of stalePaths) {
+ try {
+ await deleteRemoteFile(resolveRemoteUrl(rootUrl, path), username, password)
+ } catch {
+ /* 忽略孤立文件删除失败 */
+ }
+ }
+ }
+}
+
+async function writeRemoteState(rootUrl: string, username: string, password: string, remoteState: WebDavRemoteState) {
+ const stateUrl = resolveRemoteUrl(rootUrl, REMOTE_STATE_FILE_NAME)
+ await putRemoteFile(
+ stateUrl,
+ new Blob([JSON.stringify(remoteState, null, 2)], { type: 'application/json' }),
+ 'application/json',
+ username,
+ password,
+ )
+}
+
+async function putRemoteFile(url: string, body: Blob, contentType: string, username: string, password: string) {
+ const response = await fetch(url, {
+ method: 'PUT',
+ cache: 'no-store',
+ headers: {
+ 'Content-Type': contentType,
+ ...buildAuthHeaders(username, password),
+ },
+ body,
+ })
+
+ if (!response.ok) {
+ throw new Error(await readWebDavError(response))
+ }
+}
+
+async function deleteRemoteFile(url: string, username: string, password: string) {
+ const response = await fetch(url, {
+ method: 'DELETE',
+ cache: 'no-store',
+ headers: {
+ ...buildAuthHeaders(username, password),
+ },
+ })
+
+ if (!response.ok && response.status !== 404) {
+ throw new Error(await readWebDavError(response))
+ }
+}
+
+async function readWebDavError(response: Response) {
+ const statusText = response.statusText ? ` ${response.statusText}` : ''
+ try {
+ const text = await response.text()
+ return text ? `HTTP ${response.status}${statusText}: ${text}` : `HTTP ${response.status}${statusText}`
+ } catch {
+ return `HTTP ${response.status}${statusText}`
+ }
+}
+
+function extToMime(ext: string) {
+ const value = ext.toLowerCase()
+ if (value === 'jpg' || value === 'jpeg') return 'image/jpeg'
+ if (value === 'webp') return 'image/webp'
+ if (value === 'gif') return 'image/gif'
+ if (value === 'bmp') return 'image/bmp'
+ if (value === 'svg') return 'image/svg+xml'
+ return 'image/png'
+}
+
+function formatWebDavError(fallback: string, err: unknown) {
+ return `${fallback}:${err instanceof Error ? err.message : String(err)}`
+}
diff --git a/src/store.ts b/src/store.ts
index 6008352b..72ce9076 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -6,7 +6,7 @@ import type {
InputImage,
MaskDraft,
TaskRecord,
- ExportData,
+ WebDavSettings,
} from './types'
import { DEFAULT_SETTINGS, DEFAULT_PARAMS } from './types'
import {
@@ -16,7 +16,6 @@ import {
clearTasks as dbClearTasks,
getImage,
getAllImages,
- putImage,
deleteImage,
clearImages,
storeImage,
@@ -26,7 +25,17 @@ import { callImageApi } from './lib/api'
import { validateMaskMatchesImage } from './lib/canvasImage'
import { orderInputImagesForMask } from './lib/mask'
import { normalizeImageSize } from './lib/size'
-import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate'
+import {
+ buildLocalSnapshot,
+ clearSyncTombstones,
+ markImageDeleted,
+ markTaskDeleted,
+ readSnapshotFromBlob,
+ replaceLocalData,
+ replaceSyncTombstones,
+ snapshotToZipBlob,
+ sortTasksForDisplay,
+} from './lib/snapshot'
// ===== Image cache =====
// 内存缓存,id → dataUrl,避免每次从 IndexedDB 读取
@@ -47,12 +56,18 @@ export async function ensureImageCached(id: string): Promise
return undefined
}
+export function primeImageCache(images: Array<{ id: string; dataUrl: string }>) {
+ for (const img of images) {
+ imageCache.set(img.id, img.dataUrl)
+ }
+}
+
// ===== Store 类型 =====
interface AppState {
// 设置
settings: AppSettings
- setSettings: (s: Partial) => void
+ setSettings: (s: Partial & { webdav?: Partial }) => void
dismissedCodexCliPrompts: string[]
dismissCodexCliPrompt: (key: string) => void
@@ -64,6 +79,7 @@ interface AppState {
removeInputImage: (idx: number) => void
clearInputImages: () => void
setInputImages: (imgs: InputImage[]) => void
+ replaceInputImage: (currentId: string, nextImage: InputImage) => void
maskDraft: MaskDraft | null
setMaskDraft: (draft: MaskDraft | null) => void
clearMaskDraft: () => void
@@ -97,7 +113,9 @@ interface AppState {
setDetailTaskId: (id: string | null) => void
lightboxImageId: string | null
lightboxImageList: string[]
+ lightboxStartEditor: boolean
setLightboxImageId: (id: string | null, list?: string[]) => void
+ setLightboxStartEditor: (v: boolean) => void
showSettings: boolean
setShowSettings: (v: boolean) => void
@@ -127,11 +145,20 @@ export const useStore = create()(
settings: {
...st.settings,
...s,
+ webdav: {
+ ...st.settings.webdav,
+ ...s.webdav,
+ },
apiMode:
s.apiMode === 'images' || s.apiMode === 'responses'
? s.apiMode
: st.settings.apiMode ?? DEFAULT_SETTINGS.apiMode,
codexCli: s.codexCli ?? st.settings.codexCli ?? DEFAULT_SETTINGS.codexCli,
+ storageMode:
+ s.storageMode === 'local' || s.storageMode === 'webdav'
+ ? s.storageMode
+ : st.settings.storageMode ?? DEFAULT_SETTINGS.storageMode,
+ updatedAt: Date.now(),
},
})),
dismissedCodexCliPrompts: [],
@@ -173,6 +200,14 @@ export const useStore = create()(
...(shouldClearMask ? { maskDraft: null, maskEditorImageId: null } : {}),
}
}),
+ replaceInputImage: (currentId, nextImage) =>
+ set((s) => {
+ const shouldClearMask = s.maskDraft?.targetImageId === currentId
+ return {
+ inputImages: s.inputImages.map((img) => (img.id === currentId ? nextImage : img)),
+ ...(shouldClearMask ? { maskDraft: null, maskEditorImageId: null } : {}),
+ }
+ }),
maskDraft: null,
setMaskDraft: (maskDraft) => set({ maskDraft }),
clearMaskDraft: () => set({ maskDraft: null }),
@@ -217,8 +252,10 @@ export const useStore = create()(
setDetailTaskId: (detailTaskId) => set({ detailTaskId }),
lightboxImageId: null,
lightboxImageList: [],
+ lightboxStartEditor: false,
setLightboxImageId: (lightboxImageId, list) =>
set({ lightboxImageId, lightboxImageList: list ?? (lightboxImageId ? [lightboxImageId] : []) }),
+ setLightboxStartEditor: (lightboxStartEditor) => set({ lightboxStartEditor }),
showSettings: false,
setShowSettings: (showSettings) => set({ showSettings }),
@@ -279,7 +316,7 @@ export function showCodexCliPrompt(force = false, reason = '接口返回的提
/** 初始化:从 IndexedDB 加载任务和图片缓存,清理孤立图片 */
export async function initStore() {
const tasks = await getAllTasks()
- useStore.getState().setTasks(tasks)
+ useStore.getState().setTasks(sortTasksForDisplay(tasks))
// 收集所有任务引用的图片 id
const referencedIds = new Set()
@@ -367,6 +404,7 @@ export async function submitTask(options: { allowFullMask?: boolean } = {}) {
id: taskId,
prompt: prompt.trim(),
params: normalizedParams,
+ updatedAt: Date.now(),
inputImageIds: orderedInputImages.map((i) => i.id),
maskTargetImageId,
maskImageId,
@@ -482,7 +520,7 @@ async function executeTask(taskId: string) {
export function updateTaskInStore(taskId: string, patch: Partial) {
const { tasks, setTasks } = useStore.getState()
const updated = tasks.map((t) =>
- t.id === taskId ? { ...t, ...patch } : t,
+ t.id === taskId ? { ...t, ...patch, updatedAt: Date.now() } : t,
)
setTasks(updated)
const task = updated.find((t) => t.id === taskId)
@@ -562,6 +600,7 @@ export async function removeMultipleTasks(taskIds: string[]) {
setTasks(remaining)
for (const id of taskIds) {
await dbDeleteTask(id)
+ markTaskDeleted(id)
}
// 找出其他任务仍引用的图片
@@ -578,6 +617,7 @@ export async function removeMultipleTasks(taskIds: string[]) {
if (!stillUsed.has(imgId)) {
await deleteImage(imgId)
imageCache.delete(imgId)
+ markImageDeleted(imgId)
}
}
@@ -605,6 +645,7 @@ export async function removeTask(task: TaskRecord) {
const remaining = tasks.filter((t) => t.id !== task.id)
setTasks(remaining)
await dbDeleteTask(task.id)
+ markTaskDeleted(task.id)
// 找出其他任务仍引用的图片
const stillUsed = new Set()
@@ -620,6 +661,7 @@ export async function removeTask(task: TaskRecord) {
if (!stillUsed.has(imgId)) {
await deleteImage(imgId)
imageCache.delete(imgId)
+ markImageDeleted(imgId)
}
}
@@ -627,9 +669,10 @@ export async function removeTask(task: TaskRecord) {
}
/** 清空所有数据(含配置重置) */
-export async function clearAllData() {
+export async function clearAllData(options: { silent?: boolean } = {}) {
await dbClearTasks()
await clearImages()
+ clearSyncTombstones()
imageCache.clear()
const { setTasks, clearInputImages, clearMaskDraft, setSettings, setParams, showToast } = useStore.getState()
setTasks([])
@@ -638,75 +681,54 @@ export async function clearAllData() {
clearMaskDraft()
setSettings({ ...DEFAULT_SETTINGS })
setParams({ ...DEFAULT_PARAMS })
- showToast('所有数据已清空', 'success')
-}
-
-/** 从 dataUrl 解析出 MIME 扩展名和二进制数据 */
-function dataUrlToBytes(dataUrl: string): { ext: string; bytes: Uint8Array } {
- const match = dataUrl.match(/^data:image\/(\w+);base64,/)
- const ext = match?.[1] ?? 'png'
- const b64 = dataUrl.replace(/^data:[^;]+;base64,/, '')
- const binary = atob(b64)
- const bytes = new Uint8Array(binary.length)
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
- return { ext, bytes }
+ if (!options.silent) {
+ showToast('所有数据已清空', 'success')
+ }
}
-/** 将二进制数据还原为 dataUrl */
-function bytesToDataUrl(bytes: Uint8Array, filePath: string): string {
- const ext = filePath.split('.').pop()?.toLowerCase() ?? 'png'
- const mimeMap: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp' }
- const mime = mimeMap[ext] ?? 'image/png'
- let binary = ''
- for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
- return `data:${mime};base64,${btoa(binary)}`
+/** 初始化本地缓存数据,但保留当前设置 */
+export async function resetLocalDataPreservingSettings(options: { silent?: boolean } = {}) {
+ await dbClearTasks()
+ await clearImages()
+ clearSyncTombstones()
+ imageCache.clear()
+ const {
+ settings,
+ setTasks,
+ clearInputImages,
+ setParams,
+ setPrompt,
+ clearSelection,
+ setDetailTaskId,
+ setLightboxImageId,
+ setLightboxStartEditor,
+ showToast,
+ } = useStore.getState()
+ setTasks([])
+ clearInputImages()
+ setPrompt('')
+ setParams({ ...DEFAULT_PARAMS })
+ clearSelection()
+ setDetailTaskId(null)
+ setLightboxImageId(null, [])
+ setLightboxStartEditor(false)
+ useStore.setState({
+ settings: {
+ ...settings,
+ updatedAt: Date.now(),
+ },
+ })
+ if (!options.silent) {
+ showToast('本地缓存已初始化', 'success')
+ }
}
+/** 从 dataUrl 解析出 MIME 扩展名和二进制数据 */
/** 导出数据为 ZIP */
export async function exportData() {
try {
- const tasks = await getAllTasks()
- const images = await getAllImages()
- const { settings } = useStore.getState()
- const exportedAt = Date.now()
- const imageCreatedAtFallback = new Map()
-
- for (const task of tasks) {
- for (const id of [
- ...(task.inputImageIds || []),
- ...(task.maskImageId ? [task.maskImageId] : []),
- ...(task.outputImages || []),
- ]) {
- const prev = imageCreatedAtFallback.get(id)
- if (prev == null || task.createdAt < prev) {
- imageCreatedAtFallback.set(id, task.createdAt)
- }
- }
- }
-
- const imageFiles: ExportData['imageFiles'] = {}
- const zipFiles: Record = {}
-
- for (const img of images) {
- const { ext, bytes } = dataUrlToBytes(img.dataUrl)
- const path = `images/${img.id}.${ext}`
- const createdAt = img.createdAt ?? imageCreatedAtFallback.get(img.id) ?? exportedAt
- imageFiles[img.id] = { path, createdAt, source: img.source }
- zipFiles[path] = [bytes, { mtime: new Date(createdAt) }]
- }
-
- const manifest: ExportData = {
- version: 2,
- exportedAt: new Date(exportedAt).toISOString(),
- settings,
- tasks,
- imageFiles,
- }
-
- zipFiles['manifest.json'] = [strToU8(JSON.stringify(manifest, null, 2)), { mtime: new Date(exportedAt) }]
-
- const zipped = zipSync(zipFiles, { level: 6 })
- const blob = new Blob([zipped.buffer as ArrayBuffer], { type: 'application/zip' })
+ const snapshot = await buildLocalSnapshot(useStore.getState().settings)
+ const blob = snapshotToZipBlob(snapshot)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
@@ -727,37 +749,19 @@ export async function exportData() {
/** 导入 ZIP 数据 */
export async function importData(file: File) {
try {
- const buffer = await file.arrayBuffer()
- const unzipped = unzipSync(new Uint8Array(buffer))
-
- const manifestBytes = unzipped['manifest.json']
- if (!manifestBytes) throw new Error('ZIP 中缺少 manifest.json')
-
- const data: ExportData = JSON.parse(strFromU8(manifestBytes))
- if (!data.tasks || !data.imageFiles) throw new Error('无效的数据格式')
-
- // 还原图片
- for (const [id, info] of Object.entries(data.imageFiles)) {
- const bytes = unzipped[info.path]
- if (!bytes) continue
- const dataUrl = bytesToDataUrl(bytes, info.path)
- await putImage({ id, dataUrl, createdAt: info.createdAt, source: info.source })
- imageCache.set(id, dataUrl)
- }
-
- for (const task of data.tasks) {
- await putTask(task)
- }
-
- if (data.settings) {
- useStore.getState().setSettings(data.settings)
- }
+ const snapshot = await readSnapshotFromBlob(file)
+ await replaceLocalData(snapshot)
+ replaceSyncTombstones({
+ deletedTaskIds: snapshot.deletedTaskIds,
+ deletedImageIds: snapshot.deletedImageIds,
+ })
- const tasks = await getAllTasks()
- useStore.getState().setTasks(tasks)
+ useStore.getState().setSettings(snapshot.settings)
+ primeImageCache(snapshot.images)
+ useStore.getState().setTasks(sortTasksForDisplay(snapshot.tasks))
useStore
.getState()
- .showToast(`已导入 ${data.tasks.length} 条记录`, 'success')
+ .showToast(`已导入 ${snapshot.tasks.length} 条记录`, 'success')
} catch (e) {
useStore
.getState()
@@ -777,6 +781,23 @@ export async function addImageFromFile(file: File): Promise {
useStore.getState().addInputImage({ id, dataUrl })
}
+export async function replaceInputImageWithDataUrl(currentId: string, dataUrl: string): Promise {
+ const id = await hashDataUrl(dataUrl)
+ imageCache.set(id, dataUrl)
+ useStore.getState().replaceInputImage(currentId, { id, dataUrl })
+ return id
+}
+
+export async function addInputImageWithDataUrl(dataUrl: string): Promise {
+ const id = await hashDataUrl(dataUrl)
+ imageCache.set(id, dataUrl)
+ const state = useStore.getState()
+ if (!state.inputImages.some((image) => image.id === id)) {
+ state.addInputImage({ id, dataUrl })
+ }
+ return id
+}
+
/** 添加图片到输入(右键菜单)—— 支持 data/blob/http URL */
export async function addImageFromUrl(src: string): Promise {
const res = await fetch(src)
diff --git a/src/types.ts b/src/types.ts
index 42dd3f2d..258841a4 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,6 +1,14 @@
// ===== 设置 =====
export type ApiMode = 'images' | 'responses'
+export type StorageMode = 'local' | 'webdav'
+
+export interface WebDavSettings {
+ url: string
+ username: string
+ password: string
+ syncOnStartup: boolean
+}
export interface AppSettings {
baseUrl: string
@@ -8,7 +16,10 @@ export interface AppSettings {
model: string
timeout: number
apiMode: ApiMode
+ storageMode: StorageMode
+ webdav: WebDavSettings
codexCli: boolean
+ updatedAt?: number
}
const DEFAULT_BASE_URL = import.meta.env.VITE_DEFAULT_API_URL?.trim() || 'https://api.openai.com/v1'
@@ -21,6 +32,13 @@ export const DEFAULT_SETTINGS: AppSettings = {
model: DEFAULT_IMAGES_MODEL,
timeout: 300,
apiMode: 'images',
+ storageMode: 'local',
+ webdav: {
+ url: '',
+ username: '',
+ password: '',
+ syncOnStartup: true,
+ },
codexCli: false,
}
@@ -67,6 +85,8 @@ export interface TaskRecord {
id: string
prompt: string
params: TaskParams
+ /** 最近一次写入时间(ms) */
+ updatedAt?: number
/** API 返回的实际生效参数,用于标记与请求值不一致的情况 */
actualParams?: Partial
/** 输出图片对应的实际生效参数,key 为 outputImages 中的图片 id */
@@ -96,6 +116,8 @@ export interface StoredImage {
dataUrl: string
/** 图片首次存储时间(ms) */
createdAt?: number
+ /** 图片最近一次写入时间(ms) */
+ updatedAt?: number
/** 图片来源:用户上传 / API 生成 / 遮罩 */
source?: 'upload' | 'generated' | 'mask'
}
@@ -172,10 +194,13 @@ export interface ExportData {
exportedAt: string
settings: AppSettings
tasks: TaskRecord[]
+ deletedTaskIds?: Record
+ deletedImageIds?: Record
/** imageId → 图片信息 */
imageFiles: Record
}
diff --git a/vite.config.ts b/vite.config.ts
index b03396aa..6a06ff0f 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -29,6 +29,7 @@ export default defineConfig(({ command }) => {
},
server: {
host: true,
+ allowedHosts: ['xian-yu.top', '.xian-yu.top'],
proxy:
devProxyConfig?.enabled
? {
@@ -45,5 +46,8 @@ export default defineConfig(({ command }) => {
}
: undefined,
},
+ preview: {
+ allowedHosts: ['xian-yu.top', '.xian-yu.top'],
+ },
}
})