first commit
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
30
.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
11
.prettierrc.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 300,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"vueIndentScriptAndStyle": true
|
||||
}
|
6
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
33
README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# spritesheetgen
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
50
index.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="MyWebSite" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Spritesheet Generator - Create Game Spritesheets Online</title>
|
||||
<meta name="title" content="Spritesheet Generator - Create Game Spritesheets Online">
|
||||
<meta name="description" content="Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.">
|
||||
<meta name="keywords" content="spritesheet generator, sprite sheet maker, game development, pixel art, sprite animation, game assets, 2D game tools">
|
||||
<meta name="author" content="nu11ed">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://sprite-gen.xvx.sh/">
|
||||
<meta property="og:title" content="Spritesheet Generator - Create Game Spritesheets Online">
|
||||
<meta property="og:description" content="Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.">
|
||||
<meta property="og:image" content="/og-image.png">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:url" content="https://sprite-gen.xvx.sh/">
|
||||
<meta property="twitter:title" content="Spritesheet Generator - Create Game Spritesheets Online">
|
||||
<meta property="twitter:description" content="Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.">
|
||||
<meta property="twitter:image" content="/og-image.png">
|
||||
|
||||
<!-- Theme Color -->
|
||||
<meta name="theme-color" content="#0096ff" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#111827" media="(prefers-color-scheme: dark)">
|
||||
|
||||
<title>Spritesheet generator</title>
|
||||
|
||||
<script defer src="https://stats.xvx.sh/script.js" data-website-id="fb1e3dec-3068-4384-b281-68d29f39c7fc"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
4403
package-lock.json
generated
Normal file
39
package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "spritesheetgen",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.1",
|
||||
"gif.js": "^0.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"marked": "^15.0.7",
|
||||
"pinia": "^3.0.1",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
|
||||
"@tsconfig/node22": "^22.0.1",
|
||||
"@types/gif.js": "^0.2.5",
|
||||
"@types/node": "^22.13.14",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"npm-run-all2": "^7.0.2",
|
||||
"prettier": "3.5.3",
|
||||
"sass-embedded": "^1.86.3",
|
||||
"tailwindcss": "^4.1.1",
|
||||
"typescript": "~5.8.0",
|
||||
"vite": "^6.2.4",
|
||||
"vite-plugin-vue-devtools": "^7.7.2",
|
||||
"vue-tsc": "^2.2.8"
|
||||
}
|
||||
}
|
57
public/CHANGELOG.md
Normal file
@@ -0,0 +1,57 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [1.7.0] - 2025-05-02
|
||||
|
||||
### Removed
|
||||
- 🪟 Checkerboard pattern inside sprite cells as it could conflict with the sprite. (Thanks Rivers)
|
||||
|
||||
## [1.6.0] - 2025-04-30
|
||||
|
||||
### Added
|
||||
- 🎨 Dark mode support
|
||||
- ⭐ Preview other sprites inside cells from overview
|
||||
|
||||
## [1.5.0] - 2025-04-30
|
||||
|
||||
### Added
|
||||
- 📏 Offset indicators for better sprite alignment
|
||||
- Set base offset for offset indicators
|
||||
|
||||
## [1.4.0] - 2025-04-06
|
||||
|
||||
### Added
|
||||
- 🎥 Download as GIF functionality
|
||||
- 🗂 Download as ZIP functionality
|
||||
|
||||
## [1.3.0] - 2025-04-06
|
||||
|
||||
### Fixed
|
||||
- 📄 When importing a spritesheet, the tool will now remove transparent from the edges of each sprite so you can move them inside their cells.
|
||||
|
||||
## [1.2.0] - 2025-04-06
|
||||
|
||||
### Added
|
||||
- Import and split existing spritesheet functionality
|
||||
|
||||
## [1.1.0] - 2025-04-06
|
||||
|
||||
### Added
|
||||
- 📝 Help modal with instructions and tips
|
||||
- 🎨 Pixel perfect mode for better sprite alignment
|
||||
|
||||
## [1.0.0] - 2025-04-06
|
||||
|
||||
### Added
|
||||
- 🎉 Initial release
|
||||
- ✨ Basic spritesheet generation functionality
|
||||
- Drag and drop image upload
|
||||
- Grid-based sprite arrangement
|
||||
- Custom grid size configuration
|
||||
- 🎮 Animation preview functionality
|
||||
- Real-time animation preview
|
||||
- Adjustable animation speed
|
||||
- Frame-by-frame navigation
|
||||
- 💾 JSON import/export support
|
||||
- Save sprite arrangements
|
||||
- Load previous projects
|
||||
- Export configuration files
|
BIN
public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
public/favicon-96x96.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
public/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
12
public/favicon.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="58" height="58"><svg version="1.1" id="SvgjsSvg1026" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 58 58" xml:space="preserve">
|
||||
<path style="fill:none;stroke:#38454F;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;" d="M26,25V13.5
|
||||
c0-2.475,2.025-4.5,4.5-4.5h0c2.475,0,4.5,2.025,4.5,4.5V17c0,2.2,1.8,4,4,4h0c2.2,0,4-1.8,4-4V1"></path>
|
||||
<rect x="1" y="25" style="fill:#38454F;" width="56" height="33"></rect>
|
||||
<polygon style="fill:#CBD4D8;" points="52,53 6,53 6,30 27,30 27,38 52,38 "></polygon>
|
||||
<polygon style="fill:#546A79;" points="21,40 18,40 18,37 14,37 14,40 11,40 11,44 14,44 14,47 18,47 18,44 21,44 "></polygon>
|
||||
<circle style="fill:#DD352E;" cx="45.5" cy="45.5" r="2.5"></circle>
|
||||
<circle style="fill:#DD352E;" cx="36.5" cy="45.5" r="2.5"></circle>
|
||||
<line style="fill:none;stroke:#AFB6BB;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;" x1="25" y1="46" x2="30" y2="46"></line>
|
||||
</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||
</style></svg>
|
After Width: | Height: | Size: 1.2 KiB |
3
public/gif.worker.js
Normal file
BIN
public/og-image.png
Normal file
After Width: | Height: | Size: 7.0 KiB |
23
public/site.webmanifest
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"theme_color_dark": "#111827",
|
||||
"background_color": "#ffffff",
|
||||
"background_color_dark": "#111827",
|
||||
"display": "standalone"
|
||||
}
|
BIN
public/web-app-manifest-192x192.png
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
After Width: | Height: | Size: 13 KiB |
676
src/App.vue
Normal file
@@ -0,0 +1,676 @@
|
||||
<template>
|
||||
<div class="min-h-screen p-6 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 transition-colors duration-300">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">Spritesheet generator</h1>
|
||||
<dark-mode-toggle />
|
||||
</div>
|
||||
<div class="flex justify-center space-x-4 mb-8">
|
||||
<a href="https://git.xvx.sh/root/spritesheet-generator" target="_blank" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors" data-umami-event="source-link"> <i class="fab fa-github"></i> Source </a>
|
||||
<a href="https://discord.gg/JTev3nzeDa" target="_blank" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors" data-umami-event="discord-link"> <i class="fab fa-discord"></i> Discord </a>
|
||||
<a href="#" @click.prevent="openHelpModal" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors" data-umami-event="help-link"> <i class="fas fa-question-circle"></i> Help </a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-soft dark:shadow-gray-900/30 p-8 transition-colors duration-300">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-100">Upload sprites</h2>
|
||||
<button @click="openJSONImportDialog" class="px-4 py-2 bg-indigo-500 hover:bg-indigo-600 dark:bg-indigo-600 dark:hover:bg-indigo-700 text-white font-medium rounded-lg transition-colors flex items-center space-x-2" data-umami-event="import-json">
|
||||
<i class="fas fa-file-import"></i>
|
||||
<span>Import JSON</span>
|
||||
</button>
|
||||
</div>
|
||||
<file-uploader @upload-sprites="handleSpritesUpload" />
|
||||
<input ref="jsonFileInput" type="file" accept=".json,application/json" class="hidden" @change="handleJSONFileChange" />
|
||||
|
||||
<div v-if="sprites.length > 0" class="mt-8">
|
||||
<div class="flex flex-wrap items-center gap-6 mb-8">
|
||||
<div class="flex items-center space-x-1">
|
||||
<label for="columns" class="text-gray-700 dark:text-gray-200 font-medium">Columns:</label>
|
||||
<input
|
||||
id="columns"
|
||||
type="number"
|
||||
v-model="columns"
|
||||
min="1"
|
||||
max="10"
|
||||
class="w-20 px-3 py-2 border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Add mass position buttons -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="alignSprites('left')" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Left" data-umami-event="align-left">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</button>
|
||||
<button @click="alignSprites('center')" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Center" data-umami-event="align-center">
|
||||
<i class="fas fa-arrows-left-right"></i>
|
||||
</button>
|
||||
<button @click="alignSprites('right')" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Right" data-umami-event="align-right">
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</button>
|
||||
<button @click="alignSprites('top')" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Top" data-umami-event="align-top">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
</button>
|
||||
<button @click="alignSprites('middle')" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Middle" data-umami-event="align-middle">
|
||||
<i class="fas fa-arrows-up-down"></i>
|
||||
</button>
|
||||
<button @click="alignSprites('bottom')" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Bottom" data-umami-event="align-bottom">
|
||||
<i class="fas fa-arrow-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button @click="downloadSpritesheet" class="px-6 py-2.5 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white font-medium rounded-lg transition-colors flex items-center space-x-2" data-umami-event="download-spritesheet">
|
||||
<i class="fas fa-download"></i>
|
||||
<span>Download spritesheet</span>
|
||||
</button>
|
||||
|
||||
<button @click="exportSpritesheetJSON" class="px-6 py-2.5 bg-purple-500 hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700 text-white font-medium rounded-lg transition-colors flex items-center space-x-2" data-umami-event="export-json">
|
||||
<i class="fas fa-file-code"></i>
|
||||
<span>Export as JSON</span>
|
||||
</button>
|
||||
|
||||
<button @click="openGifFpsModal" class="px-6 py-2.5 bg-amber-500 hover:bg-amber-600 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors flex items-center space-x-2" data-umami-event="download-gif">
|
||||
<i class="fas fa-film"></i>
|
||||
<span>Download as GIF</span>
|
||||
</button>
|
||||
|
||||
<button @click="downloadAsZip" class="px-6 py-2.5 bg-teal-500 hover:bg-teal-600 dark:bg-teal-600 dark:hover:bg-teal-700 text-white font-medium rounded-lg transition-colors flex items-center space-x-2" data-umami-event="download-zip">
|
||||
<i class="fas fa-file-archive"></i>
|
||||
<span>Download as ZIP</span>
|
||||
</button>
|
||||
|
||||
<button @click="openPreviewModal" class="px-6 py-2.5 bg-green-500 hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700 text-white font-medium rounded-lg transition-colors flex items-center space-x-2" data-umami-event="preview-animation">
|
||||
<i class="fas fa-play"></i>
|
||||
<span>Preview animation</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<sprite-canvas :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-cell="updateSpriteCell" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal :is-open="isPreviewModalOpen" @close="closePreviewModal" title="Animation preview">
|
||||
<sprite-preview :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" />
|
||||
</Modal>
|
||||
|
||||
<HelpModal :is-open="isHelpModalOpen" @close="closeHelpModal" />
|
||||
<SpritesheetSplitter :is-open="isSpritesheetSplitterOpen" :image-url="spritesheetImageUrl" :image-file="spritesheetImageFile" @close="closeSpritesheetSplitter" @split="handleSplitSpritesheet" />
|
||||
<GifFpsModal :is-open="isGifFpsModalOpen" @close="closeGifFpsModal" @confirm="downloadAsGif" :default-fps="10" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import FileUploader from './components/FileUploader.vue';
|
||||
import SpriteCanvas from './components/SpriteCanvas.vue';
|
||||
import Modal from './components/utilities/Modal.vue';
|
||||
import SpritePreview from './components/SpritePreview.vue';
|
||||
import HelpModal from './components/HelpModal.vue';
|
||||
import SpritesheetSplitter from './components/SpritesheetSplitter.vue';
|
||||
import GifFpsModal from './components/GifFpsModal.vue';
|
||||
import DarkModeToggle from './components/utilities/DarkModeToggle.vue';
|
||||
import { useSettingsStore } from './stores/useSettingsStore';
|
||||
import GIF from 'gif.js';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
interface Sprite {
|
||||
id: string;
|
||||
file: File;
|
||||
img: HTMLImageElement;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface SpriteFile {
|
||||
file: File;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const sprites = ref<Sprite[]>([]);
|
||||
const columns = ref(4);
|
||||
const isPreviewModalOpen = ref(false);
|
||||
const isHelpModalOpen = ref(false);
|
||||
const isSpritesheetSplitterOpen = ref(false);
|
||||
const isGifFpsModalOpen = ref(false);
|
||||
const jsonFileInput = ref<HTMLInputElement | null>(null);
|
||||
const spritesheetImageUrl = ref('');
|
||||
const spritesheetImageFile = ref<File | null>(null);
|
||||
|
||||
const handleSpritesUpload = (files: File[]) => {
|
||||
// Check if any of the files is a JSON file
|
||||
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
|
||||
|
||||
if (jsonFile) {
|
||||
// If it's a JSON file, try to import it
|
||||
importSpritesheetJSON(jsonFile);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a single image file that might be a spritesheet
|
||||
if (files.length === 1 && files[0].type.startsWith('image/')) {
|
||||
const file = files[0];
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
// Load the image to check its dimensions
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// Ask the user if they want to split the spritesheet
|
||||
if (confirm('This looks like it might be a spritesheet. Would you like to split it into individual sprites?')) {
|
||||
// Open the spritesheet splitter
|
||||
spritesheetImageUrl.value = url;
|
||||
spritesheetImageFile.value = file;
|
||||
isSpritesheetSplitterOpen.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// If the user doesn't want to split or it's not large enough, process as a single sprite
|
||||
processImageFiles([file]);
|
||||
};
|
||||
img.src = url;
|
||||
return;
|
||||
}
|
||||
|
||||
// Process multiple image files normally
|
||||
processImageFiles(files);
|
||||
};
|
||||
|
||||
// Extract the image processing logic to a separate function for reuse
|
||||
const processImageFiles = (files: File[]) => {
|
||||
Promise.all(
|
||||
files.map(file => {
|
||||
return new Promise<Sprite>(resolve => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
})
|
||||
).then(newSprites => {
|
||||
sprites.value = [...sprites.value, ...newSprites];
|
||||
});
|
||||
};
|
||||
|
||||
const updateSpritePosition = (id: string, x: number, y: number) => {
|
||||
const spriteIndex = sprites.value.findIndex(sprite => sprite.id === id);
|
||||
if (spriteIndex !== -1) {
|
||||
// Ensure integer positions for pixel-perfect rendering
|
||||
sprites.value[spriteIndex].x = Math.floor(x);
|
||||
sprites.value[spriteIndex].y = Math.floor(y);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadSpritesheet = () => {
|
||||
if (sprites.value.length === 0) return;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Find max dimensions
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
|
||||
sprites.value.forEach(sprite => {
|
||||
if (sprite.width > maxWidth) maxWidth = sprite.width;
|
||||
if (sprite.height > maxHeight) maxHeight = sprite.height;
|
||||
});
|
||||
|
||||
// Set canvas size
|
||||
const rows = Math.ceil(sprites.value.length / columns.value);
|
||||
canvas.width = maxWidth * columns.value;
|
||||
canvas.height = maxHeight * rows;
|
||||
|
||||
// Disable image smoothing for pixel-perfect rendering
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Draw sprites with integer positions
|
||||
sprites.value.forEach((sprite, index) => {
|
||||
const col = index % columns.value;
|
||||
const row = Math.floor(index / columns.value);
|
||||
|
||||
const cellX = Math.floor(col * maxWidth);
|
||||
const cellY = Math.floor(row * maxHeight);
|
||||
|
||||
ctx.drawImage(sprite.img, Math.floor(cellX + sprite.x), Math.floor(cellY + sprite.y));
|
||||
});
|
||||
|
||||
// Create download link with PNG format
|
||||
const link = document.createElement('a');
|
||||
link.download = 'spritesheet.png';
|
||||
link.href = canvas.toDataURL('image/png', 1.0); // Use maximum quality
|
||||
link.click();
|
||||
};
|
||||
|
||||
// Preview modal control
|
||||
const openPreviewModal = () => {
|
||||
console.log('Opening preview modal');
|
||||
if (sprites.value.length === 0) {
|
||||
console.log('No sprites to preview');
|
||||
return;
|
||||
}
|
||||
isPreviewModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closePreviewModal = () => {
|
||||
isPreviewModalOpen.value = false;
|
||||
};
|
||||
|
||||
// Help modal control
|
||||
const openHelpModal = () => {
|
||||
isHelpModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeHelpModal = () => {
|
||||
isHelpModalOpen.value = false;
|
||||
};
|
||||
|
||||
// Spritesheet splitter modal control
|
||||
const closeSpritesheetSplitter = () => {
|
||||
isSpritesheetSplitterOpen.value = false;
|
||||
// Clean up the URL object to prevent memory leaks
|
||||
if (spritesheetImageUrl.value) {
|
||||
URL.revokeObjectURL(spritesheetImageUrl.value);
|
||||
spritesheetImageUrl.value = '';
|
||||
}
|
||||
spritesheetImageFile.value = null;
|
||||
};
|
||||
|
||||
// GIF FPS modal control
|
||||
const openGifFpsModal = () => {
|
||||
if (sprites.value.length === 0) return;
|
||||
isGifFpsModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeGifFpsModal = () => {
|
||||
isGifFpsModalOpen.value = false;
|
||||
};
|
||||
|
||||
// Handle the split spritesheet result
|
||||
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
|
||||
// Process sprite files with their positions
|
||||
Promise.all(
|
||||
spriteFiles.map(spriteFile => {
|
||||
return new Promise<Sprite>(resolve => {
|
||||
const url = URL.createObjectURL(spriteFile.file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve({
|
||||
id: crypto.randomUUID(),
|
||||
file: spriteFile.file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: spriteFile.x, // Use the position from the splitter
|
||||
y: spriteFile.y, // Use the position from the splitter
|
||||
});
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
})
|
||||
).then(newSprites => {
|
||||
sprites.value = [...sprites.value, ...newSprites];
|
||||
});
|
||||
};
|
||||
|
||||
// Export spritesheet as JSON with base64 images
|
||||
const exportSpritesheetJSON = async () => {
|
||||
if (sprites.value.length === 0) return;
|
||||
|
||||
// Create an array to store sprite data with base64 images
|
||||
const spritesData = await Promise.all(
|
||||
sprites.value.map(async (sprite, index) => {
|
||||
// Create a canvas for each sprite to get its base64 data
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
|
||||
// Set canvas size to match the sprite
|
||||
canvas.width = sprite.width;
|
||||
canvas.height = sprite.height;
|
||||
|
||||
// Draw the sprite
|
||||
ctx.drawImage(sprite.img, 0, 0);
|
||||
|
||||
// Get base64 data
|
||||
const base64Data = canvas.toDataURL('image/png');
|
||||
|
||||
return {
|
||||
id: sprite.id,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
x: sprite.x,
|
||||
y: sprite.y,
|
||||
base64: base64Data,
|
||||
name: sprite.file.name,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Create JSON object with all necessary data
|
||||
const jsonData = {
|
||||
columns: columns.value,
|
||||
sprites: spritesData.filter(Boolean), // Remove any null values
|
||||
};
|
||||
|
||||
// Convert to JSON string
|
||||
const jsonString = JSON.stringify(jsonData, null, 2);
|
||||
|
||||
// Create download link
|
||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.download = 'spritesheet.json';
|
||||
link.href = url;
|
||||
link.click();
|
||||
|
||||
// Clean up
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Open file dialog for JSON import
|
||||
const openJSONImportDialog = () => {
|
||||
jsonFileInput.value?.click();
|
||||
};
|
||||
|
||||
// Handle JSON file selection
|
||||
const handleJSONFileChange = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
const jsonFile = input.files[0];
|
||||
importSpritesheetJSON(jsonFile);
|
||||
// Reset input value so uploading the same file again will trigger the event
|
||||
if (jsonFileInput.value) jsonFileInput.value.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Import spritesheet from JSON
|
||||
const importSpritesheetJSON = async (jsonFile: File) => {
|
||||
try {
|
||||
const jsonText = await jsonFile.text();
|
||||
const jsonData = JSON.parse(jsonText);
|
||||
|
||||
if (!jsonData.sprites || !Array.isArray(jsonData.sprites)) {
|
||||
throw new Error('Invalid JSON format: missing sprites array');
|
||||
}
|
||||
|
||||
// Set columns if available
|
||||
if (jsonData.columns && typeof jsonData.columns === 'number') {
|
||||
columns.value = jsonData.columns;
|
||||
}
|
||||
|
||||
// Process each sprite
|
||||
// Replace current sprites with imported ones
|
||||
sprites.value = await Promise.all(
|
||||
jsonData.sprites.map(async (spriteData: any) => {
|
||||
return new Promise<Sprite>(resolve => {
|
||||
// Create image from base64
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// Create a file from the base64 data
|
||||
const byteString = atob(spriteData.base64.split(',')[1]);
|
||||
const mimeType = spriteData.base64.split(',')[0].split(':')[1].split(';')[0];
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const blob = new Blob([ab], { type: mimeType });
|
||||
const fileName = spriteData.name || `sprite-${spriteData.id}.png`;
|
||||
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,
|
||||
});
|
||||
};
|
||||
img.src = spriteData.base64;
|
||||
});
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error importing JSON:', error);
|
||||
alert('Failed to import JSON file. Please check the file format.');
|
||||
}
|
||||
};
|
||||
|
||||
// Add new alignment function
|
||||
const alignSprites = (position: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => {
|
||||
if (sprites.value.length === 0) return;
|
||||
|
||||
// Find max dimensions for the current column layout
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
sprites.value.forEach(sprite => {
|
||||
maxWidth = Math.max(maxWidth, sprite.width);
|
||||
maxHeight = Math.max(maxHeight, sprite.height);
|
||||
});
|
||||
|
||||
sprites.value = sprites.value.map((sprite, index) => {
|
||||
let x = sprite.x;
|
||||
let y = sprite.y;
|
||||
|
||||
switch (position) {
|
||||
case 'left':
|
||||
x = 0;
|
||||
break;
|
||||
case 'center':
|
||||
x = Math.floor((maxWidth - sprite.width) / 2);
|
||||
break;
|
||||
case 'right':
|
||||
x = Math.floor(maxWidth - sprite.width);
|
||||
break;
|
||||
case 'top':
|
||||
y = 0;
|
||||
break;
|
||||
case 'middle':
|
||||
y = Math.floor((maxHeight - sprite.height) / 2);
|
||||
break;
|
||||
case 'bottom':
|
||||
y = Math.floor(maxHeight - sprite.height);
|
||||
break;
|
||||
}
|
||||
|
||||
// Ensure integer positions for pixel-perfect rendering
|
||||
return { ...sprite, x: Math.floor(x), y: Math.floor(y) };
|
||||
});
|
||||
|
||||
// Force redraw of the preview canvas
|
||||
setTimeout(() => {
|
||||
const event = new Event('forceRedraw');
|
||||
window.dispatchEvent(event);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const updateSpriteCell = (id: string, newIndex: number) => {
|
||||
// Find the current index of the sprite
|
||||
const currentIndex = sprites.value.findIndex(sprite => sprite.id === id);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
// If we're trying to move to the same position, do nothing
|
||||
if (currentIndex === newIndex) return;
|
||||
|
||||
// Create a copy of the sprites array
|
||||
const newSprites = [...sprites.value];
|
||||
|
||||
// Perform a swap between the two positions
|
||||
if (newIndex < sprites.value.length) {
|
||||
// Get references to both sprites
|
||||
const movingSprite = { ...newSprites[currentIndex] };
|
||||
const targetSprite = { ...newSprites[newIndex] };
|
||||
|
||||
// Swap them
|
||||
newSprites[currentIndex] = targetSprite;
|
||||
newSprites[newIndex] = movingSprite;
|
||||
} else {
|
||||
// If dragging to an empty cell (beyond the array length)
|
||||
// Use the original reordering logic
|
||||
const [movedSprite] = newSprites.splice(currentIndex, 1);
|
||||
newSprites.splice(newIndex, 0, movedSprite);
|
||||
}
|
||||
|
||||
// Update the sprites array
|
||||
sprites.value = newSprites;
|
||||
};
|
||||
|
||||
// Download as GIF with specified FPS
|
||||
const downloadAsGif = (fps: number) => {
|
||||
if (sprites.value.length === 0) return;
|
||||
|
||||
// Find max dimensions
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
|
||||
sprites.value.forEach(sprite => {
|
||||
if (sprite.width > maxWidth) maxWidth = sprite.width;
|
||||
if (sprite.height > maxHeight) maxHeight = sprite.height;
|
||||
});
|
||||
|
||||
// Create a canvas for rendering frames
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size to just fit one sprite cell
|
||||
canvas.width = maxWidth;
|
||||
canvas.height = maxHeight;
|
||||
|
||||
// Disable image smoothing for pixel-perfect rendering
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Create GIF encoder
|
||||
const gif = new GIF({
|
||||
workers: 2,
|
||||
quality: 10,
|
||||
width: maxWidth,
|
||||
height: maxHeight,
|
||||
workerScript: '/gif.worker.js',
|
||||
});
|
||||
|
||||
// Add each sprite as a frame
|
||||
sprites.value.forEach(sprite => {
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw grid background (cell)
|
||||
ctx.fillStyle = '#f9fafb';
|
||||
ctx.fillRect(0, 0, maxWidth, maxHeight);
|
||||
|
||||
// Draw sprite
|
||||
ctx.drawImage(sprite.img, Math.floor(sprite.x), Math.floor(sprite.y));
|
||||
|
||||
// Add frame to GIF
|
||||
gif.addFrame(ctx, { copy: true, delay: 1000 / fps });
|
||||
});
|
||||
|
||||
// Generate GIF
|
||||
gif.on('finished', (blob: Blob) => {
|
||||
// Create download link
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.download = 'animation.gif';
|
||||
link.href = url;
|
||||
link.click();
|
||||
|
||||
// Clean up
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
gif.render();
|
||||
};
|
||||
|
||||
// Download as ZIP with each cell individually
|
||||
const downloadAsZip = async () => {
|
||||
if (sprites.value.length === 0) return;
|
||||
|
||||
// Create a new ZIP file
|
||||
const zip = new JSZip();
|
||||
|
||||
// Find max dimensions
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
|
||||
sprites.value.forEach(sprite => {
|
||||
if (sprite.width > maxWidth) maxWidth = sprite.width;
|
||||
if (sprite.height > maxHeight) maxHeight = sprite.height;
|
||||
});
|
||||
|
||||
// Create a canvas for rendering individual sprites
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size to just fit one sprite cell
|
||||
canvas.width = maxWidth;
|
||||
canvas.height = maxHeight;
|
||||
|
||||
// Disable image smoothing for pixel-perfect rendering
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Add each sprite as an individual file
|
||||
sprites.value.forEach((sprite, index) => {
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw grid background (cell)
|
||||
ctx.fillStyle = '#f9fafb';
|
||||
ctx.fillRect(0, 0, maxWidth, maxHeight);
|
||||
|
||||
// Draw sprite
|
||||
ctx.drawImage(sprite.img, Math.floor(sprite.x), Math.floor(sprite.y));
|
||||
|
||||
// Convert to PNG data URL
|
||||
const dataURL = canvas.toDataURL('image/png');
|
||||
|
||||
// Convert data URL to binary data
|
||||
const binaryData = atob(dataURL.split(',')[1]);
|
||||
const arrayBuffer = new ArrayBuffer(binaryData.length);
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
for (let i = 0; i < binaryData.length; i++) {
|
||||
uint8Array[i] = binaryData.charCodeAt(i);
|
||||
}
|
||||
|
||||
// Add to ZIP file
|
||||
zip.file(`sprite_${index + 1}.png`, uint8Array);
|
||||
});
|
||||
|
||||
// Generate ZIP file
|
||||
const content = await zip.generateAsync({ type: 'blob' });
|
||||
|
||||
// Create download link
|
||||
const url = URL.createObjectURL(content);
|
||||
const link = document.createElement('a');
|
||||
link.download = 'sprites.zip';
|
||||
link.href = url;
|
||||
link.click();
|
||||
|
||||
// Clean up
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
</script>
|
3
src/assets/images/close-icon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
After Width: | Height: | Size: 233 B |
3
src/assets/images/file.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
After Width: | Height: | Size: 376 B |
27
src/assets/images/fullscreen-icon.svg
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 384.97 384.97" xml:space="preserve">
|
||||
<g>
|
||||
<g id="Fullscreen_1_">
|
||||
<path d="M372.939,216.545c-6.123,0-12.03,5.269-12.03,12.03v132.333H24.061V24.061h132.333c6.388,0,12.03-5.642,12.03-12.03
|
||||
S162.409,0,156.394,0H24.061C10.767,0,0,10.767,0,24.061v336.848c0,13.293,10.767,24.061,24.061,24.061h336.848
|
||||
c13.293,0,24.061-10.767,24.061-24.061V228.395C384.97,221.731,380.085,216.545,372.939,216.545z"/>
|
||||
<path d="M372.939,0H252.636c-6.641,0-12.03,5.39-12.03,12.03s5.39,12.03,12.03,12.03h91.382L99.635,268.432
|
||||
c-4.668,4.668-4.668,12.235,0,16.903c4.668,4.668,12.235,4.668,16.891,0L360.909,40.951v91.382c0,6.641,5.39,12.03,12.03,12.03
|
||||
s12.03-5.39,12.03-12.03V12.03l0,0C384.97,5.558,379.412,0,372.939,0z"/>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
25
src/assets/main.css
Normal file
@@ -0,0 +1,25 @@
|
||||
@import 'tailwindcss';
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Dark mode transition */
|
||||
html,
|
||||
body {
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease;
|
||||
}
|
||||
|
||||
html.theme-transition * {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Additional dark mode styles */
|
||||
body.dark-mode {
|
||||
background-color: #111827;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Force dark mode styles */
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
BIN
src/assets/tut-split.mp4
Normal file
BIN
src/assets/tut.mp4
Normal file
68
src/components/FileUploader.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div
|
||||
class="border-2 border-dashed rounded-xl p-8 text-center transition-all duration-200"
|
||||
:class="{
|
||||
'border-blue-300 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/30': isDragging,
|
||||
'border-gray-200 hover:border-blue-300 hover:bg-gray-50 dark:border-gray-600 dark:hover:border-blue-500 dark:hover:bg-gray-700/50': !isDragging,
|
||||
}"
|
||||
@dragenter.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleDrop"
|
||||
@click="openFileDialog"
|
||||
data-umami-event="file-upload-area"
|
||||
>
|
||||
<input ref="fileInput" type="file" multiple accept="image/*,.json" class="hidden" @change="handleFileChange" />
|
||||
|
||||
<div class="mb-6">
|
||||
<img src="@/assets/images/file.svg" alt="File upload" class="w-20 h-20 mx-auto mb-4 opacity-75 dark:invert" />
|
||||
</div>
|
||||
|
||||
<p class="text-xl font-medium text-gray-700 dark:text-gray-200 mb-2">Drag and drop your sprite images or JSON file here</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">or</p>
|
||||
|
||||
<button class="px-6 py-2.5 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-lg transition-colors inline-flex items-center space-x-2 cursor-pointer" data-umami-event="select-files">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<span>Select files</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'uploadSprites', files: File[]): void;
|
||||
}>();
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const isDragging = ref(false);
|
||||
|
||||
const openFileDialog = () => {
|
||||
fileInput.value?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
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 = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
isDragging.value = false;
|
||||
|
||||
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
|
||||
const files = Array.from(event.dataTransfer.files).filter(file => {
|
||||
return file.type.startsWith('image/') || file.type === 'application/json' || file.name.endsWith('.json');
|
||||
});
|
||||
|
||||
if (files.length > 0) {
|
||||
emit('uploadSprites', files);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
43
src/components/GifFpsModal.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<Modal :is-open="isOpen" @close="cancel" title="GIF Settings">
|
||||
<div class="p-4">
|
||||
<div class="mb-6">
|
||||
<label for="fps" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frames Per Second (FPS)</label>
|
||||
<div class="flex items-center">
|
||||
<input id="fps" v-model="fpsValue" type="number" min="1" max="60" class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" data-umami-event="gif-fps-input" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Higher FPS will result in smoother animation but larger file size.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button @click="cancel" class="px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 font-medium rounded-lg transition-colors" data-umami-event="gif-cancel">Cancel</button>
|
||||
<button @click="confirm" class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-lg transition-colors" data-umami-event="gif-generate">Generate GIF</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import Modal from './utilities/Modal.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean;
|
||||
defaultFps?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
(e: 'confirm', fps: number): void;
|
||||
}>();
|
||||
|
||||
const fpsValue = ref(props.defaultFps || 10);
|
||||
|
||||
const confirm = () => {
|
||||
emit('confirm', fpsValue.value);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
383
src/components/HelpModal.vue
Normal file
@@ -0,0 +1,383 @@
|
||||
<template>
|
||||
<Modal :is-open="isOpen" @close="close" title="Help & information" :initialWidth="800" :initialHeight="600">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="index"
|
||||
@click="activeTab = index"
|
||||
class="px-4 py-2 font-medium text-sm transition-colors"
|
||||
:class="activeTab === index ? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-500 dark:border-blue-400' : 'text-gray-600 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400'"
|
||||
:data-umami-event="'help-tab-' + tab.name.toLowerCase().replace(/[^a-z0-9]/g, '-')"
|
||||
>
|
||||
{{ tab.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="flex-1 overflow-auto p-4 dark:bg-gray-800 dark:text-gray-200">
|
||||
<!-- Video Instructions Tab -->
|
||||
<div v-if="activeTab === 0" class="h-full flex flex-col space-y-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Video tutorial</h3>
|
||||
<div class="flex-1 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<div class="w-full aspect-video max-w-3xl mx-auto">
|
||||
<video controls class="w-full h-full object-contain rounded-lg shadow-md">
|
||||
<source src="@/assets/tut.mp4" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-4">Split spritesheet</h3>
|
||||
<div class="flex-1 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<div class="w-full aspect-video max-w-3xl mx-auto">
|
||||
<video controls class="w-full h-full object-contain rounded-lg shadow-md">
|
||||
<source src="@/assets/tut-split.mp4" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- About & Instructions Tab -->
|
||||
<div v-if="activeTab === 1" class="h-full overflow-y-auto">
|
||||
<h3 class="text-lg font-semibold mb-4">About Spritesheet generator</h3>
|
||||
|
||||
<div class="max-w-none text-gray-700 dark:text-gray-300">
|
||||
<p class="mb-4 text-base leading-relaxed">Spritesheet generator is a free, open-source tool for creating spritesheets for game development and animation projects. This tool allows you to upload individual sprite images and arrange them into a spritesheet with customizable layout.</p>
|
||||
|
||||
<h4 class="mt-6 mb-3 text-lg font-medium text-gray-900 dark:text-gray-100">How to use:</h4>
|
||||
<ol class="list-decimal pl-6 space-y-2 mb-4">
|
||||
<li>Upload your sprite images by dragging and dropping them or clicking the upload area</li>
|
||||
<li>If you upload a single large image, you'll be asked if you want to split it into individual sprites</li>
|
||||
<li>Arrange your sprites by dragging them to the desired position</li>
|
||||
<li>Adjust the number of columns to change the layout</li>
|
||||
<li>Preview your animation using the "Preview Animation" button</li>
|
||||
<li>Download your spritesheet as a PNG file</li>
|
||||
</ol>
|
||||
|
||||
<h4 class="mt-6 mb-3 text-lg font-medium text-gray-900 dark:text-gray-100">Tips:</h4>
|
||||
<ul class="list-disc pl-6 space-y-2">
|
||||
<li>For best results, use sprites with consistent dimensions</li>
|
||||
<li>When uploading a spritesheet, you can split it automatically into individual sprites</li>
|
||||
<li>The spritesheet splitter allows you to specify rows and columns or try auto-detection</li>
|
||||
<li>The preview animation plays frames in the order they appear in the spritesheet (left to right, top to bottom)</li>
|
||||
<li>You can adjust the animation speed in the preview window</li>
|
||||
<li>The tool works entirely in your browser - no files are uploaded to any server</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Donations Tab -->
|
||||
<div v-if="activeTab === 2" class="h-full overflow-y-auto">
|
||||
<h3 class="text-lg font-semibold mb-4">Support my <a href="https://xvx.sh" title="XVX" target="_blank" class="text-blue-500 hover:text-blue-600 transition-colors">projects</a>.</h3>
|
||||
|
||||
<p class="mb-6">
|
||||
Spritesheet generator is free and open-source software. If you find it useful, please consider supporting the project with a donation. Check out the source code on
|
||||
<a href="https://git.xvx.sh/root/spritesheet-generator" title="Source code" target="_blank" class="text-blue-500 hover:text-blue-600 transition-colors">Gitea</a>.
|
||||
</p>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Bitcoin -->
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="w-8 h-8 mr-3 flex items-center justify-center bg-orange-100 rounded-full">
|
||||
<svg class="w-5 h-5 text-orange-500" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M23.638 14.904c-1.602 6.43-8.113 10.34-14.542 8.736C2.67 22.05-1.244 15.525.362 9.105 1.962 2.67 8.475-1.243 14.9.358c6.43 1.605 10.342 8.115 8.738 14.548v-.002zm-6.35-4.613c.24-1.59-.974-2.45-2.64-3.03l.54-2.153-1.315-.33-.525 2.107c-.345-.087-.705-.167-1.064-.25l.526-2.127-1.32-.33-.54 2.165c-.285-.067-.565-.132-.84-.2l-1.815-.45-.35 1.407s.975.225.955.236c.535.136.63.486.615.766l-1.477 5.92c-.075.166-.24.406-.614.314.015.02-.96-.24-.96-.24l-.66 1.51 1.71.426.93.242-.54 2.19 1.32.327.54-2.17c.36.1.705.19 1.05.273l-.51 2.154 1.32.33.545-2.19c2.24.427 3.93.257 4.64-1.774.57-1.637-.03-2.58-1.217-3.196.854-.193 1.5-.76 1.68-1.93h.01zm-3.01 4.22c-.404 1.64-3.157.75-4.05.53l.72-2.9c.896.23 3.757.67 3.33 2.37zm.41-4.24c-.37 1.49-2.662.735-3.405.55l.654-2.64c.744.18 3.137.524 2.75 2.084v.006z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="text-md font-medium">Bitcoin (BTC)</h4>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<input type="text" readonly value="bc1ql2a3nxnhfwft7qex0cclj5ar2lfsslvs0aygeq" class="flex-1 p-2 text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-l-md focus:outline-none dark:text-gray-200" />
|
||||
<button @click="copyToClipboard('bc1ql2a3nxnhfwft7qex0cclj5ar2lfsslvs0aygeq')" class="px-3 bg-blue-500 text-white rounded-r-md hover:bg-blue-600 transition-colors" data-umami-event="copy-btc-address">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Litecoin -->
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="w-8 h-8 mr-3 flex items-center justify-center bg-blue-100 rounded-full">
|
||||
<svg class="w-5 h-5 text-blue-500" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0a12 12 0 1 0 0 24 12 12 0 0 0 0-24zm-.738 4.8h2.616l-1.654 6.187 2.566-.338-1.017 3.63-2.115.338-1.23 4.6h7.12l-.56 2.07H6.96l1.654-6.16-2.565.337.952-3.38 2.18-.338 1.23-4.662H4.8l.504-2.285h5.958z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="text-md font-medium">Litecoin (LTC)</h4>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<input type="text" readonly value="ltc1qdkn46hpt39ppmhk25ed2eycu7m2pj5cdzuxw84" class="flex-1 p-2 text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-l-md focus:outline-none dark:text-gray-200" />
|
||||
<button @click="copyToClipboard('ltc1qdkn46hpt39ppmhk25ed2eycu7m2pj5cdzuxw84')" class="px-3 bg-blue-500 text-white rounded-r-md hover:bg-blue-600 transition-colors" data-umami-event="copy-ltc-address">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ethereum -->
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="w-8 h-8 mr-3 flex items-center justify-center bg-purple-100 rounded-full">
|
||||
<svg class="w-5 h-5 text-purple-500" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 17.97L4.58 13.62 11.943 24l7.37-10.38-7.372 4.35h.003zM12.056 0L4.69 12.223l7.365 4.354 7.365-4.35L12.056 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="text-md font-medium">Ethereum (ETH)</h4>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<input type="text" readonly value="0x30843c72DF6E9A9226d967bf2403602f1C2aB67b" class="flex-1 p-2 text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-l-md focus:outline-none dark:text-gray-200" />
|
||||
<button @click="copyToClipboard('0x30843c72DF6E9A9226d967bf2403602f1C2aB67b')" class="px-3 bg-blue-500 text-white rounded-r-md hover:bg-blue-600 transition-colors" data-umami-event="copy-eth-address">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-gray-600 dark:text-gray-400 mt-6">Thank you for your support! ❤️</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Changelog Tab -->
|
||||
<div v-if="activeTab === 3" class="h-full overflow-y-auto changelog-container">
|
||||
<h3 class="text-lg font-semibold mb-4">Changelog</h3>
|
||||
|
||||
<div v-if="!changelogHtml" class="text-gray-500 text-center py-8">Loading changelog...</div>
|
||||
|
||||
<div v-else class="prose prose-sm max-w-none" v-html="changelogHtml"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import Modal from './utilities/Modal.vue';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
|
||||
const activeTab = ref(0);
|
||||
const changelogHtml = ref<string>('');
|
||||
|
||||
const tabs = [{ name: 'Video tutorial' }, { name: 'About & instructions' }, { name: 'Support the project' }, { name: 'Changelog' }];
|
||||
|
||||
const loadChangelog = async () => {
|
||||
try {
|
||||
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);
|
||||
changelogHtml.value = '<p class="text-red-500">Failed to load changelog</p>';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadChangelog();
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
alert('Address copied to clipboard!');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Global styles for changelog content */
|
||||
.changelog-container .prose {
|
||||
color: #374151; /* text-gray-700 */
|
||||
}
|
||||
|
||||
/* Dark mode styles */
|
||||
.dark .changelog-container .prose {
|
||||
color: #e5e7eb; /* text-gray-200 */
|
||||
}
|
||||
|
||||
.changelog-container .prose h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.changelog-container .prose h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
border-left-width: 2px;
|
||||
padding-left: 1rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.changelog-container .prose h2:first-of-type {
|
||||
border-left-color: #3b82f6; /* border-blue-500 */
|
||||
}
|
||||
|
||||
.changelog-container .prose h2:not(:first-of-type) {
|
||||
border-left-color: #d1d5db; /* border-gray-300 */
|
||||
}
|
||||
|
||||
.dark .changelog-container .prose h2:not(:first-of-type) {
|
||||
border-left-color: #4b5563; /* border-gray-600 */
|
||||
}
|
||||
|
||||
.changelog-container .prose h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.changelog-container .prose h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.changelog-container .prose ul {
|
||||
list-style-type: disc;
|
||||
list-style-position: inside;
|
||||
margin-bottom: 1rem;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.changelog-container .prose ol {
|
||||
list-style-type: decimal;
|
||||
list-style-position: inside;
|
||||
margin-bottom: 1rem;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.changelog-container .prose li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.changelog-container .prose p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.changelog-container .prose a {
|
||||
color: #3b82f6; /* text-blue-500 */
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.changelog-container .prose a:hover {
|
||||
color: #2563eb; /* text-blue-600 */
|
||||
}
|
||||
|
||||
.changelog-container .prose strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.changelog-container .prose em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.changelog-container .prose blockquote {
|
||||
padding-left: 1rem;
|
||||
border-left-width: 4px;
|
||||
border-left-color: #d1d5db; /* border-gray-300 */
|
||||
font-style: italic;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dark .changelog-container .prose blockquote {
|
||||
border-left-color: #4b5563; /* border-gray-600 */
|
||||
}
|
||||
|
||||
.changelog-container .prose code {
|
||||
background-color: #f3f4f6; /* bg-gray-100 */
|
||||
border-radius: 0.25rem;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
padding-top: 0.125rem;
|
||||
padding-bottom: 0.125rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.dark .changelog-container .prose code {
|
||||
background-color: #1f2937; /* bg-gray-800 */
|
||||
}
|
||||
|
||||
.changelog-container .prose pre {
|
||||
background-color: #f3f4f6; /* bg-gray-100 */
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.dark .changelog-container .prose pre {
|
||||
background-color: #1f2937; /* bg-gray-800 */
|
||||
}
|
||||
|
||||
.changelog-container .prose pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.changelog-container .prose hr {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
border-top-width: 1px;
|
||||
border-color: #e5e7eb; /* border-gray-200 */
|
||||
}
|
||||
|
||||
.dark .changelog-container .prose hr {
|
||||
border-color: #374151; /* border-gray-700 */
|
||||
}
|
||||
|
||||
.changelog-container .prose table {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.changelog-container .prose th {
|
||||
padding: 0.5rem 1rem;
|
||||
border-width: 1px;
|
||||
border-color: #d1d5db; /* border-gray-300 */
|
||||
background-color: #f9fafb; /* bg-gray-50 */
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark .changelog-container .prose th {
|
||||
border-color: #4b5563; /* border-gray-600 */
|
||||
background-color: #1f2937; /* bg-gray-800 */
|
||||
}
|
||||
|
||||
.changelog-container .prose td {
|
||||
padding: 0.5rem 1rem;
|
||||
border-width: 1px;
|
||||
border-color: #d1d5db; /* border-gray-300 */
|
||||
}
|
||||
|
||||
.dark .changelog-container .prose td {
|
||||
border-color: #4b5563; /* border-gray-600 */
|
||||
}
|
||||
</style>
|
514
src/components/SpriteCanvas.vue
Normal file
@@ -0,0 +1,514 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center">
|
||||
<input id="pixel-perfect" type="checkbox" v-model="settingsStore.pixelPerfect" class="mr-2" @change="drawCanvas" />
|
||||
<label for="pixel-perfect" class="dark:text-gray-200">Pixel perfect rendering (for pixel art)</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input id="allow-cell-swap" type="checkbox" v-model="allowCellSwap" class="mr-2" />
|
||||
<label for="allow-cell-swap" class="dark:text-gray-200">Allow moving between cells</label>
|
||||
</div>
|
||||
<!-- Add new checkbox for showing all sprites -->
|
||||
<div class="flex items-center">
|
||||
<input id="show-all-sprites" type="checkbox" v-model="showAllSprites" class="mr-2" />
|
||||
<label for="show-all-sprites" class="dark:text-gray-200">Compare sprites</label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<select v-model="offsetAnchor" class="px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded">
|
||||
<option value="top-left">Top Left</option>
|
||||
<option value="top-right">Top Right</option>
|
||||
<option value="bottom-left">Bottom Left</option>
|
||||
<option value="bottom-right">Bottom Right</option>
|
||||
</select>
|
||||
<label class="dark:text-gray-200">Offset indicator position</label>
|
||||
<button @click="setNewOffsetBase" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded transition-colors">Set Current as Base</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative border border-gray-300 dark:border-gray-600 rounded-lg">
|
||||
<canvas ref="canvasRef" @mousedown="startDrag" @mousemove="drag" @mouseup="stopDrag" @mouseleave="stopDrag" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="stopDrag" class="w-full" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}"></canvas>
|
||||
|
||||
<!-- Offset indicators overlay -->
|
||||
<div v-if="canvasRef" class="absolute top-0 left-0 pointer-events-none w-full h-full">
|
||||
<div v-for="pos in spritePositions" :key="pos.id" class="absolute" :style="getOffsetIndicatorStyle(pos)">
|
||||
<div class="text-xs bg-black/75 dark:bg-white/75 text-white dark:text-gray-900 px-1 rounded whitespace-nowrap">x:{{ pos.x }}, y:{{ pos.y }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed, onUnmounted } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
|
||||
interface Sprite {
|
||||
id: string;
|
||||
img: HTMLImageElement;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface CellPosition {
|
||||
col: number;
|
||||
row: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
sprites: Sprite[];
|
||||
columns: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateSprite', id: string, x: number, y: number): void;
|
||||
(e: 'updateSpriteCell', id: string, newIndex: number): void;
|
||||
}>();
|
||||
|
||||
// Get settings from store
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const ctx = ref<CanvasRenderingContext2D | null>(null);
|
||||
|
||||
// State for tracking drag operations
|
||||
const isDragging = ref(false);
|
||||
const activeSpriteId = ref<string | null>(null);
|
||||
const activeSpriteCellIndex = ref<number | null>(null);
|
||||
const dragStartX = ref(0);
|
||||
const dragStartY = ref(0);
|
||||
const dragOffsetX = ref(0);
|
||||
const dragOffsetY = ref(0);
|
||||
const allowCellSwap = ref(false);
|
||||
const currentHoverCell = ref<CellPosition | null>(null);
|
||||
|
||||
// Visual feedback refs
|
||||
const ghostSprite = ref<{ id: string; x: number; y: number } | null>(null);
|
||||
const highlightCell = ref<CellPosition | null>(null);
|
||||
|
||||
// Add these refs at the top with other refs
|
||||
const baseOffsets = ref<Record<string, { x: number; y: number }>>({});
|
||||
|
||||
const showAllSprites = ref(false);
|
||||
|
||||
const spritePositions = computed(() => {
|
||||
if (!canvasRef.value) return [];
|
||||
|
||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||
|
||||
return props.sprites.map((sprite, index) => {
|
||||
const col = index % props.columns;
|
||||
const row = Math.floor(index / props.columns);
|
||||
|
||||
// Calculate relative offset from base
|
||||
const baseOffset = baseOffsets.value[sprite.id] || { x: 0, y: 0 };
|
||||
const relativeX = sprite.x - baseOffset.x;
|
||||
const relativeY = sprite.y - baseOffset.y;
|
||||
|
||||
return {
|
||||
id: sprite.id,
|
||||
canvasX: col * maxWidth + sprite.x,
|
||||
canvasY: row * maxHeight + sprite.y,
|
||||
cellX: col * maxWidth,
|
||||
cellY: row * maxHeight,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
col,
|
||||
row,
|
||||
index,
|
||||
// Use relative offsets for display
|
||||
x: relativeX,
|
||||
y: relativeY,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const calculateMaxDimensions = () => {
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
|
||||
props.sprites.forEach(sprite => {
|
||||
maxWidth = Math.max(maxWidth, sprite.width);
|
||||
maxHeight = Math.max(maxHeight, sprite.height);
|
||||
});
|
||||
|
||||
// Add some padding to ensure sprites have room to move
|
||||
return { maxWidth: maxWidth, maxHeight: maxHeight };
|
||||
};
|
||||
|
||||
const startDrag = (event: MouseEvent) => {
|
||||
if (!canvasRef.value) return;
|
||||
|
||||
const rect = canvasRef.value.getBoundingClientRect();
|
||||
const scaleX = canvasRef.value.width / rect.width;
|
||||
const scaleY = canvasRef.value.height / rect.height;
|
||||
|
||||
const mouseX = (event.clientX - rect.left) * scaleX;
|
||||
const mouseY = (event.clientY - rect.top) * scaleY;
|
||||
|
||||
const clickedSprite = findSpriteAtPosition(mouseX, mouseY);
|
||||
if (clickedSprite) {
|
||||
isDragging.value = true;
|
||||
activeSpriteId.value = clickedSprite.id;
|
||||
dragStartX.value = mouseX;
|
||||
dragStartY.value = mouseY;
|
||||
|
||||
// Find the sprite's position to calculate offset from mouse to sprite origin
|
||||
const spritePosition = spritePositions.value.find(pos => pos.id === clickedSprite.id);
|
||||
if (spritePosition) {
|
||||
dragOffsetX.value = mouseX - spritePosition.canvasX;
|
||||
dragOffsetY.value = mouseY - spritePosition.canvasY;
|
||||
activeSpriteCellIndex.value = spritePosition.index;
|
||||
|
||||
// Store the starting cell position
|
||||
const startCell = findCellAtPosition(mouseX, mouseY);
|
||||
if (startCell) {
|
||||
currentHoverCell.value = startCell;
|
||||
highlightCell.value = null; // No highlight at the start
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const findCellAtPosition = (x: number, y: number): CellPosition | null => {
|
||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||
const col = Math.floor(x / maxWidth);
|
||||
const row = Math.floor(y / maxHeight);
|
||||
|
||||
// Check if the cell position is valid
|
||||
const totalRows = Math.ceil(props.sprites.length / props.columns);
|
||||
if (col >= 0 && col < props.columns && row >= 0 && row < totalRows) {
|
||||
const index = row * props.columns + col;
|
||||
if (index < props.sprites.length) {
|
||||
return { col, row, index };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const drag = (event: MouseEvent) => {
|
||||
if (!isDragging.value || !activeSpriteId.value || !canvasRef.value || activeSpriteCellIndex.value === null) return;
|
||||
|
||||
const rect = canvasRef.value.getBoundingClientRect();
|
||||
const scaleX = canvasRef.value.width / rect.width;
|
||||
const scaleY = canvasRef.value.height / rect.height;
|
||||
|
||||
const mouseX = (event.clientX - rect.left) * scaleX;
|
||||
const mouseY = (event.clientY - rect.top) * scaleY;
|
||||
|
||||
const spriteIndex = props.sprites.findIndex(s => s.id === activeSpriteId.value);
|
||||
if (spriteIndex === -1) return;
|
||||
|
||||
// Find the cell the mouse is currently over
|
||||
const hoverCell = findCellAtPosition(mouseX, mouseY);
|
||||
currentHoverCell.value = hoverCell;
|
||||
|
||||
if (allowCellSwap.value && hoverCell) {
|
||||
// If we're hovering over a different cell than the sprite's current cell
|
||||
if (hoverCell.index !== activeSpriteCellIndex.value) {
|
||||
// Show a highlight for the target cell
|
||||
highlightCell.value = hoverCell;
|
||||
|
||||
// Create a ghost sprite that follows the mouse
|
||||
ghostSprite.value = {
|
||||
id: activeSpriteId.value,
|
||||
x: mouseX - dragOffsetX.value,
|
||||
y: mouseY - dragOffsetY.value,
|
||||
};
|
||||
|
||||
drawCanvas();
|
||||
} else {
|
||||
// Same cell as the sprite's origin, just do regular movement
|
||||
highlightCell.value = null;
|
||||
ghostSprite.value = null;
|
||||
handleInCellMovement(mouseX, mouseY, spriteIndex);
|
||||
}
|
||||
} else {
|
||||
// Regular in-cell movement
|
||||
handleInCellMovement(mouseX, mouseY, spriteIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInCellMovement = (mouseX: number, mouseY: number, spriteIndex: number) => {
|
||||
if (!activeSpriteId.value) return;
|
||||
|
||||
const position = spritePositions.value.find(pos => pos.id === activeSpriteId.value);
|
||||
if (!position) return;
|
||||
|
||||
// Calculate new position based on mouse position and initial click offset
|
||||
const newX = mouseX - position.cellX - dragOffsetX.value;
|
||||
const newY = mouseY - position.cellY - dragOffsetY.value;
|
||||
|
||||
// Constrain within cell boundaries and ensure integer positions
|
||||
const constrainedX = Math.floor(Math.max(0, Math.min(position.maxWidth - props.sprites[spriteIndex].width, newX)));
|
||||
const constrainedY = Math.floor(Math.max(0, Math.min(position.maxHeight - props.sprites[spriteIndex].height, newY)));
|
||||
|
||||
emit('updateSprite', activeSpriteId.value, constrainedX, constrainedY);
|
||||
drawCanvas();
|
||||
};
|
||||
|
||||
const stopDrag = () => {
|
||||
if (isDragging.value && allowCellSwap.value && activeSpriteId.value && activeSpriteCellIndex.value !== null && currentHoverCell.value && activeSpriteCellIndex.value !== currentHoverCell.value.index) {
|
||||
// We've dragged from one cell to another
|
||||
// Tell parent component to update the sprite's cell index
|
||||
emit('updateSpriteCell', activeSpriteId.value, currentHoverCell.value.index);
|
||||
|
||||
// Also reset the sprite's position within the cell to 0,0
|
||||
emit('updateSprite', activeSpriteId.value, 0, 0);
|
||||
}
|
||||
|
||||
// Reset all drag state
|
||||
isDragging.value = false;
|
||||
activeSpriteId.value = null;
|
||||
activeSpriteCellIndex.value = null;
|
||||
currentHoverCell.value = null;
|
||||
highlightCell.value = null;
|
||||
ghostSprite.value = null;
|
||||
|
||||
// Redraw without highlights
|
||||
drawCanvas();
|
||||
};
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
const mouseEvent = {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
preventDefault: () => {},
|
||||
} as unknown as MouseEvent;
|
||||
startDrag(mouseEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
const mouseEvent = {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
preventDefault: () => {},
|
||||
} as unknown as MouseEvent;
|
||||
drag(mouseEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const findSpriteAtPosition = (x: number, y: number) => {
|
||||
// Search in reverse order to get the topmost sprite first
|
||||
for (let i = spritePositions.value.length - 1; i >= 0; i--) {
|
||||
const pos = spritePositions.value[i];
|
||||
const sprite = props.sprites.find(s => s.id === pos.id);
|
||||
|
||||
if (!sprite) continue;
|
||||
|
||||
if (x >= pos.canvasX && x <= pos.canvasX + sprite.width && y >= pos.canvasY && y <= pos.canvasY + sprite.height) {
|
||||
return sprite;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const drawCanvas = () => {
|
||||
if (!canvasRef.value || !ctx.value) return;
|
||||
|
||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||
|
||||
// Set canvas size
|
||||
const rows = Math.ceil(props.sprites.length / props.columns);
|
||||
canvasRef.value.width = maxWidth * props.columns;
|
||||
canvasRef.value.height = maxHeight * rows;
|
||||
|
||||
// Clear canvas
|
||||
ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
|
||||
|
||||
// Disable image smoothing based on pixel perfect setting
|
||||
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
||||
|
||||
// Draw background for each cell
|
||||
for (let col = 0; col < props.columns; col++) {
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const cellX = Math.floor(col * maxWidth);
|
||||
const cellY = Math.floor(row * maxHeight);
|
||||
|
||||
// Draw cell background
|
||||
ctx.value.fillStyle = settingsStore.darkMode ? '#1F2937' : '#f9fafb';
|
||||
ctx.value.fillRect(cellX, cellY, maxWidth, maxHeight);
|
||||
|
||||
// Highlight the target cell if specified
|
||||
if (highlightCell.value && highlightCell.value.col === col && highlightCell.value.row === row) {
|
||||
ctx.value.fillStyle = 'rgba(59, 130, 246, 0.2)'; // Light blue highlight
|
||||
ctx.value.fillRect(cellX, cellY, maxWidth, maxHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If showing all sprites, draw all sprites with transparency in each cell
|
||||
if (showAllSprites.value) {
|
||||
for (let cellIndex = 0; cellIndex < props.sprites.length; cellIndex++) {
|
||||
const cellCol = cellIndex % props.columns;
|
||||
const cellRow = Math.floor(cellIndex / props.columns);
|
||||
const cellX = Math.floor(cellCol * maxWidth);
|
||||
const cellY = Math.floor(cellRow * maxHeight);
|
||||
|
||||
// Draw all sprites with transparency in this cell
|
||||
ctx.value.globalAlpha = 0.3;
|
||||
props.sprites.forEach((sprite, spriteIndex) => {
|
||||
if (spriteIndex !== cellIndex) {
|
||||
// Don't draw the cell's own sprite with transparency
|
||||
ctx.value?.drawImage(sprite.img, cellX + sprite.x, cellY + sprite.y);
|
||||
}
|
||||
});
|
||||
ctx.value.globalAlpha = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw sprites normally
|
||||
props.sprites.forEach((sprite, index) => {
|
||||
// Skip the active sprite if we're showing a ghost instead
|
||||
if (activeSpriteId.value === sprite.id && ghostSprite.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const col = index % props.columns;
|
||||
const row = Math.floor(index / props.columns);
|
||||
|
||||
const cellX = Math.floor(col * maxWidth);
|
||||
const cellY = Math.floor(row * maxHeight);
|
||||
|
||||
// Draw sprite using integer positions for pixel-perfect rendering
|
||||
ctx.value?.drawImage(sprite.img, cellX + sprite.x, cellY + sprite.y);
|
||||
});
|
||||
|
||||
// Draw ghost sprite if we're dragging between cells
|
||||
if (ghostSprite.value && activeSpriteId.value) {
|
||||
const sprite = props.sprites.find(s => s.id === activeSpriteId.value);
|
||||
if (sprite) {
|
||||
// Semi-transparent ghost
|
||||
ctx.value.globalAlpha = 0.6;
|
||||
ctx.value.drawImage(sprite.img, Math.floor(ghostSprite.value.x), Math.floor(ghostSprite.value.y));
|
||||
ctx.value.globalAlpha = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw grid lines on top of everything
|
||||
ctx.value.strokeStyle = settingsStore.darkMode ? '#4B5563' : '#e5e7eb';
|
||||
ctx.value.lineWidth = 1;
|
||||
|
||||
for (let col = 0; col < props.columns; col++) {
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const cellX = Math.floor(col * maxWidth);
|
||||
const cellY = Math.floor(row * maxHeight);
|
||||
|
||||
// Draw grid lines
|
||||
ctx.value.strokeRect(cellX, cellY, maxWidth, maxHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (canvasRef.value) {
|
||||
ctx.value = canvasRef.value.getContext('2d');
|
||||
drawCanvas();
|
||||
}
|
||||
|
||||
// Listen for forceRedraw event from App.vue
|
||||
window.addEventListener('forceRedraw', handleForceRedraw);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('forceRedraw', handleForceRedraw);
|
||||
});
|
||||
|
||||
// Handler for force redraw event
|
||||
const handleForceRedraw = () => {
|
||||
// Ensure we're using integer positions for pixel-perfect rendering
|
||||
props.sprites.forEach(sprite => {
|
||||
sprite.x = Math.floor(sprite.x);
|
||||
sprite.y = Math.floor(sprite.y);
|
||||
});
|
||||
|
||||
// Force a redraw with the correct image smoothing settings
|
||||
if (ctx.value) {
|
||||
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
||||
drawCanvas();
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.sprites, drawCanvas, { deep: true });
|
||||
watch(() => props.columns, drawCanvas);
|
||||
watch(() => settingsStore.pixelPerfect, drawCanvas);
|
||||
watch(showAllSprites, drawCanvas);
|
||||
|
||||
// Add scale computed property
|
||||
const scale = computed(() => {
|
||||
if (!canvasRef.value) return 1;
|
||||
const rect = canvasRef.value.getBoundingClientRect();
|
||||
return rect.width / canvasRef.value.width;
|
||||
});
|
||||
|
||||
// Add new ref for offset anchor
|
||||
const offsetAnchor = ref('top-left');
|
||||
|
||||
// Add new method to calculate indicator position
|
||||
const getOffsetIndicatorStyle = (pos: any) => {
|
||||
const baseX = pos.cellX * scale.value;
|
||||
const baseY = pos.cellY * scale.value;
|
||||
const cellWidth = pos.maxWidth * scale.value;
|
||||
const cellHeight = pos.maxHeight * scale.value;
|
||||
|
||||
switch (offsetAnchor.value) {
|
||||
case 'top-right':
|
||||
return {
|
||||
left: `${baseX + cellWidth}px`,
|
||||
top: `${baseY}px`,
|
||||
transform: 'translateX(-100%)',
|
||||
};
|
||||
case 'bottom-left':
|
||||
return {
|
||||
left: `${baseX}px`,
|
||||
top: `${baseY + cellHeight}px`,
|
||||
transform: 'translateY(-100%)',
|
||||
};
|
||||
case 'bottom-right':
|
||||
return {
|
||||
left: `${baseX + cellWidth}px`,
|
||||
top: `${baseY + cellHeight}px`,
|
||||
transform: 'translate(-100%, -100%)',
|
||||
};
|
||||
default: // top-left
|
||||
return {
|
||||
left: `${baseX}px`,
|
||||
top: `${baseY}px`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const setNewOffsetBase = () => {
|
||||
// Store current positions as new base positions
|
||||
props.sprites.forEach(sprite => {
|
||||
baseOffsets.value[sprite.id] = {
|
||||
x: Math.floor(sprite.x),
|
||||
y: Math.floor(sprite.y),
|
||||
};
|
||||
});
|
||||
|
||||
// Force redraw to update offset indicators
|
||||
drawCanvas();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add styles for offset indicators */
|
||||
.pointer-events-none {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
479
src/components/SpritePreview.vue
Normal file
@@ -0,0 +1,479 @@
|
||||
<template>
|
||||
<div class="spritesheet-preview w-full">
|
||||
<!-- Controls Container -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:flex-wrap">
|
||||
<!-- Playback Controls -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="space-y-2">
|
||||
<button @click="togglePlayback" class="flex items-center gap-1.5 bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md transition-colors w-full cursor-pointer">
|
||||
<span v-if="isPlaying" class="flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
||||
<path fill-rule="evenodd" d="M6.75 5.25a.75.75 0 01.75-.75H9a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H7.5a.75.75 0 01-.75-.75V5.25zm7.5 0A.75.75 0 0115 4.5h1.5a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H15a.75.75 0 01-.75-.75V5.25z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Pause</span>
|
||||
</span>
|
||||
<span v-else class="flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
||||
<path fill-rule="evenodd" d="M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Play</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="previousFrame" class="bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 p-2 rounded-md transition-colors duration-200 w-full cursor-pointer" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 mx-auto dark:text-gray-200">
|
||||
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 01.75.75v12.06l4.72-4.72a.75.75 0 111.06 1.06l-6 6a.75.75 0 01-1.06 0l-6-6a.75.75 0 011.06-1.06l4.72 4.72V5.97a.75.75 0 01.75-.75z" clip-rule="evenodd" transform="rotate(90 12 12)" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="nextFrame" class="bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 p-2 rounded-md transition-colors duration-200 w-full cursor-pointer" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 mx-auto dark:text-gray-200">
|
||||
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 01.75.75v12.06l4.72-4.72a.75.75 0 111.06 1.06l-6 6a.75.75 0 01-1.06 0l-6-6a.75.75 0 011.06-1.06l4.72 4.72V5.97a.75.75 0 01.75-.75z" clip-rule="evenodd" transform="rotate(-90 12 12)" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Checkboxes -->
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="isDraggable" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" />
|
||||
<span class="text-sm whitespace-nowrap dark:text-gray-200">Reposition</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="showAllSprites" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" />
|
||||
<span class="text-sm whitespace-nowrap dark:text-gray-200">Compare sprites</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="settingsStore.pixelPerfect" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" @change="drawPreviewCanvas" />
|
||||
<span class="text-sm whitespace-nowrap dark:text-gray-200">Pixel perfect</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Frame Controls -->
|
||||
<div class="flex-1 min-w-[200px] space-y-6">
|
||||
<!-- Frame Navigation -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium w-30 dark:text-gray-200">Frame {{ visibleFrameNumber }}/{{ visibleFramesCount }}</span>
|
||||
<input type="range" min="0" :max="visibleFrames.length - 1" :value="visibleFrameIndex" @input="handleSliderInput" class="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" :disabled="isPlaying" :class="{ 'opacity-50': isPlaying }" />
|
||||
</div>
|
||||
|
||||
<!-- FPS Control -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium w-30 dark:text-gray-200">FPS: {{ fps }}</span>
|
||||
<input type="range" min="1" max="60" v-model.number="fps" class="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" />
|
||||
</div>
|
||||
|
||||
<!-- Zoom Control -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium w-30 dark:text-gray-200">{{ Math.round(zoom * 100) }}%</span>
|
||||
<input type="range" min="0.5" max="5" step="0.1" v-model.number="zoom" class="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showAllSprites" class="w-full mt-3">
|
||||
<div class="flex items-center mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-200">Select visible frames:</label>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<button @click="showAllFrames" class="px-2 py-1 text-sm bg-blue-500 hover:bg-blue-600 text-white rounded-md transition-colors">Show All</button>
|
||||
<button @click="hideAllFrames" class="px-2 py-1 text-sm bg-gray-500 hover:bg-gray-600 text-white rounded-md transition-colors">Hide All</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full rounded-md border border-gray-300 dark:border-gray-600 shadow-sm focus-within:ring-1 focus-within:ring-blue-500 focus-within:border-blue-500 dark:bg-gray-800">
|
||||
<div class="max-h-[200px] overflow-y-auto">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div v-for="(sprite, index) in props.sprites" :key="sprite.id" class="flex items-center gap-3 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer" @click="toggleHiddenFrame(index)">
|
||||
<input type="checkbox" :checked="!hiddenFrames.includes(index)" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" @click.stop @change="toggleHiddenFrame(index)" />
|
||||
<div class="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded flex items-center justify-center overflow-hidden">
|
||||
<img :src="sprite.img.src" class="max-w-full max-h-full object-contain" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
||||
</div>
|
||||
<span class="text-sm dark:text-gray-200">Frame {{ index + 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 relative bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg mb-6 overflow-auto min-h-[520px] shadow-sm hover:shadow-md transition-shadow duration-200">
|
||||
<canvas
|
||||
ref="previewCanvasRef"
|
||||
@mousedown="startDrag"
|
||||
@mousemove="drag"
|
||||
@mouseup="stopDrag"
|
||||
@mouseleave="stopDrag"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="stopDrag"
|
||||
class="block"
|
||||
:class="{ 'cursor-move': isDraggable }"
|
||||
:style="{ transform: `scale(${zoom})`, transformOrigin: 'top left', ...(settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}) }"
|
||||
>
|
||||
</canvas>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, onUnmounted, computed } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
|
||||
interface Sprite {
|
||||
id: string;
|
||||
img: HTMLImageElement;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
sprites: Sprite[];
|
||||
columns: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateSprite', id: string, x: number, y: number): void;
|
||||
}>();
|
||||
|
||||
const previewCanvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const ctx = ref<CanvasRenderingContext2D | null>(null);
|
||||
|
||||
// Preview state
|
||||
const currentFrameIndex = ref(0);
|
||||
const isPlaying = ref(false);
|
||||
const fps = ref(12);
|
||||
const zoom = ref(1);
|
||||
const isDraggable = ref(false);
|
||||
const showAllSprites = ref(false);
|
||||
const animationFrameId = ref<number | null>(null);
|
||||
const lastFrameTime = ref(0);
|
||||
|
||||
// Dragging state
|
||||
const isDragging = ref(false);
|
||||
const activeSpriteId = ref<string | null>(null);
|
||||
const dragStartX = ref(0);
|
||||
const dragStartY = ref(0);
|
||||
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
|
||||
|
||||
// Add this after other refs
|
||||
const hiddenFrames = ref<number[]>([]);
|
||||
|
||||
// Get settings from store
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// Add these computed properties
|
||||
const visibleFrames = computed(() => props.sprites.filter((_, index) => !hiddenFrames.value.includes(index)));
|
||||
const visibleFramesCount = computed(() => visibleFrames.value.length);
|
||||
const visibleFrameIndex = computed(() => {
|
||||
return visibleFrames.value.findIndex((_, idx) => idx === visibleFrames.value.findIndex(s => s === props.sprites[currentFrameIndex.value]));
|
||||
});
|
||||
const visibleFrameNumber = computed(() => visibleFrameIndex.value + 1);
|
||||
|
||||
// Canvas drawing
|
||||
const calculateMaxDimensions = () => {
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
|
||||
props.sprites.forEach(sprite => {
|
||||
maxWidth = Math.max(maxWidth, sprite.width);
|
||||
maxHeight = Math.max(maxHeight, sprite.height);
|
||||
});
|
||||
|
||||
return { maxWidth, maxHeight };
|
||||
};
|
||||
|
||||
const drawPreviewCanvas = () => {
|
||||
if (!previewCanvasRef.value || !ctx.value || props.sprites.length === 0) return;
|
||||
|
||||
const currentSprite = props.sprites[currentFrameIndex.value];
|
||||
if (!currentSprite) return;
|
||||
|
||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||
|
||||
// Apply pixel art optimization consistently from store
|
||||
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
||||
|
||||
// Set canvas size to just fit one sprite cell
|
||||
previewCanvasRef.value.width = maxWidth;
|
||||
previewCanvasRef.value.height = maxHeight;
|
||||
|
||||
// Clear canvas
|
||||
ctx.value.clearRect(0, 0, previewCanvasRef.value.width, previewCanvasRef.value.height);
|
||||
|
||||
// Draw grid background (cell)
|
||||
ctx.value.fillStyle = '#f9fafb';
|
||||
ctx.value.fillRect(0, 0, maxWidth, maxHeight);
|
||||
|
||||
// Keep pixel art optimization consistent throughout all drawing operations
|
||||
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
||||
|
||||
// Draw all sprites with transparency if enabled
|
||||
if (showAllSprites.value && props.sprites.length > 1) {
|
||||
ctx.value.globalAlpha = 0.3;
|
||||
props.sprites.forEach((sprite, index) => {
|
||||
if (index !== currentFrameIndex.value && !hiddenFrames.value.includes(index)) {
|
||||
// Use Math.floor for pixel-perfect positioning
|
||||
ctx.value?.drawImage(sprite.img, Math.floor(sprite.x), Math.floor(sprite.y));
|
||||
}
|
||||
});
|
||||
ctx.value.globalAlpha = 1.0;
|
||||
}
|
||||
|
||||
// Draw current sprite with integer positions for pixel-perfect rendering
|
||||
ctx.value.drawImage(currentSprite.img, Math.floor(currentSprite.x), Math.floor(currentSprite.y));
|
||||
|
||||
// Draw cell border
|
||||
ctx.value.strokeStyle = '#e5e7eb';
|
||||
ctx.value.lineWidth = 1;
|
||||
ctx.value.strokeRect(0, 0, maxWidth, maxHeight);
|
||||
};
|
||||
|
||||
// Animation control
|
||||
const togglePlayback = () => {
|
||||
isPlaying.value = !isPlaying.value;
|
||||
|
||||
if (isPlaying.value) {
|
||||
startAnimation();
|
||||
} else {
|
||||
stopAnimation();
|
||||
}
|
||||
};
|
||||
|
||||
const startAnimation = () => {
|
||||
lastFrameTime.value = performance.now();
|
||||
animateFrame();
|
||||
};
|
||||
|
||||
const stopAnimation = () => {
|
||||
if (animationFrameId.value !== null) {
|
||||
cancelAnimationFrame(animationFrameId.value);
|
||||
animationFrameId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const animateFrame = () => {
|
||||
const now = performance.now();
|
||||
const elapsed = now - lastFrameTime.value;
|
||||
const frameTime = 1000 / fps.value;
|
||||
|
||||
if (elapsed >= frameTime) {
|
||||
lastFrameTime.value = now - (elapsed % frameTime);
|
||||
nextFrame();
|
||||
}
|
||||
|
||||
animationFrameId.value = requestAnimationFrame(animateFrame);
|
||||
};
|
||||
|
||||
const nextFrame = () => {
|
||||
if (visibleFrames.value.length === 0) return;
|
||||
|
||||
const currentVisibleIndex = visibleFrameIndex.value;
|
||||
const nextVisibleIndex = (currentVisibleIndex + 1) % visibleFrames.value.length;
|
||||
currentFrameIndex.value = props.sprites.indexOf(visibleFrames.value[nextVisibleIndex]);
|
||||
drawPreviewCanvas();
|
||||
};
|
||||
|
||||
const previousFrame = () => {
|
||||
if (visibleFrames.value.length === 0) return;
|
||||
|
||||
const currentVisibleIndex = visibleFrameIndex.value;
|
||||
const prevVisibleIndex = (currentVisibleIndex - 1 + visibleFrames.value.length) % visibleFrames.value.length;
|
||||
currentFrameIndex.value = props.sprites.indexOf(visibleFrames.value[prevVisibleIndex]);
|
||||
drawPreviewCanvas();
|
||||
};
|
||||
|
||||
// Add this method to handle slider input
|
||||
const handleSliderInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const index = parseInt(target.value);
|
||||
currentFrameIndex.value = props.sprites.indexOf(visibleFrames.value[index]);
|
||||
};
|
||||
|
||||
// Drag functionality
|
||||
const startDrag = (event: MouseEvent) => {
|
||||
if (!isDraggable.value || !previewCanvasRef.value) return;
|
||||
|
||||
const rect = previewCanvasRef.value.getBoundingClientRect();
|
||||
const scaleX = previewCanvasRef.value.width / (rect.width / zoom.value);
|
||||
const scaleY = previewCanvasRef.value.height / (rect.height / zoom.value);
|
||||
|
||||
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
||||
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
||||
|
||||
const sprite = props.sprites[currentFrameIndex.value];
|
||||
|
||||
// Check if click is on sprite
|
||||
if (sprite && mouseX >= sprite.x && mouseX <= sprite.x + sprite.width && mouseY >= sprite.y && mouseY <= sprite.y + sprite.height) {
|
||||
isDragging.value = true;
|
||||
activeSpriteId.value = sprite.id;
|
||||
dragStartX.value = mouseX;
|
||||
dragStartY.value = mouseY;
|
||||
spritePosBeforeDrag.value = { x: sprite.x, y: sprite.y };
|
||||
}
|
||||
};
|
||||
|
||||
const drag = (event: MouseEvent) => {
|
||||
if (!isDragging.value || !activeSpriteId.value || !previewCanvasRef.value) return;
|
||||
|
||||
const rect = previewCanvasRef.value.getBoundingClientRect();
|
||||
const scaleX = previewCanvasRef.value.width / (rect.width / zoom.value);
|
||||
const scaleY = previewCanvasRef.value.height / (rect.height / zoom.value);
|
||||
|
||||
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
||||
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
||||
|
||||
const deltaX = Math.round(mouseX - dragStartX.value);
|
||||
const deltaY = Math.round(mouseY - dragStartY.value);
|
||||
|
||||
const sprite = props.sprites[currentFrameIndex.value];
|
||||
if (!sprite || sprite.id !== activeSpriteId.value) return;
|
||||
|
||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||
|
||||
// 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 cell
|
||||
newX = Math.max(0, Math.min(maxWidth - sprite.width, newX));
|
||||
newY = Math.max(0, Math.min(maxHeight - sprite.height, newY));
|
||||
|
||||
emit('updateSprite', activeSpriteId.value, newX, newY);
|
||||
drawPreviewCanvas();
|
||||
};
|
||||
|
||||
const stopDrag = () => {
|
||||
isDragging.value = false;
|
||||
activeSpriteId.value = null;
|
||||
};
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
const mouseEvent = new MouseEvent('mousedown', {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
});
|
||||
startDrag(mouseEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
const mouseEvent = new MouseEvent('mousemove', {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
});
|
||||
drag(mouseEvent);
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
if (previewCanvasRef.value) {
|
||||
ctx.value = previewCanvasRef.value.getContext('2d');
|
||||
drawPreviewCanvas();
|
||||
}
|
||||
|
||||
// Listen for forceRedraw event from App.vue
|
||||
window.addEventListener('forceRedraw', handleForceRedraw);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAnimation();
|
||||
window.removeEventListener('forceRedraw', handleForceRedraw);
|
||||
});
|
||||
|
||||
// Handler for force redraw event
|
||||
const handleForceRedraw = () => {
|
||||
// Ensure we're using integer positions for pixel-perfect rendering
|
||||
props.sprites.forEach(sprite => {
|
||||
sprite.x = Math.floor(sprite.x);
|
||||
sprite.y = Math.floor(sprite.y);
|
||||
});
|
||||
|
||||
// Force a redraw with the correct image smoothing settings
|
||||
if (ctx.value) {
|
||||
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
||||
drawPreviewCanvas();
|
||||
}
|
||||
};
|
||||
|
||||
// Watchers
|
||||
watch(() => props.sprites, drawPreviewCanvas, { deep: true });
|
||||
watch(currentFrameIndex, drawPreviewCanvas);
|
||||
watch(zoom, drawPreviewCanvas);
|
||||
watch(isDraggable, drawPreviewCanvas);
|
||||
watch(showAllSprites, drawPreviewCanvas);
|
||||
watch(hiddenFrames, drawPreviewCanvas);
|
||||
watch(() => settingsStore.pixelPerfect, drawPreviewCanvas);
|
||||
|
||||
// Initial draw
|
||||
if (props.sprites.length > 0) {
|
||||
drawPreviewCanvas();
|
||||
}
|
||||
|
||||
const toggleHiddenFrame = (index: number) => {
|
||||
const currentIndex = hiddenFrames.value.indexOf(index);
|
||||
if (currentIndex === -1) {
|
||||
// Adding to hidden frames
|
||||
hiddenFrames.value.push(index);
|
||||
|
||||
// If we're hiding the current frame, switch to the next visible frame
|
||||
if (index === currentFrameIndex.value) {
|
||||
const nextVisibleSprite = props.sprites.findIndex((_, i) => i !== index && !hiddenFrames.value.includes(i));
|
||||
if (nextVisibleSprite !== -1) {
|
||||
currentFrameIndex.value = nextVisibleSprite;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Removing from hidden frames
|
||||
hiddenFrames.value.splice(currentIndex, 1);
|
||||
}
|
||||
|
||||
// Force a redraw
|
||||
drawPreviewCanvas();
|
||||
};
|
||||
|
||||
const showAllFrames = () => {
|
||||
hiddenFrames.value = [];
|
||||
drawPreviewCanvas();
|
||||
};
|
||||
|
||||
const hideAllFrames = () => {
|
||||
hiddenFrames.value = props.sprites.map((_, index) => index);
|
||||
// Keep at least one frame visible
|
||||
if (hiddenFrames.value.length > 0) {
|
||||
hiddenFrames.value.splice(currentFrameIndex.value, 1);
|
||||
}
|
||||
drawPreviewCanvas();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Custom styling for range inputs */
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type='range']::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
441
src/components/SpritesheetSplitter.vue
Normal file
@@ -0,0 +1,441 @@
|
||||
<template>
|
||||
<Modal :is-open="isOpen" @close="cancel" title="Split Spritesheet">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<img :src="imageUrl" alt="Spritesheet" class="max-w-full max-h-64 border border-gray-300 rounded-lg" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label for="detection-method" class="block text-sm font-medium text-gray-700">Detection Method</label>
|
||||
<select id="detection-method" v-model="detectionMethod" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" data-umami-event="spritesheet-detection-method">
|
||||
<option value="manual">Manual (specify rows and columns)</option>
|
||||
<option value="auto">Auto-detect (experimental)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="detectionMethod === 'auto'" class="space-y-2">
|
||||
<label for="sensitivity" class="block text-sm font-medium text-gray-700">Detection Sensitivity</label>
|
||||
<input type="range" id="sensitivity" v-model="sensitivity" min="1" max="100" class="w-full" data-umami-event="spritesheet-sensitivity" />
|
||||
<div class="text-xs text-gray-500 flex justify-between">
|
||||
<span>Low</span>
|
||||
<span>High</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detectionMethod === 'manual'" class="space-y-2">
|
||||
<label for="rows" class="block text-sm font-medium text-gray-700">Rows</label>
|
||||
<input type="number" id="rows" v-model.number="rows" min="1" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" data-umami-event="spritesheet-rows" />
|
||||
</div>
|
||||
|
||||
<div v-if="detectionMethod === 'manual'" class="space-y-2">
|
||||
<label for="columns" class="block text-sm font-medium text-gray-700">Columns</label>
|
||||
<input type="number" id="columns" v-model.number="columns" min="1" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" data-umami-event="spritesheet-columns" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="remove-empty" v-model="removeEmpty" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" data-umami-event="spritesheet-remove-empty" />
|
||||
<label for="remove-empty" class="ml-2 block text-sm text-gray-700"> Remove empty sprites (transparent/background color) </label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="previewSprites.length > 0" class="space-y-2">
|
||||
<h3 class="text-sm font-medium text-gray-700">Preview ({{ previewSprites.length }} sprites)</h3>
|
||||
<div class="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2 max-h-40 overflow-y-auto p-2 border border-gray-200 rounded-lg">
|
||||
<div v-for="(sprite, index) in previewSprites" :key="index" class="relative border border-gray-300 rounded bg-gray-100 flex items-center justify-center" :style="{ width: '80px', height: '80px' }">
|
||||
<img :src="sprite.url" alt="Sprite preview" class="max-w-full max-h-full" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button @click="cancel" class="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" data-umami-event="spritesheet-cancel">Cancel</button>
|
||||
<button
|
||||
@click="confirm"
|
||||
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
:disabled="previewSprites.length === 0 || isProcessing"
|
||||
data-umami-event="spritesheet-split"
|
||||
>
|
||||
Split Spritesheet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import Modal from './utilities/Modal.vue';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
|
||||
interface SpritePreview {
|
||||
url: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isEmpty: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean;
|
||||
imageUrl: string;
|
||||
imageFile: File | null | undefined;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
(e: 'split', sprites: SpriteFile[]): void; // Change from File[] to SpriteFile[]
|
||||
}>();
|
||||
|
||||
interface SpriteFile {
|
||||
file: File;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Get settings from store
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// State
|
||||
const detectionMethod = ref<'manual' | 'auto'>('manual');
|
||||
const rows = ref(1);
|
||||
const columns = ref(1);
|
||||
const sensitivity = ref(50);
|
||||
const removeEmpty = ref(true);
|
||||
const previewSprites = ref<SpritePreview[]>([]);
|
||||
const isProcessing = ref(false);
|
||||
const imageElement = ref<HTMLImageElement | null>(null);
|
||||
|
||||
// Load the image when the component is mounted or the URL changes
|
||||
watch(() => props.imageUrl, loadImage, { immediate: true });
|
||||
|
||||
function loadImage() {
|
||||
if (!props.imageUrl) return;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
imageElement.value = img;
|
||||
|
||||
// Set default rows and columns based on image dimensions
|
||||
// This is a simple heuristic - for pixel art, we might want to detect sprite size
|
||||
const aspectRatio = img.width / img.height;
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
// Landscape orientation - likely more columns than rows
|
||||
columns.value = Math.min(Math.ceil(Math.sqrt(aspectRatio * 4)), 8);
|
||||
rows.value = Math.ceil(4 / columns.value);
|
||||
} else {
|
||||
// Portrait orientation - likely more rows than columns
|
||||
rows.value = Math.min(Math.ceil(Math.sqrt(4 / aspectRatio)), 8);
|
||||
columns.value = Math.ceil(4 / rows.value);
|
||||
}
|
||||
|
||||
// Generate initial preview
|
||||
generatePreview();
|
||||
};
|
||||
img.src = props.imageUrl;
|
||||
}
|
||||
|
||||
// Generate preview of split sprites
|
||||
async function generatePreview() {
|
||||
if (!imageElement.value) return;
|
||||
|
||||
isProcessing.value = true;
|
||||
previewSprites.value = [];
|
||||
|
||||
try {
|
||||
const img = imageElement.value;
|
||||
|
||||
if (detectionMethod.value === 'auto') {
|
||||
// Auto-detection logic would go here
|
||||
// For now, we'll use a simple algorithm based on sensitivity
|
||||
await autoDetectSprites(img);
|
||||
} else {
|
||||
// Manual splitting based on rows and columns
|
||||
await splitSpritesheet(img, rows.value, columns.value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating preview:', error);
|
||||
} finally {
|
||||
isProcessing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getSpriteBoundingBox(ctx: CanvasRenderingContext2D, width: number, height: number) {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
|
||||
let minX = width;
|
||||
let minY = height;
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
let hasContent = false;
|
||||
|
||||
// Scan through all pixels to find the bounding box
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = (y * width + x) * 4;
|
||||
// Check if pixel is not transparent (alpha > 0)
|
||||
if (data[idx + 3] > 10) {
|
||||
// Allow some tolerance for compression artifacts
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
hasContent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no non-transparent pixels found, return null
|
||||
if (!hasContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return bounding box with a small padding
|
||||
return {
|
||||
x: Math.max(0, minX - 1),
|
||||
y: Math.max(0, minY - 1),
|
||||
width: Math.min(width, maxX - minX + 3), // +1 for inclusive bounds, +2 for padding
|
||||
height: Math.min(height, maxY - minY + 3),
|
||||
};
|
||||
}
|
||||
|
||||
// Split spritesheet manually based on rows and columns
|
||||
async function splitSpritesheet(img: HTMLImageElement, rows: number, columns: number) {
|
||||
const spriteWidth = Math.floor(img.width / columns);
|
||||
const spriteHeight = Math.floor(img.height / rows);
|
||||
|
||||
const sprites: SpritePreview[] = [];
|
||||
|
||||
// Create a canvas for processing the full sprite
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Create a second canvas for the cropped sprite
|
||||
const croppedCanvas = document.createElement('canvas');
|
||||
const croppedCtx = croppedCanvas.getContext('2d');
|
||||
|
||||
if (!ctx || !croppedCtx) return;
|
||||
|
||||
canvas.width = spriteWidth;
|
||||
canvas.height = spriteHeight;
|
||||
|
||||
// Split the image into individual sprites
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < columns; col++) {
|
||||
// Clear the canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw the portion of the spritesheet
|
||||
ctx.drawImage(img, col * spriteWidth, row * spriteHeight, spriteWidth, spriteHeight, 0, 0, spriteWidth, spriteHeight);
|
||||
|
||||
// Check if the sprite is empty (all transparent or same color)
|
||||
const isEmpty = removeEmpty.value ? isCanvasEmpty(ctx, spriteWidth, spriteHeight) : false;
|
||||
|
||||
// If we're not removing empty sprites or the sprite is not empty
|
||||
if (!removeEmpty.value || !isEmpty) {
|
||||
// Get bounding box of non-transparent pixels
|
||||
const boundingBox = getSpriteBoundingBox(ctx, spriteWidth, spriteHeight);
|
||||
|
||||
let url;
|
||||
let x = 0; // Default position (will be updated if we have a bounding box)
|
||||
let y = 0;
|
||||
let width = spriteWidth;
|
||||
let height = spriteHeight;
|
||||
|
||||
if (boundingBox) {
|
||||
// The key change: preserve the original position where the sprite was found
|
||||
x = boundingBox.x;
|
||||
y = boundingBox.y;
|
||||
width = boundingBox.width;
|
||||
height = boundingBox.height;
|
||||
|
||||
// Set dimensions for the cropped sprite
|
||||
croppedCanvas.width = boundingBox.width;
|
||||
croppedCanvas.height = boundingBox.height;
|
||||
|
||||
// Draw only the non-transparent part
|
||||
croppedCtx.clearRect(0, 0, croppedCanvas.width, croppedCanvas.height);
|
||||
croppedCtx.drawImage(canvas, boundingBox.x, boundingBox.y, boundingBox.width, boundingBox.height, 0, 0, boundingBox.width, boundingBox.height);
|
||||
|
||||
// Convert to data URL
|
||||
url = croppedCanvas.toDataURL('image/png');
|
||||
} else {
|
||||
// No non-transparent pixels found, use the original sprite
|
||||
url = canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
sprites.push({
|
||||
url,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
isEmpty,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previewSprites.value = sprites;
|
||||
}
|
||||
|
||||
// Auto-detect sprites based on transparency/color differences
|
||||
async function autoDetectSprites(img: HTMLImageElement) {
|
||||
// This is a simplified implementation
|
||||
// A more sophisticated algorithm would analyze the image to find sprite boundaries
|
||||
|
||||
// For now, we'll use a simple approach:
|
||||
// 1. Try to detect the sprite size by looking for repeating patterns
|
||||
// 2. Then use that size to split the spritesheet
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Get image data for analysis
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
// Simple detection of sprite size based on transparency patterns
|
||||
// This is a very basic implementation and might not work for all spritesheets
|
||||
const { detectedWidth, detectedHeight } = detectSpriteSize(data, canvas.width, canvas.height);
|
||||
|
||||
if (detectedWidth > 0 && detectedHeight > 0) {
|
||||
const detectedRows = Math.floor(img.height / detectedHeight);
|
||||
const detectedColumns = Math.floor(img.width / detectedWidth);
|
||||
|
||||
// Use the detected size to split the spritesheet
|
||||
await splitSpritesheet(img, detectedRows, detectedColumns);
|
||||
} else {
|
||||
// Fallback to manual splitting with a reasonable guess
|
||||
const estimatedSize = Math.max(16, Math.floor(Math.min(img.width, img.height) / 8));
|
||||
const estimatedRows = Math.floor(img.height / estimatedSize);
|
||||
const estimatedColumns = Math.floor(img.width / estimatedSize);
|
||||
|
||||
await splitSpritesheet(img, estimatedRows, estimatedColumns);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to detect sprite size based on transparency patterns
|
||||
function detectSpriteSize(data: Uint8ClampedArray, width: number, height: number) {
|
||||
// This is a simplified implementation
|
||||
// A real implementation would be more sophisticated
|
||||
|
||||
// The sensitivity affects how aggressive we are in detecting patterns
|
||||
const threshold = 100 - sensitivity.value; // Lower threshold = more sensitive
|
||||
|
||||
// For now, return a simple estimate based on image size
|
||||
// In a real implementation, we would analyze the image data to find patterns
|
||||
return {
|
||||
detectedWidth: 0, // Return 0 to fall back to the manual method
|
||||
detectedHeight: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if a canvas is empty (all transparent or same color)
|
||||
function isCanvasEmpty(ctx: CanvasRenderingContext2D, width: number, height: number): boolean {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
|
||||
// Check if all pixels are transparent
|
||||
let allTransparent = true;
|
||||
let allSameColor = true;
|
||||
|
||||
// Reference values from first pixel
|
||||
const firstR = data[0];
|
||||
const firstG = data[1];
|
||||
const firstB = data[2];
|
||||
const firstA = data[3];
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const alpha = data[i + 3];
|
||||
|
||||
// Check transparency
|
||||
if (alpha > 10) {
|
||||
// Allow some tolerance for compression artifacts
|
||||
allTransparent = false;
|
||||
}
|
||||
|
||||
// Check if all pixels are the same color
|
||||
if (data[i] !== firstR || data[i + 1] !== firstG || data[i + 2] !== firstB || Math.abs(data[i + 3] - firstA) > 10) {
|
||||
allSameColor = false;
|
||||
}
|
||||
|
||||
// Early exit if we've determined it's not empty
|
||||
if (!allTransparent && !allSameColor) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return allTransparent || allSameColor;
|
||||
}
|
||||
|
||||
// Convert preview sprites to actual files
|
||||
async function createSpriteFiles(): Promise<SpriteFile[]> {
|
||||
const spriteFiles: SpriteFile[] = [];
|
||||
|
||||
for (let i = 0; i < previewSprites.value.length; i++) {
|
||||
const sprite = previewSprites.value[i];
|
||||
|
||||
// Convert data URL to blob
|
||||
const response = await fetch(sprite.url);
|
||||
const blob = await response.blob();
|
||||
|
||||
// Create file from blob
|
||||
const fileName = `sprite_${i + 1}.png`;
|
||||
const file = new File([blob], fileName, { type: 'image/png' });
|
||||
|
||||
// Create sprite file with position information
|
||||
spriteFiles.push({
|
||||
file,
|
||||
x: sprite.x,
|
||||
y: sprite.y,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
});
|
||||
}
|
||||
|
||||
return spriteFiles;
|
||||
}
|
||||
|
||||
// Actions
|
||||
function cancel() {
|
||||
emit('close');
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (previewSprites.value.length === 0) return;
|
||||
|
||||
isProcessing.value = true;
|
||||
|
||||
try {
|
||||
const files = await createSpriteFiles();
|
||||
emit('split', files);
|
||||
emit('close');
|
||||
} catch (error) {
|
||||
console.error('Error creating sprite files:', error);
|
||||
} finally {
|
||||
isProcessing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Add these watchers to automatically update preview
|
||||
watch([rows, columns, removeEmpty, detectionMethod, sensitivity], () => {
|
||||
if (imageElement.value) {
|
||||
generatePreview();
|
||||
}
|
||||
});
|
||||
</script>
|
11
src/components/utilities/DarkModeToggle.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<button @click="settingsStore.toggleDarkMode()" class="p-2 rounded-lg transition-colors" :class="settingsStore.darkMode ? 'text-yellow-400 hover:bg-gray-700' : 'text-gray-700 hover:bg-gray-100'" aria-label="Toggle dark mode" data-umami-event="toggle-dark-mode">
|
||||
<i :class="settingsStore.darkMode ? 'fas fa-sun' : 'fas fa-moon'"></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
</script>
|
223
src/components/utilities/Modal.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="modalRef"
|
||||
:style="{
|
||||
position: isFullScreen ? 'fixed' : 'absolute',
|
||||
left: isFullScreen ? '0' : `${position.x}px`,
|
||||
top: isFullScreen ? '0' : `${position.y}px`,
|
||||
width: isFullScreen ? '100%' : `${size.width}px`,
|
||||
height: isFullScreen ? '100%' : `${size.height}px`,
|
||||
}"
|
||||
class="bg-white dark:bg-gray-800 rounded-2xl border-2 border-gray-300 dark:border-gray-700 shadow-xl flex flex-col fixed z-50 transition-colors duration-300"
|
||||
:class="{ 'rounded-none border-0': isFullScreen }"
|
||||
>
|
||||
<!-- Header with drag handle -->
|
||||
<div class="flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700" :class="{ 'cursor-move': !isFullScreen }" @mousedown="startDrag" @touchstart.prevent="handleTouchStart">
|
||||
<h3 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">{{ title }}</h3>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="toggleFullScreen" class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" data-umami-event="modal-fullscreen">
|
||||
<img src="@/assets/images/fullscreen-icon.svg" class="w-4 h-4 dark:invert" alt="Fullscreen" :class="{ 'rotate-180': isFullScreen }" />
|
||||
</button>
|
||||
<button @click="close" class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" data-umami-event="modal-close">
|
||||
<img src="@/assets/images/close-icon.svg" class="w-5 h-5 dark:invert" alt="Close" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-6 flex-1 overflow-auto">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<!-- Resize handle -->
|
||||
<div v-if="!isFullScreen" class="absolute bottom-0 right-0 w-8 h-8 cursor-se-resize" @mousedown="startResize" @touchstart="startResize"></div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
initialWidth?: number;
|
||||
initialHeight?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
|
||||
const modalRef = ref<HTMLElement | null>(null);
|
||||
const position = ref({ x: 0, y: 0 });
|
||||
const size = ref({
|
||||
width: props.initialWidth || 800,
|
||||
height: props.initialHeight || 600,
|
||||
});
|
||||
|
||||
const isDragging = ref(false);
|
||||
const isResizing = ref(false);
|
||||
const startPos = ref({ x: 0, y: 0 });
|
||||
const startSize = ref({ width: 0, height: 0 });
|
||||
|
||||
// Add isFullScreen ref
|
||||
const isFullScreen = ref(false);
|
||||
|
||||
// Add previous state storage for restoring from full screen
|
||||
const previousState = ref({
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 0, height: 0 },
|
||||
});
|
||||
|
||||
// Add toggleFullScreen function
|
||||
const toggleFullScreen = () => {
|
||||
if (!isFullScreen.value) {
|
||||
// Store current state before going full screen
|
||||
previousState.value = {
|
||||
position: { ...position.value },
|
||||
size: { ...size.value },
|
||||
};
|
||||
} 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;
|
||||
|
||||
if (action === 'drag') {
|
||||
isDragging.value = true;
|
||||
startPos.value = {
|
||||
x: clientX - position.value.x,
|
||||
y: clientY - position.value.y,
|
||||
};
|
||||
} else {
|
||||
isResizing.value = true;
|
||||
startPos.value = { x: clientX, y: clientY };
|
||||
startSize.value = { ...size.value };
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMove);
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('mouseup', stopAction);
|
||||
document.addEventListener('touchend', stopAction);
|
||||
};
|
||||
|
||||
const startDrag = (event: MouseEvent | TouchEvent) => startAction(event, 'drag');
|
||||
const startResize = (event: MouseEvent | TouchEvent) => startAction(event, 'resize');
|
||||
|
||||
const handleMove = (event: MouseEvent | TouchEvent) => {
|
||||
if (!isDragging.value && !isResizing.value) return;
|
||||
|
||||
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
|
||||
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
|
||||
|
||||
if (isDragging.value) {
|
||||
const newX = clientX - startPos.value.x;
|
||||
const newY = clientY - startPos.value.y;
|
||||
position.value = constrainPosition(newX, newY);
|
||||
} else if (isResizing.value) {
|
||||
const deltaX = clientX - startPos.value.x;
|
||||
const deltaY = clientY - startPos.value.y;
|
||||
size.value = constrainSize(startSize.value.width + deltaX, startSize.value.height + deltaY);
|
||||
}
|
||||
};
|
||||
|
||||
const stopAction = () => {
|
||||
isDragging.value = false;
|
||||
isResizing.value = false;
|
||||
document.removeEventListener('mousemove', handleMove);
|
||||
document.removeEventListener('touchmove', handleMove);
|
||||
document.removeEventListener('mouseup', stopAction);
|
||||
document.removeEventListener('touchend', stopAction);
|
||||
};
|
||||
|
||||
const constrainPosition = (x: number, y: number) => {
|
||||
if (!modalRef.value) return { x, y };
|
||||
const modalRect = modalRef.value.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.max(0, Math.min(x, window.innerWidth - modalRect.width)),
|
||||
y: Math.max(0, Math.min(y, window.innerHeight - modalRect.height)),
|
||||
};
|
||||
};
|
||||
|
||||
const constrainSize = (width: number, height: number) => {
|
||||
return {
|
||||
width: Math.max(400, Math.min(width, window.innerWidth - position.value.x)),
|
||||
height: Math.max(300, Math.min(height, window.innerHeight - position.value.y)),
|
||||
};
|
||||
};
|
||||
|
||||
const centerModal = () => {
|
||||
if (!modalRef.value) return;
|
||||
position.value = {
|
||||
x: (window.innerWidth - size.value.width) / 2,
|
||||
y: (window.innerHeight - size.value.height) / 2,
|
||||
};
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
emit('close');
|
||||
position.value = { x: 0, y: 0 };
|
||||
};
|
||||
|
||||
// Event handlers
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && props.isOpen) close();
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
if (!isDragging.value && !isResizing.value) {
|
||||
position.value = constrainPosition(position.value.x, position.value.y);
|
||||
size.value = constrainSize(size.value.width, size.value.height);
|
||||
}
|
||||
};
|
||||
|
||||
// Add these new touch handling functions
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
if (isFullScreen.value) return;
|
||||
if (event.touches.length === 1) {
|
||||
startAction(event, 'drag');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
if (!isDragging.value && !isResizing.value) return;
|
||||
event.preventDefault();
|
||||
|
||||
if (event.touches.length === 1) {
|
||||
handleMove(event);
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
watch(
|
||||
() => props.isOpen,
|
||||
newValue => {
|
||||
if (newValue) nextTick(centerModal);
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('resize', handleResize);
|
||||
if (props.isOpen) centerModal();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
stopAction();
|
||||
});
|
||||
</script>
|
11
src/main.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import './assets/main.css';
|
||||
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
|
||||
app.mount('#app');
|
62
src/stores/useSettingsStore.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
const pixelPerfect = ref(true);
|
||||
const darkMode = 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;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Actions
|
||||
function togglePixelPerfect() {
|
||||
pixelPerfect.value = !pixelPerfect.value;
|
||||
}
|
||||
|
||||
function setPixelPerfect(value: boolean) {
|
||||
pixelPerfect.value = value;
|
||||
}
|
||||
|
||||
function toggleDarkMode() {
|
||||
darkMode.value = !darkMode.value;
|
||||
}
|
||||
|
||||
function setDarkMode(value: boolean) {
|
||||
darkMode.value = value;
|
||||
}
|
||||
|
||||
return {
|
||||
pixelPerfect,
|
||||
darkMode,
|
||||
togglePixelPerfect,
|
||||
setPixelPerfect,
|
||||
toggleDarkMode,
|
||||
setDarkMode,
|
||||
};
|
||||
});
|
72
tailwind.config.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
gray: {
|
||||
900: '#111827', // bg-primary
|
||||
800: '#1F2937', // bg-secondary
|
||||
700: '#374151', // bg-tertiary
|
||||
600: '#4B5563', // border
|
||||
400: '#9CA3AF', // text-secondary
|
||||
200: '#E5E7EB', // text-primary
|
||||
},
|
||||
blue: {
|
||||
500: '#3B82F6', // accent
|
||||
600: '#2563EB', // accent-hover
|
||||
},
|
||||
red: {
|
||||
500: '#EF4444', // danger
|
||||
600: '#DC2626', // danger-hover
|
||||
},
|
||||
green: {
|
||||
500: '#10B981', // success
|
||||
600: '#059669', // success-hover
|
||||
}
|
||||
},
|
||||
boxShadow: {
|
||||
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
|
||||
},
|
||||
typography: (theme) => ({
|
||||
DEFAULT: {
|
||||
css: {
|
||||
maxWidth: 'none',
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
css: {
|
||||
color: theme('colors.gray.200'),
|
||||
a: {
|
||||
color: theme('colors.blue.500'),
|
||||
'&:hover': {
|
||||
color: theme('colors.blue.400'),
|
||||
},
|
||||
},
|
||||
h1: {
|
||||
color: theme('colors.gray.200'),
|
||||
},
|
||||
h2: {
|
||||
color: theme('colors.gray.200'),
|
||||
},
|
||||
h3: {
|
||||
color: theme('colors.gray.200'),
|
||||
},
|
||||
h4: {
|
||||
color: theme('colors.gray.200'),
|
||||
},
|
||||
code: {
|
||||
color: theme('colors.gray.200'),
|
||||
backgroundColor: theme('colors.gray.800'),
|
||||
},
|
||||
pre: {
|
||||
backgroundColor: theme('colors.gray.800'),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
12
tsconfig.app.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
11
tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
19
tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
20
vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
tailwindcss()
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
})
|