Compare commits

18 Commits

Author SHA1 Message Date
9c3bb5d8fe [FEAT} Remove redundant code 2026-01-09 03:29:23 +01:00
1c2325170a [FEAT] Add docker files 2026-01-09 03:24:35 +01:00
cdb86452c3 [FEAT] Proj. pagination 2026-01-07 19:54:09 +01:00
fa23980917 [FEAT] Img replace fix 2026-01-07 19:03:33 +01:00
06ab1e45db [FEAT] Replace img 2026-01-07 18:58:47 +01:00
8a4e14750b Fix 2026-01-07 16:39:13 +01:00
bcc2faca35 [FEAT] Open project bug fix 2026-01-07 16:20:08 +01:00
f9635ba044 [FEAT] UI context menu streamline 2026-01-07 16:13:21 +01:00
ad28f6a607 [FEAT] Bug fixes 2026-01-07 00:53:23 +01:00
77ae4bb429 [FEAT] Add back to project btn, extract GIFS into frames. 2026-01-05 01:15:01 +01:00
8e71d7379a [FEAT] Add eye drop to pixel editor 2026-01-03 18:25:24 +01:00
e290eb21a4 [FEAT] Add add new frame btn 2026-01-03 17:21:15 +01:00
2f0404d698 [FEAT] Add pixel editor 2026-01-03 17:17:14 +01:00
224d0d62fe [FEAT] Clean code 2026-01-02 22:16:23 +01:00
647083d5b9 [FEAT] Frame ID start at 1 2026-01-02 22:05:35 +01:00
e720f95c4e [FEAT] Minor tweak 2026-01-02 21:40:10 +01:00
b649d6da87 [FEAT] Remove redundant code 2026-01-02 21:36:09 +01:00
e481a6e897 [FEAT] 404 page 2026-01-02 21:35:55 +01:00
63 changed files with 2144 additions and 735 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.git
.gitignore
.vscode
*.md
.DS_Store

View File

@@ -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
View 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
View 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
View 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
View File

@@ -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"
], ],

View File

@@ -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

View File

@@ -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;

View File

@@ -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
View 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>

View File

@@ -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);

View File

@@ -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 = '';
} }
}; };

View File

@@ -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);

View File

@@ -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 = '';

View File

@@ -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>

View File

@@ -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/')) {
if (replacingSpriteId.value) {
emit('replaceSprite', replacingSpriteId.value, file); 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>

View File

@@ -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');
} }

View File

@@ -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;

View File

@@ -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>

View File

@@ -5,11 +5,7 @@
:key="link.path" :key="link.path"
:to="link.path" :to="link.path"
class="px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200" class="px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200"
:class="[ :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']"
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 }} {{ link.name }}
</router-link> </router-link>

View File

@@ -34,16 +34,26 @@
Login / Register Login / Register
</button> </button>
</div> </div>
<!-- Back to Editor Button -->
<button
v-if="showBackToEditor"
@click="
$emit('back-to-editor');
$emit('close');
"
class="mx-3 mb-3 btn btn-primary w-auto flex items-center gap-2"
>
<i class="fas fa-arrow-left"></i>
Back to Editor
</button>
<router-link <router-link
v-for="link in links" v-for="link in links"
:key="link.path" :key="link.path"
:to="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="flex items-center gap-3 px-3 py-3 rounded-md text-base font-medium transition-all duration-200"
:class="[ :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']"
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')" @click="$emit('close')"
> >
<i :class="link.icon" class="w-5 text-center"></i> {{ link.name }} <i :class="link.icon" class="w-5 text-center"></i> {{ link.name }}
@@ -131,6 +141,13 @@
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 = [ const links = [
{ name: 'Home', path: '/', icon: 'fas fa-home' }, { name: 'Home', path: '/', icon: 'fas fa-home' },
{ name: 'Blog', path: '/blog', icon: 'fas fa-newspaper' }, { name: 'Blog', path: '/blog', icon: 'fas fa-newspaper' },
@@ -151,7 +168,7 @@
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();
@@ -163,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>

View File

@@ -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>

View File

@@ -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 => {

View File

@@ -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) {

View 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>

View File

@@ -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();

View File

@@ -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(

View File

@@ -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;
} }

View File

@@ -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,

View File

@@ -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%)`;
} }

View File

@@ -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);

View 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,
};
}

View File

@@ -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;
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 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))); const constrainedY = Math.floor(Math.max(-negativeSpacing, Math.min(maxHeight - negativeSpacing - sprites[spriteIndex].height, newY)));
onUpdateSprite(activeSpriteId.value, constrainedX, constrainedY); 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,

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);
} }

View 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[]);

View File

@@ -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 {}
} }
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); 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 {}
} }
if (layers.value.length > 1) {
l.sprites[i] = createEmptySprite();
} else {
l.sprites.splice(i, 1); 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);
}; };

View File

@@ -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);
} }

View 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,
};
};

View File

@@ -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;
} }
}
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; 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('/');
}; };

View File

@@ -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 });
} }

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,
}; };

View File

@@ -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',

View File

@@ -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;
} }

View File

@@ -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) {

View File

@@ -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);
} }

View File

@@ -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,

View File

@@ -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;
} }

View File

@@ -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 {

View File

@@ -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.',

View File

@@ -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',

View File

@@ -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' },

View File

@@ -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!",

View File

@@ -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,11 +638,10 @@
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'),
@@ -627,8 +656,9 @@
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.
} }
} }
); );

View File

@@ -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.',

View File

@@ -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
View 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>

View File

@@ -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.',

View File

@@ -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' });
}; };

View File

@@ -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),