From a527a0e83ba73faba0f7b024d35f19a9fc062d0a Mon Sep 17 00:00:00 2001 From: Dennis Postma Date: Sat, 5 Apr 2025 13:09:29 +0200 Subject: [PATCH] Many clean --- package-lock.json | 138 ++-- src/application/animationController.ts | 160 +++++ src/application/canvasOperations.ts | 295 +++++++++ src/application/spriteOperations.ts | 181 ++++++ src/application/state.ts | 54 ++ src/application/types.ts | 45 ++ src/application/utilities.ts | 62 ++ src/components/AnimationControls.vue | 96 +++ src/components/MainContent.vue | 36 ++ src/components/PreviewModal.vue | 552 ++-------------- src/components/ViewControls.vue | 62 ++ src/composables/useAnimation.ts | 136 ++++ src/composables/useCanvasInitialization.ts | 52 ++ src/composables/useKeyboardShortcuts.ts | 117 ++++ src/composables/useSpritePosition.ts | 134 ++++ src/composables/useSpritesheetStore.ts | 706 +-------------------- src/composables/useViewport.ts | 153 +++++ 17 files changed, 1722 insertions(+), 1257 deletions(-) create mode 100644 src/application/animationController.ts create mode 100644 src/application/canvasOperations.ts create mode 100644 src/application/spriteOperations.ts create mode 100644 src/application/state.ts create mode 100644 src/application/types.ts create mode 100644 src/application/utilities.ts create mode 100644 src/components/AnimationControls.vue create mode 100644 src/components/ViewControls.vue create mode 100644 src/composables/useAnimation.ts create mode 100644 src/composables/useCanvasInitialization.ts create mode 100644 src/composables/useKeyboardShortcuts.ts create mode 100644 src/composables/useSpritePosition.ts create mode 100644 src/composables/useViewport.ts diff --git a/package-lock.json b/package-lock.json index 54a5d53..0d4bf8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1313,43 +1313,43 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.2.tgz", - "integrity": "sha512-ZwFnxH+1z8Ehh8bNTMX3YFrYdzAv7JLY5X5X7XSFY+G9QGJVce/P9xb2mh+j5hKt8NceuHmdtllJvAHWKtsNrQ==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.3.tgz", + "integrity": "sha512-H/6r6IPFJkCfBJZ2dKZiPJ7Ueb2wbL592+9bQEl2r73qbX6yGnmQVIfiUvDRB2YI0a3PWDrzUwkvQx1XW1bNkA==", "license": "MIT", "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.29.2", - "tailwindcss": "4.1.2" + "tailwindcss": "4.1.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.2.tgz", - "integrity": "sha512-Zwz//1QKo6+KqnCKMT7lA4bspGfwEgcPAHlSthmahtgrpKDfwRGk8PKQrW8Zg/ofCDIlg6EtjSTKSxxSufC+CQ==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.3.tgz", + "integrity": "sha512-t16lpHCU7LBxDe/8dCj9ntyNpXaSTAgxWm1u2XQP5NiIu4KGSyrDJJRlK9hJ4U9yJxx0UKCVI67MJWFNll5mOQ==", "license": "MIT", "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.2", - "@tailwindcss/oxide-darwin-arm64": "4.1.2", - "@tailwindcss/oxide-darwin-x64": "4.1.2", - "@tailwindcss/oxide-freebsd-x64": "4.1.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.2", - "@tailwindcss/oxide-linux-x64-musl": "4.1.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.2" + "@tailwindcss/oxide-android-arm64": "4.1.3", + "@tailwindcss/oxide-darwin-arm64": "4.1.3", + "@tailwindcss/oxide-darwin-x64": "4.1.3", + "@tailwindcss/oxide-freebsd-x64": "4.1.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.3", + "@tailwindcss/oxide-linux-x64-musl": "4.1.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.2.tgz", - "integrity": "sha512-IxkXbntHX8lwGmwURUj4xTr6nezHhLYqeiJeqa179eihGv99pRlKV1W69WByPJDQgSf4qfmwx904H6MkQqTA8w==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.3.tgz", + "integrity": "sha512-cxklKjtNLwFl3mDYw4XpEfBY+G8ssSg9ADL4Wm6//5woi3XGqlxFsnV5Zb6v07dxw1NvEX2uoqsxO/zWQsgR+g==", "cpu": [ "arm64" ], @@ -1363,9 +1363,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.2.tgz", - "integrity": "sha512-ZRtiHSnFYHb4jHKIdzxlFm6EDfijTCOT4qwUhJ3GWxfDoW2yT3z/y8xg0nE7e72unsmSj6dtfZ9Y5r75FIrlpA==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.3.tgz", + "integrity": "sha512-mqkf2tLR5VCrjBvuRDwzKNShRu99gCAVMkVsaEOFvv6cCjlEKXRecPu9DEnxp6STk5z+Vlbh1M5zY3nQCXMXhw==", "cpu": [ "arm64" ], @@ -1379,9 +1379,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.2.tgz", - "integrity": "sha512-BiKUNZf1A0pBNzndBvnPnBxonCY49mgbOsPfILhcCE5RM7pQlRoOgN7QnwNhY284bDbfQSEOWnFR0zbPo6IDTw==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.3.tgz", + "integrity": "sha512-7sGraGaWzXvCLyxrc7d+CCpUN3fYnkkcso3rCzwUmo/LteAl2ZGCDlGvDD8Y/1D3ngxT8KgDj1DSwOnNewKhmg==", "cpu": [ "x64" ], @@ -1395,9 +1395,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.2.tgz", - "integrity": "sha512-Z30VcpUfRGkiddj4l5NRCpzbSGjhmmklVoqkVQdkEC0MOelpY+fJrVhzSaXHmWrmSvnX8yiaEqAbdDScjVujYQ==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.3.tgz", + "integrity": "sha512-E2+PbcbzIReaAYZe997wb9rId246yDkCwAakllAWSGqe6VTg9hHle67hfH6ExjpV2LSK/siRzBUs5wVff3RW9w==", "cpu": [ "x64" ], @@ -1411,9 +1411,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.2.tgz", - "integrity": "sha512-w3wsK1ChOLeQ3gFOiwabtWU5e8fY3P1Ss8jR3IFIn/V0va3ir//hZ8AwURveS4oK1Pu6b8i+yxesT4qWnLVUow==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.3.tgz", + "integrity": "sha512-GvfbJ8wjSSjbLFFE3UYz4Eh8i4L6GiEYqCtA8j2Zd2oXriPuom/Ah/64pg/szWycQpzRnbDiJozoxFU2oJZyfg==", "cpu": [ "arm" ], @@ -1427,9 +1427,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.2.tgz", - "integrity": "sha512-oY/u+xJHpndTj7B5XwtmXGk8mQ1KALMfhjWMMpE8pdVAznjJsF5KkCceJ4Fmn5lS1nHMCwZum5M3/KzdmwDMdw==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.3.tgz", + "integrity": "sha512-35UkuCWQTeG9BHcBQXndDOrpsnt3Pj9NVIB4CgNiKmpG8GnCNXeMczkUpOoqcOhO6Cc/mM2W7kaQ/MTEENDDXg==", "cpu": [ "arm64" ], @@ -1443,9 +1443,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.2.tgz", - "integrity": "sha512-k7G6vcRK/D+JOWqnKzKN/yQq1q4dCkI49fMoLcfs2pVcaUAXEqCP9NmA8Jv+XahBv5DtDjSAY3HJbjosEdKczg==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.3.tgz", + "integrity": "sha512-dm18aQiML5QCj9DQo7wMbt1Z2tl3Giht54uVR87a84X8qRtuXxUqnKQkRDK5B4bCOmcZ580lF9YcoMkbDYTXHQ==", "cpu": [ "arm64" ], @@ -1459,9 +1459,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.2.tgz", - "integrity": "sha512-fLL+c678TkYKgkDLLNxSjPPK/SzTec7q/E5pTwvpTqrth867dftV4ezRyhPM5PaiCqX651Y8Yk0wRQMcWUGnmQ==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.3.tgz", + "integrity": "sha512-LMdTmGe/NPtGOaOfV2HuO7w07jI3cflPrVq5CXl+2O93DCewADK0uW1ORNAcfu2YxDUS035eY2W38TxrsqngxA==", "cpu": [ "x64" ], @@ -1475,9 +1475,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.2.tgz", - "integrity": "sha512-0tU1Vjd1WucZ2ooq6y4nI9xyTSaH2g338bhrqk+2yzkMHskBm+pMsOCfY7nEIvALkA1PKPOycR4YVdlV7Czo+A==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.3.tgz", + "integrity": "sha512-aalNWwIi54bbFEizwl1/XpmdDrOaCjRFQRgtbv9slWjmNPuJJTIKPHf5/XXDARc9CneW9FkSTqTbyvNecYAEGw==", "cpu": [ "x64" ], @@ -1491,9 +1491,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.2.tgz", - "integrity": "sha512-r8QaMo3QKiHqUcn+vXYCypCEha+R0sfYxmaZSgZshx9NfkY+CHz91aS2xwNV/E4dmUDkTPUag7sSdiCHPzFVTg==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.3.tgz", + "integrity": "sha512-PEj7XR4OGTGoboTIAdXicKuWl4EQIjKHKuR+bFy9oYN7CFZo0eu74+70O4XuERX4yjqVZGAkCdglBODlgqcCXg==", "cpu": [ "arm64" ], @@ -1507,9 +1507,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.2.tgz", - "integrity": "sha512-lYCdkPxh9JRHXoBsPE8Pu/mppUsC2xihYArNAESub41PKhHTnvn6++5RpmFM+GLSt3ewyS8fwCVvht7ulWm6cw==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.3.tgz", + "integrity": "sha512-T8gfxECWDBENotpw3HR9SmNiHC9AOJdxs+woasRZ8Q/J4VHN0OMs7F+4yVNZ9EVN26Wv6mZbK0jv7eHYuLJLwA==", "cpu": [ "x64" ], @@ -1523,14 +1523,14 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.2.tgz", - "integrity": "sha512-3r/ZdMW0gxY8uOx1To0lpYa4coq4CzINcCX4laM1rS340Kcn0ac4A/MMFfHN8qba51aorZMYwMcOxYk4wJ9FYg==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.3.tgz", + "integrity": "sha512-lUI/QaDxLtlV52Lho6pu07CG9pSnRYLOPmKGIQjyHdTBagemc6HmgZxyjGAQ/5HMPrNeWBfTVIpQl0/jLXvWHQ==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.1.2", - "@tailwindcss/oxide": "4.1.2", - "tailwindcss": "4.1.2" + "@tailwindcss/node": "4.1.3", + "@tailwindcss/oxide": "4.1.3", + "tailwindcss": "4.1.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6" @@ -2014,9 +2014,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001709", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001709.tgz", - "integrity": "sha512-NgL3vUTnDrPCZ3zTahp4fsugQ4dc7EKTSzwQDPEel6DMoMnfH2jhry9n2Zm8onbSR+f/QtKHFOA+iAQu4kbtWA==", + "version": "1.0.30001711", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001711.tgz", + "integrity": "sha512-OpFA8GsKtoV3lCcsI3U5XBAV+oVrMu96OS8XafKqnhOaEAW2mveD1Mx81Sx/02chERwhDakuXs28zbyEc4QMKg==", "dev": true, "funding": [ { @@ -2178,9 +2178,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.131", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.131.tgz", - "integrity": "sha512-fJFRYXVEJgDCiqFOgRGJm8XR97hZ13tw7FXI9k2yC5hgY+nyzC2tMO8baq1cQR7Ur58iCkASx2zrkZPZUnfzPg==", + "version": "1.5.132", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.132.tgz", + "integrity": "sha512-QgX9EBvWGmvSRa74zqfnG7+Eno0Ak0vftBll0Pt2/z5b3bEGYL6OUXLgKPtvx73dn3dvwrlyVkjPKRRlhLYTEg==", "dev": true, "license": "ISC" }, @@ -3393,9 +3393,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.2.tgz", - "integrity": "sha512-VCsK+fitIbQF7JlxXaibFhxrPq4E2hDcG8apzHUdWFMCQWD8uLdlHg4iSkZ53cgLCCcZ+FZK7vG8VjvLcnBgKw==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz", + "integrity": "sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==", "license": "MIT" }, "node_modules/tapable": { @@ -3418,9 +3418,9 @@ } }, "node_modules/typescript": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", - "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", "bin": { diff --git a/src/application/animationController.ts b/src/application/animationController.ts new file mode 100644 index 0000000..49b801b --- /dev/null +++ b/src/application/animationController.ts @@ -0,0 +1,160 @@ +import { sprites, animation, cellSize, previewBorder } from '@/application/state'; +import { getSpriteOffset } from '@/application/utilities'; + +/** + * Start the animation + */ +export function startAnimation() { + if (sprites.value.length === 0 || !animation.canvas) return; + + animation.isPlaying = true; + animation.lastFrameTime = performance.now(); + animation.manualUpdate = false; + + animation.canvas.width = cellSize.width; + animation.canvas.height = cellSize.height; + + // Start the animation loop without resetting sprite offset + animationLoop(); +} + +/** + * Stop the animation + */ +export function stopAnimation() { + animation.isPlaying = false; + + if (animation.animationId) { + cancelAnimationFrame(animation.animationId); + animation.animationId = null; + } +} + +/** + * Render a specific frame of the animation + */ +export function renderAnimationFrame(frameIndex: number, showAllSprites = false, spriteOffset = { x: 0, y: 0 }) { + if (sprites.value.length === 0 || !animation.canvas || !animation.ctx) return; + + // Resize the animation canvas to match the cell size if needed + if (animation.canvas.width !== cellSize.width || animation.canvas.height !== cellSize.height) { + animation.canvas.width = cellSize.width; + animation.canvas.height = cellSize.height; + } + + // Clear the canvas + animation.ctx.clearRect(0, 0, animation.canvas.width, animation.canvas.height); + + // Draw background (transparent by default) + animation.ctx.fillStyle = 'transparent'; + animation.ctx.fillRect(0, 0, animation.canvas.width, animation.canvas.height); + + // Keep pixel art sharp + animation.ctx.imageSmoothingEnabled = false; + + // Draw background sprites with reduced opacity if requested + if (showAllSprites && sprites.value.length > 1) { + renderBackgroundSprites(frameIndex); + } + + // Get the current sprite and render it + renderCurrentSprite(frameIndex, spriteOffset); + + // Draw border around the cell if enabled + renderPreviewBorder(); +} + +/** + * Draw all sprites except the current one with reduced opacity + */ +function renderBackgroundSprites(frameIndex: number) { + if (!animation.ctx) return; + + // Save the current context state + animation.ctx.save(); + + // Set global alpha for background sprites + animation.ctx.globalAlpha = 0.3; + + // Draw all sprites except the current one + sprites.value.forEach((sprite, index) => { + if (index !== frameIndex) { + const spriteCellX = Math.floor(sprite.x / cellSize.width); + const spriteCellY = Math.floor(sprite.y / cellSize.height); + + // Calculate precise offset for pixel-perfect rendering + const spriteOffsetX = Math.round(sprite.x - spriteCellX * cellSize.width); + const spriteOffsetY = Math.round(sprite.y - spriteCellY * cellSize.height); + + // Draw the sprite with transparency + animation.ctx.drawImage(sprite.img, spriteOffsetX, spriteOffsetY); + } + }); + + // Restore the context to full opacity + animation.ctx.restore(); +} + +/** + * Render the current sprite at full opacity + */ +function renderCurrentSprite(frameIndex: number, spriteOffset: { x: number; y: number }) { + if (!animation.ctx) return; + + // Get the current sprite + const currentSprite = sprites.value[frameIndex % sprites.value.length]; + + // Draw the current sprite at full opacity at the specified position + animation.ctx.drawImage(currentSprite.img, spriteOffset.x, spriteOffset.y); +} + +/** + * Render a border around the animation preview if enabled + */ +function renderPreviewBorder() { + if (!animation.ctx || !animation.canvas || !previewBorder.enabled) return; + + animation.ctx.strokeStyle = previewBorder.color; + animation.ctx.lineWidth = previewBorder.width; + + // Use pixel-perfect coordinates for the border (0.5 offset for crisp lines) + const x = 0.5; + const y = 0.5; + const width = animation.canvas.width - 1; + const height = animation.canvas.height - 1; + + animation.ctx.strokeRect(x, y, width, height); +} + +/** + * Animation loop for continuous playback + */ +export function animationLoop(timestamp?: number) { + if (!animation.isPlaying) return; + + const currentTime = timestamp || performance.now(); + const elapsed = currentTime - animation.lastFrameTime; + const frameInterval = 1000 / animation.frameRate; + + if (elapsed >= frameInterval) { + animation.lastFrameTime = currentTime; + + if (sprites.value.length > 0) { + // Get the stored offset for the current frame + const frameOffset = getSpriteOffset(animation.currentFrame); + + // Render the current frame with its offset + renderAnimationFrame(animation.currentFrame, false, frameOffset); + + // Move to the next frame + animation.currentFrame = (animation.currentFrame + 1) % sprites.value.length; + + // Update the slider position if available + if (animation.slider) { + animation.slider.value = animation.currentFrame.toString(); + } + } + } + + animation.animationId = requestAnimationFrame(animationLoop); +} diff --git a/src/application/canvasOperations.ts b/src/application/canvasOperations.ts new file mode 100644 index 0000000..9101005 --- /dev/null +++ b/src/application/canvasOperations.ts @@ -0,0 +1,295 @@ +// canvasOperations.ts +import { sprites, canvas, ctx, cellSize, columns, zoomLevel, previewBorder, animation } from '@/application/state'; +import { logger, getSpriteOffset, isImageReady, getPixelPerfectCoordinate, showNotification } from '@/application/utilities'; + +/** + * Update the canvas size based on sprites and cell size + */ +export function updateCanvasSize() { + if (!canvas.value) { + logger.warn('Canvas not available for size update'); + return; + } + + if (sprites.value.length === 0) { + return; + } + + try { + const totalSprites = sprites.value.length; + const cols = columns.value; + const rows = Math.ceil(totalSprites / cols); + + if (cellSize.width <= 0 || cellSize.height <= 0) { + logger.error('Invalid cell size for canvas update', cellSize); + return; + } + + const newWidth = cols * cellSize.width; + const newHeight = rows * cellSize.height; + + // Ensure the canvas is large enough to display all sprites + if (canvas.value.width !== newWidth || canvas.value.height !== newHeight) { + canvas.value.width = newWidth; + canvas.value.height = newHeight; + + // Emit an event to update the wrapper dimensions + window.dispatchEvent( + new CustomEvent('canvas-size-updated', { + detail: { width: newWidth, height: newHeight }, + }) + ); + } + } catch (error) { + logger.error('Error updating canvas size:', error); + } +} + +/** + * Draw the grid on the canvas + */ +export function drawGrid() { + if (!ctx.value || !canvas.value) return; + + ctx.value.strokeStyle = '#333'; + ctx.value.lineWidth = 1 / zoomLevel.value; // Adjust line width based on zoom level + + // Calculate the visible area based on zoom level + const visibleWidth = canvas.value.width / zoomLevel.value; + const visibleHeight = canvas.value.height / zoomLevel.value; + + // Draw vertical lines - ensure pixel-perfect grid lines + for (let x = 0; x <= visibleWidth; x += cellSize.width) { + const pixelX = getPixelPerfectCoordinate(x); + ctx.value.beginPath(); + ctx.value.moveTo(pixelX, 0); + ctx.value.lineTo(pixelX, visibleHeight); + ctx.value.stroke(); + } + + // Draw horizontal lines - ensure pixel-perfect grid lines + for (let y = 0; y <= visibleHeight; y += cellSize.height) { + const pixelY = getPixelPerfectCoordinate(y); + ctx.value.beginPath(); + ctx.value.moveTo(0, pixelY); + ctx.value.lineTo(visibleWidth, pixelY); + ctx.value.stroke(); + } +} + +/** + * Render the spritesheet preview on the canvas + */ +export function renderSpritesheetPreview(showGrid = true) { + if (!ctx.value || !canvas.value) { + logger.error('Canvas or context not available for rendering, will retry when ready'); + setTimeout(() => { + if (ctx.value && canvas.value) { + renderSpritesheetPreview(showGrid); + } + }, 100); + return; + } + + if (sprites.value.length === 0) return; + + try { + // Make sure the canvas size is correct before rendering + updateCanvasSize(); + + // Clear the canvas + ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height); + + if (showGrid) { + drawGrid(); + } + + // First, collect all occupied cells + const occupiedCells = new Set(); + sprites.value.forEach(sprite => { + const cellX = Math.floor(sprite.x / cellSize.width); + const cellY = Math.floor(sprite.y / cellSize.height); + occupiedCells.add(`${cellX},${cellY}`); + }); + + // Draw each sprite - remove the zoom scaling from context + sprites.value.forEach((sprite, index) => { + try { + if (!sprite.img) { + logger.warn(`Sprite at index ${index} has no image, skipping render`); + return; + } + + // Check if sprite is within canvas bounds + if (sprite.x >= 0 && sprite.y >= 0 && sprite.x + sprite.width <= canvas.value!.width && sprite.y + sprite.height <= canvas.value!.height) { + renderSpriteOnCanvas(sprite, index); + } else { + logger.warn(`Sprite at index ${index} is outside canvas bounds: sprite(${sprite.x},${sprite.y}) canvas(${canvas.value!.width},${canvas.value!.height})`); + } + } catch (spriteError) { + logger.error(`Error rendering sprite at index ${index}:`, spriteError); + } + }); + + // Draw borders around occupied cells if enabled (preview only) + drawPreviewBorders(occupiedCells); + } catch (error) { + logger.error('Error in renderSpritesheetPreview:', error); + } +} + +/** + * Draw a single sprite on the canvas + */ +function renderSpriteOnCanvas(sprite: any, index: number) { + if (!ctx.value || !canvas.value) return; + + if (isImageReady(sprite.img)) { + // For pixel art, ensure we're drawing at exact pixel boundaries + const x = Math.round(sprite.x); + const y = Math.round(sprite.y); + + // Get the frame-specific offset for this sprite + const frameOffset = getSpriteOffset(index); + + // Calculate the maximum allowed offset based on sprite and cell size + const maxOffsetX = Math.max(0, cellSize.width - sprite.width); + const maxOffsetY = Math.max(0, cellSize.height - sprite.height); + + // Constrain the offset to prevent out-of-bounds positioning + const constrainedOffsetX = Math.max(0, Math.min(maxOffsetX, frameOffset.x)); + const constrainedOffsetY = Math.max(0, Math.min(maxOffsetY, frameOffset.y)); + + // Update the frame offset with the constrained values + frameOffset.x = constrainedOffsetX; + frameOffset.y = constrainedOffsetY; + + // Apply the constrained offset to the sprite position + const finalX = x + constrainedOffsetX; + const finalY = y + constrainedOffsetY; + + // Draw the image at its final position with pixel-perfect rendering + ctx.value.imageSmoothingEnabled = false; // Keep pixel art sharp + ctx.value.drawImage(sprite.img, finalX, finalY, sprite.width, sprite.height); + } else { + logger.warn(`Sprite image ${index} not fully loaded, setting onload handler`); + sprite.img.onload = () => { + if (ctx.value && canvas.value) { + renderSpriteOnCanvas(sprite, index); + } + }; + } +} + +/** + * Draw preview borders around occupied cells if enabled + */ +function drawPreviewBorders(occupiedCells: Set) { + if (!ctx.value || !canvas.value || !previewBorder.enabled || occupiedCells.size === 0) return; + + ctx.value.strokeStyle = previewBorder.color; + ctx.value.lineWidth = previewBorder.width / zoomLevel.value; // Adjust for zoom + + // Draw borders around each occupied cell + occupiedCells.forEach(cellKey => { + const [cellX, cellY] = cellKey.split(',').map(Number); + + // Calculate pixel-perfect coordinates for the cell + // Add 0.5 to align with pixel boundaries for crisp lines + const x = getPixelPerfectCoordinate(cellX * cellSize.width); + const y = getPixelPerfectCoordinate(cellY * cellSize.height); + + // Adjust width and height to ensure the border is inside the cell + const width = cellSize.width - 1; + const height = cellSize.height - 1; + + ctx.value!.strokeRect(x, y, width, height); + }); +} + +/** + * Download the spritesheet as a PNG + */ +export function downloadSpritesheet() { + if (sprites.value.length === 0 || !canvas.value) { + showNotification('No sprites to download', 'error'); + return; + } + + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = canvas.value.width; + tempCanvas.height = canvas.value.height; + const tempCtx = tempCanvas.getContext('2d'); + + if (!tempCtx) { + showNotification('Failed to create download context', 'error'); + return; + } + + tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height); + + // Ensure pixel art remains sharp in the downloaded file + tempCtx.imageSmoothingEnabled = false; + + sprites.value.forEach((sprite, index) => { + // Get the frame-specific offset for this sprite + const frameOffset = getSpriteOffset(index); + + // Calculate the cell coordinates for this sprite + const cellX = Math.floor(sprite.x / cellSize.width); + const cellY = Math.floor(sprite.y / cellSize.height); + + // Calculate the base position within the cell + const baseX = cellX * cellSize.width; + const baseY = cellY * cellSize.height; + + // Calculate the maximum allowed offset based on sprite and cell size + // This prevents sprites from going out of bounds + const maxOffsetX = Math.max(0, cellSize.width - sprite.width); + const maxOffsetY = Math.max(0, cellSize.height - sprite.height); + + // Constrain the offset to prevent out-of-bounds positioning + const constrainedOffsetX = Math.max(0, Math.min(maxOffsetX, frameOffset.x)); + const constrainedOffsetY = Math.max(0, Math.min(maxOffsetY, frameOffset.y)); + + // Apply the constrained offset to the base position + const finalX = baseX + constrainedOffsetX; + const finalY = baseY + constrainedOffsetY; + + // Draw the sprite at the calculated position + tempCtx.drawImage(sprite.img, finalX, finalY, sprite.width, sprite.height); + }); + + const link = document.createElement('a'); + link.download = 'spritesheet.png'; + link.href = tempCanvas.toDataURL('image/png'); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + showNotification('Spritesheet downloaded successfully'); +} + +/** + * Zoom-related operations + */ +export function zoomIn() { + // Increase zoom level by 0.1, max 3.0 (300%) + zoomLevel.value = Math.min(3.0, zoomLevel.value + 0.1); + renderSpritesheetPreview(); + showNotification(`Zoom: ${Math.round(zoomLevel.value * 100)}%`); +} + +export function zoomOut() { + // Decrease zoom level by 0.1, min 0.5 (50%) + zoomLevel.value = Math.max(0.5, zoomLevel.value - 0.1); + renderSpritesheetPreview(); + showNotification(`Zoom: ${Math.round(zoomLevel.value * 100)}%`); +} + +export function resetZoom() { + // Reset to default zoom level (100%) + zoomLevel.value = 1; + renderSpritesheetPreview(); + showNotification('Zoom reset to 100%'); +} diff --git a/src/application/spriteOperations.ts b/src/application/spriteOperations.ts new file mode 100644 index 0000000..59ad62b --- /dev/null +++ b/src/application/spriteOperations.ts @@ -0,0 +1,181 @@ +// spriteOperations.ts +import { type Sprite } from '@/application/types'; +import { sprites, cellSize, canvas, ctx, columns } from '@/application/state'; +import { logger, getSpriteOffset } from '@/application/utilities'; +import { updateCanvasSize, renderSpritesheetPreview } from '@/application/canvasOperations'; + +/** + * Add new sprites to the spritesheet + */ +export function addSprites(newSprites: Sprite[]) { + if (newSprites.length === 0) { + logger.warn('Attempted to add empty sprites array'); + return; + } + + try { + // Validate sprites before adding them + const validSprites = newSprites.filter(sprite => { + if (!sprite.img || sprite.width <= 0 || sprite.height <= 0) { + logger.error('Invalid sprite detected', sprite); + return false; + } + return true; + }); + + if (validSprites.length === 0) { + logger.error('No valid sprites to add'); + return; + } + + sprites.value.push(...validSprites); + sprites.value.sort((a, b) => a.uploadOrder - b.uploadOrder); + updateCellSize(); + autoArrangeSprites(); + } catch (error) { + logger.error('Error adding sprites:', error); + } +} + +/** + * Update the cell size based on the largest sprite dimensions + */ +export function updateCellSize() { + if (sprites.value.length === 0) { + return; + } + + try { + let maxWidth = 0; + let maxHeight = 0; + + sprites.value.forEach(sprite => { + if (sprite.width <= 0 || sprite.height <= 0) { + logger.warn('Sprite with invalid dimensions detected', sprite); + return; + } + maxWidth = Math.max(maxWidth, sprite.width); + maxHeight = Math.max(maxHeight, sprite.height); + }); + + if (maxWidth === 0 || maxHeight === 0) { + logger.error('Failed to calculate valid cell size'); + return; + } + + cellSize.width = maxWidth; + cellSize.height = maxHeight; + + updateCanvasSize(); + } catch (error) { + logger.error('Error updating cell size:', error); + } +} + +/** + * Automatically arrange sprites in a grid + */ +export function autoArrangeSprites() { + if (sprites.value.length === 0) { + return; + } + + try { + if (cellSize.width <= 0 || cellSize.height <= 0) { + logger.error('Invalid cell size for auto-arranging', cellSize); + return; + } + + // First update the canvas size to ensure it's large enough + updateCanvasSize(); + + // Then position each sprite in its grid cell + sprites.value.forEach((sprite, index) => { + const column = index % columns.value; + const row = Math.floor(index / columns.value); + + sprite.x = column * cellSize.width; + sprite.y = row * cellSize.height; + }); + + // Check if the canvas is ready before attempting to render + if (!ctx.value || !canvas.value) { + logger.warn('Canvas or context not available for rendering after auto-arrange'); + return; + } + + renderSpritesheetPreview(); + } catch (error) { + logger.error('Error auto-arranging sprites:', error); + } +} + +/** + * Highlight a specific sprite by its ID + */ +export function highlightSprite(spriteId: string) { + if (!ctx.value || !canvas.value) return; + + const sprite = sprites.value.find(s => s.id === spriteId); + if (!sprite) return; + + // Calculate the cell coordinates + const cellX = Math.floor(sprite.x / cellSize.width); + const cellY = Math.floor(sprite.y / cellSize.height); + + // Briefly flash the cell + ctx.value.save(); + ctx.value.fillStyle = 'rgba(0, 150, 255, 0.3)'; + ctx.value.fillRect(cellX * cellSize.width, cellY * cellSize.height, cellSize.width, cellSize.height); + ctx.value.restore(); + + // Reset after a short delay + setTimeout(() => { + renderSpritesheetPreview(); + }, 500); +} + +/** + * Clear all sprites and reset the canvas + */ +export function clearAllSprites(animation: any) { + if (!canvas.value || !ctx.value) return; + + sprites.value = []; + canvas.value.width = 400; + canvas.value.height = 300; + ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height); + + if (animation.canvas && animation.ctx) { + animation.canvas.width = 200; + animation.canvas.height = 200; + animation.ctx.clearRect(0, 0, animation.canvas.width, animation.canvas.height); + } + + animation.currentFrame = 0; +} + +/** + * Apply frame-specific offsets to the main sprite positions + */ +export function applyOffsetsToMainView(currentSpriteOffset: any) { + sprites.value.forEach((sprite, index) => { + const frameOffset = getSpriteOffset(index); + if (frameOffset.x !== 0 || frameOffset.y !== 0) { + // Update the sprite's position to include the offset + sprite.x += frameOffset.x; + sprite.y += frameOffset.y; + + // Reset the offset + frameOffset.x = 0; + frameOffset.y = 0; + } + }); + + // Reset current offset + currentSpriteOffset.x = 0; + currentSpriteOffset.y = 0; + + // Re-render the main view + renderSpritesheetPreview(); +} diff --git a/src/application/state.ts b/src/application/state.ts new file mode 100644 index 0000000..3721dab --- /dev/null +++ b/src/application/state.ts @@ -0,0 +1,54 @@ +// state.ts +import { ref, reactive } from 'vue'; +import { type Sprite, type CellSize, type AnimationState, type NotificationState, type PreviewBorderSettings } from '@/application/types'; + +// Core state +export const sprites = ref([]); +export const canvas = ref(null); +export const ctx = ref(null); +export const cellSize = reactive({ width: 0, height: 0 }); +export const columns = ref(4); // Default number of columns + +// UI state +export const draggedSprite = ref(null); +export const dragOffset = reactive({ x: 0, y: 0 }); +export const isShiftPressed = ref(false); +export const isModalOpen = ref(false); +export const isSettingsModalOpen = ref(false); +export const isSpritesModalOpen = ref(false); +export const isHelpModalOpen = ref(false); +export const zoomLevel = ref(1); // Default zoom level (1 = 100%) + +// Preview border settings +export const previewBorder = reactive({ + enabled: false, + color: '#ff0000', // Default red color + width: 2, // Default width in pixels +}); + +// Animation state +export const animation = reactive({ + canvas: null, + ctx: null, + currentFrame: 0, + isPlaying: false, + frameRate: 10, + lastFrameTime: 0, + animationId: null, + slider: null, + manualUpdate: false, +}); + +// Notification state +export const notification = reactive({ + isVisible: false, + message: '', + type: 'success', +}); + +// Store the current sprite offset for animation playback +// We'll use a Map to store offsets for each frame, so they're preserved when switching frames +export const spriteOffsets = reactive(new Map()); + +// Current sprite offset is a reactive object that will be used for the current frame +export const currentSpriteOffset = reactive({ x: 0, y: 0 }); diff --git a/src/application/types.ts b/src/application/types.ts new file mode 100644 index 0000000..7893d39 --- /dev/null +++ b/src/application/types.ts @@ -0,0 +1,45 @@ +// types.ts +export interface Sprite { + img: HTMLImageElement; + width: number; + height: number; + x: number; + y: number; + name: string; + id: string; + uploadOrder: number; +} + +export interface CellSize { + width: number; + height: number; +} + +export interface AnimationState { + canvas: HTMLCanvasElement | null; + ctx: CanvasRenderingContext2D | null; + currentFrame: number; + isPlaying: boolean; + frameRate: number; + lastFrameTime: number; + animationId: number | null; + slider: HTMLInputElement | null; + manualUpdate: boolean; +} + +export interface SpriteOffset { + x: number; + y: number; +} + +export interface NotificationState { + isVisible: boolean; + message: string; + type: 'success' | 'error'; +} + +export interface PreviewBorderSettings { + enabled: boolean; + color: string; + width: number; +} diff --git a/src/application/utilities.ts b/src/application/utilities.ts new file mode 100644 index 0000000..3634468 --- /dev/null +++ b/src/application/utilities.ts @@ -0,0 +1,62 @@ +import { spriteOffsets, notification } from '@/application/state'; + +/** + * Logger utility with consistent error format + */ +export const logger = { + warn: (message: string, details?: any) => { + console.warn(`Spritesheet: ${message}`, details || ''); + }, + error: (message: string, error?: any) => { + console.error(`Spritesheet: ${message}`, error || ''); + }, +}; + +/** + * Safely execute a function with error handling + */ +export function safeExecute(fn: () => T, errorMessage: string): T | undefined { + try { + return fn(); + } catch (error) { + logger.error(errorMessage, error); + return undefined; + } +} + +/** + * Show a notification + */ +export function showNotification(message: string, type: 'success' | 'error' = 'success') { + notification.message = message; + notification.type = type; + notification.isVisible = true; + + setTimeout(() => { + notification.isVisible = false; + }, 3000); +} + +/** + * Get the offset for a specific frame + */ +export function getSpriteOffset(frameIndex: number) { + if (!spriteOffsets.has(frameIndex)) { + spriteOffsets.set(frameIndex, { x: 0, y: 0 }); + } + return spriteOffsets.get(frameIndex)!; +} + +/** + * Check if image is fully loaded and ready to use + */ +export function isImageReady(img: HTMLImageElement): boolean { + return img.complete && img.naturalWidth !== 0; +} + +/** + * Get pixel-perfect coordinates aligned to pixel boundaries + */ +export function getPixelPerfectCoordinate(value: number): number { + return Math.floor(value) + 0.5; +} diff --git a/src/components/AnimationControls.vue b/src/components/AnimationControls.vue new file mode 100644 index 0000000..b53f60a --- /dev/null +++ b/src/components/AnimationControls.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/src/components/MainContent.vue b/src/components/MainContent.vue index fa1d1d9..d78bb3b 100644 --- a/src/components/MainContent.vue +++ b/src/components/MainContent.vue @@ -202,8 +202,28 @@ const cellBottom = cellTop + store.cellSize.height - store.draggedSprite.value.img.height; // Constrain position to stay within the cell + const oldX = store.draggedSprite.value.x; + const oldY = store.draggedSprite.value.y; + + // Update the sprite position store.draggedSprite.value.x = Math.max(cellLeft, Math.min(newX, cellRight)); store.draggedSprite.value.y = Math.max(cellTop, Math.min(newY, cellBottom)); + + // Calculate the offset within the cell + const offsetX = store.draggedSprite.value.x - cellLeft; + const offsetY = store.draggedSprite.value.y - cellTop; + + // Update the sprite offset in the store for the current sprite + const spriteIndex = store.sprites.value.findIndex(s => s.id === store.draggedSprite.value.id); + if (spriteIndex !== -1) { + const frameOffset = store.getSpriteOffset(spriteIndex); + frameOffset.x = offsetX; + frameOffset.y = offsetY; + + // Also update current offset for UI consistency + store.currentSpriteOffset.x = offsetX; + store.currentSpriteOffset.y = offsetY; + } } else { // Calculate new position based on grid cells (snap to grid) const newCellX = Math.floor((x - store.dragOffset.x) / store.cellSize.width); @@ -217,8 +237,24 @@ const boundedCellX = Math.max(0, Math.min(newCellX, maxCellX)); const boundedCellY = Math.max(0, Math.min(newCellY, maxCellY)); + const oldX = store.draggedSprite.value.x; + const oldY = store.draggedSprite.value.y; + + // Update the sprite position store.draggedSprite.value.x = boundedCellX * store.cellSize.width; store.draggedSprite.value.y = boundedCellY * store.cellSize.height; + + // When snapping to grid, reset any offsets for this sprite + const spriteIndex = store.sprites.value.findIndex(s => s.id === store.draggedSprite.value.id); + if (spriteIndex !== -1) { + const frameOffset = store.getSpriteOffset(spriteIndex); + frameOffset.x = 0; + frameOffset.y = 0; + + // Also update current offset for UI consistency + store.currentSpriteOffset.x = 0; + store.currentSpriteOffset.y = 0; + } } } diff --git a/src/components/PreviewModal.vue b/src/components/PreviewModal.vue index 67a2c3c..a9756da 100644 --- a/src/components/PreviewModal.vue +++ b/src/components/PreviewModal.vue @@ -6,83 +6,20 @@
-
-
- - -
+ + -
-
- - {{ currentFrameDisplay }} -
- -
- -
-
- - {{ animation.frameRate }} FPS -
- -
-
- -
- -
- - {{ Math.round(previewZoom * 100) }}% - - - -
- - -
- - - - - -
-
+ + +
- +
Position: {{ Math.round(spriteOffset?.x ?? 0) }}px, {{ Math.round(spriteOffset?.y ?? 0) }}px (drag to move within cell)
+
@@ -123,15 +60,22 @@ diff --git a/src/composables/useAnimation.ts b/src/composables/useAnimation.ts new file mode 100644 index 0000000..c1bd6a8 --- /dev/null +++ b/src/composables/useAnimation.ts @@ -0,0 +1,136 @@ +import { ref, computed, watch, type Ref } from 'vue'; +import { type Sprite, type AnimationState } from '@/application/types'; + +export function useAnimation(sprites: Ref, animation: Ref, onUpdateFrame: (frameIndex: number, offset: { x: number; y: number }) => void, getSpriteOffset: (frameIndex: number) => { x: number; y: number }) { + // State + const currentFrame = ref(0); + const showAllSprites = ref(false); + + // Computed + const currentFrameDisplay = computed(() => { + if (sprites.value.length === 0) return '0 / 0'; + return `${currentFrame.value + 1} / ${sprites.value.length}`; + }); + + // Methods + const startAnimation = () => { + if (sprites.value.length === 0) return; + animation.value.isPlaying = true; + animation.value.lastFrameTime = performance.now(); + animation.value.manualUpdate = false; + + if (animation.value.canvas) { + animation.value.canvas.width = animation.value.canvas.width; // Reset canvas + } + + // Start animation loop + if (!animation.value.animationId) { + animationLoop(); + } + }; + + const stopAnimation = () => { + animation.value.isPlaying = false; + + if (animation.value.animationId) { + cancelAnimationFrame(animation.value.animationId); + animation.value.animationId = null; + } + }; + + const animationLoop = (timestamp?: number) => { + if (!animation.value.isPlaying) return; + + const currentTime = timestamp || performance.now(); + const elapsed = currentTime - animation.value.lastFrameTime; + const frameInterval = 1000 / animation.value.frameRate; + + if (elapsed >= frameInterval) { + animation.value.lastFrameTime = currentTime; + + if (sprites.value.length > 0) { + // Get the stored offset for the current frame + const frameOffset = getSpriteOffset(animation.value.currentFrame); + + // Update frame with offset + onUpdateFrame(animation.value.currentFrame, frameOffset); + + // Move to the next frame + animation.value.currentFrame = (animation.value.currentFrame + 1) % sprites.value.length; + currentFrame.value = animation.value.currentFrame; + + // Update slider if available + if (animation.value.slider) { + animation.value.slider.value = animation.value.currentFrame.toString(); + } + } + } + + animation.value.animationId = requestAnimationFrame(animationLoop); + }; + + const handleFrameChange = () => { + if (animation.value.isPlaying) { + stopAnimation(); + } + + // Ensure frame is within bounds + currentFrame.value = Math.max(0, Math.min(currentFrame.value, sprites.value.length - 1)); + animation.value.currentFrame = currentFrame.value; + updateCurrentFrame(); + }; + + const handleFrameRateChange = () => { + // If animation is currently playing, restart it with the new frame rate + if (animation.value.isPlaying) { + stopAnimation(); + startAnimation(); + } + }; + + const updateCurrentFrame = () => { + // Ensure frame is within bounds + currentFrame.value = Math.max(0, Math.min(currentFrame.value, sprites.value.length - 1)); + animation.value.currentFrame = currentFrame.value; + animation.value.manualUpdate = true; + + // Get the offset for the current frame + const offset = getSpriteOffset(currentFrame.value); + + // Update frame with the current offset + onUpdateFrame(currentFrame.value, offset); + }; + + const nextFrame = () => { + if (sprites.value.length === 0) return; + currentFrame.value = (currentFrame.value + 1) % sprites.value.length; + updateCurrentFrame(); + }; + + const prevFrame = () => { + if (sprites.value.length === 0) return; + currentFrame.value = (currentFrame.value - 1 + sprites.value.length) % sprites.value.length; + updateCurrentFrame(); + }; + + // Keep currentFrame in sync with animation.currentFrame + watch( + () => animation.value.currentFrame, + newVal => { + currentFrame.value = newVal; + } + ); + + return { + currentFrame, + showAllSprites, + currentFrameDisplay, + startAnimation, + stopAnimation, + handleFrameChange, + handleFrameRateChange, + updateCurrentFrame, + nextFrame, + prevFrame, + }; +} diff --git a/src/composables/useCanvasInitialization.ts b/src/composables/useCanvasInitialization.ts new file mode 100644 index 0000000..e8be02f --- /dev/null +++ b/src/composables/useCanvasInitialization.ts @@ -0,0 +1,52 @@ +// composables/useCanvasInitialization.ts +import { ref, nextTick, type Ref } from 'vue'; +import { type CellSize, type AnimationState } from '@/application/types'; + +export function useCanvasInitialization(animation: Ref, cellSize: Ref) { + const animCanvas = ref(null); + + const initializeCanvas = async (): Promise => { + // Wait for the next tick to ensure the canvas element is rendered + await nextTick(); + + if (!animCanvas.value) { + console.error('PreviewModal: Animation canvas not found'); + return false; + } + + try { + const context = animCanvas.value.getContext('2d'); + if (!context) { + console.error('PreviewModal: Failed to get 2D context from animation canvas'); + return false; + } + + animation.value.canvas = animCanvas.value; + animation.value.ctx = context; + return true; + } catch (error) { + console.error('PreviewModal: Error initializing animation canvas:', error); + return false; + } + }; + + const updateCanvasSize = () => { + if (!animCanvas.value) { + console.warn('PreviewModal: Cannot update canvas size - canvas not found'); + return; + } + + if (cellSize.value.width && cellSize.value.height) { + animCanvas.value.width = cellSize.value.width; + animCanvas.value.height = cellSize.value.height; + } else { + console.warn('PreviewModal: Cannot update canvas size - invalid cell dimensions'); + } + }; + + return { + animCanvas, + initializeCanvas, + updateCanvasSize, + }; +} diff --git a/src/composables/useKeyboardShortcuts.ts b/src/composables/useKeyboardShortcuts.ts new file mode 100644 index 0000000..542cba2 --- /dev/null +++ b/src/composables/useKeyboardShortcuts.ts @@ -0,0 +1,117 @@ +import { onMounted, onBeforeUnmount, type Ref } from 'vue'; +import { type Sprite, type AnimationState } from '@/application/types'; + +export function useKeyboardShortcuts( + isModalOpen: Ref, + sprites: Ref, + animation: Ref, + closeModal: () => void, + startAnimation: () => void, + stopAnimation: () => void, + nextFrame: () => void, + prevFrame: () => void, + zoomIn: () => void, + zoomOut: () => void, + resetZoom: () => void, + resetSpritePosition: () => void, + panViewport: (direction: 'left' | 'right' | 'up' | 'down', amount?: number) => void, + showNotification: (message: string, type?: 'success' | 'error') => void +) { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isModalOpen.value) return; + + // Modal control + if (e.key === 'Escape') { + closeModal(); + return; + } + + // Animation control + if (e.key === ' ' || e.key === 'Spacebar') { + // Toggle play/pause + if (animation.value.isPlaying) { + stopAnimation(); + } else if (sprites.value.length > 0) { + startAnimation(); + } + e.preventDefault(); + return; + } + + // Frame navigation + if (e.key === 'ArrowRight' && !animation.value.isPlaying && sprites.value.length > 0) { + nextFrame(); + e.preventDefault(); + return; + } + + if (e.key === 'ArrowLeft' && !animation.value.isPlaying && sprites.value.length > 0) { + prevFrame(); + e.preventDefault(); + return; + } + + // Zoom controls + if (e.key === '+' || e.key === '=') { + zoomIn(); + e.preventDefault(); + return; + } + + if (e.key === '-' || e.key === '_') { + zoomOut(); + e.preventDefault(); + return; + } + + if (e.key === '0') { + resetZoom(); + e.preventDefault(); + return; + } + + // Reset sprite position + if (e.key === 'r') { + if (e.shiftKey) { + // Shift+R: Reset sprite position only + resetSpritePosition(); + } else { + // R: Reset both sprite position and viewport + resetSpritePosition(); + resetZoom(); + showNotification('View and position reset'); + } + e.preventDefault(); + return; + } + + // Viewport panning when animation is playing + if (animation.value.isPlaying) { + if (e.key === 'ArrowLeft') { + panViewport('left'); + e.preventDefault(); + } else if (e.key === 'ArrowRight') { + panViewport('right'); + e.preventDefault(); + } else if (e.key === 'ArrowUp') { + panViewport('up'); + e.preventDefault(); + } else if (e.key === 'ArrowDown') { + panViewport('down'); + e.preventDefault(); + } + } + }; + + onMounted(() => { + window.addEventListener('keydown', handleKeyDown); + }); + + onBeforeUnmount(() => { + window.removeEventListener('keydown', handleKeyDown); + }); + + return { + handleKeyDown, + }; +} diff --git a/src/composables/useSpritePosition.ts b/src/composables/useSpritePosition.ts new file mode 100644 index 0000000..5593857 --- /dev/null +++ b/src/composables/useSpritePosition.ts @@ -0,0 +1,134 @@ +import { ref, computed, type Ref } from 'vue'; +import { type Sprite, type CellSize } from '@/application/types'; + +export function useSpritePosition(sprites: Ref, currentFrame: Ref, cellSize: Ref, getSpriteOffset: (frameIndex: number) => { x: number; y: number }, onUpdateFrame: () => void, showNotification: (message: string, type?: 'success' | 'error') => void) { + // State + const isCanvasDragging = ref(false); + const canvasDragStart = ref({ x: 0, y: 0 }); + + // Computed + const spriteOffset = computed({ + get: () => { + // Get the offset for the current frame + return getSpriteOffset(currentFrame.value); + }, + set: val => { + // Update the frame-specific offset directly + const frameOffset = getSpriteOffset(currentFrame.value); + frameOffset.x = val.x; + frameOffset.y = val.y; + + // Re-render the frame + onUpdateFrame(); + }, + }); + + const hasSpriteOffset = computed(() => { + return spriteOffset.value.x !== 0 || spriteOffset.value.y !== 0; + }); + + // Methods + const resetSpritePosition = () => { + // Get current sprite + const sprite = sprites.value[currentFrame.value]; + if (!sprite) return; + + // Calculate center position + const centerX = Math.max(0, Math.floor((cellSize.value.width - sprite.width) / 2)); + const centerY = Math.max(0, Math.floor((cellSize.value.height - sprite.height) / 2)); + + // Update the sprite offset + spriteOffset.value = { x: centerX, y: centerY }; + + // Update the frame + onUpdateFrame(); + + // Show a notification + showNotification('Sprite position reset to center'); + }; + + const startCanvasDrag = (e: MouseEvent, isViewportDragging: Ref, previewZoom: Ref) => { + if (sprites.value.length === 0) return; + if (isViewportDragging.value) return; + + isCanvasDragging.value = true; + + // Store initial position + canvasDragStart.value = { + x: e.clientX, + y: e.clientY, + }; + + // Add event listeners + window.addEventListener('mousemove', e => handleCanvasDrag(e, previewZoom), { capture: true }); + window.addEventListener('mouseup', stopCanvasDrag, { capture: true }); + + e.preventDefault(); + e.stopPropagation(); + }; + + const handleCanvasDrag = (e: MouseEvent, previewZoom: Ref) => { + if (!isCanvasDragging.value) return; + + requestAnimationFrame(() => { + // Get current sprite + const sprite = sprites.value[currentFrame.value]; + if (!sprite) return; + + // Calculate delta from last position + const deltaX = e.clientX - canvasDragStart.value.x; + const deltaY = e.clientY - canvasDragStart.value.y; + + // Only move when delta exceeds the threshold for one pixel movement at current zoom + const pixelThreshold = previewZoom.value; // One pixel at current zoom level + + // Calculate the maximum allowed offset + const maxOffsetX = Math.max(0, cellSize.value.width - sprite.width); + const maxOffsetY = Math.max(0, cellSize.value.height - sprite.height); + + // Move one pixel at a time when threshold is reached + if (Math.abs(deltaX) >= pixelThreshold) { + const pixelsToMove = Math.sign(deltaX); + const newX = spriteOffset.value.x + pixelsToMove; + spriteOffset.value.x = Math.max(0, Math.min(maxOffsetX, newX)); + + // Reset the start X position for next pixel move + canvasDragStart.value.x = e.clientX; + } + + if (Math.abs(deltaY) >= pixelThreshold) { + const pixelsToMove = Math.sign(deltaY); + const newY = spriteOffset.value.y + pixelsToMove; + spriteOffset.value.y = Math.max(0, Math.min(maxOffsetY, newY)); + + // Reset the start Y position for next pixel move + canvasDragStart.value.y = e.clientY; + } + + // Update the frame + onUpdateFrame(); + }); + }; + + const stopCanvasDrag = (e: MouseEvent) => { + if (!isCanvasDragging.value) return; + + isCanvasDragging.value = false; + window.removeEventListener('mousemove', handleCanvasDrag as any, { capture: true }); + window.removeEventListener('mouseup', stopCanvasDrag, { capture: true }); + + e.preventDefault(); + e.stopPropagation(); + }; + + return { + isCanvasDragging, + canvasDragStart, + spriteOffset, + hasSpriteOffset, + resetSpritePosition, + startCanvasDrag, + handleCanvasDrag, + stopCanvasDrag, + }; +} diff --git a/src/composables/useSpritesheetStore.ts b/src/composables/useSpritesheetStore.ts index e8f68ac..f83ee75 100644 --- a/src/composables/useSpritesheetStore.ts +++ b/src/composables/useSpritesheetStore.ts @@ -1,680 +1,19 @@ -import { ref, reactive, computed } from 'vue'; +import { sprites, canvas, ctx, cellSize, columns, draggedSprite, dragOffset, isShiftPressed, isModalOpen, isSettingsModalOpen, isSpritesModalOpen, isHelpModalOpen, zoomLevel, previewBorder, animation, notification, currentSpriteOffset, spriteOffsets } from '@/application/state'; -export interface Sprite { - img: HTMLImageElement; - width: number; - height: number; - x: number; - y: number; - name: string; - id: string; - uploadOrder: number; -} +import { getSpriteOffset, showNotification } from '@/application/utilities'; -export interface CellSize { - width: number; - height: number; -} +import { addSprites, updateCellSize, autoArrangeSprites, highlightSprite, clearAllSprites, applyOffsetsToMainView } from '@/application/spriteOperations'; -export interface AnimationState { - canvas: HTMLCanvasElement | null; - ctx: CanvasRenderingContext2D | null; - currentFrame: number; - isPlaying: boolean; - frameRate: number; - lastFrameTime: number; - animationId: number | null; - slider: HTMLInputElement | null; - manualUpdate: boolean; -} +import { updateCanvasSize, renderSpritesheetPreview, drawGrid, downloadSpritesheet, zoomIn, zoomOut, resetZoom } from '@/application/canvasOperations'; -const sprites = ref([]); -const canvas = ref(null); -const ctx = ref(null); -const cellSize = reactive({ width: 0, height: 0 }); -const columns = ref(4); // Default number of columns -const draggedSprite = ref(null); -const dragOffset = reactive({ x: 0, y: 0 }); -const isShiftPressed = ref(false); -const isModalOpen = ref(false); -const isSettingsModalOpen = ref(false); -const isSpritesModalOpen = ref(false); -const isHelpModalOpen = ref(false); -const zoomLevel = ref(1); // Default zoom level (1 = 100%) - -// Preview border settings -const previewBorder = reactive({ - enabled: false, - color: '#ff0000', // Default red color - width: 2, // Default width in pixels -}); +import { startAnimation, stopAnimation, renderAnimationFrame, animationLoop } from '@/application/animationController'; +/** + * Main store function that provides access to all spritesheet functionality + */ export function useSpritesheetStore() { - const animation = reactive({ - canvas: null, - ctx: null, - currentFrame: 0, - isPlaying: false, - frameRate: 10, - lastFrameTime: 0, - animationId: null, - slider: null, - manualUpdate: false, - }); - - const notification = reactive({ - isVisible: false, - message: '', - type: 'success' as 'success' | 'error', - }); - - function addSprites(newSprites: Sprite[]) { - if (newSprites.length === 0) { - console.warn('Store: Attempted to add empty sprites array'); - return; - } - - try { - // Validate sprites before adding them - const validSprites = newSprites.filter(sprite => { - if (!sprite.img || sprite.width <= 0 || sprite.height <= 0) { - console.error('Store: Invalid sprite detected', sprite); - return false; - } - return true; - }); - - if (validSprites.length === 0) { - console.error('Store: No valid sprites to add'); - return; - } - - sprites.value.push(...validSprites); - sprites.value.sort((a, b) => a.uploadOrder - b.uploadOrder); - updateCellSize(); - autoArrangeSprites(); - } catch (error) { - console.error('Store: Error adding sprites:', error); - } - } - - function updateCellSize() { - if (sprites.value.length === 0) { - return; - } - - try { - let maxWidth = 0; - let maxHeight = 0; - - sprites.value.forEach(sprite => { - if (sprite.width <= 0 || sprite.height <= 0) { - console.warn('Store: Sprite with invalid dimensions detected', sprite); - return; - } - maxWidth = Math.max(maxWidth, sprite.width); - maxHeight = Math.max(maxHeight, sprite.height); - }); - - if (maxWidth === 0 || maxHeight === 0) { - console.error('Store: Failed to calculate valid cell size'); - return; - } - - cellSize.width = maxWidth; - cellSize.height = maxHeight; - - updateCanvasSize(); - } catch (error) { - console.error('Store: Error updating cell size:', error); - } - } - - function updateCanvasSize() { - if (!canvas.value) { - console.warn('Store: Canvas not available for size update'); - return; - } - - if (sprites.value.length === 0) { - return; - } - - try { - const totalSprites = sprites.value.length; - const cols = columns.value; - const rows = Math.ceil(totalSprites / cols); - - if (cellSize.width <= 0 || cellSize.height <= 0) { - console.error('Store: Invalid cell size for canvas update', cellSize); - return; - } - - const newWidth = cols * cellSize.width; - const newHeight = rows * cellSize.height; - - // Ensure the canvas is large enough to display all sprites - if (canvas.value.width !== newWidth || canvas.value.height !== newHeight) { - canvas.value.width = newWidth; - canvas.value.height = newHeight; - - // Emit an event to update the wrapper dimensions - window.dispatchEvent( - new CustomEvent('canvas-size-updated', { - detail: { width: newWidth, height: newHeight }, - }) - ); - } - } catch (error) { - console.error('Store: Error updating canvas size:', error); - } - } - - function autoArrangeSprites() { - if (sprites.value.length === 0) { - return; - } - - try { - if (cellSize.width <= 0 || cellSize.height <= 0) { - console.error('Store: Invalid cell size for auto-arranging', cellSize); - return; - } - - // First update the canvas size to ensure it's large enough - updateCanvasSize(); - - // Then position each sprite in its grid cell - sprites.value.forEach((sprite, index) => { - const column = index % columns.value; - const row = Math.floor(index / columns.value); - - sprite.x = column * cellSize.width; - sprite.y = row * cellSize.height; - }); - - // Check if the canvas is ready before attempting to render - if (!ctx.value || !canvas.value) { - console.warn('Store: Canvas or context not available for rendering after auto-arrange'); - return; - } - - renderSpritesheetPreview(); - - if (!animation.isPlaying && animation.manualUpdate && isModalOpen.value) { - renderAnimationFrame(animation.currentFrame); - } - } catch (error) { - console.error('Store: Error auto-arranging sprites:', error); - } - } - - function renderSpritesheetPreview(showGrid = true) { - if (!ctx.value || !canvas.value) { - console.error('Store: Canvas or context not available for rendering, will retry when ready'); - setTimeout(() => { - if (ctx.value && canvas.value) { - renderSpritesheetPreview(showGrid); - } - }, 100); - return; - } - - if (sprites.value.length === 0) return; - - try { - // Make sure the canvas size is correct before rendering - updateCanvasSize(); - - // Clear the canvas - ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height); - - if (showGrid) { - drawGrid(); - } - - // First, collect all occupied cells - const occupiedCells = new Set(); - sprites.value.forEach(sprite => { - const cellX = Math.floor(sprite.x / cellSize.width); - const cellY = Math.floor(sprite.y / cellSize.height); - occupiedCells.add(`${cellX},${cellY}`); - }); - - // Draw each sprite - remove the zoom scaling from context - sprites.value.forEach((sprite, index) => { - try { - if (!sprite.img) { - console.warn(`Store: Sprite at index ${index} has no image, skipping render`); - return; - } - - // Check if sprite is within canvas bounds - if (sprite.x >= 0 && sprite.y >= 0 && sprite.x + sprite.width <= canvas.value!.width && sprite.y + sprite.height <= canvas.value!.height) { - if (sprite.img.complete && sprite.img.naturalWidth !== 0) { - // For pixel art, ensure we're drawing at exact pixel boundaries - const x = Math.round(sprite.x); - const y = Math.round(sprite.y); - - // Get the frame-specific offset for this sprite - const frameOffset = getSpriteOffset(index); - - // Calculate the maximum allowed offset based on sprite and cell size - const maxOffsetX = Math.max(0, cellSize.width - sprite.width); - const maxOffsetY = Math.max(0, cellSize.height - sprite.height); - - // Constrain the offset to prevent out-of-bounds positioning - const constrainedOffsetX = Math.max(0, Math.min(maxOffsetX, frameOffset.x)); - const constrainedOffsetY = Math.max(0, Math.min(maxOffsetY, frameOffset.y)); - - // Update the frame offset with the constrained values - frameOffset.x = constrainedOffsetX; - frameOffset.y = constrainedOffsetY; - - // Apply the constrained offset to the sprite position - const finalX = x + constrainedOffsetX; - const finalY = y + constrainedOffsetY; - - // Draw the image at its final position with pixel-perfect rendering - ctx.value!.imageSmoothingEnabled = false; // Keep pixel art sharp - ctx.value!.drawImage(sprite.img, finalX, finalY, sprite.width, sprite.height); - } else { - console.warn(`Store: Sprite image ${index} not fully loaded, setting onload handler`); - sprite.img.onload = () => { - if (ctx.value && canvas.value) { - // For pixel art, ensure we're drawing at exact pixel boundaries - const x = Math.round(sprite.x); - const y = Math.round(sprite.y); - - // Get the frame-specific offset for this sprite - const frameOffset = getSpriteOffset(index); - - // Calculate the maximum allowed offset based on sprite and cell size - const maxOffsetX = Math.max(0, cellSize.width - sprite.width); - const maxOffsetY = Math.max(0, cellSize.height - sprite.height); - - // Constrain the offset to prevent out-of-bounds positioning - const constrainedOffsetX = Math.max(0, Math.min(maxOffsetX, frameOffset.x)); - const constrainedOffsetY = Math.max(0, Math.min(maxOffsetY, frameOffset.y)); - - // Update the frame offset with the constrained values - frameOffset.x = constrainedOffsetX; - frameOffset.y = constrainedOffsetY; - - // Apply the constrained offset to the sprite position - const finalX = x + constrainedOffsetX; - const finalY = y + constrainedOffsetY; - - ctx.value.imageSmoothingEnabled = false; // Keep pixel art sharp - ctx.value.drawImage(sprite.img, finalX, finalY, sprite.width, sprite.height); - } - }; - } - } else { - console.warn(`Store: Sprite at index ${index} is outside canvas bounds: sprite(${sprite.x},${sprite.y}) canvas(${canvas.value!.width},${canvas.value!.height})`); - } - } catch (spriteError) { - console.error(`Store: Error rendering sprite at index ${index}:`, spriteError); - } - }); - - // Draw borders around occupied cells if enabled (preview only) - if (previewBorder.enabled && occupiedCells.size > 0) { - ctx.value!.strokeStyle = previewBorder.color; - ctx.value!.lineWidth = previewBorder.width / zoomLevel.value; // Adjust for zoom - - // Draw borders around each occupied cell - occupiedCells.forEach(cellKey => { - const [cellX, cellY] = cellKey.split(',').map(Number); - - // Calculate pixel-perfect coordinates for the cell - // Add 0.5 to align with pixel boundaries for crisp lines - const x = Math.floor(cellX * cellSize.width) + 0.5; - const y = Math.floor(cellY * cellSize.height) + 0.5; - - // Adjust width and height to ensure the border is inside the cell - const width = cellSize.width - 1; - const height = cellSize.height - 1; - - ctx.value!.strokeRect(x, y, width, height); - }); - } - } catch (error) { - console.error('Store: Error in renderSpritesheetPreview:', error); - } - } - - function applyOffsetsToMainView() { - sprites.value.forEach((sprite, index) => { - const frameOffset = getSpriteOffset(index); - if (frameOffset.x !== 0 || frameOffset.y !== 0) { - // Update the sprite's position to include the offset - sprite.x += frameOffset.x; - sprite.y += frameOffset.y; - - // Reset the offset - frameOffset.x = 0; - frameOffset.y = 0; - } - }); - - // Reset current offset - currentSpriteOffset.x = 0; - currentSpriteOffset.y = 0; - - // Re-render the main view - renderSpritesheetPreview(); - } - - function drawGrid() { - if (!ctx.value || !canvas.value) return; - - ctx.value.strokeStyle = '#333'; - ctx.value.lineWidth = 1 / zoomLevel.value; // Adjust line width based on zoom level - - // Calculate the visible area based on zoom level - const visibleWidth = canvas.value.width / zoomLevel.value; - const visibleHeight = canvas.value.height / zoomLevel.value; - - // Draw vertical lines - ensure pixel-perfect grid lines - for (let x = 0; x <= visibleWidth; x += cellSize.width) { - const pixelX = Math.floor(x) + 0.5; // Align to pixel boundary for crisp lines - ctx.value.beginPath(); - ctx.value.moveTo(pixelX, 0); - ctx.value.lineTo(pixelX, visibleHeight); - ctx.value.stroke(); - } - - // Draw horizontal lines - ensure pixel-perfect grid lines - for (let y = 0; y <= visibleHeight; y += cellSize.height) { - const pixelY = Math.floor(y) + 0.5; // Align to pixel boundary for crisp lines - ctx.value.beginPath(); - ctx.value.moveTo(0, pixelY); - ctx.value.lineTo(visibleWidth, pixelY); - ctx.value.stroke(); - } - } - - function highlightSprite(spriteId: string) { - if (!ctx.value || !canvas.value) return; - - const sprite = sprites.value.find(s => s.id === spriteId); - if (!sprite) return; - - // Calculate the cell coordinates - const cellX = Math.floor(sprite.x / cellSize.width); - const cellY = Math.floor(sprite.y / cellSize.height); - - // Briefly flash the cell - ctx.value.save(); - ctx.value.fillStyle = 'rgba(0, 150, 255, 0.3)'; - ctx.value.fillRect(cellX * cellSize.width, cellY * cellSize.height, cellSize.width, cellSize.height); - ctx.value.restore(); - - // Reset after a short delay - setTimeout(() => { - renderSpritesheetPreview(); - }, 500); - } - - function clearAllSprites() { - if (!canvas.value || !ctx.value) return; - - sprites.value = []; - canvas.value.width = 400; - canvas.value.height = 300; - ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height); - - if (animation.canvas && animation.ctx) { - animation.canvas.width = 200; - animation.canvas.height = 200; - animation.ctx.clearRect(0, 0, animation.canvas.width, animation.canvas.height); - } - - animation.currentFrame = 0; - isModalOpen.value = false; - } - - function downloadSpritesheet() { - if (sprites.value.length === 0 || !canvas.value) { - showNotification('No sprites to download', 'error'); - return; - } - - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = canvas.value.width; - tempCanvas.height = canvas.value.height; - const tempCtx = tempCanvas.getContext('2d'); - - if (!tempCtx) { - showNotification('Failed to create download context', 'error'); - return; - } - - tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height); - - // Ensure pixel art remains sharp in the downloaded file - tempCtx.imageSmoothingEnabled = false; - - sprites.value.forEach((sprite, index) => { - // Get the frame-specific offset for this sprite - const frameOffset = getSpriteOffset(index); - - // Calculate the cell coordinates for this sprite - const cellX = Math.floor(sprite.x / cellSize.width); - const cellY = Math.floor(sprite.y / cellSize.height); - - // Calculate the base position within the cell - const baseX = cellX * cellSize.width; - const baseY = cellY * cellSize.height; - - // Calculate the maximum allowed offset based on sprite and cell size - // This prevents sprites from going out of bounds - const maxOffsetX = Math.max(0, cellSize.width - sprite.width); - const maxOffsetY = Math.max(0, cellSize.height - sprite.height); - - // Constrain the offset to prevent out-of-bounds positioning - const constrainedOffsetX = Math.max(0, Math.min(maxOffsetX, frameOffset.x)); - const constrainedOffsetY = Math.max(0, Math.min(maxOffsetY, frameOffset.y)); - - // Apply the constrained offset to the base position - const finalX = baseX + constrainedOffsetX; - const finalY = baseY + constrainedOffsetY; - - // Draw the sprite at the calculated position - tempCtx.drawImage(sprite.img, finalX, finalY, sprite.width, sprite.height); - }); - - const link = document.createElement('a'); - link.download = 'spritesheet.png'; - link.href = tempCanvas.toDataURL('image/png'); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - showNotification('Spritesheet downloaded successfully'); - } - - function startAnimation() { - if (sprites.value.length === 0 || !animation.canvas) return; - - animation.isPlaying = true; - animation.lastFrameTime = performance.now(); - animation.manualUpdate = false; - - animation.canvas.width = cellSize.width; - animation.canvas.height = cellSize.height; - - // Start the animation loop without resetting sprite offset - animationLoop(); - } - - function stopAnimation() { - animation.isPlaying = false; - - if (animation.animationId) { - cancelAnimationFrame(animation.animationId); - animation.animationId = null; - } - } - - function renderAnimationFrame(frameIndex: number, showAllSprites = false, spriteOffset = { x: 0, y: 0 }) { - if (sprites.value.length === 0 || !animation.canvas || !animation.ctx) return; - - if (animation.canvas.width !== cellSize.width || animation.canvas.height !== cellSize.height) { - animation.canvas.width = cellSize.width; - animation.canvas.height = cellSize.height; - } - - animation.ctx.clearRect(0, 0, animation.canvas.width, animation.canvas.height); - - // Draw background (transparent by default) - animation.ctx.fillStyle = 'transparent'; - animation.ctx.fillRect(0, 0, animation.canvas.width, animation.canvas.height); - - // Keep pixel art sharp - animation.ctx.imageSmoothingEnabled = false; - - // If showAllSprites is enabled, draw all sprites with transparency - if (showAllSprites && sprites.value.length > 1) { - // Save the current context state - animation.ctx.save(); - - // Set global alpha for background sprites - animation.ctx.globalAlpha = 0.3; - - // Draw all sprites except the current one - sprites.value.forEach((sprite, index) => { - if (index !== frameIndex) { - const spriteCellX = Math.floor(sprite.x / cellSize.width); - const spriteCellY = Math.floor(sprite.y / cellSize.height); - - // Calculate precise offset for pixel-perfect rendering - const spriteOffsetX = Math.round(sprite.x - spriteCellX * cellSize.width); - const spriteOffsetY = Math.round(sprite.y - spriteCellY * cellSize.height); - - // Draw the sprite with transparency - animation.ctx.drawImage(sprite.img, spriteOffsetX, spriteOffsetY); - } - }); - - // Restore the context to full opacity - animation.ctx.restore(); - } - - // Get the current sprite - const currentSprite = sprites.value[frameIndex % sprites.value.length]; - - const cellX = Math.floor(currentSprite.x / cellSize.width); - const cellY = Math.floor(currentSprite.y / cellSize.height); - - // Calculate the original position (without user offset) - const originalOffsetX = Math.round(currentSprite.x - cellX * cellSize.width); - const originalOffsetY = Math.round(currentSprite.y - cellY * cellSize.height); - - // Calculate precise offset for pixel-perfect rendering, including the user's drag offset - // Use the spriteOffset directly as the position within the cell - const offsetX = spriteOffset.x; - const offsetY = spriteOffset.y; - - // Draw the current sprite at full opacity at the new position - animation.ctx.drawImage(currentSprite.img, offsetX, offsetY); - - // Draw border around the cell if enabled (only for preview, not included in download) - if (previewBorder.enabled) { - animation.ctx.strokeStyle = previewBorder.color; - animation.ctx.lineWidth = previewBorder.width; - - // Use pixel-perfect coordinates for the border (0.5 offset for crisp lines) - const x = 0.5; - const y = 0.5; - const width = animation.canvas.width - 1; - const height = animation.canvas.height - 1; - - animation.ctx.strokeRect(x, y, width, height); - } - } - - // Store the current sprite offset for animation playback - // We'll use a Map to store offsets for each frame, so they're preserved when switching frames - const spriteOffsets = reactive(new Map()); - - // Current sprite offset is a reactive object that will be used for the current frame - const currentSpriteOffset = reactive({ x: 0, y: 0 }); - - // Helper function to get the offset for a specific frame - function getSpriteOffset(frameIndex: number) { - if (!spriteOffsets.has(frameIndex)) { - spriteOffsets.set(frameIndex, { x: 0, y: 0 }); - } - return spriteOffsets.get(frameIndex)!; - } - - function animationLoop(timestamp?: number) { - if (!animation.isPlaying) return; - - const currentTime = timestamp || performance.now(); - const elapsed = currentTime - animation.lastFrameTime; - const frameInterval = 1000 / animation.frameRate; - - if (elapsed >= frameInterval) { - animation.lastFrameTime = currentTime; - - if (sprites.value.length > 0) { - // Get the stored offset for the current frame - const frameOffset = getSpriteOffset(animation.currentFrame); - - // Update the current offset for rendering - currentSpriteOffset.x = frameOffset.x; - currentSpriteOffset.y = frameOffset.y; - - // Render the current frame with its offset - renderAnimationFrame(animation.currentFrame, false, frameOffset); - - // Move to the next frame - animation.currentFrame = (animation.currentFrame + 1) % sprites.value.length; - - if (animation.slider) { - animation.slider.value = animation.currentFrame.toString(); - } - } - } - - animation.animationId = requestAnimationFrame(animationLoop); - } - - function showNotification(message: string, type: 'success' | 'error' = 'success') { - notification.message = message; - notification.type = type; - notification.isVisible = true; - - setTimeout(() => { - notification.isVisible = false; - }, 3000); - } - - function zoomIn() { - // Increase zoom level by 0.1, max 3.0 (300%) - zoomLevel.value = Math.min(3.0, zoomLevel.value + 0.1); - renderSpritesheetPreview(); - showNotification(`Zoom: ${Math.round(zoomLevel.value * 100)}%`); - } - - function zoomOut() { - // Decrease zoom level by 0.1, min 0.5 (50%) - zoomLevel.value = Math.max(0.5, zoomLevel.value - 0.1); - renderSpritesheetPreview(); - showNotification(`Zoom: ${Math.round(zoomLevel.value * 100)}%`); - } - - function resetZoom() { - // Reset to default zoom level (100%) - zoomLevel.value = 1; - renderSpritesheetPreview(); - showNotification('Zoom reset to 100%'); - } - return { + // State sprites, canvas, ctx, @@ -693,23 +32,34 @@ export function useSpritesheetStore() { previewBorder, currentSpriteOffset, spriteOffsets, + + // Utils getSpriteOffset, + showNotification, + + // Sprite operations addSprites, updateCellSize, - updateCanvasSize, autoArrangeSprites, + highlightSprite, + clearAllSprites: () => clearAllSprites(animation), + applyOffsetsToMainView: () => applyOffsetsToMainView(currentSpriteOffset), + + // Canvas operations + updateCanvasSize, renderSpritesheetPreview, drawGrid, - highlightSprite, - clearAllSprites, downloadSpritesheet, - startAnimation, - stopAnimation, - renderAnimationFrame, - showNotification, zoomIn, zoomOut, resetZoom, - applyOffsetsToMainView, + + // Animation + startAnimation, + stopAnimation, + renderAnimationFrame, }; } + +// Re-export types +export { type Sprite, type CellSize, type AnimationState } from '@/application/types'; diff --git a/src/composables/useViewport.ts b/src/composables/useViewport.ts new file mode 100644 index 0000000..9968fb6 --- /dev/null +++ b/src/composables/useViewport.ts @@ -0,0 +1,153 @@ +import { ref, nextTick, type Ref } from 'vue'; +import { type AnimationState } from '@/application/types'; + +export function useViewport(animation: Ref, onUpdateFrame: () => void, showNotification: (message: string, type?: 'success' | 'error') => void) { + // State + const previewZoom = ref(1); + const viewportOffset = ref({ x: 0, y: 0 }); + const isViewportDragging = ref(false); + const viewportDragStart = ref({ x: 0, y: 0 }); + + // Methods + const zoomIn = () => { + if (previewZoom.value < 5) { + previewZoom.value = Math.min(5, previewZoom.value + 0.5); + + // Adjust container size after zoom change + nextTick(() => { + updateCanvasContainerSize(); + }); + } + }; + + const zoomOut = () => { + if (previewZoom.value > 1) { + previewZoom.value = Math.max(1, previewZoom.value - 0.5); + + // Adjust container size after zoom change + nextTick(() => { + updateCanvasContainerSize(); + }); + } + }; + + const resetZoom = () => { + previewZoom.value = 1; + viewportOffset.value = { x: 0, y: 0 }; + + // Adjust container size after zoom change + nextTick(() => { + updateCanvasContainerSize(); + }); + }; + + const updateCanvasContainerSize = () => { + // This is a no-op in the composable, but can be implemented if needed + // The actual container size is managed through reactive bindings in the template + }; + + const startViewportDrag = (e: MouseEvent, isCanvasDragging: Ref) => { + // Only enable viewport dragging when zoomed in + if (previewZoom.value <= 1 || isCanvasDragging.value) return; + + isViewportDragging.value = true; + viewportDragStart.value = { + x: e.clientX, + y: e.clientY, + }; + + // Add temporary event listeners + window.addEventListener('mousemove', handleViewportDrag); + window.addEventListener('mouseup', stopViewportDrag); + + // Prevent default to avoid text selection + e.preventDefault(); + }; + + const handleViewportDrag = (e: MouseEvent) => { + if (!isViewportDragging.value) return; + + const deltaX = e.clientX - viewportDragStart.value.x; + const deltaY = e.clientY - viewportDragStart.value.y; + + // Update viewport offset with the delta, scaled by zoom level + viewportOffset.value = { + x: viewportOffset.value.x + deltaX / previewZoom.value, + y: viewportOffset.value.y + deltaY / previewZoom.value, + }; + + // Reset drag start position + viewportDragStart.value = { + x: e.clientX, + y: e.clientY, + }; + }; + + const stopViewportDrag = () => { + isViewportDragging.value = false; + window.removeEventListener('mousemove', handleViewportDrag); + window.removeEventListener('mouseup', stopViewportDrag); + }; + + const handleCanvasWheel = (e: WheelEvent) => { + // Prevent the default scroll behavior + e.preventDefault(); + + if (e.ctrlKey) { + // Ctrl + wheel = zoom in/out + if (e.deltaY < 0) { + zoomIn(); + } else { + zoomOut(); + } + } else { + // Just wheel = pan when zoomed in + if (previewZoom.value > 1) { + // Adjust pan amount by zoom level + const panFactor = 0.5 / previewZoom.value; + + if (e.shiftKey) { + // Shift + wheel = horizontal pan + viewportOffset.value.x -= e.deltaY * panFactor; + } else { + // Regular wheel = vertical pan + viewportOffset.value.y -= e.deltaY * panFactor; + } + } + } + }; + + const panViewport = (direction: 'left' | 'right' | 'up' | 'down', amount: number = 10) => { + const panAmount = amount / previewZoom.value; + + switch (direction) { + case 'left': + viewportOffset.value.x += panAmount; + break; + case 'right': + viewportOffset.value.x -= panAmount; + break; + case 'up': + viewportOffset.value.y += panAmount; + break; + case 'down': + viewportOffset.value.y -= panAmount; + break; + } + }; + + return { + previewZoom, + viewportOffset, + isViewportDragging, + zoomIn, + zoomOut, + resetZoom, + updateCanvasContainerSize, + startViewportDrag, + handleViewportDrag, + stopViewportDrag, + handleCanvasWheel, + panViewport, + }; +}