Compare commits

21 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
ce69598175 [FEAT] Menu fixes 2026-01-02 21:30:06 +01:00
49af17c0e3 [FEAT] Add contact URL to navs 2026-01-02 21:26:16 +01:00
bade5cec0c [FEAT] Add show/hide frame ID toggle 2026-01-02 21:19:38 +01:00
63 changed files with 2212 additions and 734 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
encode zstd gzip
@@ -19,10 +19,6 @@ http://spritesheetgenerator.online:1337 {
}
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": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
"integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
"integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==",
"cpu": [
"arm"
],
@@ -1403,9 +1403,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
"integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz",
"integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==",
"cpu": [
"arm64"
],
@@ -1416,9 +1416,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz",
"integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==",
"cpu": [
"arm64"
],
@@ -1429,9 +1429,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
"integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz",
"integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==",
"cpu": [
"x64"
],
@@ -1442,9 +1442,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
"integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz",
"integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==",
"cpu": [
"arm64"
],
@@ -1455,9 +1455,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
"integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz",
"integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==",
"cpu": [
"x64"
],
@@ -1468,9 +1468,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
"integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz",
"integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==",
"cpu": [
"arm"
],
@@ -1481,9 +1481,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
"integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz",
"integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==",
"cpu": [
"arm"
],
@@ -1494,9 +1494,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
"integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz",
"integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==",
"cpu": [
"arm64"
],
@@ -1507,9 +1507,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
"integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz",
"integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==",
"cpu": [
"arm64"
],
@@ -1520,9 +1520,22 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
"integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz",
"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": [
"loong64"
],
@@ -1533,9 +1546,22 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
"integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz",
"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": [
"ppc64"
],
@@ -1546,9 +1572,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
"integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz",
"integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==",
"cpu": [
"riscv64"
],
@@ -1559,9 +1585,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
"integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz",
"integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==",
"cpu": [
"riscv64"
],
@@ -1572,9 +1598,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
"integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz",
"integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==",
"cpu": [
"s390x"
],
@@ -1585,9 +1611,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
"integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz",
"integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==",
"cpu": [
"x64"
],
@@ -1598,9 +1624,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz",
"integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==",
"cpu": [
"x64"
],
@@ -1610,10 +1636,23 @@
"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": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
"integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz",
"integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==",
"cpu": [
"arm64"
],
@@ -1624,9 +1663,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
"integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz",
"integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==",
"cpu": [
"arm64"
],
@@ -1637,9 +1676,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
"integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz",
"integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==",
"cpu": [
"ia32"
],
@@ -1650,9 +1689,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
"integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz",
"integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==",
"cpu": [
"x64"
],
@@ -1663,9 +1702,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
"integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz",
"integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==",
"cpu": [
"x64"
],
@@ -2505,9 +2544,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
"integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
"version": "2.9.12",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.12.tgz",
"integrity": "sha512-Mij6Lij93pTAIsSYy5cyBQ975Qh9uLEc5rwGTpomiZeXZL9yIS6uORJakb3ScHgfs0serMMfIbXzokPMuEiRyw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -4181,9 +4220,9 @@
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -4196,28 +4235,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.54.0",
"@rollup/rollup-android-arm64": "4.54.0",
"@rollup/rollup-darwin-arm64": "4.54.0",
"@rollup/rollup-darwin-x64": "4.54.0",
"@rollup/rollup-freebsd-arm64": "4.54.0",
"@rollup/rollup-freebsd-x64": "4.54.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
"@rollup/rollup-linux-arm-musleabihf": "4.54.0",
"@rollup/rollup-linux-arm64-gnu": "4.54.0",
"@rollup/rollup-linux-arm64-musl": "4.54.0",
"@rollup/rollup-linux-loong64-gnu": "4.54.0",
"@rollup/rollup-linux-ppc64-gnu": "4.54.0",
"@rollup/rollup-linux-riscv64-gnu": "4.54.0",
"@rollup/rollup-linux-riscv64-musl": "4.54.0",
"@rollup/rollup-linux-s390x-gnu": "4.54.0",
"@rollup/rollup-linux-x64-gnu": "4.54.0",
"@rollup/rollup-linux-x64-musl": "4.54.0",
"@rollup/rollup-openharmony-arm64": "4.54.0",
"@rollup/rollup-win32-arm64-msvc": "4.54.0",
"@rollup/rollup-win32-ia32-msvc": "4.54.0",
"@rollup/rollup-win32-x64-gnu": "4.54.0",
"@rollup/rollup-win32-x64-msvc": "4.54.0",
"@rollup/rollup-android-arm-eabi": "4.55.1",
"@rollup/rollup-android-arm64": "4.55.1",
"@rollup/rollup-darwin-arm64": "4.55.1",
"@rollup/rollup-darwin-x64": "4.55.1",
"@rollup/rollup-freebsd-arm64": "4.55.1",
"@rollup/rollup-freebsd-x64": "4.55.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.55.1",
"@rollup/rollup-linux-arm-musleabihf": "4.55.1",
"@rollup/rollup-linux-arm64-gnu": "4.55.1",
"@rollup/rollup-linux-arm64-musl": "4.55.1",
"@rollup/rollup-linux-loong64-gnu": "4.55.1",
"@rollup/rollup-linux-loong64-musl": "4.55.1",
"@rollup/rollup-linux-ppc64-gnu": "4.55.1",
"@rollup/rollup-linux-ppc64-musl": "4.55.1",
"@rollup/rollup-linux-riscv64-gnu": "4.55.1",
"@rollup/rollup-linux-riscv64-musl": "4.55.1",
"@rollup/rollup-linux-s390x-gnu": "4.55.1",
"@rollup/rollup-linux-x64-gnu": "4.55.1",
"@rollup/rollup-linux-x64-musl": "4.55.1",
"@rollup/rollup-openbsd-x64": "4.55.1",
"@rollup/rollup-openharmony-arm64": "4.55.1",
"@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"
}
},
@@ -4251,9 +4293,9 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.1.tgz",
"integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz",
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -4272,9 +4314,9 @@
}
},
"node_modules/sass-embedded": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.97.1.tgz",
"integrity": "sha512-wH3CbOThHYGX0bUyqFf7laLKyhVWIFc2lHynitkqMIUCtX2ixH9mQh0bN7+hkUu5BFt/SXvEMjFbkEbBMpQiSQ==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.97.2.tgz",
"integrity": "sha512-lKJcskySwAtJ4QRirKrikrWMFa2niAuaGenY2ElHjd55IwHUiur5IdKu6R1hEmGYMs4Qm+6rlRW0RvuAkmcryg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -4294,30 +4336,30 @@
"node": ">=16.0.0"
},
"optionalDependencies": {
"sass-embedded-all-unknown": "1.97.1",
"sass-embedded-android-arm": "1.97.1",
"sass-embedded-android-arm64": "1.97.1",
"sass-embedded-android-riscv64": "1.97.1",
"sass-embedded-android-x64": "1.97.1",
"sass-embedded-darwin-arm64": "1.97.1",
"sass-embedded-darwin-x64": "1.97.1",
"sass-embedded-linux-arm": "1.97.1",
"sass-embedded-linux-arm64": "1.97.1",
"sass-embedded-linux-musl-arm": "1.97.1",
"sass-embedded-linux-musl-arm64": "1.97.1",
"sass-embedded-linux-musl-riscv64": "1.97.1",
"sass-embedded-linux-musl-x64": "1.97.1",
"sass-embedded-linux-riscv64": "1.97.1",
"sass-embedded-linux-x64": "1.97.1",
"sass-embedded-unknown-all": "1.97.1",
"sass-embedded-win32-arm64": "1.97.1",
"sass-embedded-win32-x64": "1.97.1"
"sass-embedded-all-unknown": "1.97.2",
"sass-embedded-android-arm": "1.97.2",
"sass-embedded-android-arm64": "1.97.2",
"sass-embedded-android-riscv64": "1.97.2",
"sass-embedded-android-x64": "1.97.2",
"sass-embedded-darwin-arm64": "1.97.2",
"sass-embedded-darwin-x64": "1.97.2",
"sass-embedded-linux-arm": "1.97.2",
"sass-embedded-linux-arm64": "1.97.2",
"sass-embedded-linux-musl-arm": "1.97.2",
"sass-embedded-linux-musl-arm64": "1.97.2",
"sass-embedded-linux-musl-riscv64": "1.97.2",
"sass-embedded-linux-musl-x64": "1.97.2",
"sass-embedded-linux-riscv64": "1.97.2",
"sass-embedded-linux-x64": "1.97.2",
"sass-embedded-unknown-all": "1.97.2",
"sass-embedded-win32-arm64": "1.97.2",
"sass-embedded-win32-x64": "1.97.2"
}
},
"node_modules/sass-embedded-all-unknown": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.97.1.tgz",
"integrity": "sha512-0au5gUNibfob7W/g+ycBx74O22CL8vwHiZdEDY6J0uzMkHPiSJk//h0iRf5AUnMArFHJjFd3urIiQIaoRKYa1Q==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.97.2.tgz",
"integrity": "sha512-Fj75+vOIDv1T/dGDwEpQ5hgjXxa2SmMeShPa8yrh2sUz1U44bbmY4YSWPCdg8wb7LnwiY21B2KRFM+HF42yO4g==",
"cpu": [
"!arm",
"!arm64",
@@ -4327,13 +4369,13 @@
"license": "MIT",
"optional": true,
"dependencies": {
"sass": "1.97.1"
"sass": "1.97.2"
}
},
"node_modules/sass-embedded-android-arm": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.97.1.tgz",
"integrity": "sha512-B5dlv4utJ+yC8ZpBeWTHwSZPVKRlqA8pcaD0FAzeNm/DelIFgQUQtt0UwgYoAI6wDIiie5uSVpMK9l2DaCbiBQ==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.97.2.tgz",
"integrity": "sha512-BPT9m19ttY0QVHYYXRa6bmqmS3Fa2EHByNUEtSVcbm5PkIk1ntmYkG9fn5SJpIMbNmFDGwHx+pfcZMmkldhnRg==",
"cpu": [
"arm"
],
@@ -4347,9 +4389,9 @@
}
},
"node_modules/sass-embedded-android-arm64": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.97.1.tgz",
"integrity": "sha512-h62DmOiS2Jn87s8+8GhJcMerJnTKa1IsIa9iIKjLiqbAvBDKCGUs027RugZkM+Zx7I+vhPq86PUXBYZ9EkRxdw==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.97.2.tgz",
"integrity": "sha512-pF6I+R5uThrscd3lo9B3DyNTPyGFsopycdx0tDAESN6s+dBbiRgNgE4Zlpv50GsLocj/lDLCZaabeTpL3ubhYA==",
"cpu": [
"arm64"
],
@@ -4363,9 +4405,9 @@
}
},
"node_modules/sass-embedded-android-riscv64": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.97.1.tgz",
"integrity": "sha512-tGup88vgaXPnUHEgDMujrt5rfYadvkiVjRb/45FJTx2hQFoGVbmUXz5XqUFjIIbEjQ3kAJqp86A2jy11s43UiQ==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.97.2.tgz",
"integrity": "sha512-fprI8ZTJdz+STgARhg8zReI2QhhGIT9G8nS7H21kc3IkqPRzhfaemSxEtCqZyvDbXPcgYiDLV7AGIReHCuATog==",
"cpu": [
"riscv64"
],
@@ -4379,9 +4421,9 @@
}
},
"node_modules/sass-embedded-android-x64": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.97.1.tgz",
"integrity": "sha512-CAzKjjzu90LZduye2O9+UGX1oScMyF5/RVOa5CxACKALeIS+3XL3LVdV47kwKPoBv5B1aFUvGLscY0CR7jBAbg==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.97.2.tgz",
"integrity": "sha512-RswwSjURZxupsukEmNt2t6RGvuvIw3IAD5sDq1Pc65JFvWFY3eHqCmH0lG0oXqMg6KJcF0eOxHOp2RfmIm2+4w==",
"cpu": [
"x64"
],
@@ -4395,9 +4437,9 @@
}
},
"node_modules/sass-embedded-darwin-arm64": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.97.1.tgz",
"integrity": "sha512-tyDzspzh5PbqdAFGtVKUXuf0up6Lff3c1U8J7+4Y7jW6AWRBnq95vTzIIxfnNifGCTI2fW5e7GAZpYygKpNwcw==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.97.2.tgz",
"integrity": "sha512-xcsZNnU1XZh21RE/71OOwNqPVcGBU0qT9A4k4QirdA34+ts9cDIaR6W6lgHOBR/Bnnu6w6hXJR4Xth7oFrefPA==",
"cpu": [
"arm64"
],
@@ -4411,9 +4453,9 @@
}
},
"node_modules/sass-embedded-darwin-x64": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.97.1.tgz",
"integrity": "sha512-FMrRuSPI2ICt2M2SYaLbiG4yxn86D6ae+XtrRdrrBMhWprAcB7Iyu67bgRzZkipMZNIKKeTR7EUvJHgZzi5ixQ==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.97.2.tgz",
"integrity": "sha512-T/9DTMpychm6+H4slHCAsYJRJ6eM+9H9idKlBPliPrP4T8JdC2Cs+ZOsYqrObj6eOtAD0fGf+KgyNhnW3xVafA==",
"cpu": [
"x64"
],
@@ -4427,9 +4469,9 @@
}
},
"node_modules/sass-embedded-linux-arm": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.97.1.tgz",
"integrity": "sha512-48VxaTUApLyx1NXFdZhKqI/7FYLmz8Ju3Ki2V/p+mhn5raHgAiYeFgn8O1WGxTOh+hBb9y3FdSR5a8MNTbmKMQ==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.97.2.tgz",
"integrity": "sha512-yDRe1yifGHl6kibkDlRIJ2ZzAU03KJ1AIvsAh4dsIDgK5jx83bxZLV1ZDUv7a8KK/iV/80LZnxnu/92zp99cXQ==",
"cpu": [
"arm"
],
@@ -4443,9 +4485,9 @@
}
},
"node_modules/sass-embedded-linux-arm64": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.97.1.tgz",
"integrity": "sha512-im80gfDWRivw9Su3r3YaZmJaCATcJgu3CsCSLodPk1b1R2+X/E12zEQayvrl05EGT9PDwTtuiqKgS4ND4xjwVg==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.97.2.tgz",
"integrity": "sha512-Wh+nQaFer9tyE5xBPv5murSUZE/+kIcg8MyL5uqww6be9Iq+UmZpcJM7LUk+q8klQ9LfTmoDSNFA74uBqxD6IA==",
"cpu": [
"arm64"
],
@@ -4459,9 +4501,9 @@
}
},
"node_modules/sass-embedded-linux-musl-arm": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.97.1.tgz",
"integrity": "sha512-FUFs466t3PVViVOKY/60JgLLtl61Pf7OW+g5BeEfuqVcSvYUECVHeiYHtX1fT78PEVa0h9tHpM6XpWti+7WYFA==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.97.2.tgz",
"integrity": "sha512-GIO6xfAtahJAWItvsXZ3MD1HM6s8cKtV1/HL088aUpKJaw/2XjTCveiOO2AdgMpLNztmq9DZ1lx5X5JjqhS45g==",
"cpu": [
"arm"
],
@@ -4475,9 +4517,9 @@
}
},
"node_modules/sass-embedded-linux-musl-arm64": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.97.1.tgz",
"integrity": "sha512-kD35WSD9o0279Ptwid3Jnbovo1FYnuG2mayYk9z4ZI4mweXEK6vTu+tlvCE/MdF/zFKSj11qaxaH+uzXe2cO5A==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.97.2.tgz",
"integrity": "sha512-NfUqZSjHwnHvpSa7nyNxbWfL5obDjNBqhHUYmqbHUcmqBpFfHIQsUPgXME9DKn1yBlBc3mWnzMxRoucdYTzd2Q==",
"cpu": [
"arm64"
],
@@ -4491,9 +4533,9 @@
}
},
"node_modules/sass-embedded-linux-musl-riscv64": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.97.1.tgz",
"integrity": "sha512-ZgpYps5YHuhA2+KiLkPukRbS5298QObgUhPll/gm5i0LOZleKCwrFELpVPcbhsSBuxqji2uaag5OL+n3JRBVVg==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.97.2.tgz",
"integrity": "sha512-qtM4dJ5gLfvyTZ3QencfNbsTEShIWImSEpkThz+Y2nsCMbcMP7/jYOA03UWgPfEOKSehQQ7EIau7ncbFNoDNPQ==",
"cpu": [
"riscv64"
],
@@ -4507,9 +4549,9 @@
}
},
"node_modules/sass-embedded-linux-musl-x64": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.97.1.tgz",
"integrity": "sha512-wcAigOyyvZ6o1zVypWV7QLZqpOEVnlBqJr9MbpnRIm74qFTSbAEmShoh8yMXBymzuVSmEbThxAwW01/TLf62tA==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.97.2.tgz",
"integrity": "sha512-ZAxYOdmexcnxGnzdsDjYmNe3jGj+XW3/pF/n7e7r8y+5c6D2CQRrCUdapLgaqPt1edOPQIlQEZF8q5j6ng21yw==",
"cpu": [
"x64"
],
@@ -4523,9 +4565,9 @@
}
},
"node_modules/sass-embedded-linux-riscv64": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.97.1.tgz",
"integrity": "sha512-9j1qE1ZrLMuGb+LUmBzw93Z4TNfqlRkkxjPVZy6u5vIggeSfvGbte7eRoYBNWX6SFew/yBCL90KXIirWFSGrlQ==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.97.2.tgz",
"integrity": "sha512-reVwa9ZFEAOChXpDyNB3nNHHyAkPMD+FTctQKECqKiVJnIzv2EaFF6/t0wzyvPgBKeatA8jszAIeOkkOzbYVkQ==",
"cpu": [
"riscv64"
],
@@ -4539,9 +4581,9 @@
}
},
"node_modules/sass-embedded-linux-x64": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.97.1.tgz",
"integrity": "sha512-7nrLFYMH/UgvEgXR5JxQJ6y9N4IJmnFnYoDxN0nw0jUp+CQWQL4EJ4RqAKTGelneueRbccvt2sEyPK+X0KJ9Jg==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.97.2.tgz",
"integrity": "sha512-bvAdZQsX3jDBv6m4emaU2OMTpN0KndzTAMgJZZrKUgiC0qxBmBqbJG06Oj/lOCoXGCxAvUOheVYpezRTF+Feog==",
"cpu": [
"x64"
],
@@ -4555,9 +4597,9 @@
}
},
"node_modules/sass-embedded-unknown-all": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.97.1.tgz",
"integrity": "sha512-oPSeKc7vS2dx3ZJHiUhHKcyqNq0GWzAiR8zMVpPd/kVMl5ZfVyw+5HTCxxWDBGkX02lNpou27JkeBPCaneYGAQ==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.97.2.tgz",
"integrity": "sha512-86tcYwohjPgSZtgeU9K4LikrKBJNf8ZW/vfsFbdzsRlvc73IykiqanufwQi5qIul0YHuu9lZtDWyWxM2dH/Rsg==",
"license": "MIT",
"optional": true,
"os": [
@@ -4567,13 +4609,13 @@
"!win32"
],
"dependencies": {
"sass": "1.97.1"
"sass": "1.97.2"
}
},
"node_modules/sass-embedded-win32-arm64": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.97.1.tgz",
"integrity": "sha512-L5j7J6CbZgHGwcfVedMVpM3z5MYeighcyZE8GF2DVmjWzZI3JtPKNY11wNTD/P9o1Uql10YPOKhGH0iWIXOT7Q==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.97.2.tgz",
"integrity": "sha512-Cv28q8qNjAjZfqfzTrQvKf4JjsZ6EOQ5FxyHUQQeNzm73R86nd/8ozDa1Vmn79Hq0kwM15OCM9epanDuTG1ksA==",
"cpu": [
"arm64"
],
@@ -4587,9 +4629,9 @@
}
},
"node_modules/sass-embedded-win32-x64": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.97.1.tgz",
"integrity": "sha512-rfaZAKXU8cW3E7gvdafyD6YtgbEcsDeT99OEiHXRT0UGFuXT8qCOjpAwIKaOA3XXr2d8S42xx6cXcaZ1a+1fgw==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.97.2.tgz",
"integrity": "sha512-DVxLxkeDCGIYeyHLAvWW3yy9sy5Ruk5p472QWiyfyyG1G1ASAR8fgfIY5pT0vE6Rv+VAKVLwF3WTspUYu7S1/Q==",
"cpu": [
"x64"
],

