Compare commits
20 Commits
bade5cec0c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c3bb5d8fe | |||
| 1c2325170a | |||
| cdb86452c3 | |||
| fa23980917 | |||
| 06ab1e45db | |||
| 8a4e14750b | |||
| bcc2faca35 | |||
| f9635ba044 | |||
| ad28f6a607 | |||
| 77ae4bb429 | |||
| 8e71d7379a | |||
| e290eb21a4 | |||
| 2f0404d698 | |||
| 224d0d62fe | |||
| 647083d5b9 | |||
| e720f95c4e | |||
| b649d6da87 | |||
| e481a6e897 | |||
| ce69598175 | |||
| 49af17c0e3 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.vscode
|
||||||
|
*.md
|
||||||
|
.DS_Store
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
http://spritesheetgenerator.online:1337 {
|
:1337 {
|
||||||
root * ./dist
|
root * ./dist
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
@@ -19,10 +19,6 @@ http://spritesheetgenerator.online:1337 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log {
|
log {
|
||||||
output file /var/log/caddy/spritesheetgenerator.online.log
|
output file /var/log/caddy/app.log
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:1338 {
|
|
||||||
redir https://spritesheetgenerator.online{uri} permanent
|
|
||||||
}
|
|
||||||
24
Caddyfile.docker
Normal file
24
Caddyfile.docker
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
:1337 {
|
||||||
|
root * /srv/dist
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Aggressive caching for static assets
|
||||||
|
@static path *.js *.css *.png *.jpg *.jpeg *.gif *.webp *.svg *.woff *.woff2 *.ico
|
||||||
|
header @static Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
# Short cache for HTML (for SPA updates)
|
||||||
|
@html path *.html /
|
||||||
|
header @html Cache-Control "no-cache, must-revalidate"
|
||||||
|
|
||||||
|
# Default cache for everything else
|
||||||
|
header ?Cache-Control "max-age=1800"
|
||||||
|
|
||||||
|
try_files {path} /index.html
|
||||||
|
file_server {
|
||||||
|
precompressed zstd gzip
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output stdout
|
||||||
|
}
|
||||||
|
}
|
||||||
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the app
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM caddy:2-alpine
|
||||||
|
|
||||||
|
# Create log directory
|
||||||
|
RUN mkdir -p /var/log/caddy
|
||||||
|
|
||||||
|
# Copy built files
|
||||||
|
COPY --from=builder /app/dist /srv/dist
|
||||||
|
|
||||||
|
# Copy Caddy configuration
|
||||||
|
COPY Caddyfile.docker /etc/caddy/Caddyfile
|
||||||
|
|
||||||
|
EXPOSE 1337
|
||||||
|
|
||||||
|
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"]
|
||||||
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "${PORT:-1337}:1337"
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- caddy_logs:/var/log/caddy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy_logs:
|
||||||
390
package-lock.json
generated
390
package-lock.json
generated
@@ -1390,9 +1390,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
|
||||||
"integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
|
"integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1403,9 +1403,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz",
|
||||||
"integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
|
"integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1416,9 +1416,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz",
|
||||||
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
|
"integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1429,9 +1429,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz",
|
||||||
"integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
|
"integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1442,9 +1442,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz",
|
||||||
"integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
|
"integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1455,9 +1455,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz",
|
||||||
"integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
|
"integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1468,9 +1468,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz",
|
||||||
"integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
|
"integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1481,9 +1481,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz",
|
||||||
"integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
|
"integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1494,9 +1494,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz",
|
||||||
"integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
|
"integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1507,9 +1507,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz",
|
||||||
"integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
|
"integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1520,9 +1520,22 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz",
|
||||||
"integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
|
"integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
|
"version": "4.55.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz",
|
||||||
|
"integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -1533,9 +1546,22 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz",
|
||||||
"integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
|
"integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
|
"version": "4.55.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz",
|
||||||
|
"integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1546,9 +1572,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz",
|
||||||
"integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
|
"integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1559,9 +1585,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz",
|
||||||
"integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
|
"integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1572,9 +1598,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz",
|
||||||
"integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
|
"integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1585,9 +1611,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz",
|
||||||
"integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
|
"integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1598,9 +1624,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz",
|
||||||
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
|
"integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1610,10 +1636,23 @@
|
|||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
|
"version": "4.55.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz",
|
||||||
|
"integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz",
|
||||||
"integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
|
"integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1624,9 +1663,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz",
|
||||||
"integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
|
"integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1637,9 +1676,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz",
|
||||||
"integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
|
"integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -1650,9 +1689,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz",
|
||||||
"integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
|
"integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1663,9 +1702,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz",
|
||||||
"integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
|
"integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2505,9 +2544,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.11",
|
"version": "2.9.12",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.12.tgz",
|
||||||
"integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
|
"integrity": "sha512-Mij6Lij93pTAIsSYy5cyBQ975Qh9uLEc5rwGTpomiZeXZL9yIS6uORJakb3ScHgfs0serMMfIbXzokPMuEiRyw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -4181,9 +4220,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
|
||||||
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
|
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
@@ -4196,28 +4235,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.54.0",
|
"@rollup/rollup-android-arm-eabi": "4.55.1",
|
||||||
"@rollup/rollup-android-arm64": "4.54.0",
|
"@rollup/rollup-android-arm64": "4.55.1",
|
||||||
"@rollup/rollup-darwin-arm64": "4.54.0",
|
"@rollup/rollup-darwin-arm64": "4.55.1",
|
||||||
"@rollup/rollup-darwin-x64": "4.54.0",
|
"@rollup/rollup-darwin-x64": "4.55.1",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.54.0",
|
"@rollup/rollup-freebsd-arm64": "4.55.1",
|
||||||
"@rollup/rollup-freebsd-x64": "4.54.0",
|
"@rollup/rollup-freebsd-x64": "4.55.1",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.55.1",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.54.0",
|
"@rollup/rollup-linux-arm-musleabihf": "4.55.1",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.54.0",
|
"@rollup/rollup-linux-arm64-gnu": "4.55.1",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.54.0",
|
"@rollup/rollup-linux-arm64-musl": "4.55.1",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.54.0",
|
"@rollup/rollup-linux-loong64-gnu": "4.55.1",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.54.0",
|
"@rollup/rollup-linux-loong64-musl": "4.55.1",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.54.0",
|
"@rollup/rollup-linux-ppc64-gnu": "4.55.1",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.54.0",
|
"@rollup/rollup-linux-ppc64-musl": "4.55.1",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.54.0",
|
"@rollup/rollup-linux-riscv64-gnu": "4.55.1",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.54.0",
|
"@rollup/rollup-linux-riscv64-musl": "4.55.1",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.54.0",
|
"@rollup/rollup-linux-s390x-gnu": "4.55.1",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.54.0",
|
"@rollup/rollup-linux-x64-gnu": "4.55.1",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.54.0",
|
"@rollup/rollup-linux-x64-musl": "4.55.1",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.54.0",
|
"@rollup/rollup-openbsd-x64": "4.55.1",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.54.0",
|
"@rollup/rollup-openharmony-arm64": "4.55.1",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.54.0",
|
"@rollup/rollup-win32-arm64-msvc": "4.55.1",
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": "4.55.1",
|
||||||
|
"@rollup/rollup-win32-x64-gnu": "4.55.1",
|
||||||
|
"@rollup/rollup-win32-x64-msvc": "4.55.1",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4251,9 +4293,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/sass": {
|
"node_modules/sass": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz",
|
||||||
"integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==",
|
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4272,9 +4314,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded": {
|
"node_modules/sass-embedded": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.97.2.tgz",
|
||||||
"integrity": "sha512-wH3CbOThHYGX0bUyqFf7laLKyhVWIFc2lHynitkqMIUCtX2ixH9mQh0bN7+hkUu5BFt/SXvEMjFbkEbBMpQiSQ==",
|
"integrity": "sha512-lKJcskySwAtJ4QRirKrikrWMFa2niAuaGenY2ElHjd55IwHUiur5IdKu6R1hEmGYMs4Qm+6rlRW0RvuAkmcryg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4294,30 +4336,30 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"sass-embedded-all-unknown": "1.97.1",
|
"sass-embedded-all-unknown": "1.97.2",
|
||||||
"sass-embedded-android-arm": "1.97.1",
|
"sass-embedded-android-arm": "1.97.2",
|
||||||
"sass-embedded-android-arm64": "1.97.1",
|
"sass-embedded-android-arm64": "1.97.2",
|
||||||
"sass-embedded-android-riscv64": "1.97.1",
|
"sass-embedded-android-riscv64": "1.97.2",
|
||||||
"sass-embedded-android-x64": "1.97.1",
|
"sass-embedded-android-x64": "1.97.2",
|
||||||
"sass-embedded-darwin-arm64": "1.97.1",
|
"sass-embedded-darwin-arm64": "1.97.2",
|
||||||
"sass-embedded-darwin-x64": "1.97.1",
|
"sass-embedded-darwin-x64": "1.97.2",
|
||||||
"sass-embedded-linux-arm": "1.97.1",
|
"sass-embedded-linux-arm": "1.97.2",
|
||||||
"sass-embedded-linux-arm64": "1.97.1",
|
"sass-embedded-linux-arm64": "1.97.2",
|
||||||
"sass-embedded-linux-musl-arm": "1.97.1",
|
"sass-embedded-linux-musl-arm": "1.97.2",
|
||||||
"sass-embedded-linux-musl-arm64": "1.97.1",
|
"sass-embedded-linux-musl-arm64": "1.97.2",
|
||||||
"sass-embedded-linux-musl-riscv64": "1.97.1",
|
"sass-embedded-linux-musl-riscv64": "1.97.2",
|
||||||
"sass-embedded-linux-musl-x64": "1.97.1",
|
"sass-embedded-linux-musl-x64": "1.97.2",
|
||||||
"sass-embedded-linux-riscv64": "1.97.1",
|
"sass-embedded-linux-riscv64": "1.97.2",
|
||||||
"sass-embedded-linux-x64": "1.97.1",
|
"sass-embedded-linux-x64": "1.97.2",
|
||||||
"sass-embedded-unknown-all": "1.97.1",
|
"sass-embedded-unknown-all": "1.97.2",
|
||||||
"sass-embedded-win32-arm64": "1.97.1",
|
"sass-embedded-win32-arm64": "1.97.2",
|
||||||
"sass-embedded-win32-x64": "1.97.1"
|
"sass-embedded-win32-x64": "1.97.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-all-unknown": {
|
"node_modules/sass-embedded-all-unknown": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.97.2.tgz",
|
||||||
"integrity": "sha512-0au5gUNibfob7W/g+ycBx74O22CL8vwHiZdEDY6J0uzMkHPiSJk//h0iRf5AUnMArFHJjFd3urIiQIaoRKYa1Q==",
|
"integrity": "sha512-Fj75+vOIDv1T/dGDwEpQ5hgjXxa2SmMeShPa8yrh2sUz1U44bbmY4YSWPCdg8wb7LnwiY21B2KRFM+HF42yO4g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"!arm",
|
"!arm",
|
||||||
"!arm64",
|
"!arm64",
|
||||||
@@ -4327,13 +4369,13 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sass": "1.97.1"
|
"sass": "1.97.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-android-arm": {
|
"node_modules/sass-embedded-android-arm": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.97.2.tgz",
|
||||||
"integrity": "sha512-B5dlv4utJ+yC8ZpBeWTHwSZPVKRlqA8pcaD0FAzeNm/DelIFgQUQtt0UwgYoAI6wDIiie5uSVpMK9l2DaCbiBQ==",
|
"integrity": "sha512-BPT9m19ttY0QVHYYXRa6bmqmS3Fa2EHByNUEtSVcbm5PkIk1ntmYkG9fn5SJpIMbNmFDGwHx+pfcZMmkldhnRg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -4347,9 +4389,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-android-arm64": {
|
"node_modules/sass-embedded-android-arm64": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.97.2.tgz",
|
||||||
"integrity": "sha512-h62DmOiS2Jn87s8+8GhJcMerJnTKa1IsIa9iIKjLiqbAvBDKCGUs027RugZkM+Zx7I+vhPq86PUXBYZ9EkRxdw==",
|
"integrity": "sha512-pF6I+R5uThrscd3lo9B3DyNTPyGFsopycdx0tDAESN6s+dBbiRgNgE4Zlpv50GsLocj/lDLCZaabeTpL3ubhYA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -4363,9 +4405,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-android-riscv64": {
|
"node_modules/sass-embedded-android-riscv64": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.97.2.tgz",
|
||||||
"integrity": "sha512-tGup88vgaXPnUHEgDMujrt5rfYadvkiVjRb/45FJTx2hQFoGVbmUXz5XqUFjIIbEjQ3kAJqp86A2jy11s43UiQ==",
|
"integrity": "sha512-fprI8ZTJdz+STgARhg8zReI2QhhGIT9G8nS7H21kc3IkqPRzhfaemSxEtCqZyvDbXPcgYiDLV7AGIReHCuATog==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -4379,9 +4421,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-android-x64": {
|
"node_modules/sass-embedded-android-x64": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.97.2.tgz",
|
||||||
"integrity": "sha512-CAzKjjzu90LZduye2O9+UGX1oScMyF5/RVOa5CxACKALeIS+3XL3LVdV47kwKPoBv5B1aFUvGLscY0CR7jBAbg==",
|
"integrity": "sha512-RswwSjURZxupsukEmNt2t6RGvuvIw3IAD5sDq1Pc65JFvWFY3eHqCmH0lG0oXqMg6KJcF0eOxHOp2RfmIm2+4w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -4395,9 +4437,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-darwin-arm64": {
|
"node_modules/sass-embedded-darwin-arm64": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.97.2.tgz",
|
||||||
"integrity": "sha512-tyDzspzh5PbqdAFGtVKUXuf0up6Lff3c1U8J7+4Y7jW6AWRBnq95vTzIIxfnNifGCTI2fW5e7GAZpYygKpNwcw==",
|
"integrity": "sha512-xcsZNnU1XZh21RE/71OOwNqPVcGBU0qT9A4k4QirdA34+ts9cDIaR6W6lgHOBR/Bnnu6w6hXJR4Xth7oFrefPA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -4411,9 +4453,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-darwin-x64": {
|
"node_modules/sass-embedded-darwin-x64": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.97.2.tgz",
|
||||||
"integrity": "sha512-FMrRuSPI2ICt2M2SYaLbiG4yxn86D6ae+XtrRdrrBMhWprAcB7Iyu67bgRzZkipMZNIKKeTR7EUvJHgZzi5ixQ==",
|
"integrity": "sha512-T/9DTMpychm6+H4slHCAsYJRJ6eM+9H9idKlBPliPrP4T8JdC2Cs+ZOsYqrObj6eOtAD0fGf+KgyNhnW3xVafA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -4427,9 +4469,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-linux-arm": {
|
"node_modules/sass-embedded-linux-arm": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.97.2.tgz",
|
||||||
"integrity": "sha512-48VxaTUApLyx1NXFdZhKqI/7FYLmz8Ju3Ki2V/p+mhn5raHgAiYeFgn8O1WGxTOh+hBb9y3FdSR5a8MNTbmKMQ==",
|
"integrity": "sha512-yDRe1yifGHl6kibkDlRIJ2ZzAU03KJ1AIvsAh4dsIDgK5jx83bxZLV1ZDUv7a8KK/iV/80LZnxnu/92zp99cXQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -4443,9 +4485,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-linux-arm64": {
|
"node_modules/sass-embedded-linux-arm64": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.97.2.tgz",
|
||||||
"integrity": "sha512-im80gfDWRivw9Su3r3YaZmJaCATcJgu3CsCSLodPk1b1R2+X/E12zEQayvrl05EGT9PDwTtuiqKgS4ND4xjwVg==",
|
"integrity": "sha512-Wh+nQaFer9tyE5xBPv5murSUZE/+kIcg8MyL5uqww6be9Iq+UmZpcJM7LUk+q8klQ9LfTmoDSNFA74uBqxD6IA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -4459,9 +4501,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-linux-musl-arm": {
|
"node_modules/sass-embedded-linux-musl-arm": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.97.2.tgz",
|
||||||
"integrity": "sha512-FUFs466t3PVViVOKY/60JgLLtl61Pf7OW+g5BeEfuqVcSvYUECVHeiYHtX1fT78PEVa0h9tHpM6XpWti+7WYFA==",
|
"integrity": "sha512-GIO6xfAtahJAWItvsXZ3MD1HM6s8cKtV1/HL088aUpKJaw/2XjTCveiOO2AdgMpLNztmq9DZ1lx5X5JjqhS45g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -4475,9 +4517,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-linux-musl-arm64": {
|
"node_modules/sass-embedded-linux-musl-arm64": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.97.2.tgz",
|
||||||
"integrity": "sha512-kD35WSD9o0279Ptwid3Jnbovo1FYnuG2mayYk9z4ZI4mweXEK6vTu+tlvCE/MdF/zFKSj11qaxaH+uzXe2cO5A==",
|
"integrity": "sha512-NfUqZSjHwnHvpSa7nyNxbWfL5obDjNBqhHUYmqbHUcmqBpFfHIQsUPgXME9DKn1yBlBc3mWnzMxRoucdYTzd2Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -4491,9 +4533,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-linux-musl-riscv64": {
|
"node_modules/sass-embedded-linux-musl-riscv64": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.97.2.tgz",
|
||||||
"integrity": "sha512-ZgpYps5YHuhA2+KiLkPukRbS5298QObgUhPll/gm5i0LOZleKCwrFELpVPcbhsSBuxqji2uaag5OL+n3JRBVVg==",
|
"integrity": "sha512-qtM4dJ5gLfvyTZ3QencfNbsTEShIWImSEpkThz+Y2nsCMbcMP7/jYOA03UWgPfEOKSehQQ7EIau7ncbFNoDNPQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -4507,9 +4549,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-linux-musl-x64": {
|
"node_modules/sass-embedded-linux-musl-x64": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.97.2.tgz",
|
||||||
"integrity": "sha512-wcAigOyyvZ6o1zVypWV7QLZqpOEVnlBqJr9MbpnRIm74qFTSbAEmShoh8yMXBymzuVSmEbThxAwW01/TLf62tA==",
|
"integrity": "sha512-ZAxYOdmexcnxGnzdsDjYmNe3jGj+XW3/pF/n7e7r8y+5c6D2CQRrCUdapLgaqPt1edOPQIlQEZF8q5j6ng21yw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -4523,9 +4565,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-linux-riscv64": {
|
"node_modules/sass-embedded-linux-riscv64": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.97.2.tgz",
|
||||||
"integrity": "sha512-9j1qE1ZrLMuGb+LUmBzw93Z4TNfqlRkkxjPVZy6u5vIggeSfvGbte7eRoYBNWX6SFew/yBCL90KXIirWFSGrlQ==",
|
"integrity": "sha512-reVwa9ZFEAOChXpDyNB3nNHHyAkPMD+FTctQKECqKiVJnIzv2EaFF6/t0wzyvPgBKeatA8jszAIeOkkOzbYVkQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -4539,9 +4581,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-linux-x64": {
|
"node_modules/sass-embedded-linux-x64": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.97.2.tgz",
|
||||||
"integrity": "sha512-7nrLFYMH/UgvEgXR5JxQJ6y9N4IJmnFnYoDxN0nw0jUp+CQWQL4EJ4RqAKTGelneueRbccvt2sEyPK+X0KJ9Jg==",
|
"integrity": "sha512-bvAdZQsX3jDBv6m4emaU2OMTpN0KndzTAMgJZZrKUgiC0qxBmBqbJG06Oj/lOCoXGCxAvUOheVYpezRTF+Feog==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -4555,9 +4597,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-unknown-all": {
|
"node_modules/sass-embedded-unknown-all": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.97.2.tgz",
|
||||||
"integrity": "sha512-oPSeKc7vS2dx3ZJHiUhHKcyqNq0GWzAiR8zMVpPd/kVMl5ZfVyw+5HTCxxWDBGkX02lNpou27JkeBPCaneYGAQ==",
|
"integrity": "sha512-86tcYwohjPgSZtgeU9K4LikrKBJNf8ZW/vfsFbdzsRlvc73IykiqanufwQi5qIul0YHuu9lZtDWyWxM2dH/Rsg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4567,13 +4609,13 @@
|
|||||||
"!win32"
|
"!win32"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sass": "1.97.1"
|
"sass": "1.97.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-win32-arm64": {
|
"node_modules/sass-embedded-win32-arm64": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.97.2.tgz",
|
||||||
"integrity": "sha512-L5j7J6CbZgHGwcfVedMVpM3z5MYeighcyZE8GF2DVmjWzZI3JtPKNY11wNTD/P9o1Uql10YPOKhGH0iWIXOT7Q==",
|
"integrity": "sha512-Cv28q8qNjAjZfqfzTrQvKf4JjsZ6EOQ5FxyHUQQeNzm73R86nd/8ozDa1Vmn79Hq0kwM15OCM9epanDuTG1ksA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -4587,9 +4629,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass-embedded-win32-x64": {
|
"node_modules/sass-embedded-win32-x64": {
|
||||||
"version": "1.97.1",
|
"version": "1.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.97.2.tgz",
|
||||||
"integrity": "sha512-rfaZAKXU8cW3E7gvdafyD6YtgbEcsDeT99OEiHXRT0UGFuXT8qCOjpAwIKaOA3XXr2d8S42xx6cXcaZ1a+1fgw==",
|
"integrity": "sha512-DVxLxkeDCGIYeyHLAvWW3yy9sy5Ruk5p472QWiyfyyG1G1ASAR8fgfIY5pT0vE6Rv+VAKVLwF3WTspUYu7S1/Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [2.5.0] - 2026-01-05
|
||||||
|
- Uploading GIFS will ask you if you want to extract them to individual frames
|
||||||
|
|
||||||
|
## [2.4.0] - 2026-01-03
|
||||||
|
- Add pixel editor
|
||||||
|
|
||||||
## [2.3.0] - 2026-01-01
|
## [2.3.0] - 2026-01-01
|
||||||
- Add authentication
|
- Add authentication
|
||||||
- You can now save projects and open them
|
- You can now save projects and open them
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ html {
|
|||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Disable selection on images and canvases */
|
||||||
|
img,
|
||||||
|
canvas {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100 antialiased font-sans;
|
@apply bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100 antialiased font-sans;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => {
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => {
|
||||||
const items: BreadcrumbItem[] = [{ name: 'Home', path: '/' }];
|
const items: BreadcrumbItem[] = [{ name: 'Home', path: '/' }];
|
||||||
|
|
||||||
// Map route names to breadcrumb labels
|
|
||||||
const routeLabels: Record<string, string> = {
|
const routeLabels: Record<string, string> = {
|
||||||
'blog-overview': 'Blog',
|
'blog-overview': 'Blog',
|
||||||
'blog-detail': 'Blog',
|
'blog-detail': 'Blog',
|
||||||
@@ -27,10 +26,8 @@
|
|||||||
const routeName = route.name.toString();
|
const routeName = route.name.toString();
|
||||||
|
|
||||||
if (routeName === 'blog-detail') {
|
if (routeName === 'blog-detail') {
|
||||||
// For blog detail pages, add Blog first, then the post title
|
|
||||||
items.push({ name: 'Blog', path: '/blog' });
|
items.push({ name: 'Blog', path: '/blog' });
|
||||||
|
|
||||||
// Get the post title from route meta or params if available
|
|
||||||
const postTitle = (route.meta.title as string) || 'Article';
|
const postTitle = (route.meta.title as string) || 'Article';
|
||||||
items.push({ name: postTitle, path: route.path });
|
items.push({ name: postTitle, path: route.path });
|
||||||
} else if (routeLabels[routeName]) {
|
} else if (routeLabels[routeName]) {
|
||||||
|
|||||||
513
src/components/DrawTab.vue
Normal file
513
src/components/DrawTab.vue
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
<template>
|
||||||
|
<div class="draw-tab h-full w-full flex flex-col">
|
||||||
|
<!-- Overview Mode -->
|
||||||
|
<div v-if="!selectedFrame" class="h-full overflow-auto p-4">
|
||||||
|
<div v-for="layer in layers" :key="layer.id" class="mb-6">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-3">
|
||||||
|
{{ layer.name }}
|
||||||
|
<span class="text-xs font-normal text-gray-400 dark:text-gray-500 ml-2">({{ layer.sprites.length }} frames)</span>
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-2">
|
||||||
|
<button
|
||||||
|
v-for="(sprite, index) in layer.sprites"
|
||||||
|
:key="sprite.id"
|
||||||
|
@click="selectFrame(layer.id, index, sprite)"
|
||||||
|
class="aspect-square bg-white dark:bg-gray-800 rounded-lg border-2 border-transparent hover:border-indigo-500 transition-all overflow-hidden group relative"
|
||||||
|
:class="{ 'opacity-50': !sprite.url }"
|
||||||
|
>
|
||||||
|
<img v-if="sprite.url" :src="sprite.url" class="w-full h-full object-contain relative z-10" :style="{ imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto' }" />
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center text-gray-400 relative z-10">
|
||||||
|
<i class="fas fa-image"></i>
|
||||||
|
</div>
|
||||||
|
<span class="absolute bottom-1 right-1 text-[10px] font-mono text-gray-500 dark:text-gray-400 bg-white/80 dark:bg-gray-800/80 px-1 rounded z-20">
|
||||||
|
{{ index + 1 }}
|
||||||
|
</span>
|
||||||
|
<i class="fas fa-edit text-indigo-500 opacity-0 group-hover:opacity-100 transition-opacity text-[10px] absolute top-1 left-1 z-20"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Add New Frame Button -->
|
||||||
|
<button
|
||||||
|
@click="addNewFrame(layer)"
|
||||||
|
class="aspect-square bg-gray-50 dark:bg-gray-800/50 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-700 hover:border-indigo-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-all flex items-center justify-center text-gray-400 hover:text-indigo-500 group"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-1">
|
||||||
|
<i class="fas fa-plus text-xl group-hover:scale-110 transition-transform"></i>
|
||||||
|
<span class="text-[10px] font-medium uppercase tracking-wide">Add</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pixel Editor Mode -->
|
||||||
|
<div v-else class="h-full flex flex-col">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 rounded-t-xl z-20">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Tools -->
|
||||||
|
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
|
||||||
|
<Tooltip text="Pencil (P)">
|
||||||
|
<button @click="editor.currentTool.value = 'pencil'" :class="editor.currentTool.value === 'pencil' ? 'bg-white dark:bg-gray-600 shadow-sm' : 'hover:bg-gray-200 dark:hover:bg-gray-600'" class="p-2 rounded-md transition-all">
|
||||||
|
<i class="fas fa-pencil-alt text-gray-600 dark:text-gray-300"></i>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip text="Eraser (E)">
|
||||||
|
<button @click="editor.currentTool.value = 'eraser'" :class="editor.currentTool.value === 'eraser' ? 'bg-white dark:bg-gray-600 shadow-sm' : 'hover:bg-gray-200 dark:hover:bg-gray-600'" class="p-2 rounded-md transition-all">
|
||||||
|
<i class="fas fa-eraser text-gray-600 dark:text-gray-300"></i>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip text="Eyedropper (I)">
|
||||||
|
<button @click="editor.currentTool.value = 'picker'" :class="editor.currentTool.value === 'picker' ? 'bg-white dark:bg-gray-600 shadow-sm' : 'hover:bg-gray-200 dark:hover:bg-gray-600'" class="p-2 rounded-md transition-all">
|
||||||
|
<i class="fas fa-eye-dropper text-gray-600 dark:text-gray-300"></i>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Color Picker & History -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="relative group">
|
||||||
|
<Tooltip text="Current Color">
|
||||||
|
<div class="w-8 h-8 rounded-lg border-2 border-gray-300 dark:border-gray-600 cursor-pointer shadow-sm overflow-hidden" :style="{ backgroundColor: editor.currentColor.value }">
|
||||||
|
<input type="color" v-model="editor.currentColor.value" class="absolute inset-0 opacity-0 cursor-pointer w-full h-full" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Colors -->
|
||||||
|
<div class="flex flex-wrap gap-1 w-20">
|
||||||
|
<div v-for="color in recentColors.slice(0, 8)" :key="color" class="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600 cursor-pointer hover:scale-110 transition-transform" :style="{ backgroundColor: color }" @click="editor.currentColor.value = color" :title="color"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1"></div>
|
||||||
|
|
||||||
|
<!-- Zoom -->
|
||||||
|
<div class="flex items-center bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||||
|
<Tooltip text="Zoom Out">
|
||||||
|
<button @click="editor.zoomOut()" class="p-2 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-l-lg transition-colors">
|
||||||
|
<i class="fas fa-minus text-xs text-gray-600 dark:text-gray-300"></i>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<span class="text-xs font-mono w-12 text-center text-gray-600 dark:text-gray-300 select-none">{{ editor.zoom.value }}x</span>
|
||||||
|
<Tooltip text="Zoom In">
|
||||||
|
<button @click="editor.zoomIn()" class="p-2 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-r-lg transition-colors">
|
||||||
|
<i class="fas fa-plus text-xs text-gray-600 dark:text-gray-300"></i>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1"></div>
|
||||||
|
|
||||||
|
<!-- Canvas Size -->
|
||||||
|
<Tooltip text="Resize Canvas">
|
||||||
|
<button @click="showResizeModal = true" class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
|
||||||
|
<i class="fas fa-expand-arrows-alt text-gray-600 dark:text-gray-300"></i>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip text="Trim Empty Space">
|
||||||
|
<button @click="editor.trimCanvas()" class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
|
||||||
|
<i class="fas fa-crop-alt text-gray-600 dark:text-gray-300"></i>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1"></div>
|
||||||
|
|
||||||
|
<!-- History -->
|
||||||
|
<Tooltip text="Undo (Ctrl+Z)">
|
||||||
|
<button @click="editor.undo()" :disabled="!editor.canUndo.value" :class="editor.canUndo.value ? 'hover:bg-gray-100 dark:hover:bg-gray-700' : 'opacity-40 cursor-not-allowed'" class="p-2 rounded-lg transition-colors">
|
||||||
|
<i class="fas fa-undo text-gray-600 dark:text-gray-300"></i>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip text="Redo (Ctrl+Shift+Z)">
|
||||||
|
<button @click="editor.redo()" :disabled="!editor.canRedo.value" :class="editor.canRedo.value ? 'hover:bg-gray-100 dark:hover:bg-gray-700' : 'opacity-40 cursor-not-allowed'" class="p-2 rounded-lg transition-colors">
|
||||||
|
<i class="fas fa-redo text-gray-600 dark:text-gray-300"></i>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Toggle Options -->
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="editor.showCheckerboard.value" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400 select-none">Grid</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="showPixelHighlight" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400 select-none">Highlight</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Pointer Location Display -->
|
||||||
|
<div v-if="editor.showPointerLocation.value" class="text-xs font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded min-w-[60px] text-center">{{ editor.pointerX.value }}, {{ editor.pointerY.value }}</div>
|
||||||
|
|
||||||
|
<!-- Canvas Size -->
|
||||||
|
<div class="text-xs font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">W: {{ editor.canvasWidth.value }} H: {{ editor.canvasHeight.value }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Canvas Area -->
|
||||||
|
<div class="flex-1 overflow-auto bg-gray-200 dark:bg-gray-900 p-4">
|
||||||
|
<div class="min-h-full min-w-full flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
class="relative shrink-0"
|
||||||
|
:style="{
|
||||||
|
width: `${editor.canvasWidth.value * editor.zoom.value}px`,
|
||||||
|
height: `${editor.canvasHeight.value * editor.zoom.value}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- Background (checkerboard or solid) -->
|
||||||
|
<div class="absolute inset-0 pointer-events-none" :style="getBackgroundStyle()"></div>
|
||||||
|
|
||||||
|
<!-- Canvas -->
|
||||||
|
<canvas
|
||||||
|
ref="canvasRef"
|
||||||
|
class="relative z-10 cursor-crosshair"
|
||||||
|
:style="{
|
||||||
|
width: `${editor.canvasWidth.value * editor.zoom.value}px`,
|
||||||
|
height: `${editor.canvasHeight.value * editor.zoom.value}px`,
|
||||||
|
imageRendering: 'pixelated',
|
||||||
|
}"
|
||||||
|
@mousedown="editor.startDrawing($event)"
|
||||||
|
@mousemove="handleMouseMove"
|
||||||
|
@mouseup="editor.stopDrawing()"
|
||||||
|
@mouseleave="editor.stopDrawing()"
|
||||||
|
></canvas>
|
||||||
|
|
||||||
|
<!-- Pixel Grid Overlay (when zoomed in enough) -->
|
||||||
|
<div
|
||||||
|
v-if="editor.zoom.value >= 4"
|
||||||
|
class="absolute inset-0 pointer-events-none z-20"
|
||||||
|
:style="{
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(to right, rgba(0,0,0,0.1) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, rgba(0,0,0,0.1) 1px, transparent 1px)
|
||||||
|
`,
|
||||||
|
backgroundSize: `${editor.zoom.value}px ${editor.zoom.value}px`,
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Active Pixel Highlight -->
|
||||||
|
<div
|
||||||
|
v-if="showPixelHighlight"
|
||||||
|
class="absolute pointer-events-none z-30 border border-indigo-500/80 bg-indigo-500/20"
|
||||||
|
:style="{
|
||||||
|
left: `${editor.pointerX.value * editor.zoom.value}px`,
|
||||||
|
top: `${editor.pointerY.value * editor.zoom.value}px`,
|
||||||
|
width: `${editor.zoom.value}px`,
|
||||||
|
height: `${editor.zoom.value}px`,
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer Actions -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-xl">
|
||||||
|
<Tooltip text="Discard changes">
|
||||||
|
<button @click="cancelEdit" class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors font-medium text-sm">Cancel</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip text="Save changes to frame">
|
||||||
|
<button @click="saveEdit" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium text-sm"><i class="fas fa-save mr-2"></i>Save</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resize Modal -->
|
||||||
|
<Modal :is-open="showResizeModal" @close="showResizeModal = false" title="Resize Canvas" :initialWidth="400" :initialHeight="350">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Width</label>
|
||||||
|
<input type="number" v-model.number="resizeWidth" min="1" max="1024" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Height</label>
|
||||||
|
<input type="number" v-model.number="resizeHeight" min="1" max="1024" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Anchor Position</label>
|
||||||
|
<div class="grid grid-cols-3 gap-1 w-32 mx-auto">
|
||||||
|
<button
|
||||||
|
v-for="anchor in anchors"
|
||||||
|
:key="anchor.value"
|
||||||
|
@click="resizeAnchor = anchor.value"
|
||||||
|
:class="resizeAnchor === anchor.value ? 'bg-indigo-500 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'"
|
||||||
|
class="aspect-square rounded flex items-center justify-center text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<i :class="anchor.icon"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-4 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<button @click="showResizeModal = false" class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors font-medium text-sm">Cancel</button>
|
||||||
|
<button @click="applyResize" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium text-sm">Apply</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue';
|
||||||
|
import { usePixelEditor } from '@/composables/usePixelEditor';
|
||||||
|
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||||
|
import Modal from './utilities/Modal.vue';
|
||||||
|
import Tooltip from './utilities/Tooltip.vue';
|
||||||
|
import type { Layer, Sprite } from '@/types/sprites';
|
||||||
|
|
||||||
|
interface SelectedFrame {
|
||||||
|
layerId: string;
|
||||||
|
frameIndex: number;
|
||||||
|
sprite?: Sprite;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
layers: Layer[];
|
||||||
|
initialFrame?: { layerId: string; frameIndex: number } | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'saveFrame', layerId: string, frameIndex: number, file: File): void;
|
||||||
|
(e: 'close'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const editor = usePixelEditor();
|
||||||
|
|
||||||
|
const selectedFrame = ref<SelectedFrame | null>(null);
|
||||||
|
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
const showResizeModal = ref(false);
|
||||||
|
const resizeWidth = ref(32);
|
||||||
|
const resizeHeight = ref(32);
|
||||||
|
const resizeAnchor = ref('top-left');
|
||||||
|
const showPixelHighlight = ref(true);
|
||||||
|
|
||||||
|
const recentColors = ref<string[]>(['#ffffff', '#000000', '#ff0000', '#00ff00', '#0000ff']);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => editor.currentColor.value,
|
||||||
|
newColor => {
|
||||||
|
if (!newColor) return;
|
||||||
|
// Add to recent colors if not already at the start
|
||||||
|
if (recentColors.value[0] !== newColor) {
|
||||||
|
recentColors.value = [newColor, ...recentColors.value.filter(c => c !== newColor)].slice(0, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const anchors = [
|
||||||
|
{ value: 'top-left', icon: 'fas fa-arrow-up -rotate-45' },
|
||||||
|
{ value: 'top-center', icon: 'fas fa-arrow-up' },
|
||||||
|
{ value: 'top-right', icon: 'fas fa-arrow-up rotate-45' },
|
||||||
|
{ value: 'middle-left', icon: 'fas fa-arrow-left' },
|
||||||
|
{ value: 'center-middle', icon: 'fas fa-circle text-[6px]' },
|
||||||
|
{ value: 'middle-right', icon: 'fas fa-arrow-right' },
|
||||||
|
{ value: 'bottom-left', icon: 'fas fa-arrow-down rotate-45' },
|
||||||
|
{ value: 'bottom-center', icon: 'fas fa-arrow-down' },
|
||||||
|
{ value: 'bottom-right', icon: 'fas fa-arrow-down -rotate-45' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getCheckerboardPattern = () => {
|
||||||
|
return `conic-gradient(
|
||||||
|
#e0e0e0 0.25turn,
|
||||||
|
transparent 0.25turn 0.5turn,
|
||||||
|
#e0e0e0 0.5turn 0.75turn,
|
||||||
|
transparent 0.75turn
|
||||||
|
)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBackgroundStyle = () => {
|
||||||
|
const zoom = editor.zoom.value;
|
||||||
|
const isTransparent = settingsStore.backgroundColor === 'transparent';
|
||||||
|
|
||||||
|
// If checkerboard is enabled, show standard white/grey checkerboard
|
||||||
|
if (editor.showCheckerboard.value) {
|
||||||
|
return {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
backgroundImage: getCheckerboardPattern(),
|
||||||
|
backgroundSize: `${zoom * 2}px ${zoom * 2}px`,
|
||||||
|
backgroundPosition: '0 0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise show the configured background color (default to white if transparent)
|
||||||
|
return {
|
||||||
|
backgroundColor: isTransparent ? '#ffffff' : settingsStore.backgroundColor,
|
||||||
|
backgroundImage: 'none',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectFrame = async (layerId: string, frameIndex: number, sprite: Sprite) => {
|
||||||
|
selectedFrame.value = { layerId, frameIndex, sprite };
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
if (canvasRef.value) {
|
||||||
|
// If sprite exists, use its dimensions
|
||||||
|
if (sprite.width && sprite.height) {
|
||||||
|
editor.canvasWidth.value = sprite.width;
|
||||||
|
editor.canvasHeight.value = sprite.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.initCanvas(canvasRef.value);
|
||||||
|
if (sprite.url) {
|
||||||
|
await editor.loadFromImage(sprite.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addNewFrame = async (layer: Layer) => {
|
||||||
|
// Determine canvas size based on "biggest layer" logic
|
||||||
|
// Priority 1: Max dimensions of sprites in current layer
|
||||||
|
// Priority 2: Max dimensions of sprites in any layer
|
||||||
|
// Priority 3: Default 32x32
|
||||||
|
|
||||||
|
let targetWidth = 0;
|
||||||
|
let targetHeight = 0;
|
||||||
|
|
||||||
|
// Check current layer
|
||||||
|
if (layer.sprites.length > 0) {
|
||||||
|
targetWidth = Math.max(...layer.sprites.map(s => s.width));
|
||||||
|
targetHeight = Math.max(...layer.sprites.map(s => s.height));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still 0, check all layers
|
||||||
|
if (targetWidth === 0 || targetHeight === 0) {
|
||||||
|
props.layers.forEach(l => {
|
||||||
|
if (l.sprites.length > 0) {
|
||||||
|
targetWidth = Math.max(targetWidth, ...l.sprites.map(s => s.width));
|
||||||
|
targetHeight = Math.max(targetHeight, ...l.sprites.map(s => s.height));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default if fully empty project
|
||||||
|
if (targetWidth === 0) targetWidth = 32;
|
||||||
|
if (targetHeight === 0) targetHeight = 32;
|
||||||
|
|
||||||
|
// Set editor size
|
||||||
|
editor.canvasWidth.value = targetWidth;
|
||||||
|
editor.canvasHeight.value = targetHeight;
|
||||||
|
|
||||||
|
// Set selected frame (new)
|
||||||
|
selectedFrame.value = {
|
||||||
|
layerId: layer.id,
|
||||||
|
frameIndex: layer.sprites.length,
|
||||||
|
sprite: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
if (canvasRef.value) {
|
||||||
|
editor.initCanvas(canvasRef.value);
|
||||||
|
editor.clearCanvas(); // Ensure clean start
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
selectedFrame.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
if (!selectedFrame.value) return;
|
||||||
|
|
||||||
|
const file = await editor.toFile(`frame-${selectedFrame.value.frameIndex}.png`);
|
||||||
|
if (file) {
|
||||||
|
emit('saveFrame', selectedFrame.value.layerId, selectedFrame.value.frameIndex, file);
|
||||||
|
}
|
||||||
|
selectedFrame.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (event: MouseEvent) => {
|
||||||
|
editor.continueDrawing(event);
|
||||||
|
editor.updatePointer(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyResize = () => {
|
||||||
|
editor.resizeCanvas(resizeWidth.value, resizeHeight.value, resizeAnchor.value);
|
||||||
|
showResizeModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (!selectedFrame.value) return;
|
||||||
|
|
||||||
|
// Tool shortcuts
|
||||||
|
if (event.key === 'p' || event.key === 'P') {
|
||||||
|
editor.currentTool.value = 'pencil';
|
||||||
|
} else if (event.key === 'e' || event.key === 'E') {
|
||||||
|
editor.currentTool.value = 'eraser';
|
||||||
|
} else if (event.key === 'i' || event.key === 'I') {
|
||||||
|
editor.currentTool.value = 'picker';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo/Redo
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
|
||||||
|
if (event.shiftKey) {
|
||||||
|
editor.redo();
|
||||||
|
} else {
|
||||||
|
editor.undo();
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape to cancel
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for resize modal opening to set current dimensions
|
||||||
|
watch(showResizeModal, isOpen => {
|
||||||
|
if (isOpen) {
|
||||||
|
resizeWidth.value = editor.canvasWidth.value;
|
||||||
|
resizeHeight.value = editor.canvasHeight.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle initial frame passed from context menu
|
||||||
|
watch(
|
||||||
|
() => props.initialFrame,
|
||||||
|
async frame => {
|
||||||
|
if (frame) {
|
||||||
|
const layer = props.layers.find(l => l.id === frame.layerId);
|
||||||
|
if (layer && layer.sprites[frame.frameIndex]) {
|
||||||
|
await selectFrame(frame.layerId, frame.frameIndex, layer.sprites[frame.frameIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.draw-tab {
|
||||||
|
/* Custom scrollbar */
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(156, 163, 175, 0.5);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -86,12 +86,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
success.value = 'Thank you! Your feedback was sent.';
|
success.value = 'Thank you! Your feedback was sent.';
|
||||||
// Reset fields
|
|
||||||
name.value = '';
|
name.value = '';
|
||||||
contact.value = '';
|
contact.value = '';
|
||||||
content.value = '';
|
content.value = '';
|
||||||
|
|
||||||
// Optionally close after short delay
|
|
||||||
setTimeout(() => close(), 600);
|
setTimeout(() => close(), 600);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('Failed to send feedback:', e);
|
console.error('Failed to send feedback:', e);
|
||||||
|
|||||||
@@ -63,7 +63,6 @@
|
|||||||
if (input.files && input.files.length > 0) {
|
if (input.files && input.files.length > 0) {
|
||||||
const files = Array.from(input.files);
|
const files = Array.from(input.files);
|
||||||
emit('uploadSprites', files);
|
emit('uploadSprites', files);
|
||||||
// Reset input value so uploading the same file again will trigger the event
|
|
||||||
if (fileInput.value) fileInput.value.value = '';
|
if (fileInput.value) fileInput.value.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -170,13 +170,11 @@
|
|||||||
const response = await fetch('/CHANGELOG.md');
|
const response = await fetch('/CHANGELOG.md');
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|
||||||
// Configure marked options
|
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
gfm: true, // GitHub Flavored Markdown
|
gfm: true, // GitHub Flavored Markdown
|
||||||
breaks: true, // Convert line breaks to <br>
|
breaks: true, // Convert line breaks to <br>
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert markdown to HTML
|
|
||||||
changelogHtml.value = await marked(text);
|
changelogHtml.value = await marked(text);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load changelog:', error);
|
console.error('Failed to load changelog:', error);
|
||||||
|
|||||||
@@ -87,7 +87,6 @@
|
|||||||
copied.value = false;
|
copied.value = false;
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback for older browsers
|
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.value = shareUrl.value;
|
input.value = shareUrl.value;
|
||||||
document.body.appendChild(input);
|
document.body.appendChild(input);
|
||||||
@@ -101,14 +100,12 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start sharing when modal opens
|
|
||||||
watch(
|
watch(
|
||||||
() => props.isOpen,
|
() => props.isOpen,
|
||||||
isOpen => {
|
isOpen => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
performShare();
|
performShare();
|
||||||
} else {
|
} else {
|
||||||
// Reset state when closing
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
shareUrl.value = '';
|
shareUrl.value = '';
|
||||||
error.value = '';
|
error.value = '';
|
||||||
|
|||||||
@@ -1,36 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="showContextMenu" @click.stop class="fixed bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-xl z-50 py-1 min-w-[160px] overflow-hidden" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }">
|
<SpriteContextMenu
|
||||||
<button @click="addSprite" class="w-full px-3 py-1.5 text-left hover:bg-blue-50 dark:hover:bg-blue-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
:is-open="isContextMenuOpen"
|
||||||
<i class="fas fa-plus text-blue-600 dark:text-blue-400 text-xs w-4"></i>
|
:position="contextMenuPosition"
|
||||||
<span>Add sprite</span>
|
:has-sprite="!!contextMenuSpriteId"
|
||||||
</button>
|
:selected-count="selectedSpriteIds.size"
|
||||||
<button v-if="contextMenuSpriteId" @click="rotateSpriteInMenu(90)" class="w-full px-3 py-1.5 text-left hover:bg-green-50 dark:hover:bg-green-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
@add="addSpriteRefined"
|
||||||
<i class="fas fa-redo text-green-600 dark:text-green-400 text-xs w-4"></i>
|
@rotate="rotateSpriteInMenu"
|
||||||
<span>Rotate +90°</span>
|
@flip="flipSpriteInMenu"
|
||||||
</button>
|
@replace="replaceSprite"
|
||||||
<button v-if="contextMenuSpriteId" @click="flipSpriteInMenu('horizontal')" class="w-full px-3 py-1.5 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
@copy-to-frame="openCopyToFrameModal"
|
||||||
<i class="fas fa-arrows-alt-h text-orange-600 dark:text-orange-400 text-xs w-4"></i>
|
@edit-in-pixel-editor="openPixelEditor"
|
||||||
<span>Flip horizontal</span>
|
@remove="removeSprite"
|
||||||
</button>
|
@close="closeContextMenu"
|
||||||
<button v-if="contextMenuSpriteId" @click="flipSpriteInMenu('vertical')" class="w-full px-3 py-1.5 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
/>
|
||||||
<i class="fas fa-arrows-alt-v text-orange-600 dark:text-orange-400 text-xs w-4"></i>
|
|
||||||
<span>Flip vertical</span>
|
|
||||||
</button>
|
|
||||||
<button v-if="contextMenuSpriteId" @click="replaceSprite" class="w-full px-3 py-1.5 text-left hover:bg-purple-50 dark:hover:bg-purple-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
|
||||||
<i class="fas fa-exchange-alt text-purple-600 dark:text-purple-400 text-xs w-4"></i>
|
|
||||||
<span>Replace sprite</span>
|
|
||||||
</button>
|
|
||||||
<button v-if="contextMenuSpriteId" @click="openCopyToFrameModal" class="w-full px-3 py-1.5 text-left hover:bg-cyan-50 dark:hover:bg-cyan-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
|
||||||
<i class="fas fa-copy text-cyan-600 dark:text-cyan-400 text-xs w-4"></i>
|
|
||||||
<span>Copy to frame...</span>
|
|
||||||
</button>
|
|
||||||
<div v-if="contextMenuSpriteId" class="h-px bg-gray-200 dark:bg-gray-600 my-1"></div>
|
|
||||||
<button v-if="contextMenuSpriteId" @click="removeSprite" class="w-full px-3 py-1.5 text-left hover:bg-red-50 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 flex items-center gap-2 transition-colors text-sm">
|
|
||||||
<i class="fas fa-trash text-xs w-4"></i>
|
|
||||||
<span>{{ selectedSpriteIds.size > 1 ? `Remove ${selectedSpriteIds.size} sprites` : 'Remove sprite' }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Copy to Frame Modal -->
|
<!-- Copy to Frame Modal -->
|
||||||
<CopyToFrameModal :is-open="showCopyToFrameModal" :layers="props.layers" :initial-layer-id="copyTargetLayerId" @close="closeCopyToFrameModal" @copy="confirmCopyToFrame" />
|
<CopyToFrameModal :is-open="showCopyToFrameModal" :layers="props.layers" :initial-layer-id="copyTargetLayerId" @close="closeCopyToFrameModal" @copy="confirmCopyToFrame" />
|
||||||
@@ -56,11 +39,11 @@
|
|||||||
@touchstart="handleTouchStart"
|
@touchstart="handleTouchStart"
|
||||||
@touchmove="handleTouchMove"
|
@touchmove="handleTouchMove"
|
||||||
@touchend="stopDrag"
|
@touchend="stopDrag"
|
||||||
@contextmenu.prevent
|
@contextmenu.prevent.stop
|
||||||
@dragover="handleDragOver"
|
@dragover="handleGridDragOver"
|
||||||
@dragenter="handleDragEnter"
|
@dragenter="handleGridDragEnter"
|
||||||
@dragleave="onDragLeave"
|
@dragleave="handleGridDragLeave"
|
||||||
@drop="handleDrop"
|
@drop="handleGridDrop"
|
||||||
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
|
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
|
||||||
>
|
>
|
||||||
<!-- Grid cells -->
|
<!-- Grid cells -->
|
||||||
@@ -162,7 +145,7 @@
|
|||||||
top: `${Math.round(getCellPosition(cellIndex - 1).y)}px`,
|
top: `${Math.round(getCellPosition(cellIndex - 1).y)}px`,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
{{ cellIndex - 1 }}
|
{{ cellIndex }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Offset labels (Coordinates) -->
|
<!-- Offset labels (Coordinates) -->
|
||||||
@@ -183,7 +166,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileChange" />
|
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileChangeRefined" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -191,10 +174,12 @@
|
|||||||
import { ref, onMounted, watch, onUnmounted, toRef, computed, nextTick } from 'vue';
|
import { ref, onMounted, watch, onUnmounted, toRef, computed, nextTick } from 'vue';
|
||||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||||
import type { Sprite } from '@/types/sprites';
|
import type { Sprite } from '@/types/sprites';
|
||||||
import { useDragSprite } from '@/composables/useDragSprite';
|
import { useDragSprite, type CellPosition } from '@/composables/useDragSprite';
|
||||||
import { useFileDrop } from '@/composables/useFileDrop';
|
import { useFileDrop } from '@/composables/useFileDrop';
|
||||||
import { useGridMetrics } from '@/composables/useGridMetrics';
|
import { useGridMetrics } from '@/composables/useGridMetrics';
|
||||||
import { useBackgroundStyles } from '@/composables/useBackgroundStyles';
|
import { useBackgroundStyles } from '@/composables/useBackgroundStyles';
|
||||||
|
import { useContextMenu } from '@/composables/useContextMenu';
|
||||||
|
import SpriteContextMenu from '@/components/shared/SpriteContextMenu.vue';
|
||||||
import CopyToFrameModal from './utilities/CopyToFrameModal.vue';
|
import CopyToFrameModal from './utilities/CopyToFrameModal.vue';
|
||||||
|
|
||||||
import type { Layer } from '@/types/sprites';
|
import type { Layer } from '@/types/sprites';
|
||||||
@@ -219,13 +204,14 @@
|
|||||||
(e: 'removeSprites', ids: string[]): void;
|
(e: 'removeSprites', ids: string[]): void;
|
||||||
(e: 'replaceSprite', id: string, file: File): void;
|
(e: 'replaceSprite', id: string, file: File): void;
|
||||||
(e: 'addSprite', file: File, index?: number): void;
|
(e: 'addSprite', file: File, index?: number): void;
|
||||||
|
(e: 'addSprites', files: File[], index?: number): void;
|
||||||
(e: 'addSpriteWithResize', file: File): void;
|
(e: 'addSpriteWithResize', file: File): void;
|
||||||
(e: 'rotateSprite', id: string, angle: number): void;
|
(e: 'rotateSprite', id: string, angle: number): void;
|
||||||
(e: 'flipSprite', id: string, direction: 'horizontal' | 'vertical'): void;
|
(e: 'flipSprite', id: string, direction: 'horizontal' | 'vertical'): void;
|
||||||
(e: 'copySpriteToFrame', spriteId: string, targetLayerId: string, targetFrameIndex: number): void;
|
(e: 'copySpriteToFrame', spriteId: string, targetLayerId: string, targetFrameIndex: number): void;
|
||||||
|
(e: 'openPixelEditor', layerId: string, frameIndex: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Get settings from store
|
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
const gridContainerRef = ref<HTMLDivElement | null>(null);
|
const gridContainerRef = ref<HTMLDivElement | null>(null);
|
||||||
@@ -242,6 +228,8 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedSpriteIds = ref<Set<string>>(new Set());
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isDragging,
|
isDragging,
|
||||||
activeSpriteId,
|
activeSpriteId,
|
||||||
@@ -266,6 +254,7 @@
|
|||||||
manualCellSizeEnabled: toRef(settingsStore, 'manualCellSizeEnabled'),
|
manualCellSizeEnabled: toRef(settingsStore, 'manualCellSizeEnabled'),
|
||||||
manualCellWidth: toRef(settingsStore, 'manualCellWidth'),
|
manualCellWidth: toRef(settingsStore, 'manualCellWidth'),
|
||||||
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
|
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
|
||||||
|
selectedSpriteIds,
|
||||||
getMousePosition,
|
getMousePosition,
|
||||||
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
|
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
|
||||||
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
|
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
|
||||||
@@ -282,21 +271,19 @@
|
|||||||
onAddSpriteWithResize: file => emit('addSpriteWithResize', file),
|
onAddSpriteWithResize: file => emit('addSpriteWithResize', file),
|
||||||
});
|
});
|
||||||
|
|
||||||
const showContextMenu = ref(false);
|
const { isOpen: isContextMenuOpen, position: contextMenuPosition, contextData: contextMenuData, open: openContextMenuBase, close: closeContextMenu } = useContextMenu<{ spriteId?: string; layerId?: string; index?: number }>();
|
||||||
const contextMenuX = ref(0);
|
|
||||||
const contextMenuY = ref(0);
|
// Computed properties to access context data safely
|
||||||
const contextMenuIndex = ref<number | null>(null);
|
const contextMenuSpriteId = computed(() => contextMenuData.value?.spriteId || null);
|
||||||
const contextMenuSpriteId = ref<string | null>(null);
|
const contextMenuIndex = computed(() => contextMenuData.value?.index ?? null); // Use ?? to allow 0 index
|
||||||
const selectedSpriteIds = ref<Set<string>>(new Set());
|
|
||||||
const replacingSpriteId = ref<string | null>(null);
|
const replacingSpriteId = ref<string | null>(null);
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Copy to frame modal state
|
|
||||||
const showCopyToFrameModal = ref(false);
|
const showCopyToFrameModal = ref(false);
|
||||||
const copyTargetLayerId = ref(props.activeLayerId);
|
const copyTargetLayerId = ref(props.activeLayerId);
|
||||||
const copySpriteId = ref<string | null>(null);
|
const copySpriteId = ref<string | null>(null);
|
||||||
|
|
||||||
// Clear selection when toggling multi-select mode
|
|
||||||
watch(
|
watch(
|
||||||
() => props.isMultiSelectMode,
|
() => props.isMultiSelectMode,
|
||||||
() => {
|
() => {
|
||||||
@@ -304,7 +291,6 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use the new useGridMetrics composable for consistent calculations
|
|
||||||
const { gridMetrics: gridMetricsRef, getCellPosition: getCellPositionHelper } = useGridMetrics({
|
const { gridMetrics: gridMetricsRef, getCellPosition: getCellPositionHelper } = useGridMetrics({
|
||||||
layers: toRef(props, 'layers'),
|
layers: toRef(props, 'layers'),
|
||||||
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
|
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
|
||||||
@@ -316,13 +302,11 @@
|
|||||||
const gridMetrics = gridMetricsRef;
|
const gridMetrics = gridMetricsRef;
|
||||||
|
|
||||||
const totalCells = computed(() => {
|
const totalCells = computed(() => {
|
||||||
// Use all layers regardless of visibility to keep canvas size stable
|
|
||||||
const maxLen = Math.max(1, ...props.layers.map(l => l.sprites.length));
|
const maxLen = Math.max(1, ...props.layers.map(l => l.sprites.length));
|
||||||
return Math.max(1, Math.ceil(maxLen / props.columns)) * props.columns;
|
return Math.max(1, Math.ceil(maxLen / props.columns)) * props.columns;
|
||||||
});
|
});
|
||||||
|
|
||||||
const gridDimensions = computed(() => {
|
const gridDimensions = computed(() => {
|
||||||
// Use all layers regardless of visibility to keep canvas size stable
|
|
||||||
const maxLen = Math.max(1, ...props.layers.map(l => l.sprites.length));
|
const maxLen = Math.max(1, ...props.layers.map(l => l.sprites.length));
|
||||||
const rows = Math.max(1, Math.ceil(maxLen / props.columns));
|
const rows = Math.max(1, Math.ceil(maxLen / props.columns));
|
||||||
return {
|
return {
|
||||||
@@ -335,7 +319,6 @@
|
|||||||
return getCellPositionHelper(index, props.columns, gridMetrics.value);
|
return getCellPositionHelper(index, props.columns, gridMetrics.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use the new useBackgroundStyles composable for consistent background styling
|
|
||||||
const {
|
const {
|
||||||
backgroundColor: cellBackgroundColor,
|
backgroundColor: cellBackgroundColor,
|
||||||
backgroundImage: cellBackgroundImage,
|
backgroundImage: cellBackgroundImage,
|
||||||
@@ -353,76 +336,62 @@
|
|||||||
const getCellBackgroundPosition = () => cellBackgroundPosition.value;
|
const getCellBackgroundPosition = () => cellBackgroundPosition.value;
|
||||||
|
|
||||||
const startDrag = (event: MouseEvent) => {
|
const startDrag = (event: MouseEvent) => {
|
||||||
// If the click originated from an interactive element (button, link, input), ignore drag handling
|
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (target && target.closest('button, a, input, select, textarea')) {
|
if (target && target.closest('button, a, input, select, textarea')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!gridContainerRef.value) return;
|
if (!gridContainerRef.value) return;
|
||||||
|
|
||||||
// Hide context menu if open
|
closeContextMenu();
|
||||||
showContextMenu.value = false;
|
|
||||||
|
|
||||||
// Handle right-click for context menu
|
|
||||||
if ('button' in event && (event as MouseEvent).button === 2) {
|
if ('button' in event && (event as MouseEvent).button === 2) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const pos = getMousePosition(event, props.zoom);
|
const pos = getMousePosition(event, props.zoom);
|
||||||
if (!pos) return;
|
if (!pos) return;
|
||||||
|
|
||||||
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
|
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
|
||||||
contextMenuIndex.value = findCellAtPosition(pos.x, pos.y)?.index ?? null;
|
const clickedCell = findCellAtPosition(pos.x, pos.y);
|
||||||
contextMenuSpriteId.value = clickedSprite?.id || null;
|
const cellIndex = clickedCell?.index ?? null;
|
||||||
|
|
||||||
|
const spriteId = clickedSprite?.id || undefined;
|
||||||
|
|
||||||
if (clickedSprite) {
|
if (clickedSprite) {
|
||||||
// If the right-clicked sprite is not in the selection, clear selection and select just this one
|
|
||||||
if (!selectedSpriteIds.value.has(clickedSprite.id)) {
|
if (!selectedSpriteIds.value.has(clickedSprite.id)) {
|
||||||
selectedSpriteIds.value.clear();
|
selectedSpriteIds.value.clear();
|
||||||
selectedSpriteIds.value.add(clickedSprite.id);
|
selectedSpriteIds.value.add(clickedSprite.id);
|
||||||
}
|
}
|
||||||
// If it IS in the selection, keep the current selection (so we can apply action to all)
|
|
||||||
} else {
|
} else {
|
||||||
// Right click on empty space
|
|
||||||
selectedSpriteIds.value.clear();
|
selectedSpriteIds.value.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
contextMenuX.value = event.clientX;
|
openContextMenuBase(event, {
|
||||||
contextMenuY.value = event.clientY;
|
spriteId: spriteId,
|
||||||
|
index: cellIndex !== null ? cellIndex : undefined,
|
||||||
showContextMenu.value = true;
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore non-left mouse buttons
|
|
||||||
if ('button' in event && (event as MouseEvent).button !== 0) return;
|
if ('button' in event && (event as MouseEvent).button !== 0) return;
|
||||||
|
|
||||||
// Handle selection logic for left click
|
|
||||||
const pos = getMousePosition(event, props.zoom);
|
const pos = getMousePosition(event, props.zoom);
|
||||||
if (pos) {
|
if (pos) {
|
||||||
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
|
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
|
||||||
if (clickedSprite) {
|
if (clickedSprite) {
|
||||||
// Selection logic with multi-select mode check
|
|
||||||
if (event.ctrlKey || event.metaKey || props.isMultiSelectMode) {
|
if (event.ctrlKey || event.metaKey || props.isMultiSelectMode) {
|
||||||
// Toggle selection
|
if (!selectedSpriteIds.value.has(clickedSprite.id)) {
|
||||||
if (selectedSpriteIds.value.has(clickedSprite.id)) {
|
|
||||||
selectedSpriteIds.value.delete(clickedSprite.id);
|
|
||||||
} else {
|
|
||||||
selectedSpriteIds.value.add(clickedSprite.id);
|
selectedSpriteIds.value.add(clickedSprite.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Single select (but don't clear if dragging starts immediately?
|
|
||||||
// Usually standard behavior is to clear others unless shift/ctrl held)
|
|
||||||
if (!selectedSpriteIds.value.has(clickedSprite.id)) {
|
if (!selectedSpriteIds.value.has(clickedSprite.id)) {
|
||||||
selectedSpriteIds.value.clear();
|
selectedSpriteIds.value.clear();
|
||||||
selectedSpriteIds.value.add(clickedSprite.id);
|
selectedSpriteIds.value.add(clickedSprite.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Clicked on empty space
|
|
||||||
selectedSpriteIds.value.clear();
|
selectedSpriteIds.value.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delegate to composable for actual drag handling
|
|
||||||
dragStart(event);
|
dragStart(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -430,7 +399,6 @@
|
|||||||
const latestEvent = ref<MouseEvent | null>(null);
|
const latestEvent = ref<MouseEvent | null>(null);
|
||||||
|
|
||||||
const drag = (event: MouseEvent) => {
|
const drag = (event: MouseEvent) => {
|
||||||
// Store the latest event and schedule a single animation frame update
|
|
||||||
latestEvent.value = event;
|
latestEvent.value = event;
|
||||||
if (!pendingDrag.value) {
|
if (!pendingDrag.value) {
|
||||||
pendingDrag.value = true;
|
pendingDrag.value = true;
|
||||||
@@ -448,13 +416,12 @@
|
|||||||
if (selectedSpriteIds.value.size > 0) {
|
if (selectedSpriteIds.value.size > 0) {
|
||||||
emit('removeSprites', Array.from(selectedSpriteIds.value));
|
emit('removeSprites', Array.from(selectedSpriteIds.value));
|
||||||
selectedSpriteIds.value.clear();
|
selectedSpriteIds.value.clear();
|
||||||
showContextMenu.value = false;
|
|
||||||
contextMenuSpriteId.value = null;
|
|
||||||
} else if (contextMenuSpriteId.value) {
|
} else if (contextMenuSpriteId.value) {
|
||||||
emit('removeSprite', contextMenuSpriteId.value);
|
emit('removeSprite', contextMenuSpriteId.value);
|
||||||
showContextMenu.value = false;
|
|
||||||
contextMenuSpriteId.value = null;
|
|
||||||
}
|
}
|
||||||
|
closeContextMenu();
|
||||||
|
// Note: SpriteContextMenu component will also emit 'close' after action if using emitAndClose,
|
||||||
|
// but duplicate close calls are harmless.
|
||||||
};
|
};
|
||||||
|
|
||||||
const rotateSpriteInMenu = (angle: number) => {
|
const rotateSpriteInMenu = (angle: number) => {
|
||||||
@@ -465,7 +432,7 @@
|
|||||||
} else if (contextMenuSpriteId.value) {
|
} else if (contextMenuSpriteId.value) {
|
||||||
emit('rotateSprite', contextMenuSpriteId.value, angle);
|
emit('rotateSprite', contextMenuSpriteId.value, angle);
|
||||||
}
|
}
|
||||||
showContextMenu.value = false;
|
closeContextMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
const flipSpriteInMenu = (direction: 'horizontal' | 'vertical') => {
|
const flipSpriteInMenu = (direction: 'horizontal' | 'vertical') => {
|
||||||
@@ -476,12 +443,11 @@
|
|||||||
} else if (contextMenuSpriteId.value) {
|
} else if (contextMenuSpriteId.value) {
|
||||||
emit('flipSprite', contextMenuSpriteId.value, direction);
|
emit('flipSprite', contextMenuSpriteId.value, direction);
|
||||||
}
|
}
|
||||||
showContextMenu.value = false;
|
closeContextMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||||
// Don't delete if editing text/input
|
|
||||||
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return;
|
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return;
|
||||||
|
|
||||||
if (selectedSpriteIds.value.size > 0) {
|
if (selectedSpriteIds.value.size > 0) {
|
||||||
@@ -495,20 +461,29 @@
|
|||||||
if (contextMenuSpriteId.value && fileInput.value) {
|
if (contextMenuSpriteId.value && fileInput.value) {
|
||||||
replacingSpriteId.value = contextMenuSpriteId.value;
|
replacingSpriteId.value = contextMenuSpriteId.value;
|
||||||
fileInput.value.click();
|
fileInput.value.click();
|
||||||
showContextMenu.value = false;
|
closeContextMenu();
|
||||||
contextMenuSpriteId.value = null;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addSprite = () => {
|
// Refined addSprite with index caching logic
|
||||||
|
const pendingAddIndex = ref<number | null>(null);
|
||||||
|
|
||||||
|
const addSpriteRefined = () => {
|
||||||
|
// Capture index
|
||||||
|
if (contextMenuIndex.value !== null) {
|
||||||
|
pendingAddIndex.value = contextMenuIndex.value;
|
||||||
|
} else {
|
||||||
|
pendingAddIndex.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (fileInput.value) {
|
if (fileInput.value) {
|
||||||
fileInput.value.click();
|
fileInput.value.click();
|
||||||
showContextMenu.value = false;
|
closeContextMenu();
|
||||||
contextMenuSpriteId.value = null;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = (event: Event) => {
|
// Refined handleFileChange
|
||||||
|
const handleFileChangeRefined = (event: Event) => {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
|
|
||||||
if (input.files && input.files.length > 0) {
|
if (input.files && input.files.length > 0) {
|
||||||
@@ -517,8 +492,8 @@
|
|||||||
if (replacingSpriteId.value) {
|
if (replacingSpriteId.value) {
|
||||||
emit('replaceSprite', replacingSpriteId.value, file);
|
emit('replaceSprite', replacingSpriteId.value, file);
|
||||||
} else {
|
} else {
|
||||||
if (contextMenuIndex.value !== null) {
|
if (pendingAddIndex.value !== null) {
|
||||||
emit('addSprite', file, contextMenuIndex.value);
|
emit('addSprite', file, pendingAddIndex.value);
|
||||||
} else {
|
} else {
|
||||||
emit('addSprite', file);
|
emit('addSprite', file);
|
||||||
}
|
}
|
||||||
@@ -528,21 +503,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
replacingSpriteId.value = null;
|
replacingSpriteId.value = null;
|
||||||
contextMenuIndex.value = null;
|
pendingAddIndex.value = null; // Clear it
|
||||||
input.value = '';
|
input.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const hideContextMenu = () => {
|
|
||||||
showContextMenu.value = false;
|
|
||||||
contextMenuSpriteId.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCopyToFrameModal = () => {
|
const openCopyToFrameModal = () => {
|
||||||
if (contextMenuSpriteId.value) {
|
if (contextMenuSpriteId.value) {
|
||||||
copySpriteId.value = contextMenuSpriteId.value;
|
copySpriteId.value = contextMenuSpriteId.value;
|
||||||
copyTargetLayerId.value = props.activeLayerId;
|
copyTargetLayerId.value = props.activeLayerId;
|
||||||
showCopyToFrameModal.value = true;
|
showCopyToFrameModal.value = true;
|
||||||
showContextMenu.value = false;
|
closeContextMenu();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -558,10 +528,64 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDragLeave = (event: DragEvent) => {
|
const openPixelEditor = () => {
|
||||||
|
if (contextMenuSpriteId.value) {
|
||||||
|
// Find the frame index by finding the sprite in the active layer
|
||||||
|
const layer = props.layers.find(l => l.id === props.activeLayerId);
|
||||||
|
if (layer) {
|
||||||
|
const frameIndex = layer.sprites.findIndex(s => s.id === contextMenuSpriteId.value);
|
||||||
|
if (frameIndex !== -1) {
|
||||||
|
emit('openPixelEditor', props.activeLayerId, frameIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeContextMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Grid Drag & Drop
|
||||||
|
// Grid Drag & Drop
|
||||||
|
|
||||||
|
const handleGridDragEnter = (event: DragEvent) => {
|
||||||
|
handleDragEnter(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGridDragOver = (event: DragEvent) => {
|
||||||
|
handleDragOver(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGridDragLeave = (event: DragEvent) => {
|
||||||
handleDragLeave(event, gridContainerRef.value);
|
handleDragLeave(event, gridContainerRef.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGridDrop = (event: DragEvent) => {
|
||||||
|
handleDragLeave(event, gridContainerRef.value); // Reset drag over state
|
||||||
|
isDragOver.value = false;
|
||||||
|
|
||||||
|
if (!event.dataTransfer?.files.length) return;
|
||||||
|
|
||||||
|
// Calculate target cell immediately on drop
|
||||||
|
let targetCellIndex: number | undefined;
|
||||||
|
const pos = getMousePosition(event as unknown as MouseEvent, props.zoom);
|
||||||
|
if (pos) {
|
||||||
|
const cell = findCellAtPosition(pos.x, pos.y);
|
||||||
|
if (cell) {
|
||||||
|
targetCellIndex = cell.index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = Array.from(event.dataTransfer.files).filter(file => file.type.startsWith('image/'));
|
||||||
|
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
if (targetCellIndex !== undefined) {
|
||||||
|
// Add sprites starting from the target cell index
|
||||||
|
emit('addSprites', files, targetCellIndex);
|
||||||
|
} else {
|
||||||
|
// Fallback to default behavior (append)
|
||||||
|
emit('addSprites', files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('mouseup', stopDrag);
|
document.addEventListener('mouseup', stopDrag);
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
@@ -571,8 +595,6 @@
|
|||||||
document.removeEventListener('mouseup', stopDrag);
|
document.removeEventListener('mouseup', stopDrag);
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Watch for background color changes
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -1,33 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="showContextMenu" @click.stop class="fixed bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-xl z-50 py-1 min-w-[160px] overflow-hidden" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }">
|
<SpriteContextMenu
|
||||||
<button @click="rotateSpriteInMenu(90)" class="w-full px-3 py-1.5 text-left hover:bg-green-50 dark:hover:bg-green-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
:is-open="isContextMenuOpen"
|
||||||
<i class="fas fa-redo text-green-600 dark:text-green-400 text-xs w-4"></i>
|
:position="contextMenuPosition"
|
||||||
<span>Rotate +90°</span>
|
:has-sprite="!!contextMenuSpriteId"
|
||||||
</button>
|
@add="addSprite"
|
||||||
<button @click="flipSpriteInMenu('horizontal')" class="w-full px-3 py-1.5 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
@rotate="rotateSpriteInMenu"
|
||||||
<i class="fas fa-arrows-alt-h text-orange-600 dark:text-orange-400 text-xs w-4"></i>
|
@flip="flipSpriteInMenu"
|
||||||
<span>Flip Horizontal</span>
|
@replace="replaceSprite"
|
||||||
</button>
|
@copy-to-frame="openCopyToFrameModal"
|
||||||
<button @click="flipSpriteInMenu('vertical')" class="w-full px-3 py-1.5 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
@edit-in-pixel-editor="openPixelEditor"
|
||||||
<i class="fas fa-arrows-alt-v text-orange-600 dark:text-orange-400 text-xs w-4"></i>
|
@remove="removeSprite"
|
||||||
<span>Flip Vertical</span>
|
@close="closeContextMenu"
|
||||||
</button>
|
/>
|
||||||
<button @click="replaceSprite" class="w-full px-3 py-1.5 text-left hover:bg-purple-50 dark:hover:bg-purple-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
|
||||||
<i class="fas fa-exchange-alt text-purple-600 dark:text-purple-400 text-xs w-4"></i>
|
|
||||||
<span>Replace Sprite</span>
|
|
||||||
</button>
|
|
||||||
<button @click="openCopyToFrameModal" class="w-full px-3 py-1.5 text-left hover:bg-cyan-50 dark:hover:bg-cyan-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
|
||||||
<i class="fas fa-copy text-cyan-600 dark:text-cyan-400 text-xs w-4"></i>
|
|
||||||
<span>Copy to Frame...</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Copy to Frame Modal -->
|
<!-- Copy to Frame Modal -->
|
||||||
<CopyToFrameModal :is-open="showCopyToFrameModal" :layers="props.layers" :initial-layer-id="copyTargetLayerId" @close="closeCopyToFrameModal" @copy="confirmCopyToFrame" />
|
<CopyToFrameModal :is-open="showCopyToFrameModal" :layers="props.layers" :initial-layer-id="copyTargetLayerId" @close="closeCopyToFrameModal" @copy="confirmCopyToFrame" />
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
<div class="spritesheet-preview w-full h-full" @click="hideContextMenu">
|
<div class="spritesheet-preview w-full h-full" @click="closeContextMenu">
|
||||||
<div class="flex flex-col lg:flex-row gap-4 h-full min-h-0">
|
<div class="flex flex-col lg:flex-row gap-4 h-full min-h-0">
|
||||||
<div class="flex-1 min-w-0 flex flex-col min-h-0">
|
<div class="flex-1 min-w-0 flex flex-col min-h-0">
|
||||||
<div
|
<div
|
||||||
@@ -81,13 +72,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Current frame sprites -->
|
<!-- Current frame sprites - maintaining layer order -->
|
||||||
<template v-for="layer in getVisibleLayers()" :key="layer.id">
|
<template v-for="layer in getVisibleLayers()" :key="layer.id">
|
||||||
<img
|
<img
|
||||||
v-if="layer.sprites[currentFrameIndex]"
|
v-if="layer.sprites[currentFrameIndex]"
|
||||||
:src="layer.sprites[currentFrameIndex].url"
|
:src="layer.sprites[currentFrameIndex].url"
|
||||||
class="absolute"
|
class="absolute"
|
||||||
:class="{ 'cursor-move': isDraggable }"
|
|
||||||
:style="{
|
:style="{
|
||||||
left: `${Math.round(getCellPosition(0).x + gridMetrics.negativeSpacing + layer.sprites[currentFrameIndex].x)}px`,
|
left: `${Math.round(getCellPosition(0).x + gridMetrics.negativeSpacing + layer.sprites[currentFrameIndex].x)}px`,
|
||||||
top: `${Math.round(getCellPosition(0).y + gridMetrics.negativeSpacing + layer.sprites[currentFrameIndex].y)}px`,
|
top: `${Math.round(getCellPosition(0).y + gridMetrics.negativeSpacing + layer.sprites[currentFrameIndex].y)}px`,
|
||||||
@@ -96,12 +86,29 @@
|
|||||||
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
||||||
transform: `rotate(${layer.sprites[currentFrameIndex].rotation || 0}deg) scale(${layer.sprites[currentFrameIndex].flipX ? -1 : 1}, ${layer.sprites[currentFrameIndex].flipY ? -1 : 1})`,
|
transform: `rotate(${layer.sprites[currentFrameIndex].rotation || 0}deg) scale(${layer.sprites[currentFrameIndex].flipX ? -1 : 1}, ${layer.sprites[currentFrameIndex].flipY ? -1 : 1})`,
|
||||||
}"
|
}"
|
||||||
@mousedown="startDrag($event, layer.sprites[currentFrameIndex], layer.id)"
|
@contextmenu.prevent.stop="openContextMenu($event, layer.sprites[currentFrameIndex], layer.id)"
|
||||||
@touchstart="handleTouchStart($event, layer.sprites[currentFrameIndex], layer.id)"
|
|
||||||
@contextmenu.prevent="openContextMenu($event, layer.sprites[currentFrameIndex], layer.id)"
|
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Invisible drag overlay for active layer sprite - sits on top to capture mouse events -->
|
||||||
|
<template v-if="isDraggable">
|
||||||
|
<template v-for="layer in getVisibleLayers()" :key="'drag-' + layer.id">
|
||||||
|
<div
|
||||||
|
v-if="layer.sprites[currentFrameIndex] && (layer.id === props.activeLayerId || repositionAllLayers)"
|
||||||
|
class="absolute cursor-move"
|
||||||
|
:style="{
|
||||||
|
left: `${Math.round(getCellPosition(0).x + gridMetrics.negativeSpacing + layer.sprites[currentFrameIndex].x)}px`,
|
||||||
|
top: `${Math.round(getCellPosition(0).y + gridMetrics.negativeSpacing + layer.sprites[currentFrameIndex].y)}px`,
|
||||||
|
width: `${Math.round(layer.sprites[currentFrameIndex].width)}px`,
|
||||||
|
height: `${Math.round(layer.sprites[currentFrameIndex].height)}px`,
|
||||||
|
zIndex: 100,
|
||||||
|
}"
|
||||||
|
@mousedown="startDrag($event, layer.sprites[currentFrameIndex], layer.id)"
|
||||||
|
@touchstart="handleTouchStart($event, layer.sprites[currentFrameIndex], layer.id)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile zoom controls -->
|
<!-- Mobile zoom controls -->
|
||||||
@@ -283,6 +290,8 @@
|
|||||||
import { useAnimationFrames } from '@/composables/useAnimationFrames';
|
import { useAnimationFrames } from '@/composables/useAnimationFrames';
|
||||||
import { useGridMetrics } from '@/composables/useGridMetrics';
|
import { useGridMetrics } from '@/composables/useGridMetrics';
|
||||||
import { useBackgroundStyles } from '@/composables/useBackgroundStyles';
|
import { useBackgroundStyles } from '@/composables/useBackgroundStyles';
|
||||||
|
import { useContextMenu } from '@/composables/useContextMenu';
|
||||||
|
import SpriteContextMenu from '@/components/shared/SpriteContextMenu.vue';
|
||||||
import CopyToFrameModal from './utilities/CopyToFrameModal.vue';
|
import CopyToFrameModal from './utilities/CopyToFrameModal.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -299,11 +308,12 @@
|
|||||||
(e: 'flipSprite', id: string, direction: 'horizontal' | 'vertical'): void;
|
(e: 'flipSprite', id: string, direction: 'horizontal' | 'vertical'): void;
|
||||||
(e: 'copySpriteToFrame', spriteId: string, targetLayerId: string, targetFrameIndex: number): void;
|
(e: 'copySpriteToFrame', spriteId: string, targetLayerId: string, targetFrameIndex: number): void;
|
||||||
(e: 'replaceSprite', id: string, file: File): void;
|
(e: 'replaceSprite', id: string, file: File): void;
|
||||||
|
(e: 'removeSprite', id: string): void;
|
||||||
|
(e: 'openPixelEditor', layerId: string, frameIndex: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const previewContainerRef = ref<HTMLDivElement | null>(null);
|
const previewContainerRef = ref<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Get settings from store
|
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -316,6 +326,17 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getVisibleLayers = () => props.layers.filter(l => l.visible);
|
const getVisibleLayers = () => props.layers.filter(l => l.visible);
|
||||||
|
// Reversed order so active layer renders on top for drag operations
|
||||||
|
const getVisibleLayersReversed = () => {
|
||||||
|
const visible = props.layers.filter(l => l.visible);
|
||||||
|
// Put active layer last so it renders on top
|
||||||
|
const activeIdx = visible.findIndex(l => l.id === props.activeLayerId);
|
||||||
|
if (activeIdx > -1) {
|
||||||
|
const active = visible.splice(activeIdx, 1)[0];
|
||||||
|
visible.push(active);
|
||||||
|
}
|
||||||
|
return visible;
|
||||||
|
};
|
||||||
const maxFrames = () => Math.max(0, ...getVisibleLayers().map(l => l.sprites.length));
|
const maxFrames = () => Math.max(0, ...getVisibleLayers().map(l => l.sprites.length));
|
||||||
|
|
||||||
const { currentFrameIndex, isPlaying, fps, hiddenFrames, visibleFrames, visibleFramesCount, visibleFrameIndex, visibleFrameNumber, togglePlayback, nextFrame, previousFrame, handleSliderInput, toggleHiddenFrame, showAllFrames, hideAllFrames, stopAnimation } = useAnimationFrames({
|
const { currentFrameIndex, isPlaying, fps, hiddenFrames, visibleFrames, visibleFramesCount, visibleFrameIndex, visibleFrameNumber, togglePlayback, nextFrame, previousFrame, handleSliderInput, toggleHiddenFrame, showAllFrames, hideAllFrames, stopAnimation } = useAnimationFrames({
|
||||||
@@ -331,27 +352,18 @@
|
|||||||
onDraw: () => {}, // No longer needed for canvas drawing
|
onDraw: () => {}, // No longer needed for canvas drawing
|
||||||
});
|
});
|
||||||
|
|
||||||
// Preview state
|
|
||||||
const isDraggable = ref(false);
|
const isDraggable = ref(false);
|
||||||
const repositionAllLayers = ref(false);
|
const repositionAllLayers = ref(false);
|
||||||
const arrowKeyMovement = ref(false);
|
const arrowKeyMovement = ref(false);
|
||||||
const showAllSprites = ref(false);
|
const showAllSprites = ref(false);
|
||||||
const isDragOver = ref(false);
|
const isDragOver = ref(false);
|
||||||
|
|
||||||
// Context menu state
|
|
||||||
const showContextMenu = ref(false);
|
|
||||||
const contextMenuX = ref(0);
|
|
||||||
const contextMenuY = ref(0);
|
|
||||||
const contextMenuSpriteId = ref<string | null>(null);
|
|
||||||
const contextMenuLayerId = ref<string | null>(null);
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
const replacingSpriteId = ref<string | null>(null);
|
const replacingSpriteId = ref<string | null>(null);
|
||||||
|
|
||||||
// Copy to frame modal state
|
|
||||||
const showCopyToFrameModal = ref(false);
|
const showCopyToFrameModal = ref(false);
|
||||||
const copyTargetLayerId = ref(props.activeLayerId);
|
const copyTargetLayerId = ref(props.activeLayerId);
|
||||||
|
|
||||||
// Drag and drop for new sprites
|
|
||||||
const onDragOver = () => {
|
const onDragOver = () => {
|
||||||
isDragOver.value = true;
|
isDragOver.value = true;
|
||||||
};
|
};
|
||||||
@@ -373,10 +385,8 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const compositeFrames = computed<Sprite[]>(() => {
|
const compositeFrames = computed<Sprite[]>(() => {
|
||||||
// Show frames from the active layer for the thumbnail list
|
|
||||||
const activeLayer = props.layers.find(l => l.id === props.activeLayerId);
|
const activeLayer = props.layers.find(l => l.id === props.activeLayerId);
|
||||||
if (!activeLayer) {
|
if (!activeLayer) {
|
||||||
// Fallback to first visible layer if no active layer
|
|
||||||
const v = getVisibleLayers();
|
const v = getVisibleLayers();
|
||||||
const len = maxFrames();
|
const len = maxFrames();
|
||||||
const arr: Sprite[] = [];
|
const arr: Sprite[] = [];
|
||||||
@@ -395,7 +405,6 @@
|
|||||||
return layer.sprites[currentFrameIndex.value] || null;
|
return layer.sprites[currentFrameIndex.value] || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use the new useGridMetrics composable for consistent calculations
|
|
||||||
const { gridMetrics, getCellPosition: getCellPositionHelper } = useGridMetrics({
|
const { gridMetrics, getCellPosition: getCellPositionHelper } = useGridMetrics({
|
||||||
layers: toRef(props, 'layers'),
|
layers: toRef(props, 'layers'),
|
||||||
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
|
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
|
||||||
@@ -404,19 +413,16 @@
|
|||||||
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
|
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper function to get cell position (same as SpriteCanvas)
|
|
||||||
const getCellPosition = (index: number) => {
|
const getCellPosition = (index: number) => {
|
||||||
return getCellPositionHelper(index, props.columns, gridMetrics.value);
|
return getCellPositionHelper(index, props.columns, gridMetrics.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Computed cell dimensions (for backward compatibility with existing code)
|
|
||||||
const cellDimensions = computed(() => ({
|
const cellDimensions = computed(() => ({
|
||||||
cellWidth: gridMetrics.value.maxWidth,
|
cellWidth: gridMetrics.value.maxWidth,
|
||||||
cellHeight: gridMetrics.value.maxHeight,
|
cellHeight: gridMetrics.value.maxHeight,
|
||||||
negativeSpacing: gridMetrics.value.negativeSpacing,
|
negativeSpacing: gridMetrics.value.negativeSpacing,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Use the new useBackgroundStyles composable for consistent background styling
|
|
||||||
const {
|
const {
|
||||||
backgroundImage: previewBackgroundImage,
|
backgroundImage: previewBackgroundImage,
|
||||||
backgroundSize: previewBackgroundSize,
|
backgroundSize: previewBackgroundSize,
|
||||||
@@ -429,7 +435,6 @@
|
|||||||
|
|
||||||
const getPreviewBackgroundImage = () => previewBackgroundImage.value;
|
const getPreviewBackgroundImage = () => previewBackgroundImage.value;
|
||||||
|
|
||||||
// Dragging state
|
|
||||||
const isDragging = ref(false);
|
const isDragging = ref(false);
|
||||||
const activeSpriteId = ref<string | null>(null);
|
const activeSpriteId = ref<string | null>(null);
|
||||||
const activeLayerId = ref<string | null>(null);
|
const activeLayerId = ref<string | null>(null);
|
||||||
@@ -438,7 +443,6 @@
|
|||||||
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
|
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
|
||||||
const allSpritesPosBeforeDrag = ref<Map<string, { x: number; y: number }>>(new Map());
|
const allSpritesPosBeforeDrag = ref<Map<string, { x: number; y: number }>>(new Map());
|
||||||
|
|
||||||
// Drag functionality
|
|
||||||
const startDrag = (event: MouseEvent, sprite: Sprite, layerId: string) => {
|
const startDrag = (event: MouseEvent, sprite: Sprite, layerId: string) => {
|
||||||
if (!isDraggable.value || !previewContainerRef.value) return;
|
if (!isDraggable.value || !previewContainerRef.value) return;
|
||||||
|
|
||||||
@@ -455,7 +459,6 @@
|
|||||||
dragStartX.value = mouseX;
|
dragStartX.value = mouseX;
|
||||||
dragStartY.value = mouseY;
|
dragStartY.value = mouseY;
|
||||||
|
|
||||||
// Store initial positions for all sprites in this frame from all visible layers
|
|
||||||
allSpritesPosBeforeDrag.value.clear();
|
allSpritesPosBeforeDrag.value.clear();
|
||||||
const visibleLayers = getVisibleLayers();
|
const visibleLayers = getVisibleLayers();
|
||||||
visibleLayers.forEach(layer => {
|
visibleLayers.forEach(layer => {
|
||||||
@@ -490,7 +493,6 @@
|
|||||||
const { cellWidth, cellHeight, negativeSpacing } = cellDimensions.value;
|
const { cellWidth, cellHeight, negativeSpacing } = cellDimensions.value;
|
||||||
|
|
||||||
if (activeSpriteId.value === 'ALL_LAYERS') {
|
if (activeSpriteId.value === 'ALL_LAYERS') {
|
||||||
// Move all sprites in current frame from all visible layers
|
|
||||||
const visibleLayers = getVisibleLayers();
|
const visibleLayers = getVisibleLayers();
|
||||||
visibleLayers.forEach(layer => {
|
visibleLayers.forEach(layer => {
|
||||||
const sprite = layer.sprites[currentFrameIndex.value];
|
const sprite = layer.sprites[currentFrameIndex.value];
|
||||||
@@ -499,26 +501,21 @@
|
|||||||
const originalPos = allSpritesPosBeforeDrag.value.get(sprite.id);
|
const originalPos = allSpritesPosBeforeDrag.value.get(sprite.id);
|
||||||
if (!originalPos) return;
|
if (!originalPos) return;
|
||||||
|
|
||||||
// Calculate new position with constraints
|
|
||||||
let newX = Math.round(originalPos.x + deltaX);
|
let newX = Math.round(originalPos.x + deltaX);
|
||||||
let newY = Math.round(originalPos.y + deltaY);
|
let newY = Math.round(originalPos.y + deltaY);
|
||||||
|
|
||||||
// Constrain movement within expanded cell
|
|
||||||
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
|
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
|
||||||
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
|
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
|
||||||
|
|
||||||
emit('updateSpriteInLayer', layer.id, sprite.id, newX, newY);
|
emit('updateSpriteInLayer', layer.id, sprite.id, newX, newY);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Move only the active layer sprite
|
|
||||||
const activeSprite = props.layers.find(l => l.id === activeLayerId.value)?.sprites[currentFrameIndex.value];
|
const activeSprite = props.layers.find(l => l.id === activeLayerId.value)?.sprites[currentFrameIndex.value];
|
||||||
if (!activeSprite || activeSprite.id !== activeSpriteId.value) return;
|
if (!activeSprite || activeSprite.id !== activeSpriteId.value) return;
|
||||||
|
|
||||||
// Calculate new position with constraints and round to integers
|
|
||||||
let newX = Math.round(spritePosBeforeDrag.value.x + deltaX);
|
let newX = Math.round(spritePosBeforeDrag.value.x + deltaX);
|
||||||
let newY = Math.round(spritePosBeforeDrag.value.y + deltaY);
|
let newY = Math.round(spritePosBeforeDrag.value.y + deltaY);
|
||||||
|
|
||||||
// Constrain movement within expanded cell (allow negative values up to -negativeSpacing)
|
|
||||||
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - activeSprite.width, newX));
|
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - activeSprite.width, newX));
|
||||||
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - activeSprite.height, newY));
|
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - activeSprite.height, newY));
|
||||||
|
|
||||||
@@ -562,7 +559,6 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Arrow key movement handler
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (!isDraggable.value || !arrowKeyMovement.value) return;
|
if (!isDraggable.value || !arrowKeyMovement.value) return;
|
||||||
|
|
||||||
@@ -593,7 +589,6 @@
|
|||||||
const { cellWidth, cellHeight, negativeSpacing } = cellDimensions.value;
|
const { cellWidth, cellHeight, negativeSpacing } = cellDimensions.value;
|
||||||
|
|
||||||
if (repositionAllLayers.value) {
|
if (repositionAllLayers.value) {
|
||||||
// Move all sprites in current frame from all visible layers
|
|
||||||
const visibleLayers = getVisibleLayers();
|
const visibleLayers = getVisibleLayers();
|
||||||
visibleLayers.forEach(layer => {
|
visibleLayers.forEach(layer => {
|
||||||
const sprite = layer.sprites[currentFrameIndex.value];
|
const sprite = layer.sprites[currentFrameIndex.value];
|
||||||
@@ -602,14 +597,12 @@
|
|||||||
let newX = Math.round(sprite.x + deltaX);
|
let newX = Math.round(sprite.x + deltaX);
|
||||||
let newY = Math.round(sprite.y + deltaY);
|
let newY = Math.round(sprite.y + deltaY);
|
||||||
|
|
||||||
// Constrain movement within expanded cell
|
|
||||||
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
|
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
|
||||||
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
|
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
|
||||||
|
|
||||||
emit('updateSpriteInLayer', layer.id, sprite.id, newX, newY);
|
emit('updateSpriteInLayer', layer.id, sprite.id, newX, newY);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Move only the active layer sprite
|
|
||||||
const activeLayer = props.layers.find(l => l.id === props.activeLayerId);
|
const activeLayer = props.layers.find(l => l.id === props.activeLayerId);
|
||||||
if (!activeLayer) return;
|
if (!activeLayer) return;
|
||||||
|
|
||||||
@@ -619,7 +612,6 @@
|
|||||||
let newX = Math.round(sprite.x + deltaX);
|
let newX = Math.round(sprite.x + deltaX);
|
||||||
let newY = Math.round(sprite.y + deltaY);
|
let newY = Math.round(sprite.y + deltaY);
|
||||||
|
|
||||||
// Constrain movement within expanded cell
|
|
||||||
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
|
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
|
||||||
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
|
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
|
||||||
|
|
||||||
@@ -627,7 +619,6 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lifecycle hooks
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
});
|
});
|
||||||
@@ -637,8 +628,6 @@
|
|||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Watchers - most canvas-related watchers removed
|
|
||||||
// Keep layer watchers to ensure reactivity
|
|
||||||
watch(
|
watch(
|
||||||
() => props.layers,
|
() => props.layers,
|
||||||
() => {},
|
() => {},
|
||||||
@@ -650,41 +639,34 @@
|
|||||||
);
|
);
|
||||||
watch(currentFrameIndex, () => {});
|
watch(currentFrameIndex, () => {});
|
||||||
|
|
||||||
// Context menu functions
|
/* Context Menu */
|
||||||
|
const { isOpen: isContextMenuOpen, position: contextMenuPosition, contextData: contextMenuData, open: openContextMenuBase, close: closeContextMenu } = useContextMenu<{ spriteId: string; layerId: string }>();
|
||||||
|
|
||||||
const openContextMenu = (event: MouseEvent, sprite: Sprite, layerId: string) => {
|
const openContextMenu = (event: MouseEvent, sprite: Sprite, layerId: string) => {
|
||||||
event.preventDefault();
|
openContextMenuBase(event, { spriteId: sprite.id, layerId });
|
||||||
contextMenuSpriteId.value = sprite.id;
|
|
||||||
contextMenuLayerId.value = layerId;
|
|
||||||
contextMenuX.value = event.clientX;
|
|
||||||
contextMenuY.value = event.clientY;
|
|
||||||
showContextMenu.value = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hideContextMenu = () => {
|
const contextMenuSpriteId = computed(() => contextMenuData.value?.spriteId || null);
|
||||||
showContextMenu.value = false;
|
const contextMenuLayerId = computed(() => contextMenuData.value?.layerId || null);
|
||||||
contextMenuSpriteId.value = null;
|
|
||||||
contextMenuLayerId.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const rotateSpriteInMenu = (angle: number) => {
|
const rotateSpriteInMenu = (angle: number) => {
|
||||||
if (contextMenuSpriteId.value) {
|
if (contextMenuSpriteId.value) {
|
||||||
emit('rotateSprite', contextMenuSpriteId.value, angle);
|
emit('rotateSprite', contextMenuSpriteId.value, angle);
|
||||||
}
|
}
|
||||||
hideContextMenu();
|
// Context menu closes automatically via component emit
|
||||||
};
|
};
|
||||||
|
|
||||||
const flipSpriteInMenu = (direction: 'horizontal' | 'vertical') => {
|
const flipSpriteInMenu = (direction: 'horizontal' | 'vertical') => {
|
||||||
if (contextMenuSpriteId.value) {
|
if (contextMenuSpriteId.value) {
|
||||||
emit('flipSprite', contextMenuSpriteId.value, direction);
|
emit('flipSprite', contextMenuSpriteId.value, direction);
|
||||||
}
|
}
|
||||||
hideContextMenu();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCopyToFrameModal = () => {
|
const openCopyToFrameModal = () => {
|
||||||
if (contextMenuSpriteId.value) {
|
if (contextMenuSpriteId.value) {
|
||||||
copyTargetLayerId.value = contextMenuLayerId.value || props.activeLayerId;
|
copyTargetLayerId.value = contextMenuLayerId.value || props.activeLayerId;
|
||||||
showCopyToFrameModal.value = true;
|
showCopyToFrameModal.value = true;
|
||||||
showContextMenu.value = false;
|
closeContextMenu();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -696,15 +678,15 @@
|
|||||||
if (contextMenuSpriteId.value) {
|
if (contextMenuSpriteId.value) {
|
||||||
emit('copySpriteToFrame', contextMenuSpriteId.value, targetLayerId, targetFrameIndex);
|
emit('copySpriteToFrame', contextMenuSpriteId.value, targetLayerId, targetFrameIndex);
|
||||||
closeCopyToFrameModal();
|
closeCopyToFrameModal();
|
||||||
contextMenuSpriteId.value = null;
|
|
||||||
}
|
}
|
||||||
|
// We don't null contextMenuSpriteId here since it's computed now, but closing the menu does the trick effectively for the user flow.
|
||||||
};
|
};
|
||||||
|
|
||||||
const replaceSprite = () => {
|
const replaceSprite = () => {
|
||||||
if (contextMenuSpriteId.value && fileInput.value) {
|
if (contextMenuSpriteId.value && fileInput.value) {
|
||||||
replacingSpriteId.value = contextMenuSpriteId.value;
|
replacingSpriteId.value = contextMenuSpriteId.value;
|
||||||
fileInput.value.click();
|
fileInput.value.click();
|
||||||
hideContextMenu();
|
closeContextMenu();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -713,13 +695,39 @@
|
|||||||
|
|
||||||
if (input.files && input.files.length > 0) {
|
if (input.files && input.files.length > 0) {
|
||||||
const file = input.files[0];
|
const file = input.files[0];
|
||||||
if (file.type.startsWith('image/') && replacingSpriteId.value) {
|
if (file.type.startsWith('image/')) {
|
||||||
emit('replaceSprite', replacingSpriteId.value, file);
|
if (replacingSpriteId.value) {
|
||||||
|
emit('replaceSprite', replacingSpriteId.value, file);
|
||||||
|
} else {
|
||||||
|
// Add sprite case - use dropSprite emit as it handles adding files to layer/frame
|
||||||
|
emit('dropSprite', props.activeLayerId, currentFrameIndex.value, [file]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
replacingSpriteId.value = null;
|
replacingSpriteId.value = null;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openPixelEditor = () => {
|
||||||
|
if (contextMenuSpriteId.value && contextMenuLayerId.value) {
|
||||||
|
emit('openPixelEditor', contextMenuLayerId.value, currentFrameIndex.value);
|
||||||
|
closeContextMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSprite = () => {
|
||||||
|
if (fileInput.value) {
|
||||||
|
replacingSpriteId.value = null;
|
||||||
|
fileInput.value.click();
|
||||||
|
closeContextMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const removeSprite = () => {
|
||||||
|
if (contextMenuSpriteId.value) {
|
||||||
|
(emit as any)('removeSprite', contextMenuSpriteId.value);
|
||||||
|
closeContextMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> Detection Mode </label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> Detection Mode </label>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3" :class="{ 'grid-cols-3': isGif }">
|
||||||
<button @click="detectionMode = 'grid'" :class="['px-4 py-3 rounded-lg border-2 text-left transition-all', detectionMode === 'grid' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30' : 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500']">
|
<button @click="detectionMode = 'grid'" :class="['px-4 py-3 rounded-lg border-2 text-left transition-all', detectionMode === 'grid' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30' : 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500']">
|
||||||
<div class="font-medium text-gray-900 dark:text-gray-100">Grid</div>
|
<div class="font-medium text-gray-900 dark:text-gray-100">Grid</div>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">Split by cell size</div>
|
<div class="text-xs text-gray-500 dark:text-gray-400">Split by cell size</div>
|
||||||
@@ -19,6 +19,10 @@
|
|||||||
<div class="font-medium text-gray-900 dark:text-gray-100">Auto-detect</div>
|
<div class="font-medium text-gray-900 dark:text-gray-100">Auto-detect</div>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">Find individual sprites</div>
|
<div class="text-xs text-gray-500 dark:text-gray-400">Find individual sprites</div>
|
||||||
</button>
|
</button>
|
||||||
|
<button v-if="isGif" @click="detectionMode = 'gif'" :class="['px-4 py-3 rounded-lg border-2 text-left transition-all', detectionMode === 'gif' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30' : 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500']">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-gray-100">Frame-by-frame</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Extract GIF frames</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -115,7 +119,6 @@
|
|||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const splitter = useSpritesheetSplitter();
|
const splitter = useSpritesheetSplitter();
|
||||||
|
|
||||||
// State
|
|
||||||
const detectionMode = ref<DetectionMode>('grid');
|
const detectionMode = ref<DetectionMode>('grid');
|
||||||
const cellWidth = ref(64);
|
const cellWidth = ref(64);
|
||||||
const cellHeight = ref(64);
|
const cellHeight = ref(64);
|
||||||
@@ -126,12 +129,12 @@
|
|||||||
const isProcessing = ref(false);
|
const isProcessing = ref(false);
|
||||||
const imageElement = ref<HTMLImageElement | null>(null);
|
const imageElement = ref<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
// Computed
|
|
||||||
const gridCols = computed(() => (imageElement.value && cellWidth.value > 0 ? Math.floor(imageElement.value.width / cellWidth.value) : 0));
|
const gridCols = computed(() => (imageElement.value && cellWidth.value > 0 ? Math.floor(imageElement.value.width / cellWidth.value) : 0));
|
||||||
|
|
||||||
const gridRows = computed(() => (imageElement.value && cellHeight.value > 0 ? Math.floor(imageElement.value.height / cellHeight.value) : 0));
|
const gridRows = computed(() => (imageElement.value && cellHeight.value > 0 ? Math.floor(imageElement.value.height / cellHeight.value) : 0));
|
||||||
|
|
||||||
// Load image and set initial cell size
|
const isGif = computed(() => props.imageFile?.type === 'image/gif' || props.imageUrl.toLowerCase().endsWith('.gif'));
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.imageUrl,
|
() => props.imageUrl,
|
||||||
url => {
|
url => {
|
||||||
@@ -141,11 +144,16 @@
|
|||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
imageElement.value = img;
|
imageElement.value = img;
|
||||||
|
|
||||||
// Set suggested cell size
|
|
||||||
const suggested = splitter.getSuggestedCellSize(img.width, img.height);
|
const suggested = splitter.getSuggestedCellSize(img.width, img.height);
|
||||||
cellWidth.value = suggested.width;
|
cellWidth.value = suggested.width;
|
||||||
cellHeight.value = suggested.height;
|
cellHeight.value = suggested.height;
|
||||||
|
|
||||||
|
if (isGif.value) {
|
||||||
|
detectionMode.value = 'gif';
|
||||||
|
} else if (detectionMode.value === 'gif') {
|
||||||
|
detectionMode.value = 'grid';
|
||||||
|
}
|
||||||
|
|
||||||
generatePreview();
|
generatePreview();
|
||||||
};
|
};
|
||||||
img.src = url;
|
img.src = url;
|
||||||
@@ -153,14 +161,12 @@
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Regenerate preview when options change
|
|
||||||
watch([detectionMode, cellWidth, cellHeight, sensitivity, removeEmpty, preserveCellSize], () => {
|
watch([detectionMode, cellWidth, cellHeight, sensitivity, removeEmpty, preserveCellSize], () => {
|
||||||
if (imageElement.value) {
|
if (imageElement.value) {
|
||||||
generatePreview();
|
generatePreview();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate preview
|
|
||||||
async function generatePreview() {
|
async function generatePreview() {
|
||||||
if (!imageElement.value) return;
|
if (!imageElement.value) return;
|
||||||
|
|
||||||
@@ -177,11 +183,13 @@
|
|||||||
preserveCellSize: preserveCellSize.value,
|
preserveCellSize: preserveCellSize.value,
|
||||||
removeEmpty: removeEmpty.value,
|
removeEmpty: removeEmpty.value,
|
||||||
});
|
});
|
||||||
} else {
|
} else if (detectionMode.value === 'auto') {
|
||||||
previewSprites.value = await splitter.detectSprites(img, {
|
previewSprites.value = await splitter.detectSprites(img, {
|
||||||
sensitivity: sensitivity.value,
|
sensitivity: sensitivity.value,
|
||||||
removeEmpty: removeEmpty.value,
|
removeEmpty: removeEmpty.value,
|
||||||
});
|
});
|
||||||
|
} else if (detectionMode.value === 'gif' && props.imageFile) {
|
||||||
|
previewSprites.value = await splitter.extractGifFrames(props.imageFile);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating preview:', error);
|
console.error('Error generating preview:', error);
|
||||||
@@ -190,7 +198,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actions
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
emit('close');
|
emit('close');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,6 @@
|
|||||||
close();
|
close();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message || 'An error occurred';
|
error.value = e.message || 'An error occurred';
|
||||||
// Better PB error handling
|
|
||||||
if (e?.data?.message) error.value = e.data.message;
|
if (e?.data?.message) error.value = e.data.message;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|||||||
@@ -7,8 +7,14 @@
|
|||||||
<NavbarLogo />
|
<NavbarLogo />
|
||||||
|
|
||||||
<!-- Desktop Navigation -->
|
<!-- Desktop Navigation -->
|
||||||
<div class="hidden md:flex items-center">
|
<div class="hidden md:flex items-center gap-4">
|
||||||
<NavbarLinks />
|
<NavbarLinks />
|
||||||
|
|
||||||
|
<!-- Back to Editor Button -->
|
||||||
|
<button v-if="showBackToEditor" @click="goBackToEditor" class="btn btn-sm btn-primary shadow-indigo-500/20 shadow-lg">
|
||||||
|
<i class="fas fa-arrow-left mr-2"></i>
|
||||||
|
Back to Editor
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -54,6 +60,7 @@
|
|||||||
@open-new-project-modal="isNewProjectModalOpen = true"
|
@open-new-project-modal="isNewProjectModalOpen = true"
|
||||||
@open-project-list="isProjectListOpen = true"
|
@open-project-list="isProjectListOpen = true"
|
||||||
@open-auth-modal="isAuthModalOpen = true"
|
@open-auth-modal="isAuthModalOpen = true"
|
||||||
|
@back-to-editor="goBackToEditor"
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
<AuthModal :is-open="isAuthModalOpen" @close="isAuthModalOpen = false" />
|
<AuthModal :is-open="isAuthModalOpen" @close="isAuthModalOpen = false" />
|
||||||
@@ -64,6 +71,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import DarkModeToggle from '../utilities/DarkModeToggle.vue';
|
import DarkModeToggle from '../utilities/DarkModeToggle.vue';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import AuthModal from '@/components/auth/AuthModal.vue';
|
import AuthModal from '@/components/auth/AuthModal.vue';
|
||||||
@@ -74,7 +82,6 @@
|
|||||||
import { useProjectManager } from '@/composables/useProjectManager';
|
import { useProjectManager } from '@/composables/useProjectManager';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
||||||
// Sub-components
|
|
||||||
import NavbarLogo from './navbar/NavbarLogo.vue';
|
import NavbarLogo from './navbar/NavbarLogo.vue';
|
||||||
import NavbarLinks from './navbar/NavbarLinks.vue';
|
import NavbarLinks from './navbar/NavbarLinks.vue';
|
||||||
import NavbarProjectActions from './navbar/NavbarProjectActions.vue';
|
import NavbarProjectActions from './navbar/NavbarProjectActions.vue';
|
||||||
@@ -92,11 +99,17 @@
|
|||||||
const saveMode = ref<'save' | 'save-as'>('save');
|
const saveMode = ref<'save' | 'save-as'>('save');
|
||||||
const isSaving = ref(false);
|
const isSaving = ref(false);
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const projectStore = useProjectStore();
|
const projectStore = useProjectStore();
|
||||||
const { createProject, openProject, saveProject, saveAsProject } = useProjectManager();
|
const { createProject, openProject, saveProject, saveAsProject } = useProjectManager();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
const showBackToEditor = computed(() => {
|
||||||
|
return route.name !== 'editor' && !!projectStore.currentProject;
|
||||||
|
});
|
||||||
|
|
||||||
const handleOpenProject = async (project: Project) => {
|
const handleOpenProject = async (project: Project) => {
|
||||||
await openProject(project);
|
await openProject(project);
|
||||||
};
|
};
|
||||||
@@ -144,7 +157,6 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast('Failed to save project', 'error');
|
addToast('Failed to save project', 'error');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
// Error handled in composable but kept here for toast
|
|
||||||
} finally {
|
} finally {
|
||||||
isSaving.value = false;
|
isSaving.value = false;
|
||||||
}
|
}
|
||||||
@@ -153,4 +165,8 @@
|
|||||||
const handleCreateNewProject = (config: { width: number; height: number; columns: number; rows: number }) => {
|
const handleCreateNewProject = (config: { width: number; height: number; columns: number; rows: number }) => {
|
||||||
createProject(config);
|
createProject(config);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const goBackToEditor = () => {
|
||||||
|
router.push({ name: 'editor' });
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,12 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-4 text-sm font-medium text-gray-600 dark:text-gray-400">
|
<div class="flex items-center gap-1">
|
||||||
<router-link to="/" class="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors">Home</router-link>
|
<router-link
|
||||||
<router-link to="/blog" class="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors">Blog</router-link>
|
v-for="link in links"
|
||||||
<router-link to="/about" class="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors">About</router-link>
|
:key="link.path"
|
||||||
<router-link to="/faq" class="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors">FAQ</router-link>
|
:to="link.path"
|
||||||
|
class="px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200"
|
||||||
|
:class="[isActive(link.path) ? 'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-indigo-600 dark:hover:text-indigo-400']"
|
||||||
|
>
|
||||||
|
{{ link.name }}
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ name: 'Home', path: '/' },
|
||||||
|
{ name: 'Blog', path: '/blog' },
|
||||||
|
{ name: 'About', path: '/about' },
|
||||||
|
{ name: 'FAQ', path: '/faq' },
|
||||||
|
{ name: 'Contact', path: '/contact' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isActive = (path: string) => {
|
||||||
|
if (path === '/') {
|
||||||
|
return route.path === '/';
|
||||||
|
}
|
||||||
|
return route.path.startsWith(path);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
leave-from-class="transform opacity-100 translate-y-0"
|
leave-from-class="transform opacity-100 translate-y-0"
|
||||||
leave-to-class="transform opacity-0 -translate-y-2"
|
leave-to-class="transform opacity-0 -translate-y-2"
|
||||||
>
|
>
|
||||||
<div v-show="isOpen" class="md:hidden border-t border-gray-200 dark:border-gray-800 bg-white/95 dark:bg-gray-950/95 backdrop-blur-xl absolute top-16 left-0 w-full z-40 shadow-lg">
|
<div v-show="isOpen" class="md:hidden border-t border-gray-200 dark:border-gray-800 bg-white/95 dark:bg-gray-950/95 backdrop-blur-xl absolute top-16 left-0 w-full z-40 shadow-lg max-h-[calc(100vh-4rem)] overflow-y-auto">
|
||||||
<div class="px-2 pt-2 pb-3 space-y-1">
|
<div class="px-2 pt-2 pb-3 space-y-1">
|
||||||
<!-- User Profile (Mobile) -->
|
<!-- User Profile (Mobile) -->
|
||||||
<div v-if="authStore.user" class="px-3 py-3 mb-2 border-b border-gray-200 dark:border-gray-800">
|
<div v-if="authStore.user" class="px-3 py-3 mb-2 border-b border-gray-200 dark:border-gray-800">
|
||||||
@@ -34,17 +34,29 @@
|
|||||||
Login / Register
|
Login / Register
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<router-link to="/" class="flex items-center gap-3 px-3 py-3 rounded-md text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors" @click="$emit('close')">
|
|
||||||
<i class="fas fa-home w-5 text-center"></i> Home
|
<!-- Back to Editor Button -->
|
||||||
</router-link>
|
<button
|
||||||
<router-link to="/blog" class="flex items-center gap-3 px-3 py-3 rounded-md text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors" @click="$emit('close')">
|
v-if="showBackToEditor"
|
||||||
<i class="fas fa-newspaper w-5 text-center"></i> Blog
|
@click="
|
||||||
</router-link>
|
$emit('back-to-editor');
|
||||||
<router-link to="/about" class="flex items-center gap-3 px-3 py-3 rounded-md text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors" @click="$emit('close')">
|
$emit('close');
|
||||||
<i class="fas fa-info-circle w-5 text-center"></i> About
|
"
|
||||||
</router-link>
|
class="mx-3 mb-3 btn btn-primary w-auto flex items-center gap-2"
|
||||||
<router-link to="/faq" class="flex items-center gap-3 px-3 py-3 rounded-md text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors" @click="$emit('close')">
|
>
|
||||||
<i class="fas fa-question-circle w-5 text-center"></i> FAQ
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
Back to Editor
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<router-link
|
||||||
|
v-for="link in links"
|
||||||
|
:key="link.path"
|
||||||
|
:to="link.path"
|
||||||
|
class="flex items-center gap-3 px-3 py-3 rounded-md text-base font-medium transition-all duration-200"
|
||||||
|
:class="[isActive(link.path) ? 'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-indigo-600 dark:hover:text-indigo-400']"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
<i :class="link.icon" class="w-5 text-center"></i> {{ link.name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<!-- Project Actions -->
|
<!-- Project Actions -->
|
||||||
@@ -129,12 +141,34 @@
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const isEditorActive = computed(() => route.name === 'editor');
|
const isEditorActive = computed(() => route.name === 'editor');
|
||||||
|
|
||||||
|
import { useProjectStore } from '@/stores/useProjectStore';
|
||||||
|
const projectStore = useProjectStore();
|
||||||
|
|
||||||
|
const showBackToEditor = computed(() => {
|
||||||
|
return route.name !== 'editor' && !!projectStore.currentProject;
|
||||||
|
});
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ name: 'Home', path: '/', icon: 'fas fa-home' },
|
||||||
|
{ name: 'Blog', path: '/blog', icon: 'fas fa-newspaper' },
|
||||||
|
{ name: 'About', path: '/about', icon: 'fas fa-info-circle' },
|
||||||
|
{ name: 'FAQ', path: '/faq', icon: 'fas fa-question-circle' },
|
||||||
|
{ name: 'Contact', path: '/contact', icon: 'fas fa-envelope' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isActive = (path: string) => {
|
||||||
|
if (path === '/') {
|
||||||
|
return route.path === '/';
|
||||||
|
}
|
||||||
|
return route.path.startsWith(path);
|
||||||
|
};
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
isSaving?: boolean;
|
isSaving?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits(['close', 'open-help', 'save-project', 'open-save-modal', 'open-new-project-modal', 'open-project-list', 'open-auth-modal']);
|
defineEmits(['close', 'open-help', 'save-project', 'open-save-modal', 'open-new-project-modal', 'open-project-list', 'open-auth-modal', 'back-to-editor']);
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
@@ -146,6 +180,5 @@
|
|||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
authStore.logout();
|
authStore.logout();
|
||||||
// emit('close'); // Optional: close menu on logout? Maybe better to keep open so they can login again if they want.
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
<i class="fas fa-question-circle text-lg"></i>
|
<i class="fas fa-question-circle text-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700 ml-1.5"></div>
|
||||||
|
|
||||||
<DarkModeToggle />
|
<DarkModeToggle />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -61,7 +61,6 @@
|
|||||||
return width.value > 0 && height.value > 0 && columns.value > 0 && rows.value > 0;
|
return width.value > 0 && height.value > 0 && columns.value > 0 && rows.value > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset to defaults when opened
|
|
||||||
watch(
|
watch(
|
||||||
() => props.isOpen,
|
() => props.isOpen,
|
||||||
val => {
|
val => {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No Search Results -->
|
<!-- No Search Results -->
|
||||||
<div v-else-if="filteredProjects.length === 0" class="flex flex-col items-center justify-center py-12 text-center">
|
<div v-else-if="projects.length === 0 && searchQuery" class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<div class="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4">
|
<div class="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4">
|
||||||
<i class="fas fa-search text-2xl text-gray-400 dark:text-gray-500"></i>
|
<i class="fas fa-search text-2xl text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
<!-- Project List -->
|
<!-- Project List -->
|
||||||
<div v-else class="flex flex-col gap-3">
|
<div v-else class="flex flex-col gap-3">
|
||||||
<div
|
<div
|
||||||
v-for="project in filteredProjects"
|
v-for="project in projects"
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
class="group bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-indigo-500 dark:hover:border-indigo-500 hover:shadow-md dark:hover:shadow-indigo-500/10 transition-all cursor-pointer overflow-hidden flex flex-col sm:flex-row h-auto sm:h-32"
|
class="group bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-indigo-500 dark:hover:border-indigo-500 hover:shadow-md dark:hover:shadow-indigo-500/10 transition-all cursor-pointer overflow-hidden flex flex-col sm:flex-row h-auto sm:h-32"
|
||||||
@click="selectProject(project)"
|
@click="selectProject(project)"
|
||||||
@@ -105,9 +105,41 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="px-6 py-4 border-t border-gray-100 dark:border-gray-800 flex justify-between items-center bg-white dark:bg-gray-900">
|
<div class="px-6 py-4 border-t border-gray-100 dark:border-gray-800 flex flex-col sm:flex-row justify-between items-center bg-white dark:bg-gray-900 gap-4 sm:gap-0">
|
||||||
<p class="text-xs text-gray-400" v-if="projects.length > 0">Showing {{ filteredProjects.length }} of {{ projects.length }} project{{ projects.length !== 1 ? 's' : '' }}</p>
|
<div class="flex items-center gap-2 sm:gap-4 flex-wrap justify-center sm:justify-start w-full sm:w-auto">
|
||||||
<div class="flex gap-2 ml-auto">
|
<p class="text-xs text-gray-400 hidden md:block" v-if="projects.length > 0">Page {{ page }} of {{ totalPages }} ({{ totalItems }} items)</p>
|
||||||
|
|
||||||
|
<div class="flex gap-1 items-center" v-if="totalPages > 1">
|
||||||
|
<!-- Prev -->
|
||||||
|
<button @click="prevPage" :disabled="page <= 1" class="w-8 h-8 flex items-center justify-center text-xs font-medium rounded bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Page Numbers -->
|
||||||
|
<template v-for="p in visiblePages" :key="p">
|
||||||
|
<button
|
||||||
|
v-if="p !== '...'"
|
||||||
|
@click="goToPage(p as number)"
|
||||||
|
:class="['w-8 h-8 flex items-center justify-center text-xs font-medium rounded transition-colors', page === p ? 'bg-indigo-600 text-white' : 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300']"
|
||||||
|
>
|
||||||
|
{{ p }}
|
||||||
|
</button>
|
||||||
|
<span v-else class="w-8 h-8 flex items-center justify-center text-xs text-gray-400">...</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Next -->
|
||||||
|
<button @click="nextPage" :disabled="page >= totalPages" class="w-8 h-8 flex items-center justify-center text-xs font-medium rounded bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Jump -->
|
||||||
|
<div class="flex items-center gap-2 ml-2 border-l border-gray-200 dark:border-gray-700 pl-4 hidden sm:flex" v-if="totalPages > 5">
|
||||||
|
<span class="text-xs text-gray-400">Go to</span>
|
||||||
|
<input type="number" min="1" :max="totalPages" class="w-12 h-8 px-2 text-xs bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded focus:ring-1 focus:ring-indigo-500 outline-none text-center" @keydown.enter="jumpToPage($event)" @blur="jumpToPage($event)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 ml-auto w-full sm:w-auto justify-center sm:justify-end">
|
||||||
<button v-if="projects.length > 0" @click="loadExample" class="text-xs font-medium text-gray-500 hover:text-indigo-600 dark:hover:text-indigo-400 px-3 py-2">Load Example</button>
|
<button v-if="projects.length > 0" @click="loadExample" class="text-xs font-medium text-gray-500 hover:text-indigo-600 dark:hover:text-indigo-400 px-3 py-2">Load Example</button>
|
||||||
<button @click="close" class="btn btn-secondary text-sm">Close</button>
|
<button @click="close" class="btn btn-secondary text-sm">Close</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,23 +164,90 @@
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const projectStore = useProjectStore();
|
const projectStore = useProjectStore();
|
||||||
const { projects, isLoading: loading } = toRefs(projectStore);
|
const { createProject } = useProjectManager();
|
||||||
const { createProject, loadProjectData } = useProjectManager();
|
|
||||||
|
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
|
|
||||||
const filteredProjects = computed(() => {
|
const { projects, isLoading: loading, page, perPage, totalItems, totalPages, fetchProjects } = toRefs(projectStore);
|
||||||
if (!searchQuery.value) return projects.value;
|
|
||||||
const query = searchQuery.value.toLowerCase();
|
let searchTimeout: any = null;
|
||||||
return projects.value.filter(p => p.name.toLowerCase().includes(query));
|
|
||||||
|
watch(searchQuery, newVal => {
|
||||||
|
if (searchTimeout) clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
fetchProjects.value(1, perPage.value, newVal);
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextPage = () => {
|
||||||
|
if (page.value < totalPages.value) {
|
||||||
|
fetchProjects.value(page.value + 1, perPage.value, searchQuery.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevPage = () => {
|
||||||
|
if (page.value > 1) {
|
||||||
|
fetchProjects.value(page.value - 1, perPage.value, searchQuery.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPage = (p: number) => {
|
||||||
|
if (p !== page.value && p >= 1 && p <= totalPages.value) {
|
||||||
|
fetchProjects.value(p, perPage.value, searchQuery.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const jumpToPage = (event: any) => {
|
||||||
|
const val = parseInt(event.target.value);
|
||||||
|
if (!isNaN(val) && val >= 1 && val <= totalPages.value) {
|
||||||
|
goToPage(val);
|
||||||
|
// Clear input after jump if desired, or keep it. Keeping it is fine.
|
||||||
|
} else {
|
||||||
|
// Reset to current page if invalid
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const visiblePages = computed(() => {
|
||||||
|
const total = totalPages.value;
|
||||||
|
const current = page.value;
|
||||||
|
const delta = 2; // How many pages to show around current
|
||||||
|
const range: (number | string)[] = [];
|
||||||
|
const left = current - delta;
|
||||||
|
const right = current + delta + 1;
|
||||||
|
|
||||||
|
for (let i = 1; i <= total; i++) {
|
||||||
|
if (i === 1 || i === total || (i >= left && i < right)) {
|
||||||
|
range.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add dots
|
||||||
|
const finalRange: (number | string)[] = [];
|
||||||
|
let l: number | null = null;
|
||||||
|
|
||||||
|
for (const i of range) {
|
||||||
|
if (typeof i === 'number') {
|
||||||
|
if (l) {
|
||||||
|
if (i - l === 2) {
|
||||||
|
finalRange.push(l + 1);
|
||||||
|
} else if (i - l !== 1) {
|
||||||
|
finalRange.push('...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalRange.push(i);
|
||||||
|
l = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return finalRange;
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.isOpen,
|
() => props.isOpen,
|
||||||
isOpen => {
|
isOpen => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
projectStore.fetchProjects();
|
|
||||||
searchQuery.value = ''; // Reset search on open
|
searchQuery.value = ''; // Reset search on open
|
||||||
|
// Reset to page 1 is handled by fetchProjects default or we can explicitly call it
|
||||||
|
projectStore.fetchProjects(1, perPage.value, '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -187,9 +286,7 @@
|
|||||||
const sprites: any[] = [];
|
const sprites: any[] = [];
|
||||||
if (!project.data || !project.data.layers) return sprites;
|
if (!project.data || !project.data.layers) return sprites;
|
||||||
|
|
||||||
// Iterate through layers to find sprites
|
|
||||||
for (const layer of project.data.layers as any[]) {
|
for (const layer of project.data.layers as any[]) {
|
||||||
// Check if layer is visible (default to true if undefined)
|
|
||||||
if (layer.visible === false) continue;
|
if (layer.visible === false) continue;
|
||||||
|
|
||||||
if (layer.sprites && layer.sprites.length > 0) {
|
if (layer.sprites && layer.sprites.length > 0) {
|
||||||
|
|||||||
85
src/components/shared/SpriteContextMenu.vue
Normal file
85
src/components/shared/SpriteContextMenu.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="isOpen" @click.stop class="fixed bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-xl z-50 py-1 min-w-[160px] overflow-hidden" :style="{ left: position.x + 'px', top: position.y + 'px' }">
|
||||||
|
<button @click="emit('add')" class="w-full px-3 py-1.5 text-left hover:bg-blue-50 dark:hover:bg-blue-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||||
|
<i class="fas fa-plus text-blue-600 dark:text-blue-400 text-xs w-4"></i>
|
||||||
|
<span>Add sprite</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<template v-if="hasSprite">
|
||||||
|
<button @click="emitAndClose('rotate', 90)" class="w-full px-3 py-1.5 text-left hover:bg-green-50 dark:hover:bg-green-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||||
|
<i class="fas fa-redo text-green-600 dark:text-green-400 text-xs w-4"></i>
|
||||||
|
<span>Rotate +90°</span>
|
||||||
|
</button>
|
||||||
|
<button @click="emitAndClose('flip', 'horizontal')" class="w-full px-3 py-1.5 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||||
|
<i class="fas fa-arrows-alt-h text-orange-600 dark:text-orange-400 text-xs w-4"></i>
|
||||||
|
<span>Flip horizontal</span>
|
||||||
|
</button>
|
||||||
|
<button @click="emitAndClose('flip', 'vertical')" class="w-full px-3 py-1.5 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||||
|
<i class="fas fa-arrows-alt-v text-orange-600 dark:text-orange-400 text-xs w-4"></i>
|
||||||
|
<span>Flip vertical</span>
|
||||||
|
</button>
|
||||||
|
<button @click="emit('replace')" class="w-full px-3 py-1.5 text-left hover:bg-purple-50 dark:hover:bg-purple-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||||
|
<i class="fas fa-exchange-alt text-purple-600 dark:text-purple-400 text-xs w-4"></i>
|
||||||
|
<span>Replace sprite</span>
|
||||||
|
</button>
|
||||||
|
<button @click="emit('copyToFrame')" class="w-full px-3 py-1.5 text-left hover:bg-cyan-50 dark:hover:bg-cyan-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||||
|
<i class="fas fa-copy text-cyan-600 dark:text-cyan-400 text-xs w-4"></i>
|
||||||
|
<span>Copy to frame...</span>
|
||||||
|
</button>
|
||||||
|
<button @click="emit('editInPixelEditor')" class="w-full px-3 py-1.5 text-left hover:bg-indigo-50 dark:hover:bg-indigo-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||||
|
<i class="fas fa-paint-brush text-indigo-600 dark:text-indigo-400 text-xs w-4"></i>
|
||||||
|
<span>Edit in Pixel Editor</span>
|
||||||
|
</button>
|
||||||
|
<div class="h-px bg-gray-200 dark:bg-gray-600 my-1"></div>
|
||||||
|
<button @click="emitAndClose('remove')" class="w-full px-3 py-1.5 text-left hover:bg-red-50 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 flex items-center gap-2 transition-colors text-sm">
|
||||||
|
<i class="fas fa-trash text-xs w-4"></i>
|
||||||
|
<span>{{ (selectedCount || 0) > 1 ? `Remove ${selectedCount} sprites` : 'Remove sprite' }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isOpen: boolean;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
hasSprite: boolean;
|
||||||
|
selectedCount?: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'add'): void;
|
||||||
|
(e: 'rotate', angle: number): void;
|
||||||
|
(e: 'flip', direction: 'horizontal' | 'vertical'): void;
|
||||||
|
(e: 'replace'): void;
|
||||||
|
(e: 'copyToFrame'): void;
|
||||||
|
(e: 'editInPixelEditor'): void;
|
||||||
|
(e: 'remove'): void;
|
||||||
|
(e: 'close'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emitAndClose = (event: 'rotate' | 'flip' | 'remove', arg?: any) => {
|
||||||
|
emit(event as any, arg);
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeOnClickOutside = () => {
|
||||||
|
if (props.isOpen) {
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('click', closeOnClickOutside);
|
||||||
|
window.addEventListener('contextmenu', closeOnClickOutside); // Close on right click elsewhere
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('click', closeOnClickOutside);
|
||||||
|
window.removeEventListener('contextmenu', closeOnClickOutside);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -76,59 +76,47 @@
|
|||||||
const startPos = ref({ x: 0, y: 0 });
|
const startPos = ref({ x: 0, y: 0 });
|
||||||
const startSize = ref({ width: 0, height: 0 });
|
const startSize = ref({ width: 0, height: 0 });
|
||||||
|
|
||||||
// Add isFullScreen ref
|
|
||||||
const isFullScreen = ref(false);
|
const isFullScreen = ref(false);
|
||||||
const isMobile = ref(false);
|
const isMobile = ref(false);
|
||||||
|
|
||||||
// Add previous state storage for restoring from full screen
|
|
||||||
const previousState = ref({
|
const previousState = ref({
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
size: { width: 0, height: 0 },
|
size: { width: 0, height: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if device is mobile
|
|
||||||
const checkMobile = () => {
|
const checkMobile = () => {
|
||||||
isMobile.value = window.innerWidth < 640; // sm breakpoint in Tailwind
|
isMobile.value = window.innerWidth < 640; // sm breakpoint in Tailwind
|
||||||
|
|
||||||
// Auto fullscreen on mobile
|
|
||||||
if (isMobile.value && !isFullScreen.value) {
|
if (isMobile.value && !isFullScreen.value) {
|
||||||
toggleFullScreen();
|
toggleFullScreen();
|
||||||
} else if (!isMobile.value && isFullScreen.value && autoFullScreened.value) {
|
} else if (!isMobile.value && isFullScreen.value && autoFullScreened.value) {
|
||||||
// If we're no longer on mobile and were auto-fullscreened, exit fullscreen
|
|
||||||
toggleFullScreen();
|
toggleFullScreen();
|
||||||
autoFullScreened.value = false;
|
autoFullScreened.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track if fullscreen was automatic (for mobile)
|
|
||||||
const autoFullScreened = ref(false);
|
const autoFullScreened = ref(false);
|
||||||
|
|
||||||
// Add toggleFullScreen function
|
|
||||||
const toggleFullScreen = () => {
|
const toggleFullScreen = () => {
|
||||||
if (!isFullScreen.value) {
|
if (!isFullScreen.value) {
|
||||||
// Store current state before going full screen
|
|
||||||
previousState.value = {
|
previousState.value = {
|
||||||
position: { ...position.value },
|
position: { ...position.value },
|
||||||
size: { ...size.value },
|
size: { ...size.value },
|
||||||
};
|
};
|
||||||
|
|
||||||
// If toggling to fullscreen on mobile automatically, track it
|
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
autoFullScreened.value = true;
|
autoFullScreened.value = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Restore previous state
|
|
||||||
position.value = { ...previousState.value.position };
|
position.value = { ...previousState.value.position };
|
||||||
size.value = { ...previousState.value.size };
|
size.value = { ...previousState.value.size };
|
||||||
}
|
}
|
||||||
isFullScreen.value = !isFullScreen.value;
|
isFullScreen.value = !isFullScreen.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Unified start function for both drag and resize
|
|
||||||
const startAction = (event: MouseEvent | TouchEvent, action: 'drag' | 'resize') => {
|
const startAction = (event: MouseEvent | TouchEvent, action: 'drag' | 'resize') => {
|
||||||
if (isFullScreen.value) return;
|
if (isFullScreen.value) return;
|
||||||
|
|
||||||
// Extract the correct coordinates based on event type
|
|
||||||
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
|
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
|
||||||
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
|
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
|
||||||
|
|
||||||
@@ -211,7 +199,6 @@
|
|||||||
position.value = { x: 0, y: 0 };
|
position.value = { x: 0, y: 0 };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Event handlers
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape' && props.isOpen) close();
|
if (event.key === 'Escape' && props.isOpen) close();
|
||||||
};
|
};
|
||||||
@@ -223,7 +210,6 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add these new touch handling functions
|
|
||||||
const handleTouchStart = (event: TouchEvent) => {
|
const handleTouchStart = (event: TouchEvent) => {
|
||||||
if (isFullScreen.value) return;
|
if (isFullScreen.value) return;
|
||||||
if (event.touches.length === 1) {
|
if (event.touches.length === 1) {
|
||||||
@@ -237,7 +223,6 @@
|
|||||||
handleMove(event);
|
handleMove(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
watch(
|
watch(
|
||||||
() => props.isOpen,
|
() => props.isOpen,
|
||||||
newValue => {
|
newValue => {
|
||||||
@@ -250,7 +235,6 @@
|
|||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
window.addEventListener('resize', checkMobile);
|
window.addEventListener('resize', checkMobile);
|
||||||
|
|
||||||
// Initial check for mobile
|
|
||||||
checkMobile();
|
checkMobile();
|
||||||
|
|
||||||
if (props.isOpen) centerModal();
|
if (props.isOpen) centerModal();
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
import type { Toast } from '@/composables/useToast';
|
import type { Toast } from '@/composables/useToast';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
||||||
// Simple functional components for icons using h (avoid runtime compiler)
|
|
||||||
const SuccessIcon = {
|
const SuccessIcon = {
|
||||||
render: () =>
|
render: () =>
|
||||||
h(
|
h(
|
||||||
|
|||||||
@@ -42,30 +42,24 @@
|
|||||||
let x = mouseX.value + offsetX;
|
let x = mouseX.value + offsetX;
|
||||||
let y = mouseY.value + offsetY;
|
let y = mouseY.value + offsetY;
|
||||||
|
|
||||||
// Get tooltip dimensions (estimate if not mounted yet)
|
|
||||||
const tooltipWidth = tooltipRef.value?.offsetWidth || 200;
|
const tooltipWidth = tooltipRef.value?.offsetWidth || 200;
|
||||||
const tooltipHeight = tooltipRef.value?.offsetHeight || 30;
|
const tooltipHeight = tooltipRef.value?.offsetHeight || 30;
|
||||||
|
|
||||||
// Screen boundaries
|
|
||||||
const screenWidth = window.innerWidth;
|
const screenWidth = window.innerWidth;
|
||||||
const screenHeight = window.innerHeight;
|
const screenHeight = window.innerHeight;
|
||||||
|
|
||||||
// Adjust horizontal position if too close to right edge
|
|
||||||
if (x + tooltipWidth + padding > screenWidth) {
|
if (x + tooltipWidth + padding > screenWidth) {
|
||||||
x = mouseX.value - tooltipWidth - offsetX;
|
x = mouseX.value - tooltipWidth - offsetX;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust horizontal position if too close to left edge
|
|
||||||
if (x < padding) {
|
if (x < padding) {
|
||||||
x = padding;
|
x = padding;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust vertical position if too close to bottom edge
|
|
||||||
if (y + tooltipHeight + padding > screenHeight) {
|
if (y + tooltipHeight + padding > screenHeight) {
|
||||||
y = mouseY.value - tooltipHeight - offsetY;
|
y = mouseY.value - tooltipHeight - offsetY;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust vertical position if too close to top edge
|
|
||||||
if (y < padding) {
|
if (y < padding) {
|
||||||
y = padding;
|
y = padding;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export interface AnimationFramesOptions {
|
|||||||
export function useAnimationFrames(options: AnimationFramesOptions) {
|
export function useAnimationFrames(options: AnimationFramesOptions) {
|
||||||
const { onDraw } = options;
|
const { onDraw } = options;
|
||||||
|
|
||||||
// Convert sprites to a computed ref for reactivity
|
|
||||||
const spritesRef = computed(() => {
|
const spritesRef = computed(() => {
|
||||||
if (typeof options.sprites === 'function') {
|
if (typeof options.sprites === 'function') {
|
||||||
return options.sprites();
|
return options.sprites();
|
||||||
@@ -20,20 +19,16 @@ export function useAnimationFrames(options: AnimationFramesOptions) {
|
|||||||
return options.sprites;
|
return options.sprites;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to get sprites array
|
|
||||||
const getSprites = () => spritesRef.value;
|
const getSprites = () => spritesRef.value;
|
||||||
|
|
||||||
// State
|
|
||||||
const currentFrameIndex = ref(0);
|
const currentFrameIndex = ref(0);
|
||||||
const isPlaying = ref(false);
|
const isPlaying = ref(false);
|
||||||
const fps = ref(12);
|
const fps = ref(12);
|
||||||
const hiddenFrames = ref<number[]>([]);
|
const hiddenFrames = ref<number[]>([]);
|
||||||
|
|
||||||
// Animation internals
|
|
||||||
const animationFrameId = ref<number | null>(null);
|
const animationFrameId = ref<number | null>(null);
|
||||||
const lastFrameTime = ref(0);
|
const lastFrameTime = ref(0);
|
||||||
|
|
||||||
// Computed properties for visible frames
|
|
||||||
const visibleFrames = computed(() => getSprites().filter((_, index) => !hiddenFrames.value.includes(index)));
|
const visibleFrames = computed(() => getSprites().filter((_, index) => !hiddenFrames.value.includes(index)));
|
||||||
|
|
||||||
const visibleFramesCount = computed(() => visibleFrames.value.length);
|
const visibleFramesCount = computed(() => visibleFrames.value.length);
|
||||||
@@ -46,7 +41,6 @@ export function useAnimationFrames(options: AnimationFramesOptions) {
|
|||||||
|
|
||||||
const visibleFrameNumber = computed(() => visibleFrameIndex.value + 1);
|
const visibleFrameNumber = computed(() => visibleFrameIndex.value + 1);
|
||||||
|
|
||||||
// Animation control
|
|
||||||
const animateFrame = () => {
|
const animateFrame = () => {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
const elapsed = now - lastFrameTime.value;
|
const elapsed = now - lastFrameTime.value;
|
||||||
@@ -109,7 +103,6 @@ export function useAnimationFrames(options: AnimationFramesOptions) {
|
|||||||
currentFrameIndex.value = sprites.indexOf(visibleFrames.value[index]);
|
currentFrameIndex.value = sprites.indexOf(visibleFrames.value[index]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Frame visibility management
|
|
||||||
const toggleHiddenFrame = (index: number) => {
|
const toggleHiddenFrame = (index: number) => {
|
||||||
const sprites = getSprites();
|
const sprites = getSprites();
|
||||||
const currentIndex = hiddenFrames.value.indexOf(index);
|
const currentIndex = hiddenFrames.value.indexOf(index);
|
||||||
@@ -117,7 +110,6 @@ export function useAnimationFrames(options: AnimationFramesOptions) {
|
|||||||
if (currentIndex === -1) {
|
if (currentIndex === -1) {
|
||||||
hiddenFrames.value.push(index);
|
hiddenFrames.value.push(index);
|
||||||
|
|
||||||
// If hiding current frame, switch to next visible
|
|
||||||
if (index === currentFrameIndex.value) {
|
if (index === currentFrameIndex.value) {
|
||||||
const nextVisible = sprites.findIndex((_, i) => i !== index && !hiddenFrames.value.includes(i));
|
const nextVisible = sprites.findIndex((_, i) => i !== index && !hiddenFrames.value.includes(i));
|
||||||
if (nextVisible !== -1) {
|
if (nextVisible !== -1) {
|
||||||
@@ -140,32 +132,27 @@ export function useAnimationFrames(options: AnimationFramesOptions) {
|
|||||||
const sprites = getSprites();
|
const sprites = getSprites();
|
||||||
hiddenFrames.value = sprites.map((_, index) => index);
|
hiddenFrames.value = sprites.map((_, index) => index);
|
||||||
|
|
||||||
// Keep at least one frame visible
|
|
||||||
if (hiddenFrames.value.length > 0) {
|
if (hiddenFrames.value.length > 0) {
|
||||||
hiddenFrames.value.splice(currentFrameIndex.value, 1);
|
hiddenFrames.value.splice(currentFrameIndex.value, 1);
|
||||||
}
|
}
|
||||||
onDraw();
|
onDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopAnimation();
|
stopAnimation();
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
|
||||||
currentFrameIndex,
|
currentFrameIndex,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
fps,
|
fps,
|
||||||
hiddenFrames,
|
hiddenFrames,
|
||||||
|
|
||||||
// Computed
|
|
||||||
visibleFrames,
|
visibleFrames,
|
||||||
visibleFramesCount,
|
visibleFramesCount,
|
||||||
visibleFrameIndex,
|
visibleFrameIndex,
|
||||||
visibleFrameNumber,
|
visibleFrameNumber,
|
||||||
|
|
||||||
// Methods
|
|
||||||
togglePlayback,
|
togglePlayback,
|
||||||
nextFrame,
|
nextFrame,
|
||||||
previousFrame,
|
previousFrame,
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export interface BackgroundStyles {
|
|||||||
* Handles transparent backgrounds with checkerboard patterns and dark mode.
|
* Handles transparent backgrounds with checkerboard patterns and dark mode.
|
||||||
*/
|
*/
|
||||||
export function useBackgroundStyles(options: BackgroundStylesOptions) {
|
export function useBackgroundStyles(options: BackgroundStylesOptions) {
|
||||||
// Helper to get reactive values
|
|
||||||
const getBackgroundColor = () => (typeof options.backgroundColor === 'string' ? options.backgroundColor : options.backgroundColor.value);
|
const getBackgroundColor = () => (typeof options.backgroundColor === 'string' ? options.backgroundColor : options.backgroundColor.value);
|
||||||
const getCheckerboardEnabled = () => (typeof options.checkerboardEnabled === 'boolean' ? options.checkerboardEnabled : (options.checkerboardEnabled?.value ?? true));
|
const getCheckerboardEnabled = () => (typeof options.checkerboardEnabled === 'boolean' ? options.checkerboardEnabled : (options.checkerboardEnabled?.value ?? true));
|
||||||
const getDarkMode = () => (typeof options.darkMode === 'boolean' ? options.darkMode : (options.darkMode?.value ?? false));
|
const getDarkMode = () => (typeof options.darkMode === 'boolean' ? options.darkMode : (options.darkMode?.value ?? false));
|
||||||
@@ -40,7 +39,6 @@ export function useBackgroundStyles(options: BackgroundStylesOptions) {
|
|||||||
const darkMode = getDarkMode();
|
const darkMode = getDarkMode();
|
||||||
|
|
||||||
if (bg === 'transparent' && checkerboardEnabled) {
|
if (bg === 'transparent' && checkerboardEnabled) {
|
||||||
// Checkerboard pattern for transparent backgrounds (dark mode friendly)
|
|
||||||
const color = darkMode ? '#4b5563' : '#d1d5db';
|
const color = darkMode ? '#4b5563' : '#d1d5db';
|
||||||
return `linear-gradient(45deg, ${color} 25%, transparent 25%), linear-gradient(-45deg, ${color} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${color} 75%), linear-gradient(-45deg, transparent 75%, ${color} 75%)`;
|
return `linear-gradient(45deg, ${color} 25%, transparent 25%), linear-gradient(-45deg, ${color} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${color} 75%), linear-gradient(-45deg, transparent 75%, ${color} 75%)`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ export function useCanvas2D(canvasRef: Ref<HTMLCanvasElement | null>, options?:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to ensure integer positions for pixel-perfect rendering
|
|
||||||
const ensureIntegerPositions = <T extends { x: number; y: number }>(items: T[]) => {
|
const ensureIntegerPositions = <T extends { x: number; y: number }>(items: T[]) => {
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
item.x = Math.floor(item.x);
|
item.x = Math.floor(item.x);
|
||||||
@@ -79,7 +78,6 @@ export function useCanvas2D(canvasRef: Ref<HTMLCanvasElement | null>, options?:
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Centralized force redraw handler
|
|
||||||
const createForceRedrawHandler = <T extends { x: number; y: number }>(items: T[], drawCallback: () => void) => {
|
const createForceRedrawHandler = <T extends { x: number; y: number }>(items: T[], drawCallback: () => void) => {
|
||||||
return () => {
|
return () => {
|
||||||
ensureIntegerPositions(items);
|
ensureIntegerPositions(items);
|
||||||
@@ -88,7 +86,6 @@ export function useCanvas2D(canvasRef: Ref<HTMLCanvasElement | null>, options?:
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get mouse position relative to canvas, accounting for zoom
|
|
||||||
const getMousePosition = (event: MouseEvent, zoom = 1): { x: number; y: number } | null => {
|
const getMousePosition = (event: MouseEvent, zoom = 1): { x: number; y: number } | null => {
|
||||||
if (!canvasRef.value) return null;
|
if (!canvasRef.value) return null;
|
||||||
|
|
||||||
@@ -102,7 +99,6 @@ export function useCanvas2D(canvasRef: Ref<HTMLCanvasElement | null>, options?:
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to attach load/error listeners to images that aren't yet loaded
|
|
||||||
const attachImageListeners = (sprites: Sprite[], onLoad: () => void, tracked: WeakSet<HTMLImageElement>) => {
|
const attachImageListeners = (sprites: Sprite[], onLoad: () => void, tracked: WeakSet<HTMLImageElement>) => {
|
||||||
sprites.forEach(sprite => {
|
sprites.forEach(sprite => {
|
||||||
const img = sprite.img as HTMLImageElement | undefined;
|
const img = sprite.img as HTMLImageElement | undefined;
|
||||||
@@ -116,14 +112,12 @@ export function useCanvas2D(canvasRef: Ref<HTMLCanvasElement | null>, options?:
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fill cell background with selected color or transparent
|
|
||||||
const fillCellBackground = (x: number, y: number, width: number, height: number) => {
|
const fillCellBackground = (x: number, y: number, width: number, height: number) => {
|
||||||
if (settingsStore.backgroundColor === 'transparent') return;
|
if (settingsStore.backgroundColor === 'transparent') return;
|
||||||
const color = settingsStore.backgroundColor === 'custom' ? settingsStore.backgroundColor : settingsStore.backgroundColor;
|
const color = settingsStore.backgroundColor === 'custom' ? settingsStore.backgroundColor : settingsStore.backgroundColor;
|
||||||
fillRect(x, y, width, height, color);
|
fillRect(x, y, width, height, color);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stroke grid with theme-aware color
|
|
||||||
const strokeGridCell = (x: number, y: number, width: number, height: number) => {
|
const strokeGridCell = (x: number, y: number, width: number, height: number) => {
|
||||||
const color = settingsStore.darkMode ? '#4B5563' : '#e5e7eb';
|
const color = settingsStore.darkMode ? '#4B5563' : '#e5e7eb';
|
||||||
strokeRect(x, y, width, height, color, 1);
|
strokeRect(x, y, width, height, color, 1);
|
||||||
|
|||||||
32
src/composables/useContextMenu.ts
Normal file
32
src/composables/useContextMenu.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export interface ContextMenuPosition {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useContextMenu<T = any>() {
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const position = ref<ContextMenuPosition>({ x: 0, y: 0 });
|
||||||
|
const contextData = ref<T | null>(null);
|
||||||
|
|
||||||
|
const open = (event: MouseEvent, data: T) => {
|
||||||
|
event.preventDefault();
|
||||||
|
isOpen.value = true;
|
||||||
|
position.value = { x: event.clientX, y: event.clientY };
|
||||||
|
contextData.value = data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
contextData.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen,
|
||||||
|
position,
|
||||||
|
contextData,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ export interface DragSpriteOptions {
|
|||||||
manualCellSizeEnabled?: Ref<boolean>;
|
manualCellSizeEnabled?: Ref<boolean>;
|
||||||
manualCellWidth?: Ref<number>;
|
manualCellWidth?: Ref<number>;
|
||||||
manualCellHeight?: Ref<number>;
|
manualCellHeight?: Ref<number>;
|
||||||
|
selectedSpriteIds?: Ref<Set<string>> | ComputedRef<Set<string>>;
|
||||||
getMousePosition: (event: MouseEvent, zoom?: number) => { x: number; y: number } | null;
|
getMousePosition: (event: MouseEvent, zoom?: number) => { x: number; y: number } | null;
|
||||||
onUpdateSprite: (id: string, x: number, y: number) => void;
|
onUpdateSprite: (id: string, x: number, y: number) => void;
|
||||||
onUpdateSpriteCell?: (id: string, newIndex: number) => void;
|
onUpdateSpriteCell?: (id: string, newIndex: number) => void;
|
||||||
@@ -44,7 +45,6 @@ export interface DragSpriteOptions {
|
|||||||
export function useDragSprite(options: DragSpriteOptions) {
|
export function useDragSprite(options: DragSpriteOptions) {
|
||||||
const { getMousePosition, onUpdateSprite, onUpdateSpriteCell, onDraw } = options;
|
const { getMousePosition, onUpdateSprite, onUpdateSpriteCell, onDraw } = options;
|
||||||
|
|
||||||
// Helper to get reactive values
|
|
||||||
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
|
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
|
||||||
const getLayers = () => (options.layers ? (Array.isArray(options.layers) ? options.layers : options.layers.value) : null);
|
const getLayers = () => (options.layers ? (Array.isArray(options.layers) ? options.layers : options.layers.value) : null);
|
||||||
const getColumns = () => (typeof options.columns === 'number' ? options.columns : options.columns.value);
|
const getColumns = () => (typeof options.columns === 'number' ? options.columns : options.columns.value);
|
||||||
@@ -54,8 +54,8 @@ export function useDragSprite(options: DragSpriteOptions) {
|
|||||||
const getManualCellSizeEnabled = () => options.manualCellSizeEnabled?.value ?? false;
|
const getManualCellSizeEnabled = () => options.manualCellSizeEnabled?.value ?? false;
|
||||||
const getManualCellWidth = () => options.manualCellWidth?.value ?? 64;
|
const getManualCellWidth = () => options.manualCellWidth?.value ?? 64;
|
||||||
const getManualCellHeight = () => options.manualCellHeight?.value ?? 64;
|
const getManualCellHeight = () => options.manualCellHeight?.value ?? 64;
|
||||||
|
const getSelectedSpriteIds = () => options.selectedSpriteIds?.value ?? new Set<string>();
|
||||||
|
|
||||||
// Drag state
|
|
||||||
const isDragging = ref(false);
|
const isDragging = ref(false);
|
||||||
const activeSpriteId = ref<string | null>(null);
|
const activeSpriteId = ref<string | null>(null);
|
||||||
const activeSpriteCellIndex = ref<number | null>(null);
|
const activeSpriteCellIndex = ref<number | null>(null);
|
||||||
@@ -65,11 +65,11 @@ export function useDragSprite(options: DragSpriteOptions) {
|
|||||||
const dragOffsetY = ref(0);
|
const dragOffsetY = ref(0);
|
||||||
const currentHoverCell = ref<CellPosition | null>(null);
|
const currentHoverCell = ref<CellPosition | null>(null);
|
||||||
|
|
||||||
// Visual feedback
|
const initialSpritePositions = ref<Map<string, { x: number; y: number; index: number }>>(new Map());
|
||||||
|
|
||||||
const ghostSprite = ref<{ id: string; x: number; y: number } | null>(null);
|
const ghostSprite = ref<{ id: string; x: number; y: number } | null>(null);
|
||||||
const highlightCell = ref<CellPosition | null>(null);
|
const highlightCell = ref<CellPosition | null>(null);
|
||||||
|
|
||||||
// Use the new useGridMetrics composable for consistent calculations
|
|
||||||
const gridMetricsComposable = useGridMetrics({
|
const gridMetricsComposable = useGridMetrics({
|
||||||
layers: options.layers,
|
layers: options.layers,
|
||||||
sprites: options.sprites,
|
sprites: options.sprites,
|
||||||
@@ -83,7 +83,6 @@ export function useDragSprite(options: DragSpriteOptions) {
|
|||||||
return gridMetricsComposable.calculateCellDimensions();
|
return gridMetricsComposable.calculateCellDimensions();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Computed sprite positions
|
|
||||||
const spritePositions = computed<SpritePosition[]>(() => {
|
const spritePositions = computed<SpritePosition[]>(() => {
|
||||||
const sprites = getSprites();
|
const sprites = getSprites();
|
||||||
const columns = getColumns();
|
const columns = getColumns();
|
||||||
@@ -118,7 +117,6 @@ export function useDragSprite(options: DragSpriteOptions) {
|
|||||||
const col = Math.floor(x / maxWidth);
|
const col = Math.floor(x / maxWidth);
|
||||||
const row = Math.floor(y / maxHeight);
|
const row = Math.floor(y / maxHeight);
|
||||||
|
|
||||||
// Allow dropping anywhere in the columns, assuming infinite rows effectively
|
|
||||||
if (col >= 0 && col < columns && row >= 0) {
|
if (col >= 0 && col < columns && row >= 0) {
|
||||||
const index = row * columns + col;
|
const index = row * columns + col;
|
||||||
return { col, row, index };
|
return { col, row, index };
|
||||||
@@ -162,6 +160,19 @@ export function useDragSprite(options: DragSpriteOptions) {
|
|||||||
highlightCell.value = null;
|
highlightCell.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedIds = getSelectedSpriteIds();
|
||||||
|
initialSpritePositions.value.clear();
|
||||||
|
if (selectedIds.has(clickedSprite.id) && selectedIds.size > 1) {
|
||||||
|
const sprites = getSprites();
|
||||||
|
selectedIds.forEach(id => {
|
||||||
|
const sprite = sprites.find(s => s.id === id);
|
||||||
|
const spriteIdx = sprites.findIndex(s => s.id === id);
|
||||||
|
if (sprite) {
|
||||||
|
initialSpritePositions.value.set(id, { x: sprite.x, y: sprite.y, index: spriteIdx });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -171,23 +182,44 @@ export function useDragSprite(options: DragSpriteOptions) {
|
|||||||
const columns = getColumns();
|
const columns = getColumns();
|
||||||
const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
|
const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
|
||||||
|
|
||||||
// Use the sprite's current index in the array to calculate cell position
|
|
||||||
const cellCol = spriteIndex % columns;
|
const cellCol = spriteIndex % columns;
|
||||||
const cellRow = Math.floor(spriteIndex / columns);
|
const cellRow = Math.floor(spriteIndex / columns);
|
||||||
const cellX = Math.round(cellCol * maxWidth);
|
const cellX = Math.round(cellCol * maxWidth);
|
||||||
const cellY = Math.round(cellRow * maxHeight);
|
const cellY = Math.round(cellRow * maxHeight);
|
||||||
|
|
||||||
// Calculate new position relative to cell origin (without the negative spacing offset)
|
|
||||||
// The sprite's x,y is stored relative to where it would be drawn after the negativeSpacing offset
|
|
||||||
const newX = mouseX - cellX - negativeSpacing - dragOffsetX.value;
|
const newX = mouseX - cellX - negativeSpacing - dragOffsetX.value;
|
||||||
const newY = mouseY - cellY - negativeSpacing - dragOffsetY.value;
|
const newY = mouseY - cellY - negativeSpacing - dragOffsetY.value;
|
||||||
|
|
||||||
// The sprite can move within the full expanded cell area
|
const selectedIds = getSelectedSpriteIds();
|
||||||
// Allow negative values up to -negativeSpacing so sprite can fill the expanded area
|
const isMultiDrag = selectedIds.has(activeSpriteId.value) && selectedIds.size > 1;
|
||||||
const constrainedX = Math.floor(Math.max(-negativeSpacing, Math.min(maxWidth - negativeSpacing - sprites[spriteIndex].width, newX)));
|
|
||||||
const constrainedY = Math.floor(Math.max(-negativeSpacing, Math.min(maxHeight - negativeSpacing - sprites[spriteIndex].height, newY)));
|
|
||||||
|
|
||||||
onUpdateSprite(activeSpriteId.value, constrainedX, constrainedY);
|
if (isMultiDrag && initialSpritePositions.value.size > 0) {
|
||||||
|
const activeInitial = initialSpritePositions.value.get(activeSpriteId.value);
|
||||||
|
if (activeInitial) {
|
||||||
|
const deltaX = newX - activeInitial.x;
|
||||||
|
const deltaY = newY - activeInitial.y;
|
||||||
|
|
||||||
|
initialSpritePositions.value.forEach((initPos, id) => {
|
||||||
|
const sprite = sprites.find(s => s.id === id);
|
||||||
|
if (sprite) {
|
||||||
|
const newSpriteX = initPos.x + deltaX;
|
||||||
|
const newSpriteY = initPos.y + deltaY;
|
||||||
|
|
||||||
|
const spriteCellCol = initPos.index % columns;
|
||||||
|
const spriteCellRow = Math.floor(initPos.index / columns);
|
||||||
|
const constrainedX = Math.floor(Math.max(-negativeSpacing, Math.min(maxWidth - negativeSpacing - sprite.width, newSpriteX)));
|
||||||
|
const constrainedY = Math.floor(Math.max(-negativeSpacing, Math.min(maxHeight - negativeSpacing - sprite.height, newSpriteY)));
|
||||||
|
|
||||||
|
onUpdateSprite(id, constrainedX, constrainedY);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const constrainedX = Math.floor(Math.max(-negativeSpacing, Math.min(maxWidth - negativeSpacing - sprites[spriteIndex].width, newX)));
|
||||||
|
const constrainedY = Math.floor(Math.max(-negativeSpacing, Math.min(maxHeight - negativeSpacing - sprites[spriteIndex].height, newY)));
|
||||||
|
|
||||||
|
onUpdateSprite(activeSpriteId.value, constrainedX, constrainedY);
|
||||||
|
}
|
||||||
onDraw();
|
onDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,7 +236,10 @@ export function useDragSprite(options: DragSpriteOptions) {
|
|||||||
const hoverCell = findCellAtPosition(pos.x, pos.y);
|
const hoverCell = findCellAtPosition(pos.x, pos.y);
|
||||||
currentHoverCell.value = hoverCell;
|
currentHoverCell.value = hoverCell;
|
||||||
|
|
||||||
if (getAllowCellSwap() && hoverCell) {
|
const selectedIds = getSelectedSpriteIds();
|
||||||
|
const isMultiDrag = selectedIds.has(activeSpriteId.value) && selectedIds.size > 1;
|
||||||
|
|
||||||
|
if (getAllowCellSwap() && hoverCell && !isMultiDrag) {
|
||||||
if (hoverCell.index !== activeSpriteCellIndex.value) {
|
if (hoverCell.index !== activeSpriteCellIndex.value) {
|
||||||
highlightCell.value = hoverCell;
|
highlightCell.value = hoverCell;
|
||||||
ghostSprite.value = {
|
ghostSprite.value = {
|
||||||
@@ -224,7 +259,10 @@ export function useDragSprite(options: DragSpriteOptions) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stopDrag = () => {
|
const stopDrag = () => {
|
||||||
if (isDragging.value && getAllowCellSwap() && activeSpriteId.value && activeSpriteCellIndex.value !== null && currentHoverCell.value && activeSpriteCellIndex.value !== currentHoverCell.value.index) {
|
const selectedIds = getSelectedSpriteIds();
|
||||||
|
const isMultiDrag = activeSpriteId.value && selectedIds.has(activeSpriteId.value) && selectedIds.size > 1;
|
||||||
|
|
||||||
|
if (isDragging.value && getAllowCellSwap() && !isMultiDrag && activeSpriteId.value && activeSpriteCellIndex.value !== null && currentHoverCell.value && activeSpriteCellIndex.value !== currentHoverCell.value.index) {
|
||||||
if (onUpdateSpriteCell) {
|
if (onUpdateSpriteCell) {
|
||||||
onUpdateSpriteCell(activeSpriteId.value, currentHoverCell.value.index);
|
onUpdateSpriteCell(activeSpriteId.value, currentHoverCell.value.index);
|
||||||
}
|
}
|
||||||
@@ -237,11 +275,11 @@ export function useDragSprite(options: DragSpriteOptions) {
|
|||||||
currentHoverCell.value = null;
|
currentHoverCell.value = null;
|
||||||
highlightCell.value = null;
|
highlightCell.value = null;
|
||||||
ghostSprite.value = null;
|
ghostSprite.value = null;
|
||||||
|
initialSpritePositions.value.clear();
|
||||||
|
|
||||||
onDraw();
|
onDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Touch event handlers
|
|
||||||
const handleTouchStart = (event: TouchEvent) => {
|
const handleTouchStart = (event: TouchEvent) => {
|
||||||
if (event.touches.length === 1) {
|
if (event.touches.length === 1) {
|
||||||
const touch = event.touches[0];
|
const touch = event.touches[0];
|
||||||
@@ -271,14 +309,12 @@ export function useDragSprite(options: DragSpriteOptions) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
|
||||||
isDragging,
|
isDragging,
|
||||||
activeSpriteId,
|
activeSpriteId,
|
||||||
ghostSprite,
|
ghostSprite,
|
||||||
highlightCell,
|
highlightCell,
|
||||||
spritePositions,
|
spritePositions,
|
||||||
|
|
||||||
// Methods
|
|
||||||
startDrag,
|
startDrag,
|
||||||
drag,
|
drag,
|
||||||
stopDrag,
|
stopDrag,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { getMaxDimensionsAcrossLayers } from './useLayers';
|
|||||||
|
|
||||||
export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>, layers?: Ref<Layer[]>) => {
|
export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>, layers?: Ref<Layer[]>) => {
|
||||||
const getCellDimensions = () => {
|
const getCellDimensions = () => {
|
||||||
// If manual cell size is enabled, use manual values
|
|
||||||
if (manualCellSizeEnabled?.value) {
|
if (manualCellSizeEnabled?.value) {
|
||||||
return {
|
return {
|
||||||
cellWidth: manualCellWidth?.value ?? 64,
|
cellWidth: manualCellWidth?.value ?? 64,
|
||||||
@@ -18,11 +17,8 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use dimensions from ALL layers to keep canvas size stable when hiding layers
|
|
||||||
// Fall back to current sprites if layers ref is not provided
|
|
||||||
const { maxWidth, maxHeight } = layers?.value ? getMaxDimensionsAcrossLayers(layers.value, false) : getMaxDimensions(sprites.value);
|
const { maxWidth, maxHeight } = layers?.value ? getMaxDimensionsAcrossLayers(layers.value, false) : getMaxDimensions(sprites.value);
|
||||||
|
|
||||||
// Calculate negative spacing from all layers' sprites for consistency
|
|
||||||
const allSprites = layers?.value ? layers.value.flatMap(l => l.sprites) : sprites.value;
|
const allSprites = layers?.value ? layers.value.flatMap(l => l.sprites) : sprites.value;
|
||||||
const negativeSpacing = calculateNegativeSpacing(allSprites, negativeSpacingEnabled.value);
|
const negativeSpacing = calculateNegativeSpacing(allSprites, negativeSpacingEnabled.value);
|
||||||
|
|
||||||
@@ -50,7 +46,6 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
|||||||
canvas.height = cellHeight * rows;
|
canvas.height = cellHeight * rows;
|
||||||
ctx.imageSmoothingEnabled = false;
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
// Apply background color if not transparent
|
|
||||||
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||||
ctx.fillStyle = backgroundColor.value;
|
ctx.fillStyle = backgroundColor.value;
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
@@ -151,7 +146,6 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
|||||||
if (typeof jsonData.manualCellWidth === 'number' && manualCellWidth) manualCellWidth.value = jsonData.manualCellWidth;
|
if (typeof jsonData.manualCellWidth === 'number' && manualCellWidth) manualCellWidth.value = jsonData.manualCellWidth;
|
||||||
if (typeof jsonData.manualCellHeight === 'number' && manualCellHeight) manualCellHeight.value = jsonData.manualCellHeight;
|
if (typeof jsonData.manualCellHeight === 'number' && manualCellHeight) manualCellHeight.value = jsonData.manualCellHeight;
|
||||||
|
|
||||||
// revoke existing blob urls
|
|
||||||
if (sprites.value.length) {
|
if (sprites.value.length) {
|
||||||
sprites.value.forEach(s => {
|
sprites.value.forEach(s => {
|
||||||
if (s.url && s.url.startsWith('blob:')) {
|
if (s.url && s.url.startsWith('blob:')) {
|
||||||
@@ -215,7 +209,6 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
|||||||
|
|
||||||
sprites.value.forEach(sprite => {
|
sprites.value.forEach(sprite => {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
// Apply background color if not transparent
|
|
||||||
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||||
ctx.fillStyle = backgroundColor.value;
|
ctx.fillStyle = backgroundColor.value;
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
@@ -263,7 +256,6 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
|||||||
|
|
||||||
sprites.value.forEach((sprite, index) => {
|
sprites.value.forEach((sprite, index) => {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
// Apply background color if not transparent
|
|
||||||
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||||
ctx.fillStyle = backgroundColor.value;
|
ctx.fillStyle = backgroundColor.value;
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
|||||||
const getAllVisibleSprites = () => getVisibleLayers().flatMap(l => l.sprites);
|
const getAllVisibleSprites = () => getVisibleLayers().flatMap(l => l.sprites);
|
||||||
|
|
||||||
const getCellDimensions = () => {
|
const getCellDimensions = () => {
|
||||||
// If manual cell size is enabled, use manual values
|
|
||||||
if (manualCellSizeEnabled?.value) {
|
if (manualCellSizeEnabled?.value) {
|
||||||
return {
|
return {
|
||||||
cellWidth: manualCellWidth?.value ?? 64,
|
cellWidth: manualCellWidth?.value ?? 64,
|
||||||
@@ -20,10 +19,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, calculate from sprite dimensions across ALL layers (same as canvas)
|
|
||||||
// This ensures export dimensions match what's shown in the canvas
|
|
||||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(layersRef.value);
|
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(layersRef.value);
|
||||||
// Calculate negative spacing from ALL layers (not just visible) to keep canvas size stable
|
|
||||||
const allSprites = layersRef.value.flatMap(l => l.sprites);
|
const allSprites = layersRef.value.flatMap(l => l.sprites);
|
||||||
const negativeSpacing = calculateNegativeSpacing(allSprites, negativeSpacingEnabled.value);
|
const negativeSpacing = calculateNegativeSpacing(allSprites, negativeSpacingEnabled.value);
|
||||||
return {
|
return {
|
||||||
@@ -35,7 +31,6 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
|||||||
|
|
||||||
const drawCompositeCell = (ctx: CanvasRenderingContext2D, cellIndex: number, cellWidth: number, cellHeight: number, negativeSpacing: number) => {
|
const drawCompositeCell = (ctx: CanvasRenderingContext2D, cellIndex: number, cellWidth: number, cellHeight: number, negativeSpacing: number) => {
|
||||||
ctx.clearRect(0, 0, cellWidth, cellHeight);
|
ctx.clearRect(0, 0, cellWidth, cellHeight);
|
||||||
// Apply background color if not transparent
|
|
||||||
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||||
ctx.fillStyle = backgroundColor.value;
|
ctx.fillStyle = backgroundColor.value;
|
||||||
ctx.fillRect(0, 0, cellWidth, cellHeight);
|
ctx.fillRect(0, 0, cellWidth, cellHeight);
|
||||||
@@ -78,7 +73,6 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
|||||||
canvas.height = cellHeight * rows;
|
canvas.height = cellHeight * rows;
|
||||||
ctx.imageSmoothingEnabled = false;
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
// Apply background color to entire canvas if not transparent
|
|
||||||
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||||
ctx.fillStyle = backgroundColor.value;
|
ctx.fillStyle = backgroundColor.value;
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
@@ -170,6 +164,26 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
|||||||
const file = new File([blob], fileName, { type: mimeType });
|
const file = new File([blob], fileName, { type: mimeType });
|
||||||
resolve({ id: spriteData.id || crypto.randomUUID(), file, img, url: spriteData.base64, width: spriteData.width, height: spriteData.height, x: spriteData.x || 0, y: spriteData.y || 0, rotation: spriteData.rotation || 0, flipX: spriteData.flipX || false, flipY: spriteData.flipY || false });
|
resolve({ id: spriteData.id || crypto.randomUUID(), file, img, url: spriteData.base64, width: spriteData.width, height: spriteData.height, x: spriteData.x || 0, y: spriteData.y || 0, rotation: spriteData.rotation || 0, flipX: spriteData.flipX || false, flipY: spriteData.flipY || false });
|
||||||
};
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
console.error('Failed to load sprite image:', spriteData.name);
|
||||||
|
// Create a 1x1 transparent placeholder or similar to avoid breaking the entire project load
|
||||||
|
// For now, we'll just resolve with a "broken" sprite but maybe with 0 width/height effectively
|
||||||
|
// or we could construct a dummy file.
|
||||||
|
// Let's resolve with a valid but empty/placeholder structure to let other sprites load.
|
||||||
|
resolve({
|
||||||
|
id: spriteData.id || crypto.randomUUID(),
|
||||||
|
file: new File([], 'broken-image'),
|
||||||
|
img: new Image(), // Empty image
|
||||||
|
url: '',
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
x: spriteData.x || 0,
|
||||||
|
y: spriteData.y || 0,
|
||||||
|
rotation: 0,
|
||||||
|
flipX: false,
|
||||||
|
flipY: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
img.src = spriteData.base64;
|
img.src = spriteData.base64;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,7 +200,6 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
|||||||
const sprites: Sprite[] = await Promise.all(layerData.sprites.map((s: any) => loadSprite(s)));
|
const sprites: Sprite[] = await Promise.all(layerData.sprites.map((s: any) => loadSprite(s)));
|
||||||
newLayers.push({ id: layerData.id || crypto.randomUUID(), name: layerData.name || 'Layer', visible: layerData.visible !== false, locked: !!layerData.locked, sprites });
|
newLayers.push({ id: layerData.id || crypto.randomUUID(), name: layerData.name || 'Layer', visible: layerData.visible !== false, locked: !!layerData.locked, sprites });
|
||||||
}
|
}
|
||||||
// Ensure at least one layer with sprites is visible
|
|
||||||
if (newLayers.length > 0 && !newLayers.some(l => l.visible && l.sprites.length > 0)) {
|
if (newLayers.length > 0 && !newLayers.some(l => l.visible && l.sprites.length > 0)) {
|
||||||
const firstLayerWithSprites = newLayers.find(l => l.sprites.length > 0);
|
const firstLayerWithSprites = newLayers.find(l => l.sprites.length > 0);
|
||||||
if (firstLayerWithSprites) {
|
if (firstLayerWithSprites) {
|
||||||
@@ -194,7 +207,6 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
layersRef.value = newLayers;
|
layersRef.value = newLayers;
|
||||||
// Set active layer to the first layer with sprites
|
|
||||||
if (activeLayerId && newLayers.length > 0) {
|
if (activeLayerId && newLayers.length > 0) {
|
||||||
const firstWithSprites = newLayers.find(l => l.sprites.length > 0);
|
const firstWithSprites = newLayers.find(l => l.sprites.length > 0);
|
||||||
activeLayerId.value = firstWithSprites ? firstWithSprites.id : newLayers[0].id;
|
activeLayerId.value = firstWithSprites ? firstWithSprites.id : newLayers[0].id;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export interface FileDropOptions {
|
|||||||
export function useFileDrop(options: FileDropOptions) {
|
export function useFileDrop(options: FileDropOptions) {
|
||||||
const { onAddSprite, onAddSpriteWithResize } = options;
|
const { onAddSprite, onAddSpriteWithResize } = options;
|
||||||
|
|
||||||
// Helper to get sprites array
|
|
||||||
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
|
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
|
||||||
|
|
||||||
const isDragOver = ref(false);
|
const isDragOver = ref(false);
|
||||||
@@ -60,7 +59,6 @@ export function useFileDrop(options: FileDropOptions) {
|
|||||||
const sprites = getSprites();
|
const sprites = getSprites();
|
||||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites);
|
const { maxWidth, maxHeight } = getMaxDimensions(sprites);
|
||||||
|
|
||||||
// Check if the dropped image is larger than current cells
|
|
||||||
if (img.naturalWidth > maxWidth || img.naturalHeight > maxHeight) {
|
if (img.naturalWidth > maxWidth || img.naturalHeight > maxHeight) {
|
||||||
onAddSpriteWithResize(file);
|
onAddSpriteWithResize(file);
|
||||||
} else {
|
} else {
|
||||||
@@ -94,7 +92,6 @@ export function useFileDrop(options: FileDropOptions) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each dropped file
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
await processDroppedImage(file);
|
await processDroppedImage(file);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export interface GridMetricsOptions {
|
|||||||
* Provides a single source of truth for cell dimensions and positioning calculations.
|
* Provides a single source of truth for cell dimensions and positioning calculations.
|
||||||
*/
|
*/
|
||||||
export function useGridMetrics(options: GridMetricsOptions = {}) {
|
export function useGridMetrics(options: GridMetricsOptions = {}) {
|
||||||
// Helper to get reactive values
|
|
||||||
const getLayers = () => (options.layers ? (Array.isArray(options.layers) ? options.layers : options.layers.value) : null);
|
const getLayers = () => (options.layers ? (Array.isArray(options.layers) ? options.layers : options.layers.value) : null);
|
||||||
const getSprites = () => (options.sprites ? (Array.isArray(options.sprites) ? options.sprites : options.sprites.value) : []);
|
const getSprites = () => (options.sprites ? (Array.isArray(options.sprites) ? options.sprites : options.sprites.value) : []);
|
||||||
const getNegativeSpacingEnabled = () => (typeof options.negativeSpacingEnabled === 'boolean' ? options.negativeSpacingEnabled : (options.negativeSpacingEnabled?.value ?? false));
|
const getNegativeSpacingEnabled = () => (typeof options.negativeSpacingEnabled === 'boolean' ? options.negativeSpacingEnabled : (options.negativeSpacingEnabled?.value ?? false));
|
||||||
@@ -52,7 +51,6 @@ export function useGridMetrics(options: GridMetricsOptions = {}) {
|
|||||||
const calculateCellDimensions = (): GridMetrics => {
|
const calculateCellDimensions = (): GridMetrics => {
|
||||||
const manualCellSizeEnabled = getManualCellSizeEnabled();
|
const manualCellSizeEnabled = getManualCellSizeEnabled();
|
||||||
|
|
||||||
// If manual cell size is enabled, use manual dimensions
|
|
||||||
if (manualCellSizeEnabled) {
|
if (manualCellSizeEnabled) {
|
||||||
return {
|
return {
|
||||||
maxWidth: Math.round(getManualCellWidth()),
|
maxWidth: Math.round(getManualCellWidth()),
|
||||||
@@ -61,20 +59,16 @@ export function useGridMetrics(options: GridMetricsOptions = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get sprites to measure from layers or direct sprites array
|
|
||||||
const layers = getLayers();
|
const layers = getLayers();
|
||||||
const spritesToMeasure = layers ? layers.flatMap(l => l.sprites) : getSprites();
|
const spritesToMeasure = layers ? layers.flatMap(l => l.sprites) : getSprites();
|
||||||
|
|
||||||
// Calculate base dimensions from sprites
|
|
||||||
const base = getMaxDimensions(spritesToMeasure);
|
const base = getMaxDimensions(spritesToMeasure);
|
||||||
const baseMaxWidth = Math.max(1, base.maxWidth);
|
const baseMaxWidth = Math.max(1, base.maxWidth);
|
||||||
const baseMaxHeight = Math.max(1, base.maxHeight);
|
const baseMaxHeight = Math.max(1, base.maxHeight);
|
||||||
|
|
||||||
// Calculate negative spacing
|
|
||||||
const negativeSpacingEnabled = getNegativeSpacingEnabled();
|
const negativeSpacingEnabled = getNegativeSpacingEnabled();
|
||||||
const negativeSpacing = Math.round(calculateNegativeSpacing(spritesToMeasure, negativeSpacingEnabled));
|
const negativeSpacing = Math.round(calculateNegativeSpacing(spritesToMeasure, negativeSpacingEnabled));
|
||||||
|
|
||||||
// Add negative spacing to expand each cell
|
|
||||||
return {
|
return {
|
||||||
maxWidth: Math.round(baseMaxWidth + negativeSpacing),
|
maxWidth: Math.round(baseMaxWidth + negativeSpacing),
|
||||||
maxHeight: Math.round(baseMaxHeight + negativeSpacing),
|
maxHeight: Math.round(baseMaxHeight + negativeSpacing),
|
||||||
@@ -116,10 +110,8 @@ export function useGridMetrics(options: GridMetricsOptions = {}) {
|
|||||||
const gridMetrics = computed(() => calculateCellDimensions());
|
const gridMetrics = computed(() => calculateCellDimensions());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Computed values
|
|
||||||
gridMetrics,
|
gridMetrics,
|
||||||
|
|
||||||
// Methods
|
|
||||||
calculateCellDimensions,
|
calculateCellDimensions,
|
||||||
getCellPosition,
|
getCellPosition,
|
||||||
getSpriteCanvasPosition,
|
getSpriteCanvasPosition,
|
||||||
@@ -147,7 +139,6 @@ export function getGridMetrics(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have layers or sprites
|
|
||||||
const isLayers = spritesOrLayers.length > 0 && 'sprites' in spritesOrLayers[0];
|
const isLayers = spritesOrLayers.length > 0 && 'sprites' in spritesOrLayers[0];
|
||||||
const sprites = isLayers ? (spritesOrLayers as Layer[]).flatMap(l => l.sprites) : (spritesOrLayers as Sprite[]);
|
const sprites = isLayers ? (spritesOrLayers as Layer[]).flatMap(l => l.sprites) : (spritesOrLayers as Sprite[]);
|
||||||
|
|
||||||
|
|||||||
@@ -70,16 +70,13 @@ export const useLayers = () => {
|
|||||||
const l = activeLayer.value;
|
const l = activeLayer.value;
|
||||||
if (!l || !l.sprites.length) return;
|
if (!l || !l.sprites.length) return;
|
||||||
|
|
||||||
// Determine the cell dimensions to align within
|
|
||||||
let cellWidth: number;
|
let cellWidth: number;
|
||||||
let cellHeight: number;
|
let cellHeight: number;
|
||||||
|
|
||||||
if (settingsStore.manualCellSizeEnabled) {
|
if (settingsStore.manualCellSizeEnabled) {
|
||||||
// Use manual cell size (without negative spacing)
|
|
||||||
cellWidth = settingsStore.manualCellWidth;
|
cellWidth = settingsStore.manualCellWidth;
|
||||||
cellHeight = settingsStore.manualCellHeight;
|
cellHeight = settingsStore.manualCellHeight;
|
||||||
} else {
|
} else {
|
||||||
// Use auto-calculated dimensions based on ALL visible layers (not just active layer)
|
|
||||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
|
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
|
||||||
cellWidth = maxWidth;
|
cellWidth = maxWidth;
|
||||||
cellHeight = maxHeight;
|
cellHeight = maxHeight;
|
||||||
@@ -120,60 +117,26 @@ export const useLayers = () => {
|
|||||||
|
|
||||||
const next = [...l.sprites];
|
const next = [...l.sprites];
|
||||||
|
|
||||||
// Remove the moving sprite first
|
|
||||||
const [moving] = next.splice(currentIndex, 1);
|
const [moving] = next.splice(currentIndex, 1);
|
||||||
|
|
||||||
// Determine the actual index to insert at, considering we removed one item
|
|
||||||
// If the target index was greater than current index, it shifts down by 1 in the original array perspective?
|
|
||||||
// Actually simpler: we just want to put 'moving' at 'newIndex' in the final array.
|
|
||||||
|
|
||||||
// If newIndex is beyond the current bounds (after removal), fill with placeholders
|
|
||||||
while (next.length < newIndex) {
|
while (next.length < newIndex) {
|
||||||
next.push(createEmptySprite());
|
next.push(createEmptySprite());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now insert
|
|
||||||
// If newIndex is within bounds, we might be swapping if there was something there
|
|
||||||
// But the DragSprite logic implies we are "moving to this cell".
|
|
||||||
// If there is existing content at newIndex, we should swap or splice?
|
|
||||||
// The previous implementation did a swap if newIndex < length (before removal).
|
|
||||||
|
|
||||||
// Let's stick to the "swap" logic if there's a sprite there, or "move" if we are reordering.
|
|
||||||
// Wait, Drag and Drop usually implies "insert here" or "swap with this".
|
|
||||||
// useDragSprite says: "if allowCellSwap... updateSpriteCell".
|
|
||||||
|
|
||||||
// The original logic:
|
|
||||||
// if (newIndex < next.length) -> swap
|
|
||||||
// else -> splice (move)
|
|
||||||
|
|
||||||
// Re-evaluating original logic:
|
|
||||||
// next has NOT had the item removed yet in the original logic 'if' block.
|
|
||||||
|
|
||||||
// Let's implement robust swap/move logic.
|
|
||||||
// 1. If target is empty placeholder -> just move there (replace placeholder).
|
|
||||||
// 2. If target has sprite -> swap.
|
|
||||||
// 3. If target is out of bounds -> pad and move.
|
|
||||||
|
|
||||||
if (newIndex < l.sprites.length) {
|
if (newIndex < l.sprites.length) {
|
||||||
// Perform Swap
|
|
||||||
const target = l.sprites[newIndex];
|
const target = l.sprites[newIndex];
|
||||||
const moving = l.sprites[currentIndex];
|
const moving = l.sprites[currentIndex];
|
||||||
|
|
||||||
// Clone array
|
|
||||||
const newSprites = [...l.sprites];
|
const newSprites = [...l.sprites];
|
||||||
newSprites[currentIndex] = target;
|
newSprites[currentIndex] = target;
|
||||||
newSprites[newIndex] = moving;
|
newSprites[newIndex] = moving;
|
||||||
l.sprites = newSprites;
|
l.sprites = newSprites;
|
||||||
} else {
|
} else {
|
||||||
// Move to previously empty/non-existent cell
|
|
||||||
const newSprites = [...l.sprites];
|
const newSprites = [...l.sprites];
|
||||||
// Remove from old pos
|
|
||||||
const [moved] = newSprites.splice(currentIndex, 1);
|
const [moved] = newSprites.splice(currentIndex, 1);
|
||||||
// Pad
|
|
||||||
while (newSprites.length < newIndex) {
|
while (newSprites.length < newIndex) {
|
||||||
newSprites.push(createEmptySprite());
|
newSprites.push(createEmptySprite());
|
||||||
}
|
}
|
||||||
// Insert (or push if equal length)
|
|
||||||
newSprites.splice(newIndex, 0, moved);
|
newSprites.splice(newIndex, 0, moved);
|
||||||
l.sprites = newSprites;
|
l.sprites = newSprites;
|
||||||
}
|
}
|
||||||
@@ -190,14 +153,21 @@ export const useLayers = () => {
|
|||||||
URL.revokeObjectURL(s.url);
|
URL.revokeObjectURL(s.url);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
l.sprites.splice(i, 1);
|
|
||||||
|
if (layers.value.length > 1) {
|
||||||
|
// If there are multiple layers, we want to maintain frame alignment
|
||||||
|
// so we replace the sprite with an empty one instead of shifting
|
||||||
|
l.sprites[i] = createEmptySprite();
|
||||||
|
} else {
|
||||||
|
// If there's only one layer, we can safely remove the frame
|
||||||
|
l.sprites.splice(i, 1);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeSprites = (ids: string[]) => {
|
const removeSprites = (ids: string[]) => {
|
||||||
const l = activeLayer.value;
|
const l = activeLayer.value;
|
||||||
if (!l) return;
|
if (!l) return;
|
||||||
|
|
||||||
// Sort indices in descending order to avoid shift issues when splicing
|
|
||||||
const indicesToRemove: number[] = [];
|
const indicesToRemove: number[] = [];
|
||||||
ids.forEach(id => {
|
ids.forEach(id => {
|
||||||
const i = l.sprites.findIndex(s => s.id === id);
|
const i = l.sprites.findIndex(s => s.id === id);
|
||||||
@@ -213,7 +183,12 @@ export const useLayers = () => {
|
|||||||
URL.revokeObjectURL(s.url);
|
URL.revokeObjectURL(s.url);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
l.sprites.splice(i, 1);
|
|
||||||
|
if (layers.value.length > 1) {
|
||||||
|
l.sprites[i] = createEmptySprite();
|
||||||
|
} else {
|
||||||
|
l.sprites.splice(i, 1);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -291,21 +266,11 @@ export const useLayers = () => {
|
|||||||
const currentSprites = [...l.sprites];
|
const currentSprites = [...l.sprites];
|
||||||
|
|
||||||
if (typeof index === 'number') {
|
if (typeof index === 'number') {
|
||||||
// If index is provided, insert there (padding if needed)
|
|
||||||
while (currentSprites.length < index) {
|
while (currentSprites.length < index) {
|
||||||
currentSprites.push(createEmptySprite());
|
currentSprites.push(createEmptySprite());
|
||||||
}
|
}
|
||||||
// If valid index, replace if empty or splice?
|
|
||||||
// "Adds it not in the one I selected".
|
|
||||||
// If I select a cell, I expect it to go there.
|
|
||||||
// If the cell is empty (placeholder), replace it.
|
|
||||||
// If the cell has a sprite, maybe insert/shift?
|
|
||||||
// Usually "Add" implies append, but context menu "Add sprite" on a cell implies "Put it here".
|
|
||||||
// Let's Insert (Shift others) for safety, or check if empty.
|
|
||||||
// But simpler: just splice it in.
|
|
||||||
currentSprites.splice(index, 0, next);
|
currentSprites.splice(index, 0, next);
|
||||||
} else {
|
} else {
|
||||||
// No index, append to end
|
|
||||||
currentSprites.push(next);
|
currentSprites.push(next);
|
||||||
}
|
}
|
||||||
l.sprites = currentSprites;
|
l.sprites = currentSprites;
|
||||||
@@ -321,6 +286,64 @@ export const useLayers = () => {
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addSprites = async (files: File[], index?: number) => {
|
||||||
|
const l = activeLayer.value;
|
||||||
|
if (!l) return;
|
||||||
|
|
||||||
|
const promises = files.map(file => {
|
||||||
|
return new Promise<Sprite>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = e => {
|
||||||
|
const url = e.target?.result as string;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
resolve({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
file,
|
||||||
|
img,
|
||||||
|
url,
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
rotation: 0,
|
||||||
|
flipX: false,
|
||||||
|
flipY: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
console.error('Failed to load sprite image:', file.name);
|
||||||
|
reject(new Error(`Failed to load sprite image: ${file.name}`));
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
console.error('Failed to read sprite image file:', file.name);
|
||||||
|
reject(new Error(`Failed to read sprite image file: ${file.name}`));
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newSprites = await Promise.all(promises);
|
||||||
|
const currentSprites = [...l.sprites];
|
||||||
|
|
||||||
|
if (typeof index === 'number') {
|
||||||
|
while (currentSprites.length < index) {
|
||||||
|
currentSprites.push(createEmptySprite());
|
||||||
|
}
|
||||||
|
// Replace existing sprites at the target index instead of shifting them
|
||||||
|
currentSprites.splice(index, newSprites.length, ...newSprites);
|
||||||
|
} else {
|
||||||
|
currentSprites.push(...newSprites);
|
||||||
|
}
|
||||||
|
l.sprites = currentSprites;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding sprites:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const processImageFiles = async (files: File[]) => {
|
const processImageFiles = async (files: File[]) => {
|
||||||
for (const f of files) addSprite(f);
|
for (const f of files) addSprite(f);
|
||||||
};
|
};
|
||||||
@@ -353,7 +376,6 @@ export const useLayers = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const copySpriteToFrame = (spriteId: string, targetLayerId: string, targetFrameIndex: number) => {
|
const copySpriteToFrame = (spriteId: string, targetLayerId: string, targetFrameIndex: number) => {
|
||||||
// Find the source sprite in any layer
|
|
||||||
let sourceSprite: Sprite | undefined;
|
let sourceSprite: Sprite | undefined;
|
||||||
for (const layer of layers.value) {
|
for (const layer of layers.value) {
|
||||||
sourceSprite = layer.sprites.find(s => s.id === spriteId);
|
sourceSprite = layer.sprites.find(s => s.id === spriteId);
|
||||||
@@ -362,11 +384,9 @@ export const useLayers = () => {
|
|||||||
|
|
||||||
if (!sourceSprite) return;
|
if (!sourceSprite) return;
|
||||||
|
|
||||||
// Find target layer
|
|
||||||
const targetLayer = layers.value.find(l => l.id === targetLayerId);
|
const targetLayer = layers.value.find(l => l.id === targetLayerId);
|
||||||
if (!targetLayer) return;
|
if (!targetLayer) return;
|
||||||
|
|
||||||
// Create a deep copy of the sprite with a new ID
|
|
||||||
const copiedSprite: Sprite = {
|
const copiedSprite: Sprite = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
file: sourceSprite.file,
|
file: sourceSprite.file,
|
||||||
@@ -381,14 +401,11 @@ export const useLayers = () => {
|
|||||||
flipY: sourceSprite.flipY,
|
flipY: sourceSprite.flipY,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Expand the sprites array if necessary with empty placeholder sprites
|
|
||||||
while (targetLayer.sprites.length < targetFrameIndex) {
|
while (targetLayer.sprites.length < targetFrameIndex) {
|
||||||
targetLayer.sprites.push(createEmptySprite());
|
targetLayer.sprites.push(createEmptySprite());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace or insert the sprite at the target index
|
|
||||||
if (targetFrameIndex < targetLayer.sprites.length) {
|
if (targetFrameIndex < targetLayer.sprites.length) {
|
||||||
// Replace existing sprite at this frame
|
|
||||||
const old = targetLayer.sprites[targetFrameIndex];
|
const old = targetLayer.sprites[targetFrameIndex];
|
||||||
if (old.url && old.url.startsWith('blob:')) {
|
if (old.url && old.url.startsWith('blob:')) {
|
||||||
try {
|
try {
|
||||||
@@ -397,7 +414,6 @@ export const useLayers = () => {
|
|||||||
}
|
}
|
||||||
targetLayer.sprites[targetFrameIndex] = copiedSprite;
|
targetLayer.sprites[targetFrameIndex] = copiedSprite;
|
||||||
} else {
|
} else {
|
||||||
// Add at the end
|
|
||||||
targetLayer.sprites.push(copiedSprite);
|
targetLayer.sprites.push(copiedSprite);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -418,6 +434,7 @@ export const useLayers = () => {
|
|||||||
flipSprite,
|
flipSprite,
|
||||||
replaceSprite,
|
replaceSprite,
|
||||||
addSprite,
|
addSprite,
|
||||||
|
addSprites,
|
||||||
processImageFiles,
|
processImageFiles,
|
||||||
alignSprites,
|
alignSprites,
|
||||||
addLayer,
|
addLayer,
|
||||||
@@ -429,8 +446,6 @@ export const useLayers = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getMaxDimensionsAcrossLayers = (layers: Layer[], visibleOnly: boolean = false) => {
|
export const getMaxDimensionsAcrossLayers = (layers: Layer[], visibleOnly: boolean = false) => {
|
||||||
// When visibleOnly is false (default), consider ALL layers to keep canvas size stable
|
|
||||||
// When visibleOnly is true (export), only consider visible layers
|
|
||||||
const sprites = layers.flatMap(l => (visibleOnly ? (l.visible ? l.sprites : []) : l.sprites));
|
const sprites = layers.flatMap(l => (visibleOnly ? (l.visible ? l.sprites : []) : l.sprites));
|
||||||
return getMaxDimensionsSingle(sprites);
|
return getMaxDimensionsSingle(sprites);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ export function calculateNegativeSpacing(sprites: Sprite[], enabled: boolean): n
|
|||||||
const minWidth = Math.min(...sprites.map(s => s.width));
|
const minWidth = Math.min(...sprites.map(s => s.width));
|
||||||
const minHeight = Math.min(...sprites.map(s => s.height));
|
const minHeight = Math.min(...sprites.map(s => s.height));
|
||||||
|
|
||||||
// Available space is the gap between cell size and smallest sprite
|
|
||||||
const availableWidth = maxWidth - minWidth;
|
const availableWidth = maxWidth - minWidth;
|
||||||
const availableHeight = maxHeight - minHeight;
|
const availableHeight = maxHeight - minHeight;
|
||||||
|
|
||||||
// Use half to balance spacing equally on all sides
|
|
||||||
return Math.floor(Math.min(availableWidth, availableHeight) / 2);
|
return Math.floor(Math.min(availableWidth, availableHeight) / 2);
|
||||||
}
|
}
|
||||||
|
|||||||
425
src/composables/usePixelEditor.ts
Normal file
425
src/composables/usePixelEditor.ts
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
import { ref, computed, watch, type Ref } from 'vue';
|
||||||
|
|
||||||
|
export interface PixelEditorOptions {
|
||||||
|
initialImageUrl?: string;
|
||||||
|
initialWidth?: number;
|
||||||
|
initialHeight?: number;
|
||||||
|
backgroundColor?: Ref<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryEntry {
|
||||||
|
imageData: ImageData;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePixelEditor = (options: PixelEditorOptions = {}) => {
|
||||||
|
const canvas = ref<HTMLCanvasElement | null>(null);
|
||||||
|
const ctx = computed(() => canvas.value?.getContext('2d', { willReadFrequently: true }) ?? null);
|
||||||
|
|
||||||
|
const canvasWidth = ref(options.initialWidth || 32);
|
||||||
|
const canvasHeight = ref(options.initialHeight || 32);
|
||||||
|
|
||||||
|
const currentTool = ref<'pencil' | 'eraser' | 'picker'>('pencil');
|
||||||
|
const currentColor = ref('#000000');
|
||||||
|
const brushSize = ref(1);
|
||||||
|
const zoom = ref(1);
|
||||||
|
|
||||||
|
const isDrawing = ref(false);
|
||||||
|
const lastX = ref(0);
|
||||||
|
const lastY = ref(0);
|
||||||
|
|
||||||
|
const pointerX = ref(0);
|
||||||
|
const pointerY = ref(0);
|
||||||
|
const showPointerLocation = ref(true);
|
||||||
|
const showCheckerboard = ref(false);
|
||||||
|
|
||||||
|
// History management
|
||||||
|
const history = ref<HistoryEntry[]>([]);
|
||||||
|
const historyIndex = ref(-1);
|
||||||
|
const maxHistorySize = 50;
|
||||||
|
|
||||||
|
const canUndo = computed(() => historyIndex.value > 0);
|
||||||
|
const canRedo = computed(() => historyIndex.value < history.value.length - 1);
|
||||||
|
|
||||||
|
const saveToHistory = () => {
|
||||||
|
if (!ctx.value || !canvas.value) return;
|
||||||
|
|
||||||
|
const imageData = ctx.value.getImageData(0, 0, canvasWidth.value, canvasHeight.value);
|
||||||
|
const entry: HistoryEntry = {
|
||||||
|
imageData,
|
||||||
|
width: canvasWidth.value,
|
||||||
|
height: canvasHeight.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove any redo history
|
||||||
|
if (historyIndex.value < history.value.length - 1) {
|
||||||
|
history.value = history.value.slice(0, historyIndex.value + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
history.value.push(entry);
|
||||||
|
|
||||||
|
// Limit history size
|
||||||
|
if (history.value.length > maxHistorySize) {
|
||||||
|
history.value.shift();
|
||||||
|
} else {
|
||||||
|
historyIndex.value++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const undo = () => {
|
||||||
|
if (!canUndo.value || !ctx.value || !canvas.value) return;
|
||||||
|
|
||||||
|
historyIndex.value--;
|
||||||
|
const entry = history.value[historyIndex.value];
|
||||||
|
|
||||||
|
canvasWidth.value = entry.width;
|
||||||
|
canvasHeight.value = entry.height;
|
||||||
|
canvas.value.width = entry.width;
|
||||||
|
canvas.value.height = entry.height;
|
||||||
|
|
||||||
|
ctx.value.putImageData(entry.imageData, 0, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const redo = () => {
|
||||||
|
if (!canRedo.value || !ctx.value || !canvas.value) return;
|
||||||
|
|
||||||
|
historyIndex.value++;
|
||||||
|
const entry = history.value[historyIndex.value];
|
||||||
|
|
||||||
|
canvasWidth.value = entry.width;
|
||||||
|
canvasHeight.value = entry.height;
|
||||||
|
canvas.value.width = entry.width;
|
||||||
|
canvas.value.height = entry.height;
|
||||||
|
|
||||||
|
ctx.value.putImageData(entry.imageData, 0, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initCanvas = (canvasElement: HTMLCanvasElement) => {
|
||||||
|
canvas.value = canvasElement;
|
||||||
|
if (!ctx.value) return;
|
||||||
|
|
||||||
|
canvas.value.width = canvasWidth.value;
|
||||||
|
canvas.value.height = canvasHeight.value;
|
||||||
|
|
||||||
|
// Clear with transparent background
|
||||||
|
ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);
|
||||||
|
|
||||||
|
// Save initial state to history
|
||||||
|
saveToHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFromImage = async (imageUrl: string) => {
|
||||||
|
if (!ctx.value || !canvas.value) return;
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
|
img.onload = () => {
|
||||||
|
canvasWidth.value = img.width;
|
||||||
|
canvasHeight.value = img.height;
|
||||||
|
canvas.value!.width = img.width;
|
||||||
|
canvas.value!.height = img.height;
|
||||||
|
|
||||||
|
ctx.value!.clearRect(0, 0, img.width, img.height);
|
||||||
|
ctx.value!.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
// Clear history and save initial state
|
||||||
|
history.value = [];
|
||||||
|
historyIndex.value = -1;
|
||||||
|
saveToHistory();
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = imageUrl;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPixelCoords = (event: MouseEvent, canvasElement: HTMLCanvasElement): { x: number; y: number } => {
|
||||||
|
const rect = canvasElement.getBoundingClientRect();
|
||||||
|
const scaleX = canvasWidth.value / rect.width;
|
||||||
|
const scaleY = canvasHeight.value / rect.height;
|
||||||
|
|
||||||
|
const x = Math.floor((event.clientX - rect.left) * scaleX);
|
||||||
|
const y = Math.floor((event.clientY - rect.top) * scaleY);
|
||||||
|
|
||||||
|
return { x: Math.max(0, Math.min(x, canvasWidth.value - 1)), y: Math.max(0, Math.min(y, canvasHeight.value - 1)) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawPixel = (x: number, y: number) => {
|
||||||
|
if (!ctx.value) return;
|
||||||
|
|
||||||
|
if (currentTool.value === 'eraser') {
|
||||||
|
ctx.value.clearRect(x, y, brushSize.value, brushSize.value);
|
||||||
|
} else {
|
||||||
|
ctx.value.fillStyle = currentColor.value;
|
||||||
|
ctx.value.fillRect(x, y, brushSize.value, brushSize.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawLine = (x0: number, y0: number, x1: number, y1: number) => {
|
||||||
|
const dx = Math.abs(x1 - x0);
|
||||||
|
const dy = Math.abs(y1 - y0);
|
||||||
|
const sx = x0 < x1 ? 1 : -1;
|
||||||
|
const sy = y0 < y1 ? 1 : -1;
|
||||||
|
let err = dx - dy;
|
||||||
|
|
||||||
|
let x = x0;
|
||||||
|
let y = y0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
drawPixel(x, y);
|
||||||
|
|
||||||
|
if (x === x1 && y === y1) break;
|
||||||
|
|
||||||
|
const e2 = 2 * err;
|
||||||
|
if (e2 > -dy) {
|
||||||
|
err -= dy;
|
||||||
|
x += sx;
|
||||||
|
}
|
||||||
|
if (e2 < dx) {
|
||||||
|
err += dx;
|
||||||
|
y += sy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickColor = (x: number, y: number) => {
|
||||||
|
if (!ctx.value) return;
|
||||||
|
const p = ctx.value.getImageData(x, y, 1, 1).data;
|
||||||
|
|
||||||
|
// Ignore if fully transparent
|
||||||
|
if (p[3] === 0) return;
|
||||||
|
|
||||||
|
const hex = '#' + ('00' + p[0].toString(16)).slice(-2) + ('00' + p[1].toString(16)).slice(-2) + ('00' + p[2].toString(16)).slice(-2);
|
||||||
|
|
||||||
|
currentColor.value = hex;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDrawing = (event: MouseEvent) => {
|
||||||
|
if (!canvas.value) return;
|
||||||
|
|
||||||
|
isDrawing.value = true;
|
||||||
|
const { x, y } = getPixelCoords(event, canvas.value);
|
||||||
|
|
||||||
|
if (currentTool.value === 'picker') {
|
||||||
|
pickColor(x, y);
|
||||||
|
} else {
|
||||||
|
lastX.value = x;
|
||||||
|
lastY.value = y;
|
||||||
|
drawPixel(x, y);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const continueDrawing = (event: MouseEvent) => {
|
||||||
|
if (!canvas.value) return;
|
||||||
|
|
||||||
|
const { x, y } = getPixelCoords(event, canvas.value);
|
||||||
|
pointerX.value = x;
|
||||||
|
pointerY.value = y;
|
||||||
|
|
||||||
|
if (!isDrawing.value) return;
|
||||||
|
|
||||||
|
if (currentTool.value === 'picker') {
|
||||||
|
pickColor(x, y);
|
||||||
|
} else {
|
||||||
|
drawLine(lastX.value, lastY.value, x, y);
|
||||||
|
lastX.value = x;
|
||||||
|
lastY.value = y;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDrawing = () => {
|
||||||
|
if (isDrawing.value) {
|
||||||
|
isDrawing.value = false;
|
||||||
|
saveToHistory();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePointer = (event: MouseEvent) => {
|
||||||
|
if (!canvas.value) return;
|
||||||
|
const { x, y } = getPixelCoords(event, canvas.value);
|
||||||
|
pointerX.value = x;
|
||||||
|
pointerY.value = y;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Canvas size operations
|
||||||
|
const resizeCanvas = (newWidth: number, newHeight: number, anchor: string = 'top-left') => {
|
||||||
|
if (!ctx.value || !canvas.value) return;
|
||||||
|
|
||||||
|
const oldImageData = ctx.value.getImageData(0, 0, canvasWidth.value, canvasHeight.value);
|
||||||
|
const oldWidth = canvasWidth.value;
|
||||||
|
const oldHeight = canvasHeight.value;
|
||||||
|
|
||||||
|
canvasWidth.value = newWidth;
|
||||||
|
canvasHeight.value = newHeight;
|
||||||
|
canvas.value.width = newWidth;
|
||||||
|
canvas.value.height = newHeight;
|
||||||
|
|
||||||
|
ctx.value.clearRect(0, 0, newWidth, newHeight);
|
||||||
|
|
||||||
|
// Calculate offset based on anchor
|
||||||
|
let offsetX = 0;
|
||||||
|
let offsetY = 0;
|
||||||
|
|
||||||
|
if (anchor.includes('center')) {
|
||||||
|
offsetX = Math.floor((newWidth - oldWidth) / 2);
|
||||||
|
} else if (anchor.includes('right')) {
|
||||||
|
offsetX = newWidth - oldWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchor.includes('middle')) {
|
||||||
|
offsetY = Math.floor((newHeight - oldHeight) / 2);
|
||||||
|
} else if (anchor.includes('bottom')) {
|
||||||
|
offsetY = newHeight - oldHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temp canvas to hold old image
|
||||||
|
const tempCanvas = document.createElement('canvas');
|
||||||
|
tempCanvas.width = oldWidth;
|
||||||
|
tempCanvas.height = oldHeight;
|
||||||
|
const tempCtx = tempCanvas.getContext('2d');
|
||||||
|
if (tempCtx) {
|
||||||
|
tempCtx.putImageData(oldImageData, 0, 0);
|
||||||
|
ctx.value.drawImage(tempCanvas, offsetX, offsetY);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveToHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
const trimCanvas = () => {
|
||||||
|
if (!ctx.value || !canvas.value) return;
|
||||||
|
|
||||||
|
const imageData = ctx.value.getImageData(0, 0, canvasWidth.value, canvasHeight.value);
|
||||||
|
const { data, width, height } = imageData;
|
||||||
|
|
||||||
|
let minX = width;
|
||||||
|
let minY = height;
|
||||||
|
let maxX = 0;
|
||||||
|
let maxY = 0;
|
||||||
|
|
||||||
|
// Find bounding box of non-transparent pixels
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const alpha = data[(y * width + x) * 4 + 3];
|
||||||
|
if (alpha > 0) {
|
||||||
|
minX = Math.min(minX, x);
|
||||||
|
minY = Math.min(minY, y);
|
||||||
|
maxX = Math.max(maxX, x);
|
||||||
|
maxY = Math.max(maxY, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no non-transparent pixels found, keep at least 1x1
|
||||||
|
if (minX > maxX || minY > maxY) {
|
||||||
|
minX = 0;
|
||||||
|
minY = 0;
|
||||||
|
maxX = 0;
|
||||||
|
maxY = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newWidth = maxX - minX + 1;
|
||||||
|
const newHeight = maxY - minY + 1;
|
||||||
|
|
||||||
|
const croppedData = ctx.value.getImageData(minX, minY, newWidth, newHeight);
|
||||||
|
|
||||||
|
canvasWidth.value = newWidth;
|
||||||
|
canvasHeight.value = newHeight;
|
||||||
|
canvas.value.width = newWidth;
|
||||||
|
canvas.value.height = newHeight;
|
||||||
|
|
||||||
|
ctx.value.putImageData(croppedData, 0, 0);
|
||||||
|
|
||||||
|
saveToHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export to data URL
|
||||||
|
const toDataURL = (): string => {
|
||||||
|
if (!canvas.value) return '';
|
||||||
|
return canvas.value.toDataURL('image/png');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export to File
|
||||||
|
const toFile = async (filename: string = 'sprite.png'): Promise<File | null> => {
|
||||||
|
if (!canvas.value) return null;
|
||||||
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
canvas.value!.toBlob(blob => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(new File([blob], filename, { type: 'image/png' }));
|
||||||
|
} else {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
}, 'image/png');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Zoom controls
|
||||||
|
const zoomIn = () => {
|
||||||
|
zoom.value = Math.min(zoom.value + 2, 32);
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomOut = () => {
|
||||||
|
zoom.value = Math.max(zoom.value - 2, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setZoom = (value: number) => {
|
||||||
|
zoom.value = Math.max(1, Math.min(value, 32));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
const clearCanvas = () => {
|
||||||
|
if (!ctx.value) return;
|
||||||
|
ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);
|
||||||
|
saveToHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Canvas
|
||||||
|
canvas,
|
||||||
|
canvasWidth,
|
||||||
|
canvasHeight,
|
||||||
|
initCanvas,
|
||||||
|
loadFromImage,
|
||||||
|
clearCanvas,
|
||||||
|
|
||||||
|
// Tools
|
||||||
|
currentTool,
|
||||||
|
currentColor,
|
||||||
|
brushSize,
|
||||||
|
|
||||||
|
// Drawing
|
||||||
|
startDrawing,
|
||||||
|
continueDrawing,
|
||||||
|
stopDrawing,
|
||||||
|
updatePointer,
|
||||||
|
|
||||||
|
// Pointer
|
||||||
|
pointerX,
|
||||||
|
pointerY,
|
||||||
|
showPointerLocation,
|
||||||
|
showCheckerboard,
|
||||||
|
|
||||||
|
// Zoom
|
||||||
|
zoom,
|
||||||
|
zoomIn,
|
||||||
|
zoomOut,
|
||||||
|
setZoom,
|
||||||
|
|
||||||
|
// History
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
|
||||||
|
// Canvas operations
|
||||||
|
resizeCanvas,
|
||||||
|
trimCanvas,
|
||||||
|
|
||||||
|
// Export
|
||||||
|
toDataURL,
|
||||||
|
toFile,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -23,31 +23,46 @@ export const useProjectManager = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const createProject = (config: { width: number; height: number; columns: number; rows: number }) => {
|
const createProject = (config: { width: number; height: number; columns: number; rows: number }) => {
|
||||||
// 1. Reset Settings
|
|
||||||
settingsStore.setManualCellSize(config.width, config.height);
|
settingsStore.setManualCellSize(config.width, config.height);
|
||||||
settingsStore.manualCellSizeEnabled = true;
|
settingsStore.manualCellSizeEnabled = true;
|
||||||
|
|
||||||
// 2. Reset Layers
|
|
||||||
const newLayer = createEmptyLayer('Base');
|
const newLayer = createEmptyLayer('Base');
|
||||||
layers.value = [newLayer];
|
layers.value = [newLayer];
|
||||||
activeLayerId.value = newLayer.id;
|
activeLayerId.value = newLayer.id;
|
||||||
|
|
||||||
// 3. Set Columns
|
|
||||||
columns.value = config.columns;
|
columns.value = config.columns;
|
||||||
|
|
||||||
// 4. Reset Project Store
|
|
||||||
projectStore.currentProject = null;
|
projectStore.currentProject = null;
|
||||||
|
|
||||||
// 5. Navigate to Editor
|
|
||||||
router.push('/editor');
|
router.push('/editor');
|
||||||
};
|
};
|
||||||
|
|
||||||
const openProject = async (project: Project) => {
|
const openProject = async (project: Project) => {
|
||||||
try {
|
try {
|
||||||
if (project.data) {
|
let projectData = project.data;
|
||||||
await loadProjectData(project.data);
|
|
||||||
|
// If data is missing, we MUST fetch the full project
|
||||||
|
if (!projectData) {
|
||||||
|
await projectStore.loadProject(project.id);
|
||||||
|
// After loading, the store's currentProject will be updated.
|
||||||
|
// We should use that data.
|
||||||
|
if (projectStore.currentProject && projectStore.currentProject.id === project.id) {
|
||||||
|
projectData = projectStore.currentProject.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
projectStore.currentProject = project;
|
|
||||||
|
if (projectData) {
|
||||||
|
await loadProjectData(projectData);
|
||||||
|
} else {
|
||||||
|
console.warn('Project opened but no data found (even after fetch attempt). Opening empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we set the current project in the store if we passed in a project that might have been partial,
|
||||||
|
// but rely on what's in the store if we just fetched it.
|
||||||
|
if (!projectStore.currentProject || projectStore.currentProject.id !== project.id) {
|
||||||
|
projectStore.currentProject = project;
|
||||||
|
}
|
||||||
|
|
||||||
router.push({ name: 'editor', params: { id: project.id } });
|
router.push({ name: 'editor', params: { id: project.id } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to open project', e);
|
console.error('Failed to open project', e);
|
||||||
@@ -60,12 +75,9 @@ export const useProjectManager = () => {
|
|||||||
const data = await generateProjectJSON();
|
const data = await generateProjectJSON();
|
||||||
|
|
||||||
if (projectStore.currentProject) {
|
if (projectStore.currentProject) {
|
||||||
// Update existing project (even if name changed)
|
|
||||||
await projectStore.updateProject(projectStore.currentProject.id, name, data);
|
await projectStore.updateProject(projectStore.currentProject.id, name, data);
|
||||||
} else {
|
} else {
|
||||||
// Create new project if none exists
|
|
||||||
await projectStore.createProject(name, data);
|
await projectStore.createProject(name, data);
|
||||||
// After creating, we should update route to include ID so subsequent saves update it
|
|
||||||
const newProject = projectStore.currentProject as Project | null;
|
const newProject = projectStore.currentProject as Project | null;
|
||||||
if (newProject) {
|
if (newProject) {
|
||||||
router.replace({ name: 'editor', params: { id: newProject.id } });
|
router.replace({ name: 'editor', params: { id: newProject.id } });
|
||||||
@@ -81,9 +93,7 @@ export const useProjectManager = () => {
|
|||||||
const saveAsProject = async (name: string) => {
|
const saveAsProject = async (name: string) => {
|
||||||
try {
|
try {
|
||||||
const data = await generateProjectJSON();
|
const data = await generateProjectJSON();
|
||||||
// Always create new
|
|
||||||
await projectStore.createProject(name, data);
|
await projectStore.createProject(name, data);
|
||||||
// Navigate to new project
|
|
||||||
if (projectStore.currentProject) {
|
if (projectStore.currentProject) {
|
||||||
router.push({ name: 'editor', params: { id: projectStore.currentProject.id } });
|
router.push({ name: 'editor', params: { id: projectStore.currentProject.id } });
|
||||||
}
|
}
|
||||||
@@ -95,18 +105,14 @@ export const useProjectManager = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const closeProject = () => {
|
const closeProject = () => {
|
||||||
// Reset Layers
|
|
||||||
const newLayer = createEmptyLayer('Base');
|
const newLayer = createEmptyLayer('Base');
|
||||||
layers.value = [newLayer];
|
layers.value = [newLayer];
|
||||||
activeLayerId.value = newLayer.id;
|
activeLayerId.value = newLayer.id;
|
||||||
|
|
||||||
// Reset columns
|
|
||||||
columns.value = 4;
|
columns.value = 4;
|
||||||
|
|
||||||
// Reset Project Store
|
|
||||||
projectStore.currentProject = null;
|
projectStore.currentProject = null;
|
||||||
|
|
||||||
// Navigate Home
|
|
||||||
router.push('/');
|
router.push('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,10 @@ export function useSEO(metadata: SEOMetaData) {
|
|||||||
const imageUrl = metadata.image ? `${SITE_URL}${metadata.image}` : `${SITE_URL}${DEFAULT_IMAGE}`;
|
const imageUrl = metadata.image ? `${SITE_URL}${metadata.image}` : `${SITE_URL}${DEFAULT_IMAGE}`;
|
||||||
|
|
||||||
const metaTags: any[] = [
|
const metaTags: any[] = [
|
||||||
// Primary Meta Tags
|
|
||||||
{ name: 'title', content: fullTitle },
|
{ name: 'title', content: fullTitle },
|
||||||
{ name: 'description', content: metadata.description },
|
{ name: 'description', content: metadata.description },
|
||||||
{ name: 'robots', content: 'index, follow' },
|
{ name: 'robots', content: 'index, follow' },
|
||||||
|
|
||||||
// Open Graph / Facebook
|
|
||||||
{ property: 'og:type', content: metadata.type || 'website' },
|
{ property: 'og:type', content: metadata.type || 'website' },
|
||||||
{ property: 'og:url', content: fullUrl },
|
{ property: 'og:url', content: fullUrl },
|
||||||
{ property: 'og:title', content: fullTitle },
|
{ property: 'og:title', content: fullTitle },
|
||||||
@@ -37,7 +35,6 @@ export function useSEO(metadata: SEOMetaData) {
|
|||||||
{ property: 'og:image', content: imageUrl },
|
{ property: 'og:image', content: imageUrl },
|
||||||
{ property: 'og:site_name', content: SITE_NAME },
|
{ property: 'og:site_name', content: SITE_NAME },
|
||||||
|
|
||||||
// Twitter
|
|
||||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||||
{ name: 'twitter:url', content: fullUrl },
|
{ name: 'twitter:url', content: fullUrl },
|
||||||
{ name: 'twitter:title', content: fullTitle },
|
{ name: 'twitter:title', content: fullTitle },
|
||||||
@@ -45,7 +42,6 @@ export function useSEO(metadata: SEOMetaData) {
|
|||||||
{ name: 'twitter:image', content: imageUrl },
|
{ name: 'twitter:image', content: imageUrl },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add article-specific meta tags
|
|
||||||
if (metadata.type === 'article') {
|
if (metadata.type === 'article') {
|
||||||
if (metadata.author) {
|
if (metadata.author) {
|
||||||
metaTags.push({ property: 'article:author', content: metadata.author });
|
metaTags.push({ property: 'article:author', content: metadata.author });
|
||||||
@@ -58,7 +54,6 @@ export function useSEO(metadata: SEOMetaData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add keywords if provided
|
|
||||||
if (metadata.keywords) {
|
if (metadata.keywords) {
|
||||||
metaTags.push({ name: 'keywords', content: metadata.keywords });
|
metaTags.push({ name: 'keywords', content: metadata.keywords });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export const buildShareUrl = (id: string): string => {
|
|||||||
* Share a spritesheet by uploading to PocketBase
|
* Share a spritesheet by uploading to PocketBase
|
||||||
*/
|
*/
|
||||||
export const shareSpritesheet = async (layersRef: Ref<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>): Promise<ShareResult> => {
|
export const shareSpritesheet = async (layersRef: Ref<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>): Promise<ShareResult> => {
|
||||||
// Build layers data with base64 sprites (same format as exportSpritesheetJSON)
|
|
||||||
const layersData = await Promise.all(
|
const layersData = await Promise.all(
|
||||||
layersRef.value.map(async layer => {
|
layersRef.value.map(async layer => {
|
||||||
const sprites = await Promise.all(
|
const sprites = await Promise.all(
|
||||||
@@ -80,7 +79,6 @@ export const shareSpritesheet = async (layersRef: Ref<Layer[]>, columns: Ref<num
|
|||||||
ctx.drawImage(sprite.img, 0, 0);
|
ctx.drawImage(sprite.img, 0, 0);
|
||||||
}
|
}
|
||||||
const base64 = canvas.toDataURL('image/png');
|
const base64 = canvas.toDataURL('image/png');
|
||||||
// Since we bake transformations into the image, set them to 0/false in metadata
|
|
||||||
return {
|
return {
|
||||||
id: sprite.id,
|
id: sprite.id,
|
||||||
width: sprite.width,
|
width: sprite.width,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export const useSprites = () => {
|
|||||||
const sprites = ref<Sprite[]>([]);
|
const sprites = ref<Sprite[]>([]);
|
||||||
const columns = ref(4);
|
const columns = ref(4);
|
||||||
|
|
||||||
// Clamp and coerce columns to a safe range [1..10]
|
|
||||||
watch(columns, val => {
|
watch(columns, val => {
|
||||||
const num = typeof val === 'number' ? val : parseInt(String(val));
|
const num = typeof val === 'number' ? val : parseInt(String(val));
|
||||||
const safe = Number.isFinite(num) && num >= 1 ? Math.min(num, 10) : 1;
|
const safe = Number.isFinite(num) && num >= 1 ? Math.min(num, 10) : 1;
|
||||||
|
|||||||
@@ -49,10 +49,8 @@ export function useSpritesheetSplitter() {
|
|||||||
let height = cellHeight;
|
let height = cellHeight;
|
||||||
|
|
||||||
if (preserveCellSize) {
|
if (preserveCellSize) {
|
||||||
// Keep full cell with transparent padding
|
|
||||||
url = canvas.toDataURL('image/png');
|
url = canvas.toDataURL('image/png');
|
||||||
} else {
|
} else {
|
||||||
// Crop to sprite bounds
|
|
||||||
const bounds = getSpriteBounds(ctx, cellWidth, cellHeight);
|
const bounds = getSpriteBounds(ctx, cellWidth, cellHeight);
|
||||||
if (bounds) {
|
if (bounds) {
|
||||||
x = bounds.x;
|
x = bounds.x;
|
||||||
@@ -94,7 +92,6 @@ export function useSpritesheetSplitter() {
|
|||||||
|
|
||||||
const imageData = ctx.getImageData(0, 0, img.width, img.height);
|
const imageData = ctx.getImageData(0, 0, img.width, img.height);
|
||||||
|
|
||||||
// Initialize worker lazily
|
|
||||||
if (!worker.value) {
|
if (!worker.value) {
|
||||||
try {
|
try {
|
||||||
worker.value = new Worker(new URL('../workers/irregularSpriteDetection.worker.ts', import.meta.url), { type: 'module' });
|
worker.value = new Worker(new URL('../workers/irregularSpriteDetection.worker.ts', import.meta.url), { type: 'module' });
|
||||||
@@ -161,7 +158,6 @@ export function useSpritesheetSplitter() {
|
|||||||
spriteCtx.clearRect(0, 0, width, height);
|
spriteCtx.clearRect(0, 0, width, height);
|
||||||
spriteCtx.drawImage(sourceCanvas, x, y, width, height, 0, 0, width, height);
|
spriteCtx.drawImage(sourceCanvas, x, y, width, height, 0, 0, width, height);
|
||||||
|
|
||||||
// Remove background color
|
|
||||||
removeBackground(spriteCtx, width, height, backgroundColor);
|
removeBackground(spriteCtx, width, height, backgroundColor);
|
||||||
|
|
||||||
const isEmpty = removeEmpty ? isCanvasEmpty(spriteCtx, width, height) : false;
|
const isEmpty = removeEmpty ? isCanvasEmpty(spriteCtx, width, height) : false;
|
||||||
@@ -209,7 +205,6 @@ export function useSpritesheetSplitter() {
|
|||||||
|
|
||||||
if (!hasContent) return null;
|
if (!hasContent) return null;
|
||||||
|
|
||||||
// Add small padding
|
|
||||||
const pad = 1;
|
const pad = 1;
|
||||||
return {
|
return {
|
||||||
x: Math.max(0, minX - pad),
|
x: Math.max(0, minX - pad),
|
||||||
@@ -305,11 +300,65 @@ export function useSpritesheetSplitter() {
|
|||||||
return { width: cellWidth, height: cellHeight };
|
return { width: cellWidth, height: cellHeight };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract frames from a GIF file using ImageDecoder API
|
||||||
|
*/
|
||||||
|
async function extractGifFrames(file: File): Promise<SpritePreview[]> {
|
||||||
|
if (!('ImageDecoder' in window)) {
|
||||||
|
console.warn('ImageDecoder API not supported');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const decoder = new ImageDecoder({ data: new DataView(arrayBuffer), type: 'image/gif' });
|
||||||
|
const sprites: SpritePreview[] = [];
|
||||||
|
|
||||||
|
let frameIndex = 0;
|
||||||
|
// eslint-disable-next-line no-constant-condition
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const result = await decoder.decode({ frameIndex });
|
||||||
|
const frame = result.image;
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = frame.displayWidth;
|
||||||
|
canvas.height = frame.displayHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (ctx) {
|
||||||
|
ctx.drawImage(frame, 0, 0);
|
||||||
|
sprites.push({
|
||||||
|
url: canvas.toDataURL('image/png'),
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
isEmpty: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.close(); // Important to release resources
|
||||||
|
frameIndex++;
|
||||||
|
} catch (err) {
|
||||||
|
// End of frames (RangeError) or other error
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprites;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error extracting GIF frames:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isProcessing,
|
isProcessing,
|
||||||
previewSprites,
|
previewSprites,
|
||||||
splitByGrid,
|
splitByGrid,
|
||||||
detectSprites,
|
detectSprites,
|
||||||
|
extractGifFrames,
|
||||||
getSuggestedCellSize,
|
getSuggestedCellSize,
|
||||||
cleanup,
|
cleanup,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export interface FAQItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useStructuredData() {
|
export function useStructuredData() {
|
||||||
// Organization Schema
|
|
||||||
const addOrganizationSchema = () => {
|
const addOrganizationSchema = () => {
|
||||||
const schema = {
|
const schema = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
@@ -46,7 +45,6 @@ export function useStructuredData() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// WebSite Schema
|
|
||||||
const addWebSiteSchema = () => {
|
const addWebSiteSchema = () => {
|
||||||
const schema = {
|
const schema = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
@@ -71,7 +69,6 @@ export function useStructuredData() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// BlogPosting Schema
|
|
||||||
const addBlogPostSchema = (post: BlogPostSchema) => {
|
const addBlogPostSchema = (post: BlogPostSchema) => {
|
||||||
const schema = {
|
const schema = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
@@ -109,7 +106,6 @@ export function useStructuredData() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Breadcrumb Schema
|
|
||||||
const addBreadcrumbSchema = (items: BreadcrumbItem[]) => {
|
const addBreadcrumbSchema = (items: BreadcrumbItem[]) => {
|
||||||
const schema = {
|
const schema = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
@@ -132,7 +128,6 @@ export function useStructuredData() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Blog List Schema
|
|
||||||
const addBlogListSchema = (posts: BlogPostSchema[]) => {
|
const addBlogListSchema = (posts: BlogPostSchema[]) => {
|
||||||
const schema = {
|
const schema = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
@@ -164,7 +159,6 @@ export function useStructuredData() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// FAQ Schema
|
|
||||||
const addFAQSchema = (faqs: FAQItem[]) => {
|
const addFAQSchema = (faqs: FAQItem[]) => {
|
||||||
const schema = {
|
const schema = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export function useZoom(options: ZoomOptions) {
|
|||||||
if (currentIndex < options.allowedValues.length - 1) {
|
if (currentIndex < options.allowedValues.length - 1) {
|
||||||
zoom.value = options.allowedValues[currentIndex + 1];
|
zoom.value = options.allowedValues[currentIndex + 1];
|
||||||
} else if (currentIndex === -1) {
|
} else if (currentIndex === -1) {
|
||||||
// Find the nearest higher value
|
|
||||||
const higher = options.allowedValues.find(v => v > zoom.value);
|
const higher = options.allowedValues.find(v => v > zoom.value);
|
||||||
if (higher !== undefined) {
|
if (higher !== undefined) {
|
||||||
zoom.value = higher;
|
zoom.value = higher;
|
||||||
@@ -49,7 +48,6 @@ export function useZoom(options: ZoomOptions) {
|
|||||||
if (currentIndex > 0) {
|
if (currentIndex > 0) {
|
||||||
zoom.value = options.allowedValues[currentIndex - 1];
|
zoom.value = options.allowedValues[currentIndex - 1];
|
||||||
} else if (currentIndex === -1) {
|
} else if (currentIndex === -1) {
|
||||||
// Find the nearest lower value
|
|
||||||
const lower = [...options.allowedValues].reverse().find(v => v < zoom.value);
|
const lower = [...options.allowedValues].reverse().find(v => v < zoom.value);
|
||||||
if (lower !== undefined) {
|
if (lower !== undefined) {
|
||||||
zoom.value = lower;
|
zoom.value = lower;
|
||||||
@@ -66,7 +64,6 @@ export function useZoom(options: ZoomOptions) {
|
|||||||
if (isStepOptions(options)) {
|
if (isStepOptions(options)) {
|
||||||
zoom.value = Math.max(options.min, Math.min(options.max, value));
|
zoom.value = Math.max(options.min, Math.min(options.max, value));
|
||||||
} else {
|
} else {
|
||||||
// Snap to nearest allowed value
|
|
||||||
const nearest = options.allowedValues.reduce((prev, curr) => (Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev));
|
const nearest = options.allowedValues.reduce((prev, curr) => (Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev));
|
||||||
zoom.value = nearest;
|
zoom.value = nearest;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ const router = createRouter({
|
|||||||
name: 'editor',
|
name: 'editor',
|
||||||
component: () => import('../views/EditorView.vue'),
|
component: () => import('../views/EditorView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
name: 'not-found',
|
||||||
|
component: () => import('../views/NotFound.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
scrollBehavior(to, from, savedPosition) {
|
scrollBehavior(to, from, savedPosition) {
|
||||||
if (savedPosition) {
|
if (savedPosition) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL);
|
const pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL);
|
||||||
const user = ref(pb.authStore.model);
|
const user = ref(pb.authStore.model);
|
||||||
|
|
||||||
// Sync user state on change
|
|
||||||
pb.authStore.onChange(() => {
|
pb.authStore.onChange(() => {
|
||||||
user.value = pb.authStore.model;
|
user.value = pb.authStore.model;
|
||||||
});
|
});
|
||||||
@@ -21,7 +20,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
password,
|
password,
|
||||||
passwordConfirm,
|
passwordConfirm,
|
||||||
});
|
});
|
||||||
// Auto login after register
|
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,19 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
const projects = ref<Project[]>([]);
|
const projects = ref<Project[]>([]);
|
||||||
const currentProject = ref<Project | null>(null);
|
const currentProject = ref<Project | null>(null);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
const page = ref(1);
|
||||||
|
const perPage = ref(12);
|
||||||
|
const totalItems = ref(0);
|
||||||
|
const totalPages = ref(0);
|
||||||
|
|
||||||
async function fetchProjects() {
|
async function fetchProjects(pageVal = 1, perPageVal = 12, searchVal = '') {
|
||||||
if (!authStore.user) return;
|
if (!authStore.user) return;
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const records = await authStore.pb.collection('projects').getList(1, 50, {
|
const filter = searchVal ? `name ~ "${searchVal}"` : '';
|
||||||
|
const records = await authStore.pb.collection('projects').getList(pageVal, perPageVal, {
|
||||||
sort: '-updated',
|
sort: '-updated',
|
||||||
|
filter,
|
||||||
});
|
});
|
||||||
projects.value = records.items.map((r: any) => ({
|
projects.value = records.items.map((r: any) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -30,6 +36,10 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
created: r.created,
|
created: r.created,
|
||||||
updated: r.updated,
|
updated: r.updated,
|
||||||
}));
|
}));
|
||||||
|
page.value = records.page;
|
||||||
|
perPage.value = records.perPage;
|
||||||
|
totalItems.value = records.totalItems;
|
||||||
|
totalPages.value = records.totalPages;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch projects:', error);
|
console.error('Failed to fetch projects:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -53,7 +63,8 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
created: record.created,
|
created: record.created,
|
||||||
updated: record.updated,
|
updated: record.updated,
|
||||||
};
|
};
|
||||||
await fetchProjects();
|
// Refresh current page
|
||||||
|
await fetchProjects(page.value, perPage.value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create project:', error);
|
console.error('Failed to create project:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -76,7 +87,9 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
data: record.data,
|
data: record.data,
|
||||||
updated: record.updated,
|
updated: record.updated,
|
||||||
};
|
};
|
||||||
await fetchProjects();
|
// No need to fetch all, just update locals or re-fetch current page if desired.
|
||||||
|
// For now, let's re-fetch to keep consistency.
|
||||||
|
await fetchProjects(page.value, perPage.value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update project:', error);
|
console.error('Failed to update project:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -115,7 +128,9 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
if (currentProject.value?.id === id) {
|
if (currentProject.value?.id === id) {
|
||||||
currentProject.value = null;
|
currentProject.value = null;
|
||||||
}
|
}
|
||||||
await fetchProjects();
|
// If we delete the last item on a page, we might want to go back a page.
|
||||||
|
// For simplicity, just refetch current page.
|
||||||
|
await fetchProjects(page.value, perPage.value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete project:', error);
|
console.error('Failed to delete project:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -127,6 +142,10 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
projects,
|
projects,
|
||||||
currentProject,
|
currentProject,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
totalItems,
|
||||||
|
totalPages,
|
||||||
fetchProjects,
|
fetchProjects,
|
||||||
createProject,
|
createProject,
|
||||||
updateProject,
|
updateProject,
|
||||||
|
|||||||
@@ -10,27 +10,21 @@ const manualCellWidth = ref(64);
|
|||||||
const manualCellHeight = ref(64);
|
const manualCellHeight = ref(64);
|
||||||
const checkerboardEnabled = ref(false);
|
const checkerboardEnabled = ref(false);
|
||||||
|
|
||||||
// Initialize dark mode from localStorage or system preference
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// Check localStorage first
|
|
||||||
const storedDarkMode = localStorage.getItem('darkMode');
|
const storedDarkMode = localStorage.getItem('darkMode');
|
||||||
if (storedDarkMode !== null) {
|
if (storedDarkMode !== null) {
|
||||||
darkMode.value = storedDarkMode === 'true';
|
darkMode.value = storedDarkMode === 'true';
|
||||||
} else {
|
} else {
|
||||||
// If not in localStorage, check system preference
|
darkMode.value = true;
|
||||||
darkMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', () => {
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
// Watch for changes to update localStorage and apply class
|
|
||||||
watch(
|
watch(
|
||||||
darkMode,
|
darkMode,
|
||||||
newValue => {
|
newValue => {
|
||||||
// Save to localStorage
|
|
||||||
localStorage.setItem('darkMode', newValue.toString());
|
localStorage.setItem('darkMode', newValue.toString());
|
||||||
|
|
||||||
// Apply or remove dark class on document
|
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
} else {
|
} else {
|
||||||
@@ -40,7 +34,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Actions
|
|
||||||
function togglePixelPerfect() {
|
function togglePixelPerfect() {
|
||||||
pixelPerfect.value = !pixelPerfect.value;
|
pixelPerfect.value = !pixelPerfect.value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export interface SpritePreview {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Detection mode for sprite splitting */
|
/** Detection mode for sprite splitting */
|
||||||
export type DetectionMode = 'grid' | 'auto';
|
export type DetectionMode = 'grid' | 'auto' | 'gif';
|
||||||
|
|
||||||
/** Options for grid-based splitting */
|
/** Options for grid-based splitting */
|
||||||
export interface GridSplitOptions {
|
export interface GridSplitOptions {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
const { addBreadcrumbSchema } = useStructuredData();
|
const { addBreadcrumbSchema } = useStructuredData();
|
||||||
|
|
||||||
// Set SEO synchronously
|
|
||||||
useSEO({
|
useSEO({
|
||||||
title: 'About Us - Our Mission & Story',
|
title: 'About Us - Our Mission & Story',
|
||||||
description: 'Learn about Spritesheet Generator, a free tool designed to help game developers and artists streamline their workflow with optimized spritesheet creation.',
|
description: 'Learn about Spritesheet Generator, a free tool designed to help game developers and artists streamline their workflow with optimized spritesheet creation.',
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
|
|
||||||
const slug = computed(() => route.params.slug as string);
|
const slug = computed(() => route.params.slug as string);
|
||||||
|
|
||||||
// Reactive SEO data that updates when post loads
|
|
||||||
const pageTitle = computed(() => (post.value ? `${post.value.title} - Spritesheet Generator` : 'Blog Post - Spritesheet Generator'));
|
const pageTitle = computed(() => (post.value ? `${post.value.title} - Spritesheet Generator` : 'Blog Post - Spritesheet Generator'));
|
||||||
|
|
||||||
const pageDescription = computed(() => post.value?.description || 'Read our latest article about spritesheet generation and game development.');
|
const pageDescription = computed(() => post.value?.description || 'Read our latest article about spritesheet generation and game development.');
|
||||||
@@ -24,7 +23,6 @@
|
|||||||
|
|
||||||
const keywords = computed(() => post.value?.keywords || 'sprite sheet, game development, blog');
|
const keywords = computed(() => post.value?.keywords || 'sprite sheet, game development, blog');
|
||||||
|
|
||||||
// Dynamic meta tags using reactive computed values
|
|
||||||
useHead({
|
useHead({
|
||||||
title: pageTitle,
|
title: pageTitle,
|
||||||
meta: [
|
meta: [
|
||||||
@@ -33,7 +31,6 @@
|
|||||||
{ name: 'keywords', content: keywords },
|
{ name: 'keywords', content: keywords },
|
||||||
{ name: 'robots', content: 'index, follow' },
|
{ name: 'robots', content: 'index, follow' },
|
||||||
|
|
||||||
// Open Graph
|
|
||||||
{ property: 'og:type', content: 'article' },
|
{ property: 'og:type', content: 'article' },
|
||||||
{ property: 'og:url', content: pageUrl },
|
{ property: 'og:url', content: pageUrl },
|
||||||
{ property: 'og:title', content: pageTitle },
|
{ property: 'og:title', content: pageTitle },
|
||||||
@@ -43,7 +40,6 @@
|
|||||||
{ property: 'article:author', content: computed(() => post.value?.author || 'streetshadow') },
|
{ property: 'article:author', content: computed(() => post.value?.author || 'streetshadow') },
|
||||||
{ property: 'article:published_time', content: computed(() => post.value?.date || '') },
|
{ property: 'article:published_time', content: computed(() => post.value?.date || '') },
|
||||||
|
|
||||||
// Twitter
|
|
||||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||||
{ name: 'twitter:url', content: pageUrl },
|
{ name: 'twitter:url', content: pageUrl },
|
||||||
{ name: 'twitter:title', content: pageTitle },
|
{ name: 'twitter:title', content: pageTitle },
|
||||||
@@ -54,7 +50,6 @@
|
|||||||
script: computed(() => {
|
script: computed(() => {
|
||||||
const scripts = [];
|
const scripts = [];
|
||||||
|
|
||||||
// Breadcrumb schema
|
|
||||||
scripts.push({
|
scripts.push({
|
||||||
type: 'application/ld+json',
|
type: 'application/ld+json',
|
||||||
children: JSON.stringify({
|
children: JSON.stringify({
|
||||||
@@ -83,7 +78,6 @@
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Blog post schema
|
|
||||||
if (post.value) {
|
if (post.value) {
|
||||||
scripts.push({
|
scripts.push({
|
||||||
type: 'application/ld+json',
|
type: 'application/ld+json',
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
const { addBreadcrumbSchema } = useStructuredData();
|
const { addBreadcrumbSchema } = useStructuredData();
|
||||||
const posts = ref<BlogPost[]>([]);
|
const posts = ref<BlogPost[]>([]);
|
||||||
|
|
||||||
// Set SEO meta tags synchronously
|
|
||||||
useSEO({
|
useSEO({
|
||||||
title: 'Blog - Latest Articles on Spritesheet Generation',
|
title: 'Blog - Latest Articles on Spritesheet Generation',
|
||||||
description: 'Explore our latest articles about sprite sheet generation, game development, pixel art, and sprite animation techniques.',
|
description: 'Explore our latest articles about sprite sheet generation, game development, pixel art, and sprite animation techniques.',
|
||||||
@@ -18,7 +17,6 @@
|
|||||||
keywords: 'sprite sheet blog, game development articles, pixel art tutorials, sprite animation',
|
keywords: 'sprite sheet blog, game development articles, pixel art tutorials, sprite animation',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add breadcrumb synchronously
|
|
||||||
addBreadcrumbSchema([
|
addBreadcrumbSchema([
|
||||||
{ name: 'Home', url: '/' },
|
{ name: 'Home', url: '/' },
|
||||||
{ name: 'Blog', url: '/blog' },
|
{ name: 'Blog', url: '/blog' },
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
const { addBreadcrumbSchema } = useStructuredData();
|
const { addBreadcrumbSchema } = useStructuredData();
|
||||||
|
|
||||||
// Set SEO synchronously
|
|
||||||
useSEO({
|
useSEO({
|
||||||
title: 'Contact Us - Get in Touch',
|
title: 'Contact Us - Get in Touch',
|
||||||
description: "Contact the Spritesheet Generator team. Join our Discord community or report bugs and contribute on Gitea. We'd love to hear from you!",
|
description: "Contact the Spritesheet Generator team. Join our Discord community or report bugs and contribute on Gitea. We'd love to hear from you!",
|
||||||
|
|||||||
@@ -9,9 +9,7 @@
|
|||||||
<div class="px-5 py-4 border-b border-gray-200/50 dark:border-gray-700/50 flex items-center justify-between shrink-0 bg-gray-50/50 dark:bg-gray-800/10">
|
<div class="px-5 py-4 border-b border-gray-200/50 dark:border-gray-700/50 flex items-center justify-between shrink-0 bg-gray-50/50 dark:bg-gray-800/10">
|
||||||
<h2 class="text-sm font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">Editor Tools</h2>
|
<h2 class="text-sm font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">Editor Tools</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button @click="closeProject" class="btn btn-secondary btn-sm w-8 h-8 p-0 justify-center border-gray-200 dark:border-gray-700 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 dark:text-gray-400" title="Close Project">
|
<button @click="closeProject" class="btn btn-secondary btn-sm border-gray-200 dark:border-gray-700 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 dark:text-gray-400" title="Close Project"><i class="fas fa-times mr-1"></i>Close</button>
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
<button @click="openFileDialog" class="btn btn-primary btn-sm shadow-indigo-500/20" title="Add more sprites"><i class="fas fa-plus mr-1"></i> Add</button>
|
<button @click="openFileDialog" class="btn btn-primary btn-sm shadow-indigo-500/20" title="Add more sprites"><i class="fas fa-plus mr-1"></i> Add</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,7 +87,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Canvas Grid Settings (Editor only) -->
|
<!-- Canvas Grid Settings (Editor only) -->
|
||||||
<section v-if="activeTab === 'canvas'">
|
<section v-if="activeTab === 'canvas' || activeTab === 'preview'">
|
||||||
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">Grid Layout</h3>
|
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">Grid Layout</h3>
|
||||||
<div class="card p-3 bg-gray-50/50 dark:bg-gray-800/40 space-y-3">
|
<div class="card p-3 bg-gray-50/50 dark:bg-gray-800/40 space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -112,7 +110,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- View Options (Editor only) -->
|
<!-- View Options (Editor only) -->
|
||||||
<section v-if="activeTab === 'canvas'">
|
<section v-if="activeTab === 'canvas' || activeTab === 'preview'">
|
||||||
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">View Options</h3>
|
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">View Options</h3>
|
||||||
<div class="card p-2 bg-gray-50/50 dark:bg-gray-800/40 grid grid-cols-2 gap-2">
|
<div class="card p-2 bg-gray-50/50 dark:bg-gray-800/40 grid grid-cols-2 gap-2">
|
||||||
<Tooltip text="Disable anti-aliasing for crisp pixel art rendering">
|
<Tooltip text="Disable anti-aliasing for crisp pixel art rendering">
|
||||||
@@ -173,7 +171,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Tools (Editor only) -->
|
<!-- Tools (Editor only) -->
|
||||||
<section v-if="activeTab === 'canvas'">
|
<section v-if="activeTab === 'canvas' || activeTab === 'preview'">
|
||||||
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">Tools</h3>
|
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">Tools</h3>
|
||||||
<div class="card p-2 bg-gray-50/50 dark:bg-gray-800/40 space-y-2">
|
<div class="card p-2 bg-gray-50/50 dark:bg-gray-800/40 space-y-2">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -243,6 +241,13 @@
|
|||||||
>
|
>
|
||||||
<i class="fas fa-play mr-2"></i>Preview
|
<i class="fas fa-play mr-2"></i>Preview
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'draw'"
|
||||||
|
:class="activeTab === 'draw' ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
|
||||||
|
class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200"
|
||||||
|
>
|
||||||
|
<i class="fas fa-paint-brush mr-2"></i>Draw
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Background Color (Compact) -->
|
<!-- Background Color (Compact) -->
|
||||||
@@ -303,9 +308,11 @@
|
|||||||
@rotate-sprite="rotateSprite"
|
@rotate-sprite="rotateSprite"
|
||||||
@flip-sprite="flipSprite"
|
@flip-sprite="flipSprite"
|
||||||
@copy-sprite-to-frame="copySpriteToFrame"
|
@copy-sprite-to-frame="copySpriteToFrame"
|
||||||
|
@open-pixel-editor="openPixelEditor"
|
||||||
|
@add-sprites="addSprites"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="activeTab === 'preview'" class="h-full flex items-center justify-center">
|
<div v-else-if="activeTab === 'preview'" class="h-full flex items-center justify-center">
|
||||||
<sprite-preview
|
<sprite-preview
|
||||||
:layers="layers"
|
:layers="layers"
|
||||||
:active-layer-id="activeLayerId"
|
:active-layer-id="activeLayerId"
|
||||||
@@ -316,9 +323,14 @@
|
|||||||
@rotate-sprite="rotateSprite"
|
@rotate-sprite="rotateSprite"
|
||||||
@flip-sprite="flipSprite"
|
@flip-sprite="flipSprite"
|
||||||
@copy-sprite-to-frame="copySpriteToFrame"
|
@copy-sprite-to-frame="copySpriteToFrame"
|
||||||
|
@remove-sprite="removeSprite"
|
||||||
@replace-sprite="replaceSprite"
|
@replace-sprite="replaceSprite"
|
||||||
|
@open-pixel-editor="openPixelEditor"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="activeTab === 'draw'" class="h-full">
|
||||||
|
<draw-tab :layers="layers" :initial-frame="pixelEditorFrame" @save-frame="handleSaveFrame" @close="pixelEditorFrame = null" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,6 +352,7 @@
|
|||||||
import FileUploader from '@/components/FileUploader.vue';
|
import FileUploader from '@/components/FileUploader.vue';
|
||||||
import SpriteCanvas from '@/components/SpriteCanvas.vue';
|
import SpriteCanvas from '@/components/SpriteCanvas.vue';
|
||||||
import SpritePreview from '@/components/SpritePreview.vue';
|
import SpritePreview from '@/components/SpritePreview.vue';
|
||||||
|
import DrawTab from '@/components/DrawTab.vue';
|
||||||
import SpritesheetSplitter from '@/components/SpritesheetSplitter.vue';
|
import SpritesheetSplitter from '@/components/SpritesheetSplitter.vue';
|
||||||
import GifFpsModal from '@/components/GifFpsModal.vue';
|
import GifFpsModal from '@/components/GifFpsModal.vue';
|
||||||
import ShareModal from '@/components/ShareModal.vue';
|
import ShareModal from '@/components/ShareModal.vue';
|
||||||
@@ -360,8 +373,29 @@
|
|||||||
const projectStore = useProjectStore();
|
const projectStore = useProjectStore();
|
||||||
const { closeProject, loadProjectData } = useProjectManager();
|
const { closeProject, loadProjectData } = useProjectManager();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteInLayer, updateSpriteCell, removeSprite, removeSprites, replaceSprite, addSprite, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer, rotateSprite, flipSprite, copySpriteToFrame } =
|
const {
|
||||||
useLayers();
|
layers,
|
||||||
|
visibleLayers,
|
||||||
|
activeLayer,
|
||||||
|
activeLayerId,
|
||||||
|
columns,
|
||||||
|
updateSpritePosition,
|
||||||
|
updateSpriteInLayer,
|
||||||
|
updateSpriteCell,
|
||||||
|
removeSprite,
|
||||||
|
removeSprites,
|
||||||
|
replaceSprite,
|
||||||
|
addSprite,
|
||||||
|
addSprites,
|
||||||
|
processImageFiles,
|
||||||
|
alignSprites,
|
||||||
|
addLayer,
|
||||||
|
removeLayer,
|
||||||
|
moveLayer,
|
||||||
|
rotateSprite,
|
||||||
|
flipSprite,
|
||||||
|
copySpriteToFrame,
|
||||||
|
} = useLayers();
|
||||||
|
|
||||||
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(
|
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(
|
||||||
layers,
|
layers,
|
||||||
@@ -374,7 +408,6 @@
|
|||||||
toRef(settingsStore, 'manualCellHeight')
|
toRef(settingsStore, 'manualCellHeight')
|
||||||
);
|
);
|
||||||
|
|
||||||
// Zoom Control
|
|
||||||
const {
|
const {
|
||||||
zoom,
|
zoom,
|
||||||
increase: zoomIn,
|
increase: zoomIn,
|
||||||
@@ -387,7 +420,6 @@
|
|||||||
initial: 1,
|
initial: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// View Options & Tools
|
|
||||||
const isMultiSelectMode = ref(false);
|
const isMultiSelectMode = ref(false);
|
||||||
const showActiveBorder = ref(true);
|
const showActiveBorder = ref(true);
|
||||||
const allowCellSwap = ref(false);
|
const allowCellSwap = ref(false);
|
||||||
@@ -397,7 +429,6 @@
|
|||||||
const customColor = ref('#ffffff');
|
const customColor = ref('#ffffff');
|
||||||
const isCustomMode = ref(false);
|
const isCustomMode = ref(false);
|
||||||
|
|
||||||
// Background Color Logic
|
|
||||||
const presetBgColors = ['transparent', '#ffffff', '#000000', '#f9fafb'] as const;
|
const presetBgColors = ['transparent', '#ffffff', '#000000', '#f9fafb'] as const;
|
||||||
const isHexColor = (val: string) => /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(val);
|
const isHexColor = (val: string) => /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(val);
|
||||||
|
|
||||||
@@ -445,7 +476,8 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cellSize = computed(getCellSize);
|
const cellSize = computed(getCellSize);
|
||||||
const activeTab = ref<'canvas' | 'preview'>('canvas');
|
const activeTab = ref<'canvas' | 'preview' | 'draw'>('canvas');
|
||||||
|
const pixelEditorFrame = ref<{ layerId: string; frameIndex: number } | null>(null);
|
||||||
|
|
||||||
const isSpritesheetSplitterOpen = ref(false);
|
const isSpritesheetSplitterOpen = ref(false);
|
||||||
const isGifFpsModalOpen = ref(false);
|
const isGifFpsModalOpen = ref(false);
|
||||||
@@ -460,7 +492,6 @@
|
|||||||
const editingLayerName = ref('');
|
const editingLayerName = ref('');
|
||||||
const layerNameInput = ref<HTMLInputElement | null>(null);
|
const layerNameInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Upload Handlers
|
|
||||||
const handleSpritesUpload = async (files: File[]) => {
|
const handleSpritesUpload = async (files: File[]) => {
|
||||||
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
|
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
|
||||||
|
|
||||||
@@ -561,7 +592,6 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Layer Editing
|
|
||||||
const startEditingLayer = (layerId: string, currentName: string) => {
|
const startEditingLayer = (layerId: string, currentName: string) => {
|
||||||
editingLayerId.value = layerId;
|
editingLayerId.value = layerId;
|
||||||
editingLayerName.value = currentName;
|
editingLayerName.value = currentName;
|
||||||
@@ -608,27 +638,27 @@
|
|||||||
flipX: false,
|
flipX: false,
|
||||||
flipY: false,
|
flipY: false,
|
||||||
};
|
};
|
||||||
const insertIndex = frameIndex + fileIdx;
|
const targetIndex = frameIndex + fileIdx;
|
||||||
if (insertIndex < layer.sprites.length) {
|
|
||||||
layer.sprites = [...layer.sprites.slice(0, insertIndex), sprite, ...layer.sprites.slice(insertIndex + 1)];
|
// Extend the array with empty slots if needed
|
||||||
} else {
|
while (layer.sprites.length <= targetIndex) {
|
||||||
while (layer.sprites.length < insertIndex) {
|
layer.sprites.push({
|
||||||
layer.sprites.push({
|
id: crypto.randomUUID(),
|
||||||
id: crypto.randomUUID(),
|
file: new File([], 'empty'),
|
||||||
file: new File([], 'empty'),
|
img: new Image(),
|
||||||
img: new Image(),
|
url: '',
|
||||||
url: '',
|
width: 0,
|
||||||
width: 0,
|
height: 0,
|
||||||
height: 0,
|
x: 0,
|
||||||
x: 0,
|
y: 0,
|
||||||
y: 0,
|
rotation: 0,
|
||||||
rotation: 0,
|
flipX: false,
|
||||||
flipX: false,
|
flipY: false,
|
||||||
flipY: false,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
layer.sprites = [...layer.sprites, sprite];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now replace at target index (either existing sprite or empty placeholder)
|
||||||
|
layer.sprites = [...layer.sprites.slice(0, targetIndex), sprite, ...layer.sprites.slice(targetIndex + 1)];
|
||||||
};
|
};
|
||||||
img.src = url;
|
img.src = url;
|
||||||
};
|
};
|
||||||
@@ -636,11 +666,52 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveFrame = (layerId: string, frameIndex: number, file: File) => {
|
||||||
|
const layer = layers.value.find(l => l.id === layerId);
|
||||||
|
if (!layer) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = e => {
|
||||||
|
const url = e.target?.result as string;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const oldSprite = layer.sprites[frameIndex];
|
||||||
|
const sprite = {
|
||||||
|
id: oldSprite?.id || crypto.randomUUID(),
|
||||||
|
file,
|
||||||
|
img,
|
||||||
|
url,
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
x: oldSprite?.x || 0,
|
||||||
|
y: oldSprite?.y || 0,
|
||||||
|
rotation: oldSprite?.rotation || 0,
|
||||||
|
flipX: oldSprite?.flipX || false,
|
||||||
|
flipY: oldSprite?.flipY || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (frameIndex < layer.sprites.length) {
|
||||||
|
layer.sprites[frameIndex] = sprite;
|
||||||
|
} else {
|
||||||
|
layer.sprites.push(sprite);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
pixelEditorFrame.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPixelEditor = (layerId: string, frameIndex: number) => {
|
||||||
|
pixelEditorFrame.value = { layerId, frameIndex };
|
||||||
|
activeTab.value = 'draw';
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const id = route.params.id as string;
|
const id = route.params.id as string;
|
||||||
if (id) {
|
if (id) {
|
||||||
if (projectStore.currentProject?.id !== id) {
|
if (projectStore.currentProject?.id !== id) {
|
||||||
// Only load if active project is different
|
|
||||||
await projectStore.loadProject(id);
|
await projectStore.loadProject(id);
|
||||||
if (projectStore.currentProject?.data) {
|
if (projectStore.currentProject?.data) {
|
||||||
await loadProjectData(projectStore.currentProject.data);
|
await loadProjectData(projectStore.currentProject.data);
|
||||||
@@ -660,8 +731,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If navigated to /editor without ID, maybe clear logic?
|
|
||||||
// We likely want to keep state if user created new project.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
const { addFAQSchema, addBreadcrumbSchema } = useStructuredData();
|
const { addFAQSchema, addBreadcrumbSchema } = useStructuredData();
|
||||||
|
|
||||||
// Set SEO synchronously
|
|
||||||
useSEO({
|
useSEO({
|
||||||
title: 'FAQ - Frequently Asked Questions',
|
title: 'FAQ - Frequently Asked Questions',
|
||||||
description: 'Find answers to common questions about the Spritesheet Generator. Learn about supported formats, export options, and how to use the tool effectively.',
|
description: 'Find answers to common questions about the Spritesheet Generator. Learn about supported formats, export options, and how to use the tool effectively.',
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useHead } from '@vueuse/head';
|
|||||||
export function useHomeViewSEO() {
|
export function useHomeViewSEO() {
|
||||||
const { addOrganizationSchema, addWebSiteSchema } = useStructuredData();
|
const { addOrganizationSchema, addWebSiteSchema } = useStructuredData();
|
||||||
|
|
||||||
// Set page SEO synchronously
|
|
||||||
useSEO({
|
useSEO({
|
||||||
title: 'Spritesheet Generator - Create Game Spritesheets Online',
|
title: 'Spritesheet Generator - Create Game Spritesheets Online',
|
||||||
description: 'Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.',
|
description: 'Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.',
|
||||||
@@ -14,13 +13,10 @@ export function useHomeViewSEO() {
|
|||||||
keywords: 'spritesheet generator, sprite sheet maker, game development, pixel art, sprite animation, game assets, 2D game tools',
|
keywords: 'spritesheet generator, sprite sheet maker, game development, pixel art, sprite animation, game assets, 2D game tools',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add organization schema
|
|
||||||
addOrganizationSchema();
|
addOrganizationSchema();
|
||||||
|
|
||||||
// Add website schema
|
|
||||||
addWebSiteSchema();
|
addWebSiteSchema();
|
||||||
|
|
||||||
// Add SoftwareApplication schema
|
|
||||||
useHead({
|
useHead({
|
||||||
script: [
|
script: [
|
||||||
{
|
{
|
||||||
|
|||||||
18
src/views/NotFound.vue
Normal file
18
src/views/NotFound.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
|
||||||
|
<div class="mb-8 relative">
|
||||||
|
<div class="text-9xl font-black text-gray-200 dark:text-gray-800 select-none">404</div>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div class="text-6xl">👻</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-4">Page not found</h1>
|
||||||
|
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-md mb-8">Oops! The page you're looking for doesn't exist or has been moved.</p>
|
||||||
|
|
||||||
|
<router-link to="/" class="px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-xl transition-all shadow-lg hover:shadow-indigo-500/30 flex items-center gap-2">
|
||||||
|
<i class="fas fa-home"></i>
|
||||||
|
<span>Go home</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
const { addBreadcrumbSchema } = useStructuredData();
|
const { addBreadcrumbSchema } = useStructuredData();
|
||||||
|
|
||||||
// Set SEO synchronously
|
|
||||||
useSEO({
|
useSEO({
|
||||||
title: 'Privacy Policy - Your Data Protection',
|
title: 'Privacy Policy - Your Data Protection',
|
||||||
description: 'Read our privacy policy. Spritesheet Generator is a client-side tool that does not collect personal data or upload your images to our servers.',
|
description: 'Read our privacy policy. Spritesheet Generator is a client-side tool that does not collect personal data or upload your images to our servers.',
|
||||||
|
|||||||
@@ -169,7 +169,6 @@
|
|||||||
|
|
||||||
const data = spritesheetData.value;
|
const data = spritesheetData.value;
|
||||||
|
|
||||||
// Apply config settings
|
|
||||||
columns.value = data.config.columns;
|
columns.value = data.config.columns;
|
||||||
settingsStore.negativeSpacingEnabled = data.config.negativeSpacingEnabled;
|
settingsStore.negativeSpacingEnabled = data.config.negativeSpacingEnabled;
|
||||||
settingsStore.backgroundColor = data.config.backgroundColor;
|
settingsStore.backgroundColor = data.config.backgroundColor;
|
||||||
@@ -177,7 +176,6 @@
|
|||||||
settingsStore.manualCellWidth = data.config.manualCellWidth;
|
settingsStore.manualCellWidth = data.config.manualCellWidth;
|
||||||
settingsStore.manualCellHeight = data.config.manualCellHeight;
|
settingsStore.manualCellHeight = data.config.manualCellHeight;
|
||||||
|
|
||||||
// Load sprites into layers
|
|
||||||
const loadSprite = (spriteData: any): Promise<Sprite> =>
|
const loadSprite = (spriteData: any): Promise<Sprite> =>
|
||||||
new Promise(resolve => {
|
new Promise(resolve => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -199,7 +197,6 @@
|
|||||||
height: spriteData.height,
|
height: spriteData.height,
|
||||||
x: spriteData.x || 0,
|
x: spriteData.x || 0,
|
||||||
y: spriteData.y || 0,
|
y: spriteData.y || 0,
|
||||||
// Transformations are already baked into the base64 image
|
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
flipX: false,
|
flipX: false,
|
||||||
flipY: false,
|
flipY: false,
|
||||||
@@ -226,10 +223,8 @@
|
|||||||
activeLayerId.value = firstWithSprites ? firstWithSprites.id : newLayers[0].id;
|
activeLayerId.value = firstWithSprites ? firstWithSprites.id : newLayers[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear current project so it's treated as new/unsaved
|
|
||||||
projectStore.currentProject = null;
|
projectStore.currentProject = null;
|
||||||
|
|
||||||
// Navigate to editor
|
|
||||||
router.push({ name: 'editor' });
|
router.push({ name: 'editor' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ interface WorkerResponse {
|
|||||||
backgroundColor: [number, number, number, number];
|
backgroundColor: [number, number, number, number];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-allocate arrays for better performance
|
|
||||||
let maskBuffer: Uint8Array;
|
let maskBuffer: Uint8Array;
|
||||||
let visitedBuffer: Uint8Array;
|
let visitedBuffer: Uint8Array;
|
||||||
let stackBuffer: Int32Array;
|
let stackBuffer: Int32Array;
|
||||||
@@ -43,7 +42,6 @@ self.onmessage = function (e: MessageEvent<WorkerMessage>) {
|
|||||||
function detectIrregularSprites(imageData: ImageData, sensitivity: number, maxSize: number): { sprites: SpriteRegion[]; backgroundColor: [number, number, number, number] } {
|
function detectIrregularSprites(imageData: ImageData, sensitivity: number, maxSize: number): { sprites: SpriteRegion[]; backgroundColor: [number, number, number, number] } {
|
||||||
const { data, width, height } = imageData;
|
const { data, width, height } = imageData;
|
||||||
|
|
||||||
// Downsample for very large images
|
|
||||||
const shouldDownsample = width > maxSize || height > maxSize;
|
const shouldDownsample = width > maxSize || height > maxSize;
|
||||||
let processedData: Uint8ClampedArray;
|
let processedData: Uint8ClampedArray;
|
||||||
let processedWidth: number;
|
let processedWidth: number;
|
||||||
@@ -61,23 +59,17 @@ function detectIrregularSprites(imageData: ImageData, sensitivity: number, maxSi
|
|||||||
processedHeight = height;
|
processedHeight = height;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fast background detection using histogram
|
|
||||||
const backgroundColor = fastBackgroundDetection(processedData, processedWidth, processedHeight);
|
const backgroundColor = fastBackgroundDetection(processedData, processedWidth, processedHeight);
|
||||||
|
|
||||||
// Create optimized mask
|
|
||||||
const mask = createOptimizedMask(processedData, processedWidth, processedHeight, backgroundColor, sensitivity);
|
const mask = createOptimizedMask(processedData, processedWidth, processedHeight, backgroundColor, sensitivity);
|
||||||
|
|
||||||
// Clean up mask with morphological operations
|
|
||||||
const cleanedMask = cleanUpMask(mask, processedWidth, processedHeight);
|
const cleanedMask = cleanUpMask(mask, processedWidth, processedHeight);
|
||||||
|
|
||||||
// Find connected components with optimized flood fill
|
|
||||||
const sprites = findOptimizedConnectedComponents(cleanedMask, processedWidth, processedHeight);
|
const sprites = findOptimizedConnectedComponents(cleanedMask, processedWidth, processedHeight);
|
||||||
|
|
||||||
// Filter noise
|
|
||||||
const minSpriteSize = Math.max(4, Math.floor(Math.min(processedWidth, processedHeight) / 100));
|
const minSpriteSize = Math.max(4, Math.floor(Math.min(processedWidth, processedHeight) / 100));
|
||||||
const filteredSprites = sprites.filter(sprite => sprite.pixelCount >= minSpriteSize);
|
const filteredSprites = sprites.filter(sprite => sprite.pixelCount >= minSpriteSize);
|
||||||
|
|
||||||
// Scale results back up if downsampled
|
|
||||||
const finalSprites = shouldDownsample
|
const finalSprites = shouldDownsample
|
||||||
? filteredSprites.map(sprite => ({
|
? filteredSprites.map(sprite => ({
|
||||||
x: Math.floor(sprite.x / scale),
|
x: Math.floor(sprite.x / scale),
|
||||||
@@ -88,7 +80,6 @@ function detectIrregularSprites(imageData: ImageData, sensitivity: number, maxSi
|
|||||||
}))
|
}))
|
||||||
: filteredSprites;
|
: filteredSprites;
|
||||||
|
|
||||||
// Convert background color back to original format
|
|
||||||
const finalBackgroundColor: [number, number, number, number] = [backgroundColor[0], backgroundColor[1], backgroundColor[2], backgroundColor[3]];
|
const finalBackgroundColor: [number, number, number, number] = [backgroundColor[0], backgroundColor[1], backgroundColor[2], backgroundColor[3]];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -120,10 +111,8 @@ function downsampleImageData(data: Uint8ClampedArray, width: number, height: num
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fastBackgroundDetection(data: Uint8ClampedArray, width: number, height: number): Uint32Array {
|
function fastBackgroundDetection(data: Uint8ClampedArray, width: number, height: number): Uint32Array {
|
||||||
// Enhanced background detection focusing on edges and corners
|
|
||||||
const colorCounts = new Map<string, number>();
|
const colorCounts = new Map<string, number>();
|
||||||
|
|
||||||
// Sample from corners (most likely to be background)
|
|
||||||
const cornerSamples = [
|
const cornerSamples = [
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[width - 1, 0],
|
[width - 1, 0],
|
||||||
@@ -131,26 +120,21 @@ function fastBackgroundDetection(data: Uint8ClampedArray, width: number, height:
|
|||||||
[width - 1, height - 1],
|
[width - 1, height - 1],
|
||||||
];
|
];
|
||||||
|
|
||||||
// Sample from edges (also likely background)
|
|
||||||
const edgeSamples: [number, number][] = [];
|
const edgeSamples: [number, number][] = [];
|
||||||
const edgeStep = Math.max(1, Math.floor(Math.min(width, height) / 20));
|
const edgeStep = Math.max(1, Math.floor(Math.min(width, height) / 20));
|
||||||
|
|
||||||
// Top and bottom edges
|
|
||||||
for (let x = 0; x < width; x += edgeStep) {
|
for (let x = 0; x < width; x += edgeStep) {
|
||||||
edgeSamples.push([x, 0]);
|
edgeSamples.push([x, 0]);
|
||||||
edgeSamples.push([x, height - 1]);
|
edgeSamples.push([x, height - 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Left and right edges
|
|
||||||
for (let y = 0; y < height; y += edgeStep) {
|
for (let y = 0; y < height; y += edgeStep) {
|
||||||
edgeSamples.push([0, y]);
|
edgeSamples.push([0, y]);
|
||||||
edgeSamples.push([width - 1, y]);
|
edgeSamples.push([width - 1, y]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all samples
|
|
||||||
const allSamples = [...cornerSamples, ...edgeSamples];
|
const allSamples = [...cornerSamples, ...edgeSamples];
|
||||||
|
|
||||||
// Count colors with tolerance grouping
|
|
||||||
const tolerance = 15;
|
const tolerance = 15;
|
||||||
|
|
||||||
for (const [x, y] of allSamples) {
|
for (const [x, y] of allSamples) {
|
||||||
@@ -160,7 +144,6 @@ function fastBackgroundDetection(data: Uint8ClampedArray, width: number, height:
|
|||||||
const b = data[idx + 2];
|
const b = data[idx + 2];
|
||||||
const a = data[idx + 3];
|
const a = data[idx + 3];
|
||||||
|
|
||||||
// Find existing similar color or create new entry
|
|
||||||
let matched = false;
|
let matched = false;
|
||||||
for (const [colorKey, count] of colorCounts.entries()) {
|
for (const [colorKey, count] of colorCounts.entries()) {
|
||||||
const [existingR, existingG, existingB, existingA] = colorKey.split(',').map(Number);
|
const [existingR, existingG, existingB, existingA] = colorKey.split(',').map(Number);
|
||||||
@@ -178,7 +161,6 @@ function fastBackgroundDetection(data: Uint8ClampedArray, width: number, height:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find most common color
|
|
||||||
let maxCount = 0;
|
let maxCount = 0;
|
||||||
let backgroundColor = [0, 0, 0, 0];
|
let backgroundColor = [0, 0, 0, 0];
|
||||||
|
|
||||||
@@ -195,13 +177,10 @@ function fastBackgroundDetection(data: Uint8ClampedArray, width: number, height:
|
|||||||
function createOptimizedMask(data: Uint8ClampedArray, width: number, height: number, backgroundColor: Uint32Array, sensitivity: number): Uint8Array {
|
function createOptimizedMask(data: Uint8ClampedArray, width: number, height: number, backgroundColor: Uint32Array, sensitivity: number): Uint8Array {
|
||||||
const size = width * height;
|
const size = width * height;
|
||||||
|
|
||||||
// Reuse buffer if possible
|
|
||||||
if (!maskBuffer || maskBuffer.length < size) {
|
if (!maskBuffer || maskBuffer.length < size) {
|
||||||
maskBuffer = new Uint8Array(size);
|
maskBuffer = new Uint8Array(size);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map sensitivity (1-100) to more aggressive thresholds
|
|
||||||
// Higher sensitivity = stricter background matching (lower tolerance)
|
|
||||||
const colorTolerance = Math.round(50 - sensitivity * 0.45); // 50 down to 5
|
const colorTolerance = Math.round(50 - sensitivity * 0.45); // 50 down to 5
|
||||||
const alphaTolerance = Math.round(40 - sensitivity * 0.35); // 40 down to 5
|
const alphaTolerance = Math.round(40 - sensitivity * 0.35); // 40 down to 5
|
||||||
|
|
||||||
@@ -214,14 +193,12 @@ function createOptimizedMask(data: Uint8ClampedArray, width: number, height: num
|
|||||||
const b = data[idx + 2];
|
const b = data[idx + 2];
|
||||||
const a = data[idx + 3];
|
const a = data[idx + 3];
|
||||||
|
|
||||||
// Handle fully transparent pixels (common background case)
|
|
||||||
if (a < 10) {
|
if (a < 10) {
|
||||||
maskBuffer[i] = 0; // Treat as background
|
maskBuffer[i] = 0; // Treat as background
|
||||||
idx += 4;
|
idx += 4;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate color difference using Euclidean distance for better accuracy
|
|
||||||
const rDiff = r - bgR;
|
const rDiff = r - bgR;
|
||||||
const gDiff = g - bgG;
|
const gDiff = g - bgG;
|
||||||
const bDiff = b - bgB;
|
const bDiff = b - bgB;
|
||||||
@@ -230,7 +207,6 @@ function createOptimizedMask(data: Uint8ClampedArray, width: number, height: num
|
|||||||
const colorDistance = Math.sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff);
|
const colorDistance = Math.sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff);
|
||||||
const alphaDistance = Math.abs(aDiff);
|
const alphaDistance = Math.abs(aDiff);
|
||||||
|
|
||||||
// Pixel is foreground if it's significantly different from background
|
|
||||||
const isBackground = colorDistance <= colorTolerance && alphaDistance <= alphaTolerance;
|
const isBackground = colorDistance <= colorTolerance && alphaDistance <= alphaTolerance;
|
||||||
maskBuffer[i] = isBackground ? 0 : 1;
|
maskBuffer[i] = isBackground ? 0 : 1;
|
||||||
|
|
||||||
@@ -241,20 +217,12 @@ function createOptimizedMask(data: Uint8ClampedArray, width: number, height: num
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cleanUpMask(mask: Uint8Array, width: number, height: number): Uint8Array {
|
function cleanUpMask(mask: Uint8Array, width: number, height: number): Uint8Array {
|
||||||
// Simple morphological closing to fill small gaps in sprites
|
|
||||||
// and opening to remove small noise
|
|
||||||
|
|
||||||
const cleaned = new Uint8Array(mask.length);
|
const cleaned = new Uint8Array(mask.length);
|
||||||
|
|
||||||
// Erosion followed by dilation (opening) to remove small noise
|
|
||||||
// Then dilation followed by erosion (closing) to fill gaps
|
|
||||||
|
|
||||||
// Simple 3x3 kernel operations
|
|
||||||
for (let y = 1; y < height - 1; y++) {
|
for (let y = 1; y < height - 1; y++) {
|
||||||
for (let x = 1; x < width - 1; x++) {
|
for (let x = 1; x < width - 1; x++) {
|
||||||
const idx = y * width + x;
|
const idx = y * width + x;
|
||||||
|
|
||||||
// Count non-zero neighbors in 3x3 area
|
|
||||||
let neighbors = 0;
|
let neighbors = 0;
|
||||||
for (let dy = -1; dy <= 1; dy++) {
|
for (let dy = -1; dy <= 1; dy++) {
|
||||||
for (let dx = -1; dx <= 1; dx++) {
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
@@ -263,13 +231,10 @@ function cleanUpMask(mask: Uint8Array, width: number, height: number): Uint8Arra
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use majority rule for cleaning
|
|
||||||
// If more than half the neighbors are foreground, make this foreground
|
|
||||||
cleaned[idx] = neighbors >= 5 ? 1 : 0;
|
cleaned[idx] = neighbors >= 5 ? 1 : 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy borders as-is
|
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < width; x++) {
|
||||||
cleaned[x] = mask[x]; // Top row
|
cleaned[x] = mask[x]; // Top row
|
||||||
cleaned[(height - 1) * width + x] = mask[(height - 1) * width + x]; // Bottom row
|
cleaned[(height - 1) * width + x] = mask[(height - 1) * width + x]; // Bottom row
|
||||||
@@ -285,7 +250,6 @@ function cleanUpMask(mask: Uint8Array, width: number, height: number): Uint8Arra
|
|||||||
function findOptimizedConnectedComponents(mask: Uint8Array, width: number, height: number): SpriteRegion[] {
|
function findOptimizedConnectedComponents(mask: Uint8Array, width: number, height: number): SpriteRegion[] {
|
||||||
const size = width * height;
|
const size = width * height;
|
||||||
|
|
||||||
// Reuse buffers
|
|
||||||
if (!visitedBuffer || visitedBuffer.length < size) {
|
if (!visitedBuffer || visitedBuffer.length < size) {
|
||||||
visitedBuffer = new Uint8Array(size);
|
visitedBuffer = new Uint8Array(size);
|
||||||
}
|
}
|
||||||
@@ -293,7 +257,6 @@ function findOptimizedConnectedComponents(mask: Uint8Array, width: number, heigh
|
|||||||
stackBuffer = new Int32Array(size * 2);
|
stackBuffer = new Int32Array(size * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear visited array
|
|
||||||
visitedBuffer.fill(0);
|
visitedBuffer.fill(0);
|
||||||
|
|
||||||
const sprites: SpriteRegion[] = [];
|
const sprites: SpriteRegion[] = [];
|
||||||
@@ -337,13 +300,11 @@ function optimizedFloodFill(mask: Uint8Array, visited: Uint8Array, startX: numbe
|
|||||||
visited[idx] = 1;
|
visited[idx] = 1;
|
||||||
pixelCount++;
|
pixelCount++;
|
||||||
|
|
||||||
// Update bounding box
|
|
||||||
if (x < minX) minX = x;
|
if (x < minX) minX = x;
|
||||||
if (y < minY) minY = y;
|
if (y < minY) minY = y;
|
||||||
if (x > maxX) maxX = x;
|
if (x > maxX) maxX = x;
|
||||||
if (y > maxY) maxY = y;
|
if (y > maxY) maxY = y;
|
||||||
|
|
||||||
// Add neighbors (check bounds to avoid stack overflow)
|
|
||||||
if (x + 1 < width && !visited[idx + 1] && mask[idx + 1]) {
|
if (x + 1 < width && !visited[idx + 1] && mask[idx + 1]) {
|
||||||
stackBuffer[stackTop++] = x + 1;
|
stackBuffer[stackTop++] = x + 1;
|
||||||
stackBuffer[stackTop++] = y;
|
stackBuffer[stackTop++] = y;
|
||||||
@@ -364,7 +325,6 @@ function optimizedFloodFill(mask: Uint8Array, visited: Uint8Array, startX: numbe
|
|||||||
|
|
||||||
if (pixelCount === 0) return null;
|
if (pixelCount === 0) return null;
|
||||||
|
|
||||||
// Add padding
|
|
||||||
const padding = 1;
|
const padding = 1;
|
||||||
return {
|
return {
|
||||||
x: Math.max(0, minX - padding),
|
x: Math.max(0, minX - padding),
|
||||||
|
|||||||
Reference in New Issue
Block a user