View File

@@ -1,5 +1,11 @@
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
- Add authentication
- You can now save projects and open them

View File

@@ -9,6 +9,14 @@ html {
scroll-behavior: smooth;
}
/* Disable selection on images and canvases */
img,
canvas {
user-select: none;
-webkit-user-select: none;
-webkit-user-drag: none;
}
body {
@apply bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100 antialiased font-sans;
min-height: 100vh;

View File

@@ -13,7 +13,6 @@
const breadcrumbs = computed<BreadcrumbItem[]>(() => {
const items: BreadcrumbItem[] = [{ name: 'Home', path: '/' }];
// Map route names to breadcrumb labels
const routeLabels: Record<string, string> = {
'blog-overview': 'Blog',
'blog-detail': 'Blog',
@@ -27,10 +26,8 @@
const routeName = route.name.toString();
if (routeName === 'blog-detail') {
// For blog detail pages, add Blog first, then the post title
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';
items.push({ name: postTitle, path: route.path });
} 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.';
// Reset fields
name.value = '';
contact.value = '';
content.value = '';
// Optionally close after short delay
setTimeout(() => close(), 600);
} catch (e: any) {
console.error('Failed to send feedback:', e);

View File

@@ -63,7 +63,6 @@
if (input.files && input.files.length > 0) {
const files = Array.from(input.files);
emit('uploadSprites', files);
// Reset input value so uploading the same file again will trigger the event
if (fileInput.value) fileInput.value.value = '';
}
};

View File

@@ -170,13 +170,11 @@
const response = await fetch('/CHANGELOG.md');
const text = await response.text();
// Configure marked options
marked.setOptions({
gfm: true, // GitHub Flavored Markdown
breaks: true, // Convert line breaks to <br>
});
// Convert markdown to HTML
changelogHtml.value = await marked(text);
} catch (error) {
console.error('Failed to load changelog:', error);

View File

@@ -87,7 +87,6 @@
copied.value = false;
}, 2000);
} catch {
// Fallback for older browsers
const input = document.createElement('input');
input.value = shareUrl.value;
document.body.appendChild(input);
@@ -101,14 +100,12 @@
}
};
// Start sharing when modal opens
watch(
() => props.isOpen,
isOpen => {
if (isOpen) {
performShare();
} else {
// Reset state when closing
loading.value = false;
shareUrl.value = '';
error.value = '';

View File

@@ -1,36 +1,19 @@
<template>
<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' }">
<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">
<i class="fas fa-plus text-blue-600 dark:text-blue-400 text-xs w-4"></i>
<span>Add sprite</span>
</button>
<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">
<i class="fas fa-redo text-green-600 dark:text-green-400 text-xs w-4"></i>
<span>Rotate +90°</span>
</button>
<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">
<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 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>
<SpriteContextMenu
:is-open="isContextMenuOpen"
:position="contextMenuPosition"
:has-sprite="!!contextMenuSpriteId"
:selected-count="selectedSpriteIds.size"
@add="addSpriteRefined"
@rotate="rotateSpriteInMenu"
@flip="flipSpriteInMenu"
@replace="replaceSprite"
@copy-to-frame="openCopyToFrameModal"
@edit-in-pixel-editor="openPixelEditor"
@remove="removeSprite"
@close="closeContextMenu"
/>
<!-- Copy to Frame Modal -->
<CopyToFrameModal :is-open="showCopyToFrameModal" :layers="props.layers" :initial-layer-id="copyTargetLayerId" @close="closeCopyToFrameModal" @copy="confirmCopyToFrame" />
@@ -56,11 +39,11 @@
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="stopDrag"
@contextmenu.prevent
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="onDragLeave"
@drop="handleDrop"
@contextmenu.prevent.stop
@dragover="handleGridDragOver"
@dragenter="handleGridDragEnter"
@dragleave="handleGridDragLeave"
@drop="handleGridDrop"
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
>
<!-- Grid cells -->
@@ -151,15 +134,29 @@
draggable="false"
/>
<!-- Offset labels -->
<!-- Frame IDs -->
<div
v-if="showFrameIds"
v-for="cellIndex in totalCells"
:key="`frame-id-${cellIndex}`"
class="absolute pointer-events-none font-mono text-[10px] leading-none text-gray-500 dark:text-gray-400 bg-white/80 dark:bg-gray-800/90 px-1.5 py-1 rounded-br-lg border-r border-b border-gray-200 dark:border-gray-600 shadow-sm z-0"
:style="{
left: `${Math.round(getCellPosition(cellIndex - 1).x)}px`,
top: `${Math.round(getCellPosition(cellIndex - 1).y)}px`,
}"
>
{{ cellIndex }}
</div>
<!-- Offset labels (Coordinates) -->
<div
v-if="showOffsetLabels"
v-for="position in spritePositions"
:key="`label-${position.id}`"
class="absolute text-[23px] leading-none font-mono text-cyan-600 dark:text-cyan-400 bg-white/90 dark:bg-gray-900/90 px-1 py-0.5 rounded-sm pointer-events-none"
class="absolute text-[10px] sm:text-xs leading-none font-mono font-medium text-indigo-600 dark:text-indigo-400 bg-white/95 dark:bg-gray-900/95 px-1.5 py-0.5 rounded shadow-sm border border-indigo-200 dark:border-indigo-900 pointer-events-none z-10"
:style="{
left: `${Math.round(position.cellX + position.maxWidth - 2)}px`,
top: `${Math.round(position.cellY + position.maxHeight - 2)}px`,
left: `${Math.round(position.cellX + position.maxWidth)}px`,
top: `${Math.round(position.cellY + position.maxHeight)}px`,
transform: 'translate(-100%, -100%)',
}"
>
@@ -169,7 +166,7 @@
</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>
</template>
@@ -177,10 +174,12 @@
import { ref, onMounted, watch, onUnmounted, toRef, computed, nextTick } from 'vue';
import { useSettingsStore } from '@/stores/useSettingsStore';
import type { Sprite } from '@/types/sprites';
import { useDragSprite } from '@/composables/useDragSprite';
import { useDragSprite, type CellPosition } from '@/composables/useDragSprite';
import { useFileDrop } from '@/composables/useFileDrop';
import { useGridMetrics } from '@/composables/useGridMetrics';
import { useBackgroundStyles } from '@/composables/useBackgroundStyles';
import { useContextMenu } from '@/composables/useContextMenu';
import SpriteContextMenu from '@/components/shared/SpriteContextMenu.vue';
import CopyToFrameModal from './utilities/CopyToFrameModal.vue';
import type { Layer } from '@/types/sprites';
@@ -195,6 +194,7 @@
allowCellSwap: boolean;
showAllSprites: boolean;
showOffsetLabels: boolean;
showFrameIds: boolean;
}>();
const emit = defineEmits<{
@@ -204,13 +204,14 @@
(e: 'removeSprites', ids: string[]): void;
(e: 'replaceSprite', id: string, file: File): void;
(e: 'addSprite', file: File, index?: number): void;
(e: 'addSprites', files: File[], index?: number): void;
(e: 'addSpriteWithResize', file: File): void;
(e: 'rotateSprite', id: string, angle: number): void;
(e: 'flipSprite', id: string, direction: 'horizontal' | 'vertical'): 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 gridContainerRef = ref<HTMLDivElement | null>(null);
@@ -227,6 +228,8 @@
};
};
const selectedSpriteIds = ref<Set<string>>(new Set());
const {
isDragging,
activeSpriteId,
@@ -251,6 +254,7 @@
manualCellSizeEnabled: toRef(settingsStore, 'manualCellSizeEnabled'),
manualCellWidth: toRef(settingsStore, 'manualCellWidth'),
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
selectedSpriteIds,
getMousePosition,
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
@@ -267,21 +271,19 @@
onAddSpriteWithResize: file => emit('addSpriteWithResize', file),
});
const showContextMenu = ref(false);
const contextMenuX = ref(0);
const contextMenuY = ref(0);
const contextMenuIndex = ref<number | null>(null);
const contextMenuSpriteId = ref<string | null>(null);
const selectedSpriteIds = ref<Set<string>>(new Set());
const { isOpen: isContextMenuOpen, position: contextMenuPosition, contextData: contextMenuData, open: openContextMenuBase, close: closeContextMenu } = useContextMenu<{ spriteId?: string; layerId?: string; index?: number }>();
// Computed properties to access context data safely
const contextMenuSpriteId = computed(() => contextMenuData.value?.spriteId || null);
const contextMenuIndex = computed(() => contextMenuData.value?.index ?? null); // Use ?? to allow 0 index
const replacingSpriteId = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
// Copy to frame modal state
const showCopyToFrameModal = ref(false);
const copyTargetLayerId = ref(props.activeLayerId);
const copySpriteId = ref<string | null>(null);
// Clear selection when toggling multi-select mode
watch(
() => props.isMultiSelectMode,
() => {
@@ -289,7 +291,6 @@
}
);
// Use the new useGridMetrics composable for consistent calculations
const { gridMetrics: gridMetricsRef, getCellPosition: getCellPositionHelper } = useGridMetrics({
layers: toRef(props, 'layers'),
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
@@ -301,13 +302,11 @@
const gridMetrics = gridMetricsRef;
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));
return Math.max(1, Math.ceil(maxLen / props.columns)) * props.columns;
});
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 rows = Math.max(1, Math.ceil(maxLen / props.columns));
return {
@@ -320,7 +319,6 @@
return getCellPositionHelper(index, props.columns, gridMetrics.value);
};
// Use the new useBackgroundStyles composable for consistent background styling
const {
backgroundColor: cellBackgroundColor,
backgroundImage: cellBackgroundImage,
@@ -338,76 +336,62 @@
const getCellBackgroundPosition = () => cellBackgroundPosition.value;
const startDrag = (event: MouseEvent) => {
// If the click originated from an interactive element (button, link, input), ignore drag handling
const target = event.target as HTMLElement;
if (target && target.closest('button, a, input, select, textarea')) {
return;
}
if (!gridContainerRef.value) return;
// Hide context menu if open
showContextMenu.value = false;
closeContextMenu();
// Handle right-click for context menu
if ('button' in event && (event as MouseEvent).button === 2) {
event.preventDefault();
const pos = getMousePosition(event, props.zoom);
if (!pos) return;
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
contextMenuIndex.value = findCellAtPosition(pos.x, pos.y)?.index ?? null;
contextMenuSpriteId.value = clickedSprite?.id || null;
const clickedCell = findCellAtPosition(pos.x, pos.y);
const cellIndex = clickedCell?.index ?? null;
const spriteId = clickedSprite?.id || undefined;
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)) {
selectedSpriteIds.value.clear();
selectedSpriteIds.value.add(clickedSprite.id);
}
// If it IS in the selection, keep the current selection (so we can apply action to all)
} else {
// Right click on empty space
selectedSpriteIds.value.clear();
}
contextMenuX.value = event.clientX;
contextMenuY.value = event.clientY;
showContextMenu.value = true;
openContextMenuBase(event, {
spriteId: spriteId,
index: cellIndex !== null ? cellIndex : undefined,
});
return;
}
// Ignore non-left mouse buttons
if ('button' in event && (event as MouseEvent).button !== 0) return;
// Handle selection logic for left click
const pos = getMousePosition(event, props.zoom);
if (pos) {
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
if (clickedSprite) {
// Selection logic with multi-select mode check
if (event.ctrlKey || event.metaKey || props.isMultiSelectMode) {
// Toggle selection
if (selectedSpriteIds.value.has(clickedSprite.id)) {
selectedSpriteIds.value.delete(clickedSprite.id);
} else {
if (!selectedSpriteIds.value.has(clickedSprite.id)) {
selectedSpriteIds.value.add(clickedSprite.id);
}
} 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)) {
selectedSpriteIds.value.clear();
selectedSpriteIds.value.add(clickedSprite.id);
}
}
} else {
// Clicked on empty space
selectedSpriteIds.value.clear();
}
}
// Delegate to composable for actual drag handling
dragStart(event);
};
@@ -415,7 +399,6 @@
const latestEvent = ref<MouseEvent | null>(null);
const drag = (event: MouseEvent) => {
// Store the latest event and schedule a single animation frame update
latestEvent.value = event;
if (!pendingDrag.value) {
pendingDrag.value = true;
@@ -433,13 +416,12 @@
if (selectedSpriteIds.value.size > 0) {
emit('removeSprites', Array.from(selectedSpriteIds.value));
selectedSpriteIds.value.clear();
showContextMenu.value = false;
contextMenuSpriteId.value = null;
} else if (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) => {
@@ -450,7 +432,7 @@
} else if (contextMenuSpriteId.value) {
emit('rotateSprite', contextMenuSpriteId.value, angle);
}
showContextMenu.value = false;
closeContextMenu();
};
const flipSpriteInMenu = (direction: 'horizontal' | 'vertical') => {
@@ -461,12 +443,11 @@
} else if (contextMenuSpriteId.value) {
emit('flipSprite', contextMenuSpriteId.value, direction);
}
showContextMenu.value = false;
closeContextMenu();
};
const handleKeyDown = (event: KeyboardEvent) => {
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 (selectedSpriteIds.value.size > 0) {
@@ -480,20 +461,29 @@
if (contextMenuSpriteId.value && fileInput.value) {
replacingSpriteId.value = contextMenuSpriteId.value;
fileInput.value.click();
showContextMenu.value = false;
contextMenuSpriteId.value = null;
closeContextMenu();
}
};
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) {
fileInput.value.click();
showContextMenu.value = false;
contextMenuSpriteId.value = null;
closeContextMenu();
}
};
const handleFileChange = (event: Event) => {
// Refined handleFileChange
const handleFileChangeRefined = (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
@@ -502,8 +492,8 @@
if (replacingSpriteId.value) {
emit('replaceSprite', replacingSpriteId.value, file);
} else {
if (contextMenuIndex.value !== null) {
emit('addSprite', file, contextMenuIndex.value);
if (pendingAddIndex.value !== null) {
emit('addSprite', file, pendingAddIndex.value);
} else {
emit('addSprite', file);
}
@@ -513,21 +503,16 @@
}
}
replacingSpriteId.value = null;
contextMenuIndex.value = null;
pendingAddIndex.value = null; // Clear it
input.value = '';
};
const hideContextMenu = () => {
showContextMenu.value = false;
contextMenuSpriteId.value = null;
};
const openCopyToFrameModal = () => {
if (contextMenuSpriteId.value) {
copySpriteId.value = contextMenuSpriteId.value;
copyTargetLayerId.value = props.activeLayerId;
showCopyToFrameModal.value = true;
showContextMenu.value = false;
closeContextMenu();
}
};
@@ -543,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);
};
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(() => {
document.addEventListener('mouseup', stopDrag);
document.addEventListener('keydown', handleKeyDown);
@@ -556,8 +595,6 @@
document.removeEventListener('mouseup', stopDrag);
document.removeEventListener('keydown', handleKeyDown);
});
// Watch for background color changes
</script>
<style scoped></style>

View File

@@ -1,33 +1,24 @@
<template>
<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' }">
<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">
<i class="fas fa-redo text-green-600 dark:text-green-400 text-xs w-4"></i>
<span>Rotate +90°</span>
</button>
<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">
<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="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 @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>
<SpriteContextMenu
:is-open="isContextMenuOpen"
:position="contextMenuPosition"
:has-sprite="!!contextMenuSpriteId"
@add="addSprite"
@rotate="rotateSpriteInMenu"
@flip="flipSpriteInMenu"
@replace="replaceSprite"
@copy-to-frame="openCopyToFrameModal"
@edit-in-pixel-editor="openPixelEditor"
@remove="removeSprite"
@close="closeContextMenu"
/>
<!-- Copy to Frame Modal -->
<CopyToFrameModal :is-open="showCopyToFrameModal" :layers="props.layers" :initial-layer-id="copyTargetLayerId" @close="closeCopyToFrameModal" @copy="confirmCopyToFrame" />
</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-1 min-w-0 flex flex-col min-h-0">
<div
@@ -81,13 +72,12 @@
</template>
</template>
<!-- Current frame sprites -->
<!-- Current frame sprites - maintaining layer order -->
<template v-for="layer in getVisibleLayers()" :key="layer.id">
<img
v-if="layer.sprites[currentFrameIndex]"
:src="layer.sprites[currentFrameIndex].url"
class="absolute"
:class="{ 'cursor-move': isDraggable }"
: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`,
@@ -96,12 +86,29 @@
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})`,
}"
@mousedown="startDrag($event, layer.sprites[currentFrameIndex], layer.id)"
@touchstart="handleTouchStart($event, layer.sprites[currentFrameIndex], layer.id)"
@contextmenu.prevent="openContextMenu($event, layer.sprites[currentFrameIndex], layer.id)"
@contextmenu.prevent.stop="openContextMenu($event, layer.sprites[currentFrameIndex], layer.id)"
draggable="false"
/>
</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>
<!-- Mobile zoom controls -->
@@ -283,6 +290,8 @@
import { useAnimationFrames } from '@/composables/useAnimationFrames';
import { useGridMetrics } from '@/composables/useGridMetrics';
import { useBackgroundStyles } from '@/composables/useBackgroundStyles';
import { useContextMenu } from '@/composables/useContextMenu';
import SpriteContextMenu from '@/components/shared/SpriteContextMenu.vue';
import CopyToFrameModal from './utilities/CopyToFrameModal.vue';
const props = defineProps<{
@@ -299,11 +308,12 @@
(e: 'flipSprite', id: string, direction: 'horizontal' | 'vertical'): void;
(e: 'copySpriteToFrame', spriteId: string, targetLayerId: string, targetFrameIndex: number): 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);
// Get settings from store
const settingsStore = useSettingsStore();
const {
@@ -316,6 +326,17 @@
});
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 { 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
});
// Preview state
const isDraggable = ref(false);
const repositionAllLayers = ref(false);
const arrowKeyMovement = ref(false);
const showAllSprites = 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 replacingSpriteId = ref<string | null>(null);
// Copy to frame modal state
const showCopyToFrameModal = ref(false);
const copyTargetLayerId = ref(props.activeLayerId);
// Drag and drop for new sprites
const onDragOver = () => {
isDragOver.value = true;
};
@@ -373,10 +385,8 @@
};
const compositeFrames = computed<Sprite[]>(() => {
// Show frames from the active layer for the thumbnail list
const activeLayer = props.layers.find(l => l.id === props.activeLayerId);
if (!activeLayer) {
// Fallback to first visible layer if no active layer
const v = getVisibleLayers();
const len = maxFrames();
const arr: Sprite[] = [];
@@ -395,7 +405,6 @@
return layer.sprites[currentFrameIndex.value] || null;
});
// Use the new useGridMetrics composable for consistent calculations
const { gridMetrics, getCellPosition: getCellPositionHelper } = useGridMetrics({
layers: toRef(props, 'layers'),
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
@@ -404,19 +413,16 @@
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
});
// Helper function to get cell position (same as SpriteCanvas)
const getCellPosition = (index: number) => {
return getCellPositionHelper(index, props.columns, gridMetrics.value);
};
// Computed cell dimensions (for backward compatibility with existing code)
const cellDimensions = computed(() => ({
cellWidth: gridMetrics.value.maxWidth,
cellHeight: gridMetrics.value.maxHeight,
negativeSpacing: gridMetrics.value.negativeSpacing,
}));
// Use the new useBackgroundStyles composable for consistent background styling
const {
backgroundImage: previewBackgroundImage,
backgroundSize: previewBackgroundSize,
@@ -429,7 +435,6 @@
const getPreviewBackgroundImage = () => previewBackgroundImage.value;
// Dragging state
const isDragging = ref(false);
const activeSpriteId = ref<string | null>(null);
const activeLayerId = ref<string | null>(null);
@@ -438,7 +443,6 @@
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
const allSpritesPosBeforeDrag = ref<Map<string, { x: number; y: number }>>(new Map());
// Drag functionality
const startDrag = (event: MouseEvent, sprite: Sprite, layerId: string) => {
if (!isDraggable.value || !previewContainerRef.value) return;
@@ -455,7 +459,6 @@
dragStartX.value = mouseX;
dragStartY.value = mouseY;
// Store initial positions for all sprites in this frame from all visible layers
allSpritesPosBeforeDrag.value.clear();
const visibleLayers = getVisibleLayers();
visibleLayers.forEach(layer => {
@@ -490,7 +493,6 @@
const { cellWidth, cellHeight, negativeSpacing } = cellDimensions.value;
if (activeSpriteId.value === 'ALL_LAYERS') {
// Move all sprites in current frame from all visible layers
const visibleLayers = getVisibleLayers();
visibleLayers.forEach(layer => {
const sprite = layer.sprites[currentFrameIndex.value];
@@ -499,26 +501,21 @@
const originalPos = allSpritesPosBeforeDrag.value.get(sprite.id);
if (!originalPos) return;
// Calculate new position with constraints
let newX = Math.round(originalPos.x + deltaX);
let newY = Math.round(originalPos.y + deltaY);
// Constrain movement within expanded cell
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
emit('updateSpriteInLayer', layer.id, sprite.id, newX, newY);
});
} else {
// Move only the active layer sprite
const activeSprite = props.layers.find(l => l.id === activeLayerId.value)?.sprites[currentFrameIndex.value];
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 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));
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - activeSprite.height, newY));
@@ -562,7 +559,6 @@
}
};
// Arrow key movement handler
const handleKeyDown = (event: KeyboardEvent) => {
if (!isDraggable.value || !arrowKeyMovement.value) return;
@@ -593,7 +589,6 @@
const { cellWidth, cellHeight, negativeSpacing } = cellDimensions.value;
if (repositionAllLayers.value) {
// Move all sprites in current frame from all visible layers
const visibleLayers = getVisibleLayers();
visibleLayers.forEach(layer => {
const sprite = layer.sprites[currentFrameIndex.value];
@@ -602,14 +597,12 @@
let newX = Math.round(sprite.x + deltaX);
let newY = Math.round(sprite.y + deltaY);
// Constrain movement within expanded cell
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
emit('updateSpriteInLayer', layer.id, sprite.id, newX, newY);
});
} else {
// Move only the active layer sprite
const activeLayer = props.layers.find(l => l.id === props.activeLayerId);
if (!activeLayer) return;
@@ -619,7 +612,6 @@
let newX = Math.round(sprite.x + deltaX);
let newY = Math.round(sprite.y + deltaY);
// Constrain movement within expanded cell
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
@@ -627,7 +619,6 @@
}
};
// Lifecycle hooks
onMounted(() => {
window.addEventListener('keydown', handleKeyDown);
});
@@ -637,8 +628,6 @@
window.removeEventListener('keydown', handleKeyDown);
});
// Watchers - most canvas-related watchers removed
// Keep layer watchers to ensure reactivity
watch(
() => props.layers,
() => {},
@@ -650,41 +639,34 @@
);
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) => {
event.preventDefault();
contextMenuSpriteId.value = sprite.id;
contextMenuLayerId.value = layerId;
contextMenuX.value = event.clientX;
contextMenuY.value = event.clientY;
showContextMenu.value = true;
openContextMenuBase(event, { spriteId: sprite.id, layerId });
};
const hideContextMenu = () => {
showContextMenu.value = false;
contextMenuSpriteId.value = null;
contextMenuLayerId.value = null;
};
const contextMenuSpriteId = computed(() => contextMenuData.value?.spriteId || null);
const contextMenuLayerId = computed(() => contextMenuData.value?.layerId || null);
const rotateSpriteInMenu = (angle: number) => {
if (contextMenuSpriteId.value) {
emit('rotateSprite', contextMenuSpriteId.value, angle);
}
hideContextMenu();
// Context menu closes automatically via component emit
};
const flipSpriteInMenu = (direction: 'horizontal' | 'vertical') => {
if (contextMenuSpriteId.value) {
emit('flipSprite', contextMenuSpriteId.value, direction);
}
hideContextMenu();
};
const openCopyToFrameModal = () => {
if (contextMenuSpriteId.value) {
copyTargetLayerId.value = contextMenuLayerId.value || props.activeLayerId;
showCopyToFrameModal.value = true;
showContextMenu.value = false;
closeContextMenu();
}
};
@@ -696,15 +678,15 @@
if (contextMenuSpriteId.value) {
emit('copySpriteToFrame', contextMenuSpriteId.value, targetLayerId, targetFrameIndex);
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 = () => {
if (contextMenuSpriteId.value && fileInput.value) {
replacingSpriteId.value = contextMenuSpriteId.value;
fileInput.value.click();
hideContextMenu();
closeContextMenu();
}
};
@@ -713,13 +695,39 @@
if (input.files && input.files.length > 0) {
const file = input.files[0];
if (file.type.startsWith('image/') && replacingSpriteId.value) {
emit('replaceSprite', replacingSpriteId.value, file);
if (file.type.startsWith('image/')) {
if (replacingSpriteId.value) {
emit('replaceSprite', replacingSpriteId.value, file);
} else {
// Add sprite case - use dropSprite emit as it handles adding files to layer/frame
emit('dropSprite', props.activeLayerId, currentFrameIndex.value, [file]);
}
}
}
replacingSpriteId.value = null;
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>
<style scoped>

View File

@@ -10,7 +10,7 @@
<div class="space-y-4">
<div class="space-y-2">
<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']">
<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>
@@ -19,6 +19,10 @@
<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>
</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>
@@ -115,7 +119,6 @@
const settingsStore = useSettingsStore();
const splitter = useSpritesheetSplitter();
// State
const detectionMode = ref<DetectionMode>('grid');
const cellWidth = ref(64);
const cellHeight = ref(64);
@@ -126,12 +129,12 @@
const isProcessing = ref(false);
const imageElement = ref<HTMLImageElement | null>(null);
// Computed
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));
// Load image and set initial cell size
const isGif = computed(() => props.imageFile?.type === 'image/gif' || props.imageUrl.toLowerCase().endsWith('.gif'));
watch(
() => props.imageUrl,
url => {
@@ -141,11 +144,16 @@
img.onload = () => {
imageElement.value = img;
// Set suggested cell size
const suggested = splitter.getSuggestedCellSize(img.width, img.height);
cellWidth.value = suggested.width;
cellHeight.value = suggested.height;
if (isGif.value) {
detectionMode.value = 'gif';
} else if (detectionMode.value === 'gif') {
detectionMode.value = 'grid';
}
generatePreview();
};
img.src = url;
@@ -153,14 +161,12 @@
{ immediate: true }
);
// Regenerate preview when options change
watch([detectionMode, cellWidth, cellHeight, sensitivity, removeEmpty, preserveCellSize], () => {
if (imageElement.value) {
generatePreview();
}
});
// Generate preview
async function generatePreview() {
if (!imageElement.value) return;
@@ -177,11 +183,13 @@
preserveCellSize: preserveCellSize.value,
removeEmpty: removeEmpty.value,
});
} else {
} else if (detectionMode.value === 'auto') {
previewSprites.value = await splitter.detectSprites(img, {
sensitivity: sensitivity.value,
removeEmpty: removeEmpty.value,
});
} else if (detectionMode.value === 'gif' && props.imageFile) {
previewSprites.value = await splitter.extractGifFrames(props.imageFile);
}
} catch (error) {
console.error('Error generating preview:', error);
@@ -190,7 +198,6 @@
}
}
// Actions
function cancel() {
emit('close');
}

View File

@@ -92,7 +92,6 @@
close();
} catch (e: any) {
error.value = e.message || 'An error occurred';
// Better PB error handling
if (e?.data?.message) error.value = e.data.message;
} finally {
loading.value = false;

View File

@@ -7,8 +7,14 @@
<NavbarLogo />
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center">
<div class="hidden md:flex items-center gap-4">
<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>
@@ -54,6 +60,7 @@
@open-new-project-modal="isNewProjectModalOpen = true"
@open-project-list="isProjectListOpen = true"
@open-auth-modal="isAuthModalOpen = true"
@back-to-editor="goBackToEditor"
/>
</nav>
<AuthModal :is-open="isAuthModalOpen" @close="isAuthModalOpen = false" />
@@ -64,6 +71,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import DarkModeToggle from '../utilities/DarkModeToggle.vue';
import { useAuthStore } from '@/stores/useAuthStore';
import AuthModal from '@/components/auth/AuthModal.vue';
@@ -74,7 +82,6 @@
import { useProjectManager } from '@/composables/useProjectManager';
import { useToast } from '@/composables/useToast';
// Sub-components
import NavbarLogo from './navbar/NavbarLogo.vue';
import NavbarLinks from './navbar/NavbarLinks.vue';
import NavbarProjectActions from './navbar/NavbarProjectActions.vue';
@@ -92,11 +99,17 @@
const saveMode = ref<'save' | 'save-as'>('save');
const isSaving = ref(false);
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const projectStore = useProjectStore();
const { createProject, openProject, saveProject, saveAsProject } = useProjectManager();
const { addToast } = useToast();
const showBackToEditor = computed(() => {
return route.name !== 'editor' && !!projectStore.currentProject;
});
const handleOpenProject = async (project: Project) => {
await openProject(project);
};
@@ -144,7 +157,6 @@
} catch (error) {
addToast('Failed to save project', 'error');
console.error(error);
// Error handled in composable but kept here for toast
} finally {
isSaving.value = false;
}
@@ -153,4 +165,8 @@
const handleCreateNewProject = (config: { width: number; height: number; columns: number; rows: number }) => {
createProject(config);
};
const goBackToEditor = () => {
router.push({ name: 'editor' });
};
</script>

View File

@@ -1,12 +1,34 @@
<template>
<div class="flex items-center gap-4 text-sm font-medium text-gray-600 dark:text-gray-400">
<router-link to="/" class="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors">Home</router-link>
<router-link to="/blog" class="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors">Blog</router-link>
<router-link to="/about" class="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors">About</router-link>
<router-link to="/faq" class="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors">FAQ</router-link>
<div class="flex items-center gap-1">
<router-link
v-for="link in links"
:key="link.path"
:to="link.path"
class="px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200"
:class="[isActive(link.path) ? 'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-indigo-600 dark:hover:text-indigo-400']"
>
{{ link.name }}
</router-link>
</div>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router';
import { useRoute } from 'vue-router';
const route = useRoute();
const links = [
{ name: 'Home', path: '/' },
{ name: 'Blog', path: '/blog' },
{ name: 'About', path: '/about' },
{ name: 'FAQ', path: '/faq' },
{ name: 'Contact', path: '/contact' },
];
const isActive = (path: string) => {
if (path === '/') {
return route.path === '/';
}
return route.path.startsWith(path);
};
</script>

View File

@@ -7,7 +7,7 @@
leave-from-class="transform opacity-100 translate-y-0"
leave-to-class="transform opacity-0 -translate-y-2"
>
<div v-show="isOpen" class="md:hidden border-t border-gray-200 dark:border-gray-800 bg-white/95 dark:bg-gray-950/95 backdrop-blur-xl absolute top-16 left-0 w-full z-40 shadow-lg">
<div v-show="isOpen" class="md:hidden border-t border-gray-200 dark:border-gray-800 bg-white/95 dark:bg-gray-950/95 backdrop-blur-xl absolute top-16 left-0 w-full z-40 shadow-lg max-h-[calc(100vh-4rem)] overflow-y-auto">
<div class="px-2 pt-2 pb-3 space-y-1">
<!-- User Profile (Mobile) -->
<div v-if="authStore.user" class="px-3 py-3 mb-2 border-b border-gray-200 dark:border-gray-800">
@@ -34,17 +34,29 @@
Login / Register
</button>
</div>
<router-link to="/" class="flex items-center gap-3 px-3 py-3 rounded-md text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors" @click="$emit('close')">
<i class="fas fa-home w-5 text-center"></i> Home
</router-link>
<router-link to="/blog" class="flex items-center gap-3 px-3 py-3 rounded-md text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors" @click="$emit('close')">
<i class="fas fa-newspaper w-5 text-center"></i> Blog
</router-link>
<router-link to="/about" class="flex items-center gap-3 px-3 py-3 rounded-md text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors" @click="$emit('close')">
<i class="fas fa-info-circle w-5 text-center"></i> About
</router-link>
<router-link to="/faq" class="flex items-center gap-3 px-3 py-3 rounded-md text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors" @click="$emit('close')">
<i class="fas fa-question-circle w-5 text-center"></i> FAQ
<!-- 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
v-for="link in links"
:key="link.path"
:to="link.path"
class="flex items-center gap-3 px-3 py-3 rounded-md text-base font-medium transition-all duration-200"
:class="[isActive(link.path) ? 'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-indigo-600 dark:hover:text-indigo-400']"
@click="$emit('close')"
>
<i :class="link.icon" class="w-5 text-center"></i> {{ link.name }}
</router-link>
<!-- Project Actions -->
@@ -129,12 +141,34 @@
const route = useRoute();
const isEditorActive = computed(() => route.name === 'editor');
import { useProjectStore } from '@/stores/useProjectStore';
const projectStore = useProjectStore();
const showBackToEditor = computed(() => {
return route.name !== 'editor' && !!projectStore.currentProject;
});
const links = [
{ name: 'Home', path: '/', icon: 'fas fa-home' },
{ name: 'Blog', path: '/blog', icon: 'fas fa-newspaper' },
{ name: 'About', path: '/about', icon: 'fas fa-info-circle' },
{ name: 'FAQ', path: '/faq', icon: 'fas fa-question-circle' },
{ name: 'Contact', path: '/contact', icon: 'fas fa-envelope' },
];
const isActive = (path: string) => {
if (path === '/') {
return route.path === '/';
}
return route.path.startsWith(path);
};
defineProps<{
isOpen: 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';
const authStore = useAuthStore();
@@ -146,6 +180,5 @@
const handleLogout = () => {
authStore.logout();
// emit('close'); // Optional: close menu on logout? Maybe better to keep open so they can login again if they want.
};
</script>

View File

@@ -15,6 +15,9 @@
<i class="fas fa-question-circle text-lg"></i>
</button>
</Tooltip>
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700 ml-1.5"></div>
<DarkModeToggle />
</div>
</template>

View File

@@ -61,7 +61,6 @@
return width.value > 0 && height.value > 0 && columns.value > 0 && rows.value > 0;
});
// Reset to defaults when opened
watch(
() => props.isOpen,
val => {

View File

@@ -40,7 +40,7 @@
</div>
<!-- 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">
<i class="fas fa-search text-2xl text-gray-400 dark:text-gray-500"></i>
</div>
@@ -51,7 +51,7 @@
<!-- Project List -->
<div v-else class="flex flex-col gap-3">
<div
v-for="project in filteredProjects"
v-for="project in projects"
: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"
@click="selectProject(project)"
@@ -105,9 +105,41 @@
</div>
<!-- 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">
<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 gap-2 ml-auto">
<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">
<div class="flex items-center gap-2 sm:gap-4 flex-wrap justify-center sm:justify-start w-full sm:w-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 @click="close" class="btn btn-secondary text-sm">Close</button>
</div>
@@ -132,23 +164,90 @@
}>();
const projectStore = useProjectStore();
const { projects, isLoading: loading } = toRefs(projectStore);
const { createProject, loadProjectData } = useProjectManager();
const { createProject } = useProjectManager();
const searchQuery = ref('');
const filteredProjects = computed(() => {
if (!searchQuery.value) return projects.value;
const query = searchQuery.value.toLowerCase();
return projects.value.filter(p => p.name.toLowerCase().includes(query));
const { projects, isLoading: loading, page, perPage, totalItems, totalPages, fetchProjects } = toRefs(projectStore);
let searchTimeout: any = null;
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(
() => props.isOpen,
isOpen => {
if (isOpen) {
projectStore.fetchProjects();
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[] = [];
if (!project.data || !project.data.layers) return sprites;
// Iterate through layers to find sprites
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.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 startSize = ref({ width: 0, height: 0 });
// Add isFullScreen ref
const isFullScreen = ref(false);
const isMobile = ref(false);
// Add previous state storage for restoring from full screen
const previousState = ref({
position: { x: 0, y: 0 },
size: { width: 0, height: 0 },
});
// Check if device is mobile
const checkMobile = () => {
isMobile.value = window.innerWidth < 640; // sm breakpoint in Tailwind
// Auto fullscreen on mobile
if (isMobile.value && !isFullScreen.value) {
toggleFullScreen();
} else if (!isMobile.value && isFullScreen.value && autoFullScreened.value) {
// If we're no longer on mobile and were auto-fullscreened, exit fullscreen
toggleFullScreen();
autoFullScreened.value = false;
}
};
// Track if fullscreen was automatic (for mobile)
const autoFullScreened = ref(false);
// Add toggleFullScreen function
const toggleFullScreen = () => {
if (!isFullScreen.value) {
// Store current state before going full screen
previousState.value = {
position: { ...position.value },
size: { ...size.value },
};
// If toggling to fullscreen on mobile automatically, track it
if (isMobile.value) {
autoFullScreened.value = true;
}
} else {
// Restore previous state
position.value = { ...previousState.value.position };
size.value = { ...previousState.value.size };
}
isFullScreen.value = !isFullScreen.value;
};
// Unified start function for both drag and resize
const startAction = (event: MouseEvent | TouchEvent, action: 'drag' | 'resize') => {
if (isFullScreen.value) return;
// Extract the correct coordinates based on event type
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
@@ -211,7 +199,6 @@
position.value = { x: 0, y: 0 };
};
// Event handlers
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && props.isOpen) close();
};
@@ -223,7 +210,6 @@
}
};
// Add these new touch handling functions
const handleTouchStart = (event: TouchEvent) => {
if (isFullScreen.value) return;
if (event.touches.length === 1) {
@@ -237,7 +223,6 @@
handleMove(event);
};
// Lifecycle
watch(
() => props.isOpen,
newValue => {
@@ -250,7 +235,6 @@
window.addEventListener('resize', handleResize);
window.addEventListener('resize', checkMobile);
// Initial check for mobile
checkMobile();
if (props.isOpen) centerModal();

View File

@@ -27,7 +27,6 @@
import type { Toast } from '@/composables/useToast';
import { useToast } from '@/composables/useToast';
// Simple functional components for icons using h (avoid runtime compiler)
const SuccessIcon = {
render: () =>
h(

View File

@@ -42,30 +42,24 @@
let x = mouseX.value + offsetX;
let y = mouseY.value + offsetY;
// Get tooltip dimensions (estimate if not mounted yet)
const tooltipWidth = tooltipRef.value?.offsetWidth || 200;
const tooltipHeight = tooltipRef.value?.offsetHeight || 30;
// Screen boundaries
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight;
// Adjust horizontal position if too close to right edge
if (x + tooltipWidth + padding > screenWidth) {
x = mouseX.value - tooltipWidth - offsetX;
}
// Adjust horizontal position if too close to left edge
if (x < padding) {
x = padding;
}
// Adjust vertical position if too close to bottom edge
if (y + tooltipHeight + padding > screenHeight) {
y = mouseY.value - tooltipHeight - offsetY;
}
// Adjust vertical position if too close to top edge
if (y < padding) {
y = padding;
}

View File

@@ -9,7 +9,6 @@ export interface AnimationFramesOptions {
export function useAnimationFrames(options: AnimationFramesOptions) {
const { onDraw } = options;
// Convert sprites to a computed ref for reactivity
const spritesRef = computed(() => {
if (typeof options.sprites === 'function') {
return options.sprites();
@@ -20,20 +19,16 @@ export function useAnimationFrames(options: AnimationFramesOptions) {
return options.sprites;
});
// Helper to get sprites array
const getSprites = () => spritesRef.value;
// State
const currentFrameIndex = ref(0);
const isPlaying = ref(false);
const fps = ref(12);
const hiddenFrames = ref<number[]>([]);
// Animation internals
const animationFrameId = ref<number | null>(null);
const lastFrameTime = ref(0);
// Computed properties for visible frames
const visibleFrames = computed(() => getSprites().filter((_, index) => !hiddenFrames.value.includes(index)));
const visibleFramesCount = computed(() => visibleFrames.value.length);
@@ -46,7 +41,6 @@ export function useAnimationFrames(options: AnimationFramesOptions) {
const visibleFrameNumber = computed(() => visibleFrameIndex.value + 1);
// Animation control
const animateFrame = () => {
const now = performance.now();
const elapsed = now - lastFrameTime.value;
@@ -109,7 +103,6 @@ export function useAnimationFrames(options: AnimationFramesOptions) {
currentFrameIndex.value = sprites.indexOf(visibleFrames.value[index]);
};
// Frame visibility management
const toggleHiddenFrame = (index: number) => {
const sprites = getSprites();
const currentIndex = hiddenFrames.value.indexOf(index);
@@ -117,7 +110,6 @@ export function useAnimationFrames(options: AnimationFramesOptions) {
if (currentIndex === -1) {
hiddenFrames.value.push(index);
// If hiding current frame, switch to next visible
if (index === currentFrameIndex.value) {
const nextVisible = sprites.findIndex((_, i) => i !== index && !hiddenFrames.value.includes(i));
if (nextVisible !== -1) {
@@ -140,32 +132,27 @@ export function useAnimationFrames(options: AnimationFramesOptions) {
const sprites = getSprites();
hiddenFrames.value = sprites.map((_, index) => index);
// Keep at least one frame visible
if (hiddenFrames.value.length > 0) {
hiddenFrames.value.splice(currentFrameIndex.value, 1);
}
onDraw();
};
// Cleanup on unmount
onUnmounted(() => {
stopAnimation();
});
return {
// State
currentFrameIndex,
isPlaying,
fps,
hiddenFrames,
// Computed
visibleFrames,
visibleFramesCount,
visibleFrameIndex,
visibleFrameNumber,
// Methods
togglePlayback,
nextFrame,
previousFrame,

View File

@@ -18,7 +18,6 @@ export interface BackgroundStyles {
* Handles transparent backgrounds with checkerboard patterns and dark mode.
*/
export function useBackgroundStyles(options: BackgroundStylesOptions) {
// Helper to get reactive values
const getBackgroundColor = () => (typeof options.backgroundColor === 'string' ? options.backgroundColor : options.backgroundColor.value);
const getCheckerboardEnabled = () => (typeof options.checkerboardEnabled === 'boolean' ? options.checkerboardEnabled : (options.checkerboardEnabled?.value ?? true));
const getDarkMode = () => (typeof options.darkMode === 'boolean' ? options.darkMode : (options.darkMode?.value ?? false));
@@ -40,7 +39,6 @@ export function useBackgroundStyles(options: BackgroundStylesOptions) {
const darkMode = getDarkMode();
if (bg === 'transparent' && checkerboardEnabled) {
// Checkerboard pattern for transparent backgrounds (dark mode friendly)
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%)`;
}

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[]) => {
items.forEach(item => {
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) => {
return () => {
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 => {
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>) => {
sprites.forEach(sprite => {
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) => {
if (settingsStore.backgroundColor === 'transparent') return;
const color = settingsStore.backgroundColor === 'custom' ? settingsStore.backgroundColor : settingsStore.backgroundColor;
fillRect(x, y, width, height, color);
};
// Stroke grid with theme-aware color
const strokeGridCell = (x: number, y: number, width: number, height: number) => {
const color = settingsStore.darkMode ? '#4B5563' : '#e5e7eb';
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>;
manualCellWidth?: Ref<number>;
manualCellHeight?: Ref<number>;
selectedSpriteIds?: Ref<Set<string>> | ComputedRef<Set<string>>;
getMousePosition: (event: MouseEvent, zoom?: number) => { x: number; y: number } | null;
onUpdateSprite: (id: string, x: number, y: number) => void;
onUpdateSpriteCell?: (id: string, newIndex: number) => void;
@@ -44,7 +45,6 @@ export interface DragSpriteOptions {
export function useDragSprite(options: DragSpriteOptions) {
const { getMousePosition, onUpdateSprite, onUpdateSpriteCell, onDraw } = options;
// Helper to get reactive values
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 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 getManualCellWidth = () => options.manualCellWidth?.value ?? 64;
const getManualCellHeight = () => options.manualCellHeight?.value ?? 64;
const getSelectedSpriteIds = () => options.selectedSpriteIds?.value ?? new Set<string>();
// Drag state
const isDragging = ref(false);
const activeSpriteId = ref<string | null>(null);
const activeSpriteCellIndex = ref<number | null>(null);
@@ -65,11 +65,11 @@ export function useDragSprite(options: DragSpriteOptions) {
const dragOffsetY = ref(0);
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 highlightCell = ref<CellPosition | null>(null);
// Use the new useGridMetrics composable for consistent calculations
const gridMetricsComposable = useGridMetrics({
layers: options.layers,
sprites: options.sprites,
@@ -83,7 +83,6 @@ export function useDragSprite(options: DragSpriteOptions) {
return gridMetricsComposable.calculateCellDimensions();
};
// Computed sprite positions
const spritePositions = computed<SpritePosition[]>(() => {
const sprites = getSprites();
const columns = getColumns();
@@ -118,7 +117,6 @@ export function useDragSprite(options: DragSpriteOptions) {
const col = Math.floor(x / maxWidth);
const row = Math.floor(y / maxHeight);
// Allow dropping anywhere in the columns, assuming infinite rows effectively
if (col >= 0 && col < columns && row >= 0) {
const index = row * columns + col;
return { col, row, index };
@@ -162,6 +160,19 @@ export function useDragSprite(options: DragSpriteOptions) {
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 { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
// Use the sprite's current index in the array to calculate cell position
const cellCol = spriteIndex % columns;
const cellRow = Math.floor(spriteIndex / columns);
const cellX = Math.round(cellCol * maxWidth);
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 newY = mouseY - cellY - negativeSpacing - dragOffsetY.value;
// The sprite can move within the full expanded cell area
// Allow negative values up to -negativeSpacing so sprite can fill the expanded area
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 selectedIds = getSelectedSpriteIds();
const isMultiDrag = selectedIds.has(activeSpriteId.value) && selectedIds.size > 1;
onUpdateSprite(activeSpriteId.value, constrainedX, constrainedY);
if (isMultiDrag && initialSpritePositions.value.size > 0) {
const activeInitial = initialSpritePositions.value.get(activeSpriteId.value);
if (activeInitial) {
const deltaX = newX - activeInitial.x;
const deltaY = newY - activeInitial.y;
initialSpritePositions.value.forEach((initPos, id) => {
const sprite = sprites.find(s => s.id === id);
if (sprite) {
const newSpriteX = initPos.x + deltaX;
const newSpriteY = initPos.y + deltaY;
const spriteCellCol = initPos.index % columns;
const spriteCellRow = Math.floor(initPos.index / columns);
const constrainedX = Math.floor(Math.max(-negativeSpacing, Math.min(maxWidth - negativeSpacing - sprite.width, newSpriteX)));
const constrainedY = Math.floor(Math.max(-negativeSpacing, Math.min(maxHeight - negativeSpacing - sprite.height, newSpriteY)));
onUpdateSprite(id, constrainedX, constrainedY);
}
});
}
} else {
const constrainedX = Math.floor(Math.max(-negativeSpacing, Math.min(maxWidth - negativeSpacing - sprites[spriteIndex].width, newX)));
const constrainedY = Math.floor(Math.max(-negativeSpacing, Math.min(maxHeight - negativeSpacing - sprites[spriteIndex].height, newY)));
onUpdateSprite(activeSpriteId.value, constrainedX, constrainedY);
}
onDraw();
};
@@ -204,7 +236,10 @@ export function useDragSprite(options: DragSpriteOptions) {
const hoverCell = findCellAtPosition(pos.x, pos.y);
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) {
highlightCell.value = hoverCell;
ghostSprite.value = {
@@ -224,7 +259,10 @@ export function useDragSprite(options: DragSpriteOptions) {
};
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) {
onUpdateSpriteCell(activeSpriteId.value, currentHoverCell.value.index);
}
@@ -237,11 +275,11 @@ export function useDragSprite(options: DragSpriteOptions) {
currentHoverCell.value = null;
highlightCell.value = null;
ghostSprite.value = null;
initialSpritePositions.value.clear();
onDraw();
};
// Touch event handlers
const handleTouchStart = (event: TouchEvent) => {
if (event.touches.length === 1) {
const touch = event.touches[0];
@@ -271,14 +309,12 @@ export function useDragSprite(options: DragSpriteOptions) {
};
return {
// State
isDragging,
activeSpriteId,
ghostSprite,
highlightCell,
spritePositions,
// Methods
startDrag,
drag,
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[]>) => {
const getCellDimensions = () => {
// If manual cell size is enabled, use manual values
if (manualCellSizeEnabled?.value) {
return {
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);
// Calculate negative spacing from all layers' sprites for consistency
const allSprites = layers?.value ? layers.value.flatMap(l => l.sprites) : sprites.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;
ctx.imageSmoothingEnabled = false;
// Apply background color if not transparent
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
ctx.fillStyle = backgroundColor.value;
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.manualCellHeight === 'number' && manualCellHeight) manualCellHeight.value = jsonData.manualCellHeight;
// revoke existing blob urls
if (sprites.value.length) {
sprites.value.forEach(s => {
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 => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Apply background color if not transparent
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
ctx.fillStyle = backgroundColor.value;
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) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Apply background color if not transparent
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
ctx.fillStyle = backgroundColor.value;
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 getCellDimensions = () => {
// If manual cell size is enabled, use manual values
if (manualCellSizeEnabled?.value) {
return {
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);
// Calculate negative spacing from ALL layers (not just visible) to keep canvas size stable
const allSprites = layersRef.value.flatMap(l => l.sprites);
const negativeSpacing = calculateNegativeSpacing(allSprites, negativeSpacingEnabled.value);
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) => {
ctx.clearRect(0, 0, cellWidth, cellHeight);
// Apply background color if not transparent
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
ctx.fillStyle = backgroundColor.value;
ctx.fillRect(0, 0, cellWidth, cellHeight);
@@ -78,7 +73,6 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
canvas.height = cellHeight * rows;
ctx.imageSmoothingEnabled = false;
// Apply background color to entire canvas if not transparent
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
ctx.fillStyle = backgroundColor.value;
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 });
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;
});
@@ -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)));
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)) {
const firstLayerWithSprites = newLayers.find(l => l.sprites.length > 0);
if (firstLayerWithSprites) {
@@ -194,7 +207,6 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
}
}
layersRef.value = newLayers;
// Set active layer to the first layer with sprites
if (activeLayerId && newLayers.length > 0) {
const firstWithSprites = newLayers.find(l => l.sprites.length > 0);
activeLayerId.value = firstWithSprites ? firstWithSprites.id : newLayers[0].id;

View File

@@ -11,7 +11,6 @@ export interface FileDropOptions {
export function useFileDrop(options: FileDropOptions) {
const { onAddSprite, onAddSpriteWithResize } = options;
// Helper to get sprites array
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
const isDragOver = ref(false);
@@ -60,7 +59,6 @@ export function useFileDrop(options: FileDropOptions) {
const sprites = getSprites();
const { maxWidth, maxHeight } = getMaxDimensions(sprites);
// Check if the dropped image is larger than current cells
if (img.naturalWidth > maxWidth || img.naturalHeight > maxHeight) {
onAddSpriteWithResize(file);
} else {
@@ -94,7 +92,6 @@ export function useFileDrop(options: FileDropOptions) {
return;
}
// Process each dropped file
for (const file of files) {
await processDroppedImage(file);
}

View File

@@ -37,7 +37,6 @@ export interface GridMetricsOptions {
* Provides a single source of truth for cell dimensions and positioning calculations.
*/
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 getSprites = () => (options.sprites ? (Array.isArray(options.sprites) ? options.sprites : options.sprites.value) : []);
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 manualCellSizeEnabled = getManualCellSizeEnabled();
// If manual cell size is enabled, use manual dimensions
if (manualCellSizeEnabled) {
return {
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 spritesToMeasure = layers ? layers.flatMap(l => l.sprites) : getSprites();
// Calculate base dimensions from sprites
const base = getMaxDimensions(spritesToMeasure);
const baseMaxWidth = Math.max(1, base.maxWidth);
const baseMaxHeight = Math.max(1, base.maxHeight);
// Calculate negative spacing
const negativeSpacingEnabled = getNegativeSpacingEnabled();
const negativeSpacing = Math.round(calculateNegativeSpacing(spritesToMeasure, negativeSpacingEnabled));
// Add negative spacing to expand each cell
return {
maxWidth: Math.round(baseMaxWidth + negativeSpacing),
maxHeight: Math.round(baseMaxHeight + negativeSpacing),
@@ -116,10 +110,8 @@ export function useGridMetrics(options: GridMetricsOptions = {}) {
const gridMetrics = computed(() => calculateCellDimensions());
return {
// Computed values
gridMetrics,
// Methods
calculateCellDimensions,
getCellPosition,
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 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;
if (!l || !l.sprites.length) return;
// Determine the cell dimensions to align within
let cellWidth: number;
let cellHeight: number;
if (settingsStore.manualCellSizeEnabled) {
// Use manual cell size (without negative spacing)
cellWidth = settingsStore.manualCellWidth;
cellHeight = settingsStore.manualCellHeight;
} else {
// Use auto-calculated dimensions based on ALL visible layers (not just active layer)
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
cellWidth = maxWidth;
cellHeight = maxHeight;
@@ -120,60 +117,26 @@ export const useLayers = () => {
const next = [...l.sprites];
// Remove the moving sprite first
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) {
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) {
// Perform Swap
const target = l.sprites[newIndex];
const moving = l.sprites[currentIndex];
// Clone array
const newSprites = [...l.sprites];
newSprites[currentIndex] = target;
newSprites[newIndex] = moving;
l.sprites = newSprites;
} else {
// Move to previously empty/non-existent cell
const newSprites = [...l.sprites];
// Remove from old pos
const [moved] = newSprites.splice(currentIndex, 1);
// Pad
while (newSprites.length < newIndex) {
newSprites.push(createEmptySprite());
}
// Insert (or push if equal length)
newSprites.splice(newIndex, 0, moved);
l.sprites = newSprites;
}
@@ -190,14 +153,21 @@ export const useLayers = () => {
URL.revokeObjectURL(s.url);
} catch {}
}
l.sprites.splice(i, 1);
if (layers.value.length > 1) {
// If there are multiple layers, we want to maintain frame alignment
// so we replace the sprite with an empty one instead of shifting
l.sprites[i] = createEmptySprite();
} else {
// If there's only one layer, we can safely remove the frame
l.sprites.splice(i, 1);
}
};
const removeSprites = (ids: string[]) => {
const l = activeLayer.value;
if (!l) return;
// Sort indices in descending order to avoid shift issues when splicing
const indicesToRemove: number[] = [];
ids.forEach(id => {
const i = l.sprites.findIndex(s => s.id === id);
@@ -213,7 +183,12 @@ export const useLayers = () => {
URL.revokeObjectURL(s.url);
} catch {}
}
l.sprites.splice(i, 1);
if (layers.value.length > 1) {
l.sprites[i] = createEmptySprite();
} else {
l.sprites.splice(i, 1);
}
});
};
@@ -291,21 +266,11 @@ export const useLayers = () => {
const currentSprites = [...l.sprites];
if (typeof index === 'number') {
// If index is provided, insert there (padding if needed)
while (currentSprites.length < index) {
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);
} else {
// No index, append to end
currentSprites.push(next);
}
l.sprites = currentSprites;
@@ -321,6 +286,64 @@ export const useLayers = () => {
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[]) => {
for (const f of files) addSprite(f);
};
@@ -353,7 +376,6 @@ export const useLayers = () => {
};
const copySpriteToFrame = (spriteId: string, targetLayerId: string, targetFrameIndex: number) => {
// Find the source sprite in any layer
let sourceSprite: Sprite | undefined;
for (const layer of layers.value) {
sourceSprite = layer.sprites.find(s => s.id === spriteId);
@@ -362,11 +384,9 @@ export const useLayers = () => {
if (!sourceSprite) return;
// Find target layer
const targetLayer = layers.value.find(l => l.id === targetLayerId);
if (!targetLayer) return;
// Create a deep copy of the sprite with a new ID
const copiedSprite: Sprite = {
id: crypto.randomUUID(),
file: sourceSprite.file,
@@ -381,14 +401,11 @@ export const useLayers = () => {
flipY: sourceSprite.flipY,
};
// Expand the sprites array if necessary with empty placeholder sprites
while (targetLayer.sprites.length < targetFrameIndex) {
targetLayer.sprites.push(createEmptySprite());
}
// Replace or insert the sprite at the target index
if (targetFrameIndex < targetLayer.sprites.length) {
// Replace existing sprite at this frame
const old = targetLayer.sprites[targetFrameIndex];
if (old.url && old.url.startsWith('blob:')) {
try {
@@ -397,7 +414,6 @@ export const useLayers = () => {
}
targetLayer.sprites[targetFrameIndex] = copiedSprite;
} else {
// Add at the end
targetLayer.sprites.push(copiedSprite);
}
};
@@ -418,6 +434,7 @@ export const useLayers = () => {
flipSprite,
replaceSprite,
addSprite,
addSprites,
processImageFiles,
alignSprites,
addLayer,
@@ -429,8 +446,6 @@ export const useLayers = () => {
};
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));
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 minHeight = Math.min(...sprites.map(s => s.height));
// Available space is the gap between cell size and smallest sprite
const availableWidth = maxWidth - minWidth;
const availableHeight = maxHeight - minHeight;
// Use half to balance spacing equally on all sides
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 }) => {
// 1. Reset Settings
settingsStore.setManualCellSize(config.width, config.height);
settingsStore.manualCellSizeEnabled = true;
// 2. Reset Layers
const newLayer = createEmptyLayer('Base');
layers.value = [newLayer];
activeLayerId.value = newLayer.id;
// 3. Set Columns
columns.value = config.columns;
// 4. Reset Project Store
projectStore.currentProject = null;
// 5. Navigate to Editor
router.push('/editor');
};
const openProject = async (project: Project) => {
try {
if (project.data) {
await loadProjectData(project.data);
let projectData = project.data;
// If data is missing, we MUST fetch the full project
if (!projectData) {
await projectStore.loadProject(project.id);
// After loading, the store's currentProject will be updated.
// We should use that data.
if (projectStore.currentProject && projectStore.currentProject.id === project.id) {
projectData = projectStore.currentProject.data;
}
}
projectStore.currentProject = project;
if (projectData) {
await loadProjectData(projectData);
} else {
console.warn('Project opened but no data found (even after fetch attempt). Opening empty.');
}
// Ensure we set the current project in the store if we passed in a project that might have been partial,
// but rely on what's in the store if we just fetched it.
if (!projectStore.currentProject || projectStore.currentProject.id !== project.id) {
projectStore.currentProject = project;
}
router.push({ name: 'editor', params: { id: project.id } });
} catch (e) {
console.error('Failed to open project', e);
@@ -60,12 +75,9 @@ export const useProjectManager = () => {
const data = await generateProjectJSON();
if (projectStore.currentProject) {
// Update existing project (even if name changed)
await projectStore.updateProject(projectStore.currentProject.id, name, data);
} else {
// Create new project if none exists
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;
if (newProject) {
router.replace({ name: 'editor', params: { id: newProject.id } });
@@ -81,9 +93,7 @@ export const useProjectManager = () => {
const saveAsProject = async (name: string) => {
try {
const data = await generateProjectJSON();
// Always create new
await projectStore.createProject(name, data);
// Navigate to new project
if (projectStore.currentProject) {
router.push({ name: 'editor', params: { id: projectStore.currentProject.id } });
}
@@ -95,18 +105,14 @@ export const useProjectManager = () => {
};
const closeProject = () => {
// Reset Layers
const newLayer = createEmptyLayer('Base');
layers.value = [newLayer];
activeLayerId.value = newLayer.id;
// Reset columns
columns.value = 4;
// Reset Project Store
projectStore.currentProject = null;
// Navigate Home
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 metaTags: any[] = [
// Primary Meta Tags
{ name: 'title', content: fullTitle },
{ name: 'description', content: metadata.description },
{ name: 'robots', content: 'index, follow' },
// Open Graph / Facebook
{ property: 'og:type', content: metadata.type || 'website' },
{ property: 'og:url', content: fullUrl },
{ property: 'og:title', content: fullTitle },
@@ -37,7 +35,6 @@ export function useSEO(metadata: SEOMetaData) {
{ property: 'og:image', content: imageUrl },
{ property: 'og:site_name', content: SITE_NAME },
// Twitter
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:url', content: fullUrl },
{ name: 'twitter:title', content: fullTitle },
@@ -45,7 +42,6 @@ export function useSEO(metadata: SEOMetaData) {
{ name: 'twitter:image', content: imageUrl },
];
// Add article-specific meta tags
if (metadata.type === 'article') {
if (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) {
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
*/
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(
layersRef.value.map(async layer => {
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);
}
const base64 = canvas.toDataURL('image/png');
// Since we bake transformations into the image, set them to 0/false in metadata
return {
id: sprite.id,
width: sprite.width,

View File

@@ -5,7 +5,6 @@ export const useSprites = () => {
const sprites = ref<Sprite[]>([]);
const columns = ref(4);
// Clamp and coerce columns to a safe range [1..10]
watch(columns, val => {
const num = typeof val === 'number' ? val : parseInt(String(val));
const safe = Number.isFinite(num) && num >= 1 ? Math.min(num, 10) : 1;

View File

@@ -49,10 +49,8 @@ export function useSpritesheetSplitter() {
let height = cellHeight;
if (preserveCellSize) {
// Keep full cell with transparent padding
url = canvas.toDataURL('image/png');
} else {
// Crop to sprite bounds
const bounds = getSpriteBounds(ctx, cellWidth, cellHeight);
if (bounds) {
x = bounds.x;
@@ -94,7 +92,6 @@ export function useSpritesheetSplitter() {
const imageData = ctx.getImageData(0, 0, img.width, img.height);
// Initialize worker lazily
if (!worker.value) {
try {
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.drawImage(sourceCanvas, x, y, width, height, 0, 0, width, height);
// Remove background color
removeBackground(spriteCtx, width, height, backgroundColor);
const isEmpty = removeEmpty ? isCanvasEmpty(spriteCtx, width, height) : false;
@@ -209,7 +205,6 @@ export function useSpritesheetSplitter() {
if (!hasContent) return null;
// Add small padding
const pad = 1;
return {
x: Math.max(0, minX - pad),
@@ -305,11 +300,65 @@ export function useSpritesheetSplitter() {
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 {
isProcessing,
previewSprites,
splitByGrid,
detectSprites,
extractGifFrames,
getSuggestedCellSize,
cleanup,
};

View File

@@ -24,7 +24,6 @@ export interface FAQItem {
}
export function useStructuredData() {
// Organization Schema
const addOrganizationSchema = () => {
const schema = {
'@context': 'https://schema.org',
@@ -46,7 +45,6 @@ export function useStructuredData() {
});
};
// WebSite Schema
const addWebSiteSchema = () => {
const schema = {
'@context': 'https://schema.org',
@@ -71,7 +69,6 @@ export function useStructuredData() {
});
};
// BlogPosting Schema
const addBlogPostSchema = (post: BlogPostSchema) => {
const schema = {
'@context': 'https://schema.org',
@@ -109,7 +106,6 @@ export function useStructuredData() {
});
};
// Breadcrumb Schema
const addBreadcrumbSchema = (items: BreadcrumbItem[]) => {
const schema = {
'@context': 'https://schema.org',
@@ -132,7 +128,6 @@ export function useStructuredData() {
});
};
// Blog List Schema
const addBlogListSchema = (posts: BlogPostSchema[]) => {
const schema = {
'@context': 'https://schema.org',
@@ -164,7 +159,6 @@ export function useStructuredData() {
});
};
// FAQ Schema
const addFAQSchema = (faqs: FAQItem[]) => {
const schema = {
'@context': 'https://schema.org',

View File

@@ -32,7 +32,6 @@ export function useZoom(options: ZoomOptions) {
if (currentIndex < options.allowedValues.length - 1) {
zoom.value = options.allowedValues[currentIndex + 1];
} else if (currentIndex === -1) {
// Find the nearest higher value
const higher = options.allowedValues.find(v => v > zoom.value);
if (higher !== undefined) {
zoom.value = higher;
@@ -49,7 +48,6 @@ export function useZoom(options: ZoomOptions) {
if (currentIndex > 0) {
zoom.value = options.allowedValues[currentIndex - 1];
} else if (currentIndex === -1) {
// Find the nearest lower value
const lower = [...options.allowedValues].reverse().find(v => v < zoom.value);
if (lower !== undefined) {
zoom.value = lower;
@@ -66,7 +64,6 @@ export function useZoom(options: ZoomOptions) {
if (isStepOptions(options)) {
zoom.value = Math.max(options.min, Math.min(options.max, value));
} else {
// Snap to nearest allowed value
const nearest = options.allowedValues.reduce((prev, curr) => (Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev));
zoom.value = nearest;
}

View File

@@ -49,6 +49,11 @@ const router = createRouter({
name: 'editor',
component: () => import('../views/EditorView.vue'),
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('../views/NotFound.vue'),
},
],
scrollBehavior(to, from, 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 user = ref(pb.authStore.model);
// Sync user state on change
pb.authStore.onChange(() => {
user.value = pb.authStore.model;
});
@@ -21,7 +20,6 @@ export const useAuthStore = defineStore('auth', () => {
password,
passwordConfirm,
});
// Auto login after register
await login(email, password);
}

View File

@@ -15,13 +15,19 @@ export const useProjectStore = defineStore('project', () => {
const projects = ref<Project[]>([]);
const currentProject = ref<Project | null>(null);
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;
isLoading.value = true;
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',
filter,
});
projects.value = records.items.map((r: any) => ({
id: r.id,
@@ -30,6 +36,10 @@ export const useProjectStore = defineStore('project', () => {
created: r.created,
updated: r.updated,
}));
page.value = records.page;
perPage.value = records.perPage;
totalItems.value = records.totalItems;
totalPages.value = records.totalPages;
} catch (error) {
console.error('Failed to fetch projects:', error);
} finally {
@@ -53,7 +63,8 @@ export const useProjectStore = defineStore('project', () => {
created: record.created,
updated: record.updated,
};
await fetchProjects();
// Refresh current page
await fetchProjects(page.value, perPage.value);
} catch (error) {
console.error('Failed to create project:', error);
throw error;
@@ -76,7 +87,9 @@ export const useProjectStore = defineStore('project', () => {
data: record.data,
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) {
console.error('Failed to update project:', error);
throw error;
@@ -115,7 +128,9 @@ export const useProjectStore = defineStore('project', () => {
if (currentProject.value?.id === id) {
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) {
console.error('Failed to delete project:', error);
} finally {
@@ -127,6 +142,10 @@ export const useProjectStore = defineStore('project', () => {
projects,
currentProject,
isLoading,
page,
perPage,
totalItems,
totalPages,
fetchProjects,
createProject,
updateProject,

View File

@@ -10,27 +10,21 @@ const manualCellWidth = ref(64);
const manualCellHeight = ref(64);
const checkerboardEnabled = ref(false);
// Initialize dark mode from localStorage or system preference
if (typeof window !== 'undefined') {
// Check localStorage first
const storedDarkMode = localStorage.getItem('darkMode');
if (storedDarkMode !== null) {
darkMode.value = storedDarkMode === 'true';
} else {
// If not in localStorage, check system preference
darkMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches;
darkMode.value = true;
}
}
export const useSettingsStore = defineStore('settings', () => {
// Watch for changes to update localStorage and apply class
watch(
darkMode,
newValue => {
// Save to localStorage
localStorage.setItem('darkMode', newValue.toString());
// Apply or remove dark class on document
if (newValue) {
document.documentElement.classList.add('dark');
} else {
@@ -40,7 +34,6 @@ export const useSettingsStore = defineStore('settings', () => {
{ immediate: true }
);
// Actions
function togglePixelPerfect() {
pixelPerfect.value = !pixelPerfect.value;
}

View File

@@ -22,7 +22,7 @@ export interface SpritePreview {
}
/** Detection mode for sprite splitting */
export type DetectionMode = 'grid' | 'auto';
export type DetectionMode = 'grid' | 'auto' | 'gif';
/** Options for grid-based splitting */
export interface GridSplitOptions {

View File

@@ -4,7 +4,6 @@
const { addBreadcrumbSchema } = useStructuredData();
// Set SEO synchronously
useSEO({
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.',

View File

@@ -13,7 +13,6 @@
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 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');
// Dynamic meta tags using reactive computed values
useHead({
title: pageTitle,
meta: [
@@ -33,7 +31,6 @@
{ name: 'keywords', content: keywords },
{ name: 'robots', content: 'index, follow' },
// Open Graph
{ property: 'og:type', content: 'article' },
{ property: 'og:url', content: pageUrl },
{ property: 'og:title', content: pageTitle },
@@ -43,7 +40,6 @@
{ property: 'article:author', content: computed(() => post.value?.author || 'streetshadow') },
{ property: 'article:published_time', content: computed(() => post.value?.date || '') },
// Twitter
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:url', content: pageUrl },
{ name: 'twitter:title', content: pageTitle },
@@ -54,7 +50,6 @@
script: computed(() => {
const scripts = [];
// Breadcrumb schema
scripts.push({
type: 'application/ld+json',
children: JSON.stringify({
@@ -83,7 +78,6 @@
}),
});
// Blog post schema
if (post.value) {
scripts.push({
type: 'application/ld+json',

View File

@@ -9,7 +9,6 @@
const { addBreadcrumbSchema } = useStructuredData();
const posts = ref<BlogPost[]>([]);
// Set SEO meta tags synchronously
useSEO({
title: 'Blog - Latest Articles on Spritesheet Generation',
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',
});
// Add breadcrumb synchronously
addBreadcrumbSchema([
{ name: 'Home', url: '/' },
{ name: 'Blog', url: '/blog' },

View File

@@ -4,7 +4,6 @@
const { addBreadcrumbSchema } = useStructuredData();
// Set SEO synchronously
useSEO({
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!",

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">
<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">
<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">
<i class="fas fa-times"></i>
</button>
<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>
<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>
@@ -89,7 +87,7 @@
</section>
<!-- 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>
<div class="card p-3 bg-gray-50/50 dark:bg-gray-800/40 space-y-3">
<div class="flex items-center justify-between">
@@ -112,7 +110,7 @@
</section>
<!-- 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>
<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">
@@ -148,23 +146,32 @@
:class="showOffsetLabels ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium transition-colors"
>
<i class="fas fa-tag mr-2"></i>Labels
<i class="fas fa-tag mr-2"></i>Coordinates
</button>
</Tooltip>
<Tooltip text="Compare with ghost overlays" class="col-span-2">
<Tooltip text="Show frame index number">
<button
@click="showFrameIds = !showFrameIds"
:class="showFrameIds ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium transition-colors"
>
<i class="fas fa-hashtag mr-2"></i>Frame IDs
</button>
</Tooltip>
<Tooltip text="Compare with ghost overlays">
<button
@click="showAllSprites = !showAllSprites"
:class="showAllSprites ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium transition-colors"
>
<i class="fas fa-clone mr-2"></i>Ghost compare
<i class="fas fa-clone mr-2"></i>Compare
</button>
</Tooltip>
</div>
</section>
<!-- 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>
<div class="card p-2 bg-gray-50/50 dark:bg-gray-800/40 space-y-2">
<div class="flex gap-2">
@@ -234,6 +241,13 @@
>
<i class="fas fa-play mr-2"></i>Preview
</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>
<!-- Background Color (Compact) -->
@@ -284,6 +298,7 @@
:allow-cell-swap="allowCellSwap"
:show-all-sprites="showAllSprites"
:show-offset-labels="showOffsetLabels"
:show-frame-ids="showFrameIds"
@update-sprite="updateSpritePosition"
@update-sprite-cell="updateSpriteCell"
@remove-sprite="removeSprite"
@@ -293,9 +308,11 @@
@rotate-sprite="rotateSprite"
@flip-sprite="flipSprite"
@copy-sprite-to-frame="copySpriteToFrame"
@open-pixel-editor="openPixelEditor"
@add-sprites="addSprites"
/>
</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
:layers="layers"
:active-layer-id="activeLayerId"
@@ -306,9 +323,14 @@
@rotate-sprite="rotateSprite"
@flip-sprite="flipSprite"
@copy-sprite-to-frame="copySpriteToFrame"
@remove-sprite="removeSprite"
@replace-sprite="replaceSprite"
@open-pixel-editor="openPixelEditor"
/>
</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>
@@ -330,6 +352,7 @@
import FileUploader from '@/components/FileUploader.vue';
import SpriteCanvas from '@/components/SpriteCanvas.vue';
import SpritePreview from '@/components/SpritePreview.vue';
import DrawTab from '@/components/DrawTab.vue';
import SpritesheetSplitter from '@/components/SpritesheetSplitter.vue';
import GifFpsModal from '@/components/GifFpsModal.vue';
import ShareModal from '@/components/ShareModal.vue';
@@ -350,8 +373,29 @@
const projectStore = useProjectStore();
const { closeProject, loadProjectData } = useProjectManager();
const settingsStore = useSettingsStore();
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteInLayer, updateSpriteCell, removeSprite, removeSprites, replaceSprite, addSprite, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer, rotateSprite, flipSprite, copySpriteToFrame } =
useLayers();
const {
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(
layers,
@@ -364,7 +408,6 @@
toRef(settingsStore, 'manualCellHeight')
);
// Zoom Control
const {
zoom,
increase: zoomIn,
@@ -377,16 +420,15 @@
initial: 1,
});
// View Options & Tools
const isMultiSelectMode = ref(false);
const showActiveBorder = ref(true);
const allowCellSwap = ref(false);
const showAllSprites = ref(false);
const showOffsetLabels = ref(false);
const showFrameIds = ref(false);
const customColor = ref('#ffffff');
const isCustomMode = ref(false);
// Background Color Logic
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);
@@ -434,7 +476,8 @@
};
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 isGifFpsModalOpen = ref(false);
@@ -449,7 +492,6 @@
const editingLayerName = ref('');
const layerNameInput = ref<HTMLInputElement | null>(null);
// Upload Handlers
const handleSpritesUpload = async (files: File[]) => {
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
@@ -550,7 +592,6 @@
}
};
// Layer Editing
const startEditingLayer = (layerId: string, currentName: string) => {
editingLayerId.value = layerId;
editingLayerName.value = currentName;
@@ -597,27 +638,27 @@
flipX: false,
flipY: false,
};
const insertIndex = frameIndex + fileIdx;
if (insertIndex < layer.sprites.length) {
layer.sprites = [...layer.sprites.slice(0, insertIndex), sprite, ...layer.sprites.slice(insertIndex + 1)];
} else {
while (layer.sprites.length < insertIndex) {
layer.sprites.push({
id: crypto.randomUUID(),
file: new File([], 'empty'),
img: new Image(),
url: '',
width: 0,
height: 0,
x: 0,
y: 0,
rotation: 0,
flipX: false,
flipY: false,
});
}
layer.sprites = [...layer.sprites, sprite];
const targetIndex = frameIndex + fileIdx;
// Extend the array with empty slots if needed
while (layer.sprites.length <= targetIndex) {
layer.sprites.push({
id: crypto.randomUUID(),
file: new File([], 'empty'),
img: new Image(),
url: '',
width: 0,
height: 0,
x: 0,
y: 0,
rotation: 0,
flipX: false,
flipY: false,
});
}
// 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;
};
@@ -625,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 () => {
const id = route.params.id as string;
if (id) {
if (projectStore.currentProject?.id !== id) {
// Only load if active project is different
await projectStore.loadProject(id);
if (projectStore.currentProject?.data) {
await loadProjectData(projectStore.currentProject.data);
@@ -649,8 +731,6 @@
}
}
} 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();
// Set SEO synchronously
useSEO({
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.',

View File

@@ -5,7 +5,6 @@ import { useHead } from '@vueuse/head';
export function useHomeViewSEO() {
const { addOrganizationSchema, addWebSiteSchema } = useStructuredData();
// Set page SEO synchronously
useSEO({
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.',
@@ -14,13 +13,10 @@ export function useHomeViewSEO() {
keywords: 'spritesheet generator, sprite sheet maker, game development, pixel art, sprite animation, game assets, 2D game tools',
});
// Add organization schema
addOrganizationSchema();
// Add website schema
addWebSiteSchema();
// Add SoftwareApplication schema
useHead({
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();
// Set SEO synchronously
useSEO({
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.',

View File

@@ -169,7 +169,6 @@
const data = spritesheetData.value;
// Apply config settings
columns.value = data.config.columns;
settingsStore.negativeSpacingEnabled = data.config.negativeSpacingEnabled;
settingsStore.backgroundColor = data.config.backgroundColor;
@@ -177,7 +176,6 @@
settingsStore.manualCellWidth = data.config.manualCellWidth;
settingsStore.manualCellHeight = data.config.manualCellHeight;
// Load sprites into layers
const loadSprite = (spriteData: any): Promise<Sprite> =>
new Promise(resolve => {
const img = new Image();
@@ -199,7 +197,6 @@
height: spriteData.height,
x: spriteData.x || 0,
y: spriteData.y || 0,
// Transformations are already baked into the base64 image
rotation: 0,
flipX: false,
flipY: false,
@@ -226,10 +223,8 @@
activeLayerId.value = firstWithSprites ? firstWithSprites.id : newLayers[0].id;
}
// Clear current project so it's treated as new/unsaved
projectStore.currentProject = null;
// Navigate to editor
router.push({ name: 'editor' });
};

View File

@@ -19,7 +19,6 @@ interface WorkerResponse {
backgroundColor: [number, number, number, number];
}
// Pre-allocate arrays for better performance
let maskBuffer: Uint8Array;
let visitedBuffer: Uint8Array;
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] } {
const { data, width, height } = imageData;
// Downsample for very large images
const shouldDownsample = width > maxSize || height > maxSize;
let processedData: Uint8ClampedArray;
let processedWidth: number;
@@ -61,23 +59,17 @@ function detectIrregularSprites(imageData: ImageData, sensitivity: number, maxSi
processedHeight = height;
}
// Fast background detection using histogram
const backgroundColor = fastBackgroundDetection(processedData, processedWidth, processedHeight);
// Create optimized mask
const mask = createOptimizedMask(processedData, processedWidth, processedHeight, backgroundColor, sensitivity);
// Clean up mask with morphological operations
const cleanedMask = cleanUpMask(mask, processedWidth, processedHeight);
// Find connected components with optimized flood fill
const sprites = findOptimizedConnectedComponents(cleanedMask, processedWidth, processedHeight);
// Filter noise
const minSpriteSize = Math.max(4, Math.floor(Math.min(processedWidth, processedHeight) / 100));
const filteredSprites = sprites.filter(sprite => sprite.pixelCount >= minSpriteSize);
// Scale results back up if downsampled
const finalSprites = shouldDownsample
? filteredSprites.map(sprite => ({
x: Math.floor(sprite.x / scale),
@@ -88,7 +80,6 @@ function detectIrregularSprites(imageData: ImageData, sensitivity: number, maxSi
}))
: filteredSprites;
// Convert background color back to original format
const finalBackgroundColor: [number, number, number, number] = [backgroundColor[0], backgroundColor[1], backgroundColor[2], backgroundColor[3]];
return {
@@ -120,10 +111,8 @@ function downsampleImageData(data: Uint8ClampedArray, width: number, height: num
}
function fastBackgroundDetection(data: Uint8ClampedArray, width: number, height: number): Uint32Array {
// Enhanced background detection focusing on edges and corners
const colorCounts = new Map<string, number>();
// Sample from corners (most likely to be background)
const cornerSamples = [
[0, 0],
[width - 1, 0],
@@ -131,26 +120,21 @@ function fastBackgroundDetection(data: Uint8ClampedArray, width: number, height:
[width - 1, height - 1],
];
// Sample from edges (also likely background)
const edgeSamples: [number, number][] = [];
const edgeStep = Math.max(1, Math.floor(Math.min(width, height) / 20));
// Top and bottom edges
for (let x = 0; x < width; x += edgeStep) {
edgeSamples.push([x, 0]);
edgeSamples.push([x, height - 1]);
}
// Left and right edges
for (let y = 0; y < height; y += edgeStep) {
edgeSamples.push([0, y]);
edgeSamples.push([width - 1, y]);
}
// Collect all samples
const allSamples = [...cornerSamples, ...edgeSamples];
// Count colors with tolerance grouping
const tolerance = 15;
for (const [x, y] of allSamples) {
@@ -160,7 +144,6 @@ function fastBackgroundDetection(data: Uint8ClampedArray, width: number, height:
const b = data[idx + 2];
const a = data[idx + 3];
// Find existing similar color or create new entry
let matched = false;
for (const [colorKey, count] of colorCounts.entries()) {
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 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 {
const size = width * height;
// Reuse buffer if possible
if (!maskBuffer || maskBuffer.length < 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 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 a = data[idx + 3];
// Handle fully transparent pixels (common background case)
if (a < 10) {
maskBuffer[i] = 0; // Treat as background
idx += 4;
continue;
}
// Calculate color difference using Euclidean distance for better accuracy
const rDiff = r - bgR;
const gDiff = g - bgG;
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 alphaDistance = Math.abs(aDiff);
// Pixel is foreground if it's significantly different from background
const isBackground = colorDistance <= colorTolerance && alphaDistance <= alphaTolerance;
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 {
// Simple morphological closing to fill small gaps in sprites
// and opening to remove small noise
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 x = 1; x < width - 1; x++) {
const idx = y * width + x;
// Count non-zero neighbors in 3x3 area
let neighbors = 0;
for (let dy = -1; dy <= 1; dy++) {
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;
}
}
// Copy borders as-is
for (let x = 0; x < width; x++) {
cleaned[x] = mask[x]; // Top 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[] {
const size = width * height;
// Reuse buffers
if (!visitedBuffer || visitedBuffer.length < size) {
visitedBuffer = new Uint8Array(size);
}
@@ -293,7 +257,6 @@ function findOptimizedConnectedComponents(mask: Uint8Array, width: number, heigh
stackBuffer = new Int32Array(size * 2);
}
// Clear visited array
visitedBuffer.fill(0);
const sprites: SpriteRegion[] = [];
@@ -337,13 +300,11 @@ function optimizedFloodFill(mask: Uint8Array, visited: Uint8Array, startX: numbe
visited[idx] = 1;
pixelCount++;
// Update bounding box
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
// Add neighbors (check bounds to avoid stack overflow)
if (x + 1 < width && !visited[idx + 1] && mask[idx + 1]) {
stackBuffer[stackTop++] = x + 1;
stackBuffer[stackTop++] = y;
@@ -364,7 +325,6 @@ function optimizedFloodFill(mask: Uint8Array, visited: Uint8Array, startX: numbe
if (pixelCount === 0) return null;
// Add padding
const padding = 1;
return {
x: Math.max(0, minX - padding